tournaments

Tournaments

Single-elimination brackets: winners reported by server code (never client-trusted); join policy + champion reward are game SPIs.

Category Meta Seams 6 Services 2 Options 1 Wire ids 10 Unity types 2
Server
services.AddCrossplayTournaments();
Unity package
com.crossplay.tournaments
Depends on
core

Seams you implement 5

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayTournaments(); and it wins.

ITournamentEventFormat game SPI

SPI: the EVENT-based side of the format family — scheduling rules for tournaments whose unit of play is an N-entrant scored EVENT (a race, a heat, a battle-royale round) rather than a pairwise match. A round is one or more events; each event's result is a full ranked finishing order (reported server-side via TryReportEventResult — unforgeable, the exact TryReportResult convention), and standings are placement points summed through ITournamentScoring. The shipped implementation is EventSeriesFormat (every entrant in every event — the racing-championship shape); a game may implement its own (heats, split groups, eliminations). Register it exactly like any format, BEFORE AddCrossplayTournaments: services.AddSingleton<ITournamentFormat>(new EventSeriesFormat(events: 8));

  • void OnEventResult(TournamentRecord record, TournamentEvent evt, ITournamentScoring scoring)

    A ranked result was just recorded on evt — advance the schedule: draw later events if the format is lazy, and crown the points champion when its rules are satisfied (via scoring).

ITournamentFormat game SPI

SPI: the tournament's SCHEDULING RULES — what a valid field size is, how the field is paired into matches, and how a recorded result advances the schedule until a champion is decided. The shipped default is SingleEliminationFormat (the classic bracket, byte-identical to the pre-SPI behavior); RoundRobinFormat and SwissFormat ship as built-in alternatives, and a game may implement its own (double elimination, group stage → knockout…). Register a format BEFORE AddCrossplayTournaments (a TryAdd seam): services.AddSingleton<ITournamentFormat>(new SwissFormat(rounds: 5));

  • bool IsValidSize(int size, TournamentOptions options)

    Is size a legal field size for this format (within the option caps)? Single elimination demands a power of two; round-robin and Swiss take any size.

  • void OnResult(TournamentRecord record, TournamentMatch match)

    A winner was just recorded on match — advance the schedule: fill dependent slots, draw the next round when the current one closes, and crown the champion when the format's rules are satisfied.

  • void Seed(TournamentRecord record)

    Draws the opening schedule for Participants (the record is already in the Running state). May decide the event immediately (a one-entrant field, byes collapsing a bracket).

ITournamentPolicy game SPI

SPI the GAME implements to gate who may register in a bracket — rating floors, entry fees, faction or level checks, ban lists: all game logic. The framework calls CanJoin once per join attempt AFTER its own structural checks pass (the bracket is open, not full, and the character is not already registered); returning false vetoes the join (TryJoin returns false with reason JoinRefused, no registration).

Example
public sealed class RatedTournamentPolicy : ITournamentPolicy
{
    public bool CanJoin(ISession session, long characterId, TournamentRecord tournament)
        => _ratings.Get(characterId) >= 1500; // gold-tier characters and up only
}
  • bool CanJoin(ISession session, long characterId, TournamentRecord tournament)

    Approve (or veto) registering characterId in tournament.

ITournamentRewardGrantor game SPI

SPI the GAME implements to decide what a champion receives — the returned byte[] is an OPAQUE, game-defined reward blob that rides the TournamentChampion push to participants; the framework never inspects it (one game encodes a trophy id, another a currency grant receipt, another nothing). Called exactly once, server-side, when a bracket crowns its champion. The default NullTournamentRewardGrantor grants nothing (an empty blob).

Example
public sealed class TrophyGrantor : ITournamentRewardGrantor
{
    public byte[] Grant(long tournamentId, long championCharacterId)
    {
        _economy.TryCredit(championCharacterId, GoldCurrencyId, 1000, "tournament");
        return new byte[] { 0x01 }; // opaque trophy marker the client renders however it likes
    }
}
  • byte[] Grant(long tournamentId, long championCharacterId)

    Produces the champion's opaque reward blob (and, optionally, performs the actual grant).

ITournamentScoring game SPI

SPI the GAME implements to decide what a finishing position in an N-entrant EVENT is worth — the scoring half of the event-based format family (ITournamentEventFormat): standings are each entrant's points summed over every reported event, and the champion is the entrant with the highest total (ties break toward earlier registration — deterministic). The default PlacementPointsTournamentScoring reads the config-bindable EventPlacementPoints table. Pairwise (match-based) formats never consult it.

Example
public sealed class WinnerTakesAllScoring : ITournamentScoring
{
    public double PointsForPlacement(int placement, int entrantCount)
        => placement == 0 ? 1 : 0; // only event wins count toward the championship
}
  • double PointsForPlacement(int placement, int entrantCount)

    The points one finishing position in one event is worth.

Providers you can swap 1

Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.

ITournamentStore provider

Persistence seam for tournament state. The default InMemoryTournamentStore keeps records in RAM (single node, lost on restart); wiring AddCrossplayPersistentStores after a provider overrides it with a document-backed store so tournaments survive restarts, and a cluster backend (AddCrossplayRedis) with one shared across nodes. Register your own with services.AddSingleton<ITournamentStore, MyStore>() before AddCrossplayTournaments (TryAdd seam).

  • IReadOnlyList<TournamentRecord> All()

    Every tournament (for the browse list).

  • TournamentRecord Get(long id)

    Loads one tournament by id, or null when the id is unknown.

  • long NextId()

    Atomically returns the next tournament id (starts at 1).

  • void Save(TournamentRecord record)

    Inserts or replaces a tournament's full record (called after each mutation).

  • bool TryRemove(long id)

    Drops one tournament, reporting whether it is gone. Backs both the explicit delete path and the expiry sweep. The default is a no-op that reports failure so stores written before the sweep existed keep compiling — but a store that cannot remove keeps every record it was ever handed, which is precisely the unbounded growth the sweep exists to bound: implement it.

Services you call 2

Crossplay implements these. Resolve them from DI and call them from your own systems.

ITournamentClock

Wall-clock seam (unix UTC ms) so retention math is testable. Piece-local on purpose.

  • long UtcNowMs { get; }

    The current time as unix-epoch milliseconds (UTC).

ITournamentService

Single-elimination tournaments — the server API GAME CODE calls (resolve it from DI: [Inject] ITournamentService tournaments). Create/join ride the wire (a player registers the selected character); TryReportResult is SERVER game-code only (no wire mutation path — a match winner can never be client-forged), and advances the bracket, crowning a champion when the final resolves. Transport-free: it raises Changed / Championed and the handler pushes to online participants.

  • IReadOnlyList<TournamentRecord> All()

    Every tournament (for the browse list).

  • event Action<long, long, byte[]> Championed

    Raised once when a champion is crowned: (tournamentId, championCharacterId, rewardBlob — the opaque payload from Grant).

  • event Action<long> Changed

    Raised when a bracket changes (created/joined/seeded/advanced): the tournament id. The handler subscribes to push a fresh bracket to participants; game code may too.

  • TournamentRecord Get(long tournamentId)

    The full record of one tournament, or null when the id is unknown.

  • int PurgeExpired()

    Removes every tournament past its configured retention (see CompletedRetentionMinutes, StaleRegistrationRetentionMinutes, RunningRetentionMinutes). Runs automatically — throttled by PurgeIntervalSeconds — ahead of each create, so records cannot accumulate forever without a timer or a scheduler dependency; call it yourself to sweep on your own cadence.

  • bool TryCreate(string name, byte size, out long tournamentId, out ushort reason)

    Creates a new tournament in the Registering state (no participants yet). The TRUSTED server-side entry point — it performs no identity check, so never hand it a request that came off the wire; use the TryCreate overload for that. The MaxOpenTournaments ceiling still applies here.

  • bool TryCreate(ISession session, string name, byte size, out long tournamentId, out ushort reason)

    Creates a tournament ON BEHALF OF a player — the overload every wire path must use. It resolves the caller the same way TryJoin does (authenticated session + selected character) and refuses anything else, then charges the new record to that account for the per-account ceiling.

  • bool TryDelete(long tournamentId, out ushort reason)

    Removes a tournament outright, at any point in its lifecycle. Server game-code only — there is deliberately no wire path, so a player can never delete someone else's bracket.

  • bool TryJoin(ISession session, long tournamentId, out ushort reason)

    Registers the session's selected character in an open bracket (auto-seeds when it fills).

  • bool TryReportEventResult(long tournamentId, int eventIndex, IReadOnlyList<long> rankedEntrantIds, out ushort reason)

    Records one N-entrant EVENT's full ranked finishing order and advances the schedule, crowning the points champion when the format's rules are satisfied. Server game-code only — unforgeable, there is no client wire path to this (the exact TryReportResult convention). Only meaningful when the configured format is an ITournamentEventFormat (e.g. EventSeriesFormat); on a pairwise tournament it refuses with UnknownTournament.

  • bool TryReportResult(long tournamentId, int matchIndex, long winnerCharacterId, out ushort reason)

    Records a match winner and advances the bracket, crowning a champion when the final resolves. Server game-code only — unforgeable, there is no client wire path to this.

  • bool TryStart(long tournamentId, out ushort reason)

    Seeds the bracket now (byes fill empty slots) instead of waiting for it to fill. Server game-code only.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

TournamentOptions

Tournaments tuning (defaults here, never inline).

  • bool AutoStartWhenFull { get; set; }

    Seed the bracket automatically once registration reaches the bracket size.

  • double CompletedRetentionMinutes { get; set; }

    Minutes a Complete tournament is retained before the expiry sweep removes it. Finished brackets are history, not state — keeping them forever is the slow half of the same growth problem. 0 keeps completed records indefinitely.

  • double[] EventPlacementPoints { get; set; }

    Placement→points table for EVENT-based (N-entrant) formats: index 0 is the points the event winner earns, index 1 the runner-up, and so on; placements past the end of the table score 0. Only consulted by the default PlacementPointsTournamentScoring — a game that registers its own ITournamentScoring replaces this table entirely. Pairwise (match-based) formats never read it.

  • int MaxListedTournaments { get; set; }

    Max rows returned by the browse list (newest first). The list is an unbounded query over every stored tournament, so the reply size must be capped rather than tracking the store's row count. 0 returns no rows — the browse list is a cap, never an "unlimited" switch.

  • int MaxNameLength { get; set; }

    Max name length accepted on create (longer is trimmed).

  • int MaxOpenTournaments { get; set; }

    Ceiling on tournaments that are not yet Complete; create is refused past it with TooManyTournaments. Every create allocates a record AND a store write, so without a ceiling a client that can reach the game port turns "create" into unbounded storage growth that survives restarts and replicates across the cluster. 0 disables the ceiling (only sane on a server where create is not reachable from the wire).

  • int MaxOpenTournamentsPerAccount { get; set; }

    Ceiling on not-yet-Complete tournaments attributable to ONE account. Bounds the damage a single logged-in player can do: without it one account can consume MaxOpenTournaments by itself and deny creation to everyone else. 0 disables the per-account ceiling (the global one still applies).

  • int MaxSize { get; set; }

    Largest allowed bracket size (a power of two).

  • int MinSize { get; set; }

    Smallest allowed bracket size (a power of two).

  • double PurgeIntervalSeconds { get; set; }

    Minimum seconds between two expiry sweeps. The sweep is driven lazily off create (no timer, no scheduler dependency) and walks every stored record, so a create storm must not turn into a store-query storm. 0 sweeps on every create.

  • double RunningRetentionMinutes { get; set; }

    Minutes a Running tournament is retained before the expiry sweep removes it. Defaults to 0 (never) because only the game knows how long its matches legitimately take — a sweep that deletes a live bracket is worse than one that leaks a record. Set it when your matches have a known upper bound.

  • double StaleRegistrationRetentionMinutes { get; set; }

    Minutes a still-Registering tournament is retained before the expiry sweep removes it. This is the abandoned-bracket case — created, never filled, never started — and the one an attacker (or a bored player) leaves behind occupying MaxOpenTournaments. 0 keeps them indefinitely.

Wire messages 10

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

3901 TournamentCreateRequest

Client → server: create a single-elimination tournament. The reply is a TournamentCreateResponse.

3902 TournamentCreateResponse

Server → client: the create verdict (reply to TournamentCreateRequest).

3903 TournamentJoinRequest

Client → server: register the selected character in a tournament. The reply is a TournamentJoinResponse.

3904 TournamentJoinResponse

Server → client: the join verdict (reply to TournamentJoinRequest).

3905 TournamentListRequest

Client → server: list open/running tournaments. Carries no fields; the reply is a TournamentList.

3906 TournamentList

Server → client: the tournament list (reply to TournamentListRequest).

3907 TournamentBracketRequest

Client → server: request the full bracket of one tournament. The reply is a TournamentBracket.

3908 TournamentBracket

Server → client: the full bracket (matches + state + champion). Sent as the reply to a TournamentBracketRequest and, unsolicited, alongside each TournamentUpdated push.

3909 TournamentUpdated

Server → client: pushed to a tournament's participants when its bracket changes (created/joined/ seeded/advanced). A fresh TournamentBracket rides along so the client need not round-trip.

3910 TournamentChampion

Server → client: pushed to participants when a tournament crowns its champion. RewardData is the game grantor's opaque blob.

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

ITournamentClient

The Tournaments piece, client side: create/join single-elimination brackets, browse and query bracket state, and receive live bracket-update + champion pushes. Match results are reported by the game's SERVER code (never the client), so this client only reads the bracket and acts on updates. Character ids and reward blobs are the GAME's vocabulary — this client never interprets them.

  • UniTask<TournamentCreateResponse> CreateAsync(string name, byte size, CancellationToken ct = default)

    Creates a single-elimination tournament of size (a power of two).

  • UniTask<TournamentJoinResponse> JoinAsync(long tournamentId, CancellationToken ct = default)

    Registers the selected character in a tournament.

  • UniTask<TournamentList> ListAsync(CancellationToken ct = default)

    Lists open/running tournaments.

  • UniTask<TournamentBracket> BracketAsync(long tournamentId, CancellationToken ct = default)

    Fetches the full bracket of one tournament.

  • event Action<TournamentUpdated> Updated

    Raised when a joined tournament's bracket changes (the fresh bracket follows via BracketChanged).

  • event Action<TournamentBracket> BracketChanged

    Raised with the fresh bracket whenever the server pushes an update.

  • event Action<TournamentChampion> Championed

    Raised when a joined tournament crowns its champion (with the game's opaque reward).

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayTournamentsView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.