Meta

Matches

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

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.

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[]> 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 (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.

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.