matches

Matches

Match lifecycle (countdown/duration/return-zone) with results bridged to Leaderboards.

Category Meta Seams 2 Services 1 Options 1 Wire ids 4 Unity types 2
Server
services.AddCrossplayMatches();
Unity package
com.crossplay.matches
Depends on
world

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.

Example
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 OnRejoined(MatchContext match, MatchParticipant participant)

    A disconnected participant's account reconnected and TryRejoin re-bound the new session — it has already been teleported back into the match zone and sent a fresh MatchState (so anything the rules push here arrives after the lifecycle state). Override to restore game-specific state the rejoiner missed: re-push scores or objectives, re-grant a loadout, reposition the avatar. Default: no-op — existing rules keep compiling and behaving unchanged.

  • 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[]> MatchEnded

    Raised 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> MatchStarted

    Raised 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: isolates them in a fresh instanced zone and runs countdown → running → ended.

  • bool TryRejoin(ISession session, out ushort reason)

    Re-binds a RECONNECTED player to the live match their old session dropped out of: finds the match holding a participant keyed to this session's ACCOUNT whose session died, re-binds the new session, teleports the avatar back into the match's instanced zone (the same authored-spawn path StartMatch used), pushes an immediate MatchState, and then calls OnRejoined so the game's rules can restore game-specific state. Server game-code only — the game's glue drives it after the returning player is back in the world (logged in, character selected, avatar entered — the same preconditions StartMatch has), the same way it drives StartMatch.

  • bool TrySpectate(ISession session, long matchId, out ushort reason)

    Admits a NON-participant as an invisible World spectator inside a live match's instanced zone, positioned at the zone's reference (spawn) point: the spectator receives every broadcast in view plus this match's MatchState pushes (one immediately), the participant list is untouched, and the piece cleans the spectator up on match end (evicted from the zone, MatchEnded results included) and on disconnect. Gated by AllowSpectators (off by default) and budgeted by MaxSpectatorsPerMatch. Panning the view afterwards is World's standard spectator wire (SpectatorMove); the camera itself is the game's.

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).

  • bool AllowSpectators { get; set; }

    Spectator gate: when true, TrySpectate admits NON-participants into a match's instanced zone as invisible World spectators (they receive every broadcast in view plus the match's MatchState pushes; the participant list is untouched). Off by default: the spectator-less v1 behavior, byte for byte.

  • 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.

  • int MaxSpectatorsPerMatch { get; set; }

    Most spectators one match will admit (0 = unlimited). Each spectator costs interest bookkeeping and receives every broadcast in view, so the admission is budgeted.

  • 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.

2201 MatchState

Server → participants: where the match stands. Data is opaque and game-defined (teams, mode, map seed…) — the framework never interprets it.

2202 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.

2203 MatchEnded

Server → participants: the match is over — ranked results, best first.

2204 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 State

    The latest lifecycle state (null until a match involves you).

  • event Action<MatchState> StateChanged

    A lifecycle push arrived.

  • event Action<MatchEnded> Ended

    Your 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.