Tournaments
Single-elimination brackets: winners reported by server code (never client-trusted); join policy + champion reward are game SPIs.
Seams you implement 3
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayTournaments(); and it wins.
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).
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).
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).
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).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
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[]> ChampionedRaised once when a champion is crowned: (tournamentId, championCharacterId, rewardBlob — the opaque payload from Grant).
-
event Action<long> ChangedRaised 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.
-
bool TryCreate(string name, byte size, out long tournamentId, out ushort reason)Creates a new tournament in the Registering state (no participants yet).
-
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 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.
-
int MaxNameLength { get; set; }Max name length accepted on create (longer is trimmed).
-
int MaxSize { get; set; }Largest allowed bracket size (a power of two).
-
int MinSize { get; set; }Smallest allowed bracket size (a power of two).
Wire messages 10
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
TournamentCreateRequest Client → server: create a single-elimination tournament. The reply is a TournamentCreateResponse.
TournamentCreateResponse Server → client: the create verdict (reply to TournamentCreateRequest).
TournamentJoinRequest Client → server: register the selected character in a tournament. The reply is a TournamentJoinResponse.
TournamentJoinResponse Server → client: the join verdict (reply to TournamentJoinRequest).
TournamentListRequest Client → server: list open/running tournaments. Carries no fields; the reply is a TournamentList.
TournamentList Server → client: the tournament list (reply to TournamentListRequest).
TournamentBracketRequest Client → server: request the full bracket of one tournament. The reply is a TournamentBracket.
TournamentBracket Server → client: the full bracket (matches + state + champion). Sent as the reply to a TournamentBracketRequest and, unsolicited, alongside each TournamentUpdated push.
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.
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> UpdatedRaised when a joined tournament's bracket changes (the fresh bracket follows via BracketChanged).
-
event Action<TournamentBracket> BracketChangedRaised with the fresh bracket whenever the server pushes an update.
-
event Action<TournamentChampion> ChampionedRaised 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.