Handoff and Topologies (Protocol v3)¶
How a Signal Fish room moves from the universal relay floor to a peer-to-peer topology at lobby finalization, what the three topologies mean, and how every plan falls back to the floor. This is the server-side counterpart to the Transport Fallback Contract, which covers the client-side state machine; this document focuses on the finalization handoff seam and the topology shapes. For the wire-level message details see the Protocol v3 additions section.
The three topologies¶
A negotiated v3 room resolves to exactly one topology for its lifetime:
- Relay — the v2 server-relay hub, and the universal floor. Every client
supports it;
GameDatais fanned out through the server. A relay room sends every v3 member an explicit no-peerrelay/relaySessionPlan; v2 members receive no plan and its authoritative game-data path stays v2-compatible. - Host — a star around one elected authoritative peer (the host). Each client
connects only to the host; clients never connect to each other. Used with the
webrtcordirecttransport. - Mesh — full mesh: every peer connects to every other peer. Used with the
webrtctransport.
Topology is one axis; the data-path transport (relay, direct, webrtc)
is the other. Only four pairs are legal — the three upgrade rungs plus the floor:
The finalization handoff seam¶
The handoff happens at lobby finalization — when a member sends StartGame while
every current player is ready, and the server broadcasts the unchanged
GameStarting. The sequence per recipient is strictly GameStarting
then SessionPlan:
- Finalize. The coordinator finalizes the room and broadcasts
GameStartingto every member, exactly as in v2. - Select a plan. The server computes a single room-wide plan by walking the
richest-first ladder, settling on the first rung that fits the per-game desired
ceiling, has its transport enabled, and is supported by every member.
host + directalso requires at least one endpoint-ready host; without one, that rung is skipped. Otherwise it settles on the relay floor. This is the single source of truth insrc/server/session_policy.rs(UPGRADE_LADDER+RELAY_FLOOR). - Elect a host (host topology only). Prefer the room's designated authority when it can execute the selected plan; otherwise the earliest eligible joiner, breaking ties by the smaller UUID for determinism. Direct hosts must have a validated self-declared endpoint.
- Emit per-recipient
SessionPlans. Each v3 member receives a plan tailored to it — one shared publicationgeneration, its ownpeerslist, per-recipientinitiateflags, and ICE servers only when the selected transport is WebRTC. A Direct plan instead repeats the elected host's validateddirect_endpoint. A relay-floor plan has no host, endpoint, peers, or ICE and explicitly resets stale P2P state. - Record the decision. A non-relay decision is stored as the room's active session plan (topology, transport, host) — the single source of truth for the session the room is running. Late joins, reconnects, and departures consult this stored decision; the ladder is never re-run mid-session.
all ready
|
v
finalize ──> broadcast GameStarting (v2, unchanged)
|
v
choose_session_plan(members, config) # ladder walk; all-members-v3 gate
|
+-- for each v3 member:
build per-recipient peers + initiate flags
if transport == webrtc:
build per-recipient ICE servers (STUN + minted TURN)
else if relay floor:
use empty host/peers/ICE as an authoritative reset
send SessionPlan (after GameStarting, ordering preserved)
The all-members-v3 gate is the back-compat invariant for P2P upgrades: a single
v2 or relay-only member forces the room plan to the relay floor. The v3 members
receive the explicit relay reset; every v3-only message still has a
negotiated-v3 recipient gate, so it can never reach a v2 client. Signal itself
is same-room plus WebRTC-transport gated.
Per-recipient peer lists¶
The same room produces a different plan for each recipient:
- Mesh. Each recipient's
peersis every other member.initiatefollows the glare rule — the lesser UUID offers — so exactly one side of each pair sends the offer. - Host. The host's
peersis every client (eachinitiate = false— the host answers all). Each client'speersis just the host (initiate = true— clients offer to the host). Clients never appear in each other's peer lists. - Relay. Every v3 recipient gets the same empty-peer
relay/relayreset.
Late join and reconnect¶
A peer joining or reconnecting after finalization is brought into the session
the room is actually running. A non-relay active plan is rehydrated over the
current members, never selected again from the ladder; absence of a sticky plan
derives the explicit relay floor. Every current v3 member receives a complete,
tailored SessionPlan: current peers, glare-correct initiate flags, the stored
host, and freshly minted ICE for WebRTC. A new joiner that cannot run the
session's sticky pair is rejected at admission with ROOM_SESSION_INCOMPATIBLE
(a running session must not split its data path); an incumbent that
reconnects with downgraded capabilities keeps its seat, receives an empty-peer
plan, and participates through relay. Protocol-v2
members receive only their frozen lifecycle traffic.
The full refresh replaces the old additive NewPeer membership delta. It gives
incumbents and the joining actor one authoritative peer set and a shared new
generation. Clients rebuild even retained WebRTC links with the refreshed ICE
credentials and reject delayed signals from the prior generation. See the
late-join decision table.
ICE can also arrive before any plan: an eligible v3 client — one that
negotiated the WebRTC transport and the game's desired topology — joining (or
reconnecting into) a non-finalized room of a non-relay-desired game receives the
same composed ICE list on its RoomJoined / Reconnected payload (the
ICE pre-gather), so candidate gathering can
start during the lobby wait. A client that negotiated only a rung below the
desired one forfeits the head start — the finalize-time SessionPlan still
delivers its ICE if the whole room settles there. The SessionPlan ICE always
supersedes it; joins and reconnects into a Finalized room never pre-gather —
their fresh ICE is the late-join SessionPlan's job, exactly as described
above.
Mid-session re-planning (host failover and self-heal)¶
Topology and transport are sticky for the session lifetime: the ladder runs
once at finalization and is never re-run mid-session, even though the capability
intersection can only widen as members depart — a mid-game data-path migration
would disrupt gameplay for zero correctness gain. Only an invalidated plan
parameter triggers a re-emission: a host-topology session whose stored
host can no longer anchor the session — no longer a member, or (after a
reconnect that downgraded its negotiated capabilities) seated but no longer
capable of the session's topology/transport. Both membership-touching events
check for it:
- A departure leaves the stored host invalid (the usual case: the host
itself departed; also any later departure after a wedged entry — e.g. a
re-plan skipped by a transient storage error, or a host whose reconnect
downgraded its capabilities). The server re-elects a host
over the remaining members and sends every remaining v3 member a fresh
per-recipient
SessionPlan— same topology and transport, newhost, fresh per-recipient ICE for WebRTC. The departure itself is still signaled byPlayerLeft; v3 adds the terminal delivery watermark while the v2 projection stays frozen. An ex-host that later reconnects is paired as a client of the re-elected host. - A late join / reconnect finds the stored host invalid. The same
re-election + full re-plan runs first (one re-plan event), delivering every
current v3 member — the joiner included, even one that cannot run the
session itself (the heal is about the room; such a joiner's plan carries
empty
peers) — a fresh plan. A normal late join with the host present and capable refreshes membership-derived plan fields without counting a host re-plan.
Re-election is execution-aware: only members that negotiated v3 plus the stored sticky (topology, transport) pair are electable, and a Direct candidate must still expose a validated endpoint — a member that cannot run the session (which can even hold authority) is never named host of a session it cannot run. The authority preference passes the same filter; among qualifying members the rule is unchanged (authority preferred, else earliest joiner, smaller-UUID tie-break). If no member qualifies, the stored plan is removed in the same transaction that publishes an explicit Relay/Relay reset to every remaining v3 member. The reset clears the host, endpoint, peers, and ICE servers with a fresh generation. It does not count as a host re-plan or mint TURN credentials. The room stays on the relay floor. Direct endpoints are repeated on full plan refreshes. A running Direct host endpoint change immediately publishes a fresh shared generation. Endpoint loss uses the same election or Relay reset. Duplicate endpoints and non-host metadata do not refresh plans. A room finalized to relay is not upgraded mid-game. Metadata and the plan change commit only after exact-member delivery reservations succeed; a rejected update leaves the old metadata intact and returns an error so the host can retry.
Re-issued and late-join plan peer lists use the session-capability predicate:
peers[] names only members that negotiated v3 plus the session's topology and
transport. New seat-fills cannot weaken this shape: a joiner that cannot run
the stored non-relay session is rejected at admission with
ROOM_SESSION_INCOMPATIBLE (issue #421) rather than seated on a split data
path. An incumbent reconnecting with downgraded capabilities keeps its seat,
receives its plan with an
empty peers list — it participates via the relay floor, with host kept as
elected, informational — never appears in other members' lists, and every
publication observes it (mixed_path_members_observed + a warn log). At
finalization the filter is vacuous for non-relay plans, because an upgrade is
selected only when every member supports it. Host election uses a deliberately
stricter predicate: for Direct, the candidate must additionally have a
validated endpoint. Non-host Direct clients need no endpoint.
- Any other departure (a mesh member, a host-topology client while the host
remains) re-emits nothing:
PlayerLeftalready tells peers to prune the departed member, and no plan parameter changed. - The last member departs (or the room is cleaned up): the stored plan is dropped.
The client contract stays uniform: the latest SessionPlan wins — on a
changed generation, rebuild retained WebRTC pairs, reject other-generation
signals, and connect per peers[].initiate. Direct adapters must replace the
old socket and endpoint on each fresh generation, even when peer IDs match.
Fallback to the floor¶
Every non-relay plan carries fallback: "relay". P2P status never disables the
relay floor: the server keeps accepting GameData regardless of any peer's
reported state (TransportStatus { connected: false }). A client that cannot
establish -- or loses -- its P2P path can fall back without renegotiation. The
physical WebSocket can still close loudly when its delivery contract fails. The
full client-side state machine, including timeouts and the
TransportStatus signaling, lives in the
Transport Fallback Contract.
See also¶
- Transport Fallback Contract — client-side state machine and the relay-floor guarantee.
- Protocol v3 additions — the wire messages
(
Signal,SessionPlan,TransportStatus,PeerTransportStatus), the selection ladder, the glare rule, and ICE/TURN. - TURN and STUN configuration — enabling ephemeral TURN credentials.
- Platform Integration Guide — which WebRTC stack to use per platform and the cross-stack interop traps.
- ADR-0001: Protocol v3 two-axis design.