Matches
Match lifecycle (countdown/duration/return-zone) with results bridged to Leaderboards.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayMatches(); and it wins.
IMatchResultSink game SPI
Where final results go besides the participants — the composition layer bridges this to Leaderboards when both pieces are present (Matches itself never references another piece).
-
void Report(MatchContext match, MatchResult[] results)Reports a finished match's ranked results (called once, at teardown, after the MatchEnded push). Implement to persist results / feed a leaderboard; the default (NullMatchResultSink) does nothing.
IMatchRules game SPI
SPI the game implements to give matches their meaning — scoring, win conditions, teams — the plug-in point of this piece. The framework owns the lifecycle (instanced zone, countdown, clock, results, teleports); the rules own everything the wire calls "opaque". Default (DefaultMatchRules): events are ignored, matches run their clock out. How to implement. Register your rules in DI BEFORE AddCrossplayMatches (it is a TryAdd seam). Score by mutating Score (the piece ranks by it and the highest wins). Trust nothing from OnClientEvent raw — validate it. Return true from OnTick to end early (a team hit the target score); otherwise the clock ends it.
public sealed class CoinRaceRules : IMatchRules
{
public void OnStarted(MatchContext m) { }
public void OnClientEvent(MatchContext m, MatchParticipant p, byte[] data)
{
if (data.Length == 1 && data[0] == CollectedCoin) p.Score += 1; // validated + scored
}
public bool OnTick(MatchContext m, float dt)
{
foreach (var p in m.Participants) if (p.Score >= 10) return true; // first to 10 ends it
return false;
}
} -
void OnClientEvent(MatchContext match, MatchParticipant participant, byte[] data)A participant sent an opaque MatchEvent — validate and score it (or don't).
-
void OnStarted(MatchContext match)The countdown finished — the match is live. Seed initial state / spawn NPCs here.
-
bool OnTick(MatchContext match, float deltaSeconds)Called every server tick while running.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IMatchService
Server-authoritative match lifecycle over instanced zones: the GAME hands in a group (from a lobby, a queue, or anything else), the piece isolates them in a fresh zone, runs countdown → running → ended, feeds the rules, publishes ranked results, and returns everyone.
-
event Action<MatchContext, MatchResult[]> MatchEndedRaised after a match ends (results already pushed to clients + reported to the sink): (context, ranked results). Hook this for post-match game logic.
-
event Action<MatchContext> MatchStartedRaised when a match's countdown completes and play begins (the rules' OnStarted has already run). Hook this to place match-start content — the symmetric sibling of MatchEnded.
-
long StartMatch(IReadOnlyList<ISession> participants, byte[] data = null)Starts a match for these sessions (spectator-less v1): isolates them in a fresh instanced zone and runs countdown → running → ended.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
MatchOptions
Configurable match parameters (defaults here; never inlined in logic).
-
float CountdownSeconds { get; set; }Seconds of countdown before the match runs.
-
string LeaderboardBoard { get; set; }Leaderboard board results are reported to by the Hosting bridge (empty = don't).
-
float MatchDurationSeconds { get; set; }Match length in seconds (0 = until the rules end it).
-
int MaxEventBytes { get; set; }Largest opaque MatchEvent payload accepted.
-
float ReadyTimeoutSeconds { get; set; }Longest extra wait (seconds) past the countdown for stragglers' ready signals before the match starts anyway (a crashed loader must never hold four players hostage).
-
bool RequireReadyToStart { get; set; }Ready-gate: when true, the countdown does not hand over to Running until every still-connected participant has sent MatchReady (their scene finished loading), or ReadyTimeoutSeconds of extra waiting elapses — whichever comes first. Off by default: the classic fixed countdown, byte for byte.
-
string ReturnZone { get; set; }Zone participants return to when the match ends (empty = the world's default zone).
-
float StatePushIntervalSeconds { get; set; }Seconds between periodic MatchState pushes while running.
Wire messages 4
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
MatchState Server → participants: where the match stands. Data is opaque and game-defined (teams, mode, map seed…) — the framework never interprets it.
MatchEvent Client → server: an opaque in-match event ("collected coin 7", "hit checkpoint") — the game's IMatchRules decides what it means and whether it scores. Never trusted raw.
MatchEnded Server → participants: the match is over — ranked results, best first.
MatchReady Client → server: "I finished loading — start when everyone has." Sent once by each participant after their game scene is up (see IMatchClient.SetReady). Only meaningful during the countdown of a match whose server enabled MatchOptions.RequireReadyToStart; otherwise dropped harmlessly, so a client may always send it.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IMatchClient
The Matches piece, client side: the server teleports you into the match instance and pushes the lifecycle — you observe StateChanged (countdown/running/ended + the game's opaque data), send opaque events for the server rules to interpret, and receive ranked Ended results. No UI, no assumptions about what a match looks like.
-
MatchState StateThe latest lifecycle state (null until a match involves you).
-
event Action<MatchState> StateChangedA lifecycle push arrived.
-
event Action<MatchEnded> EndedYour match ended — ranked results, best first.
-
void SendEvent(byte[] data)Sends an opaque in-match event ("collected coin 7") for the server rules to score.
-
void SetReady()Tells the server this player finished loading (the countdown ready-gate). Call once your game scene is up; on servers without RequireReadyToStart it is dropped harmlessly, so calling unconditionally is always safe.
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.
ICrossplayMatchView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.