Skip to content

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; GameData is fanned out through the server. A relay room sends every v3 member an explicit no-peer relay/relay SessionPlan; 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 webrtc or direct transport.
  • Mesh — full mesh: every peer connects to every other peer. Used with the webrtc transport.

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:

Text Only
mesh + webrtc      ← richest
host + webrtc
host + direct
relay (floor)      ← always available

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:

  1. Finalize. The coordinator finalizes the room and broadcasts GameStarting to every member, exactly as in v2.
  2. 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 + direct also 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 in src/server/session_policy.rs (UPGRADE_LADDER + RELAY_FLOOR).
  3. 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.
  4. Emit per-recipient SessionPlans. Each v3 member receives a plan tailored to it — one shared publication generation, its own peers list, per-recipient initiate flags, and ICE servers only when the selected transport is WebRTC. A Direct plan instead repeats the elected host's validated direct_endpoint. A relay-floor plan has no host, endpoint, peers, or ICE and explicitly resets stale P2P state.
  5. 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.
Text Only
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 peers is every other member. initiate follows the glare rule — the lesser UUID offers — so exactly one side of each pair sends the offer.
  • Host. The host's peers is every client (each initiate = false — the host answers all). Each client's peers is 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/relay reset.

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, new host, fresh per-recipient ICE for WebRTC. The departure itself is still signaled by PlayerLeft; 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: PlayerLeft already 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