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).
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> EliminationChangedRaised when an account is eliminated (true) or restored (false).
-
event Action<RoundContext> EndedRaised 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> PhaseChangedRaised 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.
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.
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.
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 CurrentThe 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> StateChangedRaised on every state push (transitions + the periodic countdown refresh).
-
event Action<RoundEliminations> EliminationsChangedRaised when a scope's elimination set changes (full snapshot).
-
event Action<RoundEnded> EndedRaised 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).