Meta

Rounds

Server-authoritative phase/round state machine per game-defined scope: opaque phase ids, per-phase deadlines, IRoundFlow SPI transitions, and an authoritative elimination set. No client verbs (unforgeable).

Server
services.AddCrossplayRounds();
Unity package
com.crossplay.rounds
Depends on
core

Seams you implement 1

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

IRoundFlow game SPI

THE plug-in point of the Rounds piece: the game's phase graph. Called on the server tick whenever the current phase's deadline expires; the returned RoundTransition is what happens next. Event-driven transitions (bomb planted, all tasks done, one side wiped) don't go through this — the game calls TryAdvance directly when its own logic decides.

// warmup(15s) → play(180s) → intermission(10s) → play … until round 5, then end:
public RoundTransition OnDeadline(RoundContext ctx) => ctx.PhaseId switch
{
    Warmup       => RoundTransition.To(Play, 180f),
    Play         => ctx.Round >= 5 ? RoundTransition.End()
                                   : RoundTransition.To(Intermission, 10f),
    Intermission => RoundTransition.To(Play, 180f, round: (ushort)(ctx.Round + 1)),
    _            => RoundTransition.End(),
};
  • RoundTransition OnDeadline(RoundContext ctx)

    Decides the transition when ctx's phase deadline expires.

Services you call 1

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

IRoundService

Server-authoritative phase/round state machine: opaque phase ids, per-phase deadlines, transitions via the game's IRoundFlow, and an authoritative elimination set — keyed by a game-supplied scope id the piece never interprets. This is the piece that owns "warmup → round 1 → intermission → round 2" or "tasks → meeting → vote → eject" on the server; it deliberately owns NO zones, NO teleports, and NO scores (Matches/Dungeons/the game do).

  • RoundResult ClearEliminations(long scopeId)

    Empties the elimination set (a new round in a round-based eliminator) and pushes the fresh snapshot when it changed anything.

  • RoundContext ContextOf(long scopeId)

    The live context of the scope's flow, or null when none runs. Read it synchronously; never cache it past the current callback.

  • event Action<RoundContext, long, bool> EliminationChanged

    Raised when an account is eliminated (true) or restored (false).

  • event Action<RoundContext> Ended

    Raised when a flow ends (explicitly, by the flow SPI, or because every participant went offline). The context is still fully readable inside the handler.

  • bool IsEliminated(long scopeId, long accountId)

    True when the account is currently eliminated in the scope's flow.

  • event Action<RoundContext, ushort, ushort> PhaseChanged

    Raised after every phase transition: (context, fromPhaseId, toPhaseId). The start transition reports fromPhaseId 0. The server-side glue seam — grant Economy rewards between rounds, start the next Match, open a Voting ballot… all without piece-to-piece references.

  • RoundResult TryAdvance(long scopeId, ushort phaseId, float seconds, ushort round = 0, byte[] data = null)

    Forces a transition NOW — the event-driven path (bomb planted, all tasks done, one side wiped). Resets the deadline to seconds (0 = untimed) and pushes the new state immediately.

  • RoundResult TryEliminate(long scopeId, long accountId)

    Adds a participant to the authoritative elimination set and pushes the fresh RoundEliminations snapshot. Idempotent (an already-eliminated account is Ok, no push). What elimination MEANS (spectate, ghost, bench) is the game's.

  • RoundResult TryEnd(long scopeId)

    Ends the flow: broadcasts RoundEnded, fires Ended, forgets the state.

  • RoundResult TryRestore(long scopeId, long accountId)

    Removes a participant from the elimination set (a revive / un-eject) and pushes the fresh snapshot. Idempotent.

  • RoundResult TryStart(long scopeId, IReadOnlyList<ISession> participants, ushort phaseId, float seconds, ushort round = 1, byte[] data = null, string scheduleName = null)

    Starts a flow for scopeId with the given participants, entering phaseId with a seconds deadline (0 = untimed). Pushes the initial RoundState immediately.

Configuration 1

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

RoundOptions

Configurable round-flow parameters, passed to AddCrossplayRounds. Defaults live here, never inline in the service (the no-hardcoded-values rule).

  • bool EndWhenEmpty { get; set; }

    When true (the default) a flow whose participants have ALL gone offline is ended automatically (checked on the push cadence) — no leaked timers for abandoned matches. Individual disconnects never end a flow; only a fully-empty one.

  • bool LoopSchedule { get; set; }

    Whether the PhaseSchedule LOOPS. When true (the default) completing the last phase wraps to the first entry with the round counter incremented — an endless cycle (rounds-based PvP, survival waves). When false the schedule is TERMINAL: completing the last phase ENDS the flow (RoundEnded + the Ended event) instead of wrapping — a finite campaign of rooms/levels that can be beaten. A composed match ends with it (the match policy ends the match when its round flow ends), so this is how "clear the final room → win" is expressed. Applies to both timed phases (via SchedulePhaseFlow) and UntilCleared phases (via the composition bridge that advances them).

  • int MaxDataBytes { get; set; }

    Maximum size of the opaque phase Data payload, in bytes. Larger payloads are refused (DataTooLarge) on start/advance and dropped with a warning when returned by an IRoundFlow transition.

  • int MaxParticipants { get; set; }

    Maximum participants per flow; 0 means unlimited.

  • List<PhaseDefinition> PhaseSchedule { get; set; }

    An OPTIONAL declarative phase cycle. Phase ids are opaque; when the list is non-empty, AddCrossplayRounds TryAdds the shipped SchedulePhaseFlow as the IRoundFlow: a timed phase advances to the next entry on its deadline, wrapping to the first entry increments the round (cycle) counter, and an UntilCleared phase is untimed (Seconds ignored, deadline 0) — something outside this piece advances it (e.g. a composition bridge watching a population). A game-registered IRoundFlow still wins the seam.

  • Dictionary<string, List<PhaseDefinition>> Schedules { get; set; }

    OPTIONAL named phase cycles for per-instance selection (a level/world select): a scope can run a NAMED schedule instead of the single global PhaseSchedule — the flow is started with a schedule name (e.g. the match carries "w1l1" chosen at queue time) and both the timed advance (SchedulePhaseFlow) and the composition bridge that advances until-cleared phases walk THAT scope's schedule. Empty (the default) = only the global schedule exists.

  • float StatePushIntervalSeconds { get; set; }

    Seconds between periodic RoundState pushes while a flow runs. The interval push keeps countdown displays honest and re-syncs reconnectors (state is resolved per account at send time, so a returning session is at most one interval away from current state).

Wire messages 3

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

4801 RoundState

Server → participants: the authoritative phase/round state of a flow. Pushed immediately on every transition and on a fixed interval while the flow runs (so late joiners and reconnectors are always one push away from current state, and countdown displays stay honest). PhaseId is an abstract, game-defined id — 7 is "meeting" in one game and "storm circle 3" in another; the framework never knows. Clients render it however they like: a HUD banner, a text line, a timer bar. Reconnect note: a running flow is keyed by account id and is connection-independent — a participant is fixed at start and is NOT evicted by a link drop; the flow keeps running and re-pushes this state on the interval, so after a reconnect the client cache self-heals within one interval (this is distinct from the World piece's spatial/interest state, which does not survive session eviction and must be re-entered on IReconnectEvents.Resumed). The one residual: a flow that ENDS entirely during the gap is not replayed — reconcile that via the round-ended event, not a stale cached state.

4802 RoundEliminations

Server → participants: the flow's current elimination set, as a full snapshot (idempotent — a missed push heals on the next one). What "eliminated" means (spectate, ghost, bench, out of the race) is entirely the game's; the framework only tracks and broadcasts the authoritative set.

4803 RoundEnded

Server → participants: the flow ended (explicitly by the game, by the game's IRoundFlow returning an end transition, or because every participant went offline). Carries the final phase/round/data so clients can render an outro without having cached anything.

Unity services you inject 1

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

IRoundClient

Data-layer rounds service (no UI): the server's phase/round pushes as events + cached snapshots. Read-only by design — phases and eliminations are unforgeable, so there is no request API at all; a game reacts to StateChanged ("meeting started → open my vote UI") and renders. Self-registers under the Crossplay client context.

  • RoundState Current

    The most recently pushed state across all scopes, or null before any push (a client is normally in one flow at a time; use StateOf for multi-scope games).

  • RoundState StateOf(long scopeId)

    The last state pushed for a specific scope, or null (also null after its flow ended).

  • bool IsEliminated(long scopeId, long accountId)

    True when the account is in the scope's current elimination set.

  • event Action<RoundState> StateChanged

    Raised on every state push (transitions + the periodic countdown refresh).

  • event Action<RoundEliminations> EliminationsChanged

    Raised when a scope's elimination set changes (full snapshot).

  • event Action<RoundEnded> Ended

    Raised when a flow ends (its cached state/eliminations are dropped first).

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.

ICrossplayRoundsView

Narrow contract the presenter drives. UI-only: render calls in (no intents — the Rounds piece is read-only by design).