Transport Fallback Contract (Protocol v3)¶
How Signal Fish Server upgrades a room from the universal relay floor to a peer-to-peer data path, and how clients fall back when that path fails. This is the client-side contract for the v3 session flow, plus the server-side guarantees and the observability it exposes.
The relay floor always stays open¶
The server's WebSocket relay is the universal floor: every client supports it,
and the server relays GameData through it unconditionally, regardless of any
peer-to-peer state. P2P (WebRTC or direct) is an upgrade on top of the floor —
attempted by default for capable v3 clients (the mesh-first session.default_topology),
and only ever an additive path: never a replacement the server enforces.
Concretely, the server keeps relaying GameData even after a client reports its
P2P path failed (TransportStatus { connected: false }). The floor never closes,
so a client that cannot establish (or loses) a P2P connection always has a working
transport to fall back to. This is the central invariant the rest of the contract
depends on.
Client transport / fallback state machine¶
A v3 client drives its data-path transport from the per-recipient SessionPlan it
receives after the unchanged GameStarting. A room that resolves to the relay
floor emits an explicit no-peer relay/relay plan to v3 members; v2 members
receive no plan and keep using the WebSocket relay exactly as before.
A SessionPlan is not necessarily a once-per-session event: the server re-issues
it mid-session on host failover (the host of a host-topology session is
gone — departed, detected missing on a later membership event, or seated but no
longer capable of the session after a capability-downgrading reconnect) and on
every finalized-room join / reconnect. Each event refreshes every current
v3 member with a complete plan; delivery remains version-gated. Re-issued plan peer lists name
only peers that can run the session — that negotiated the session's topology and
transport. A new joiner that cannot run a finalized room's non-relay
session is rejected at admission with ROOM_SESSION_INCOMPATIBLE — admitting
it would silently split the data path, because its WebSocket-relayed traffic
reaches the room while P2P traffic between capable members never reaches it.
What can remain is an incumbent reconnecting with downgraded capabilities: it
receives its plan with an empty peers
list — truthful, it has no P2P peers; the relay floor is its data path — and
never appears in other members' peers; every publication observes this
mixed-path shape (mixed_path_members_observed + a warn log). (At
finalization this filter is vacuous: a plan is only selected when every member
supports it.) Topology and transport never change across re-issues —
they are sticky for the session lifetime — so the client rule is simple: the
latest SessionPlan wins. When its generation changes, tear down and rebuild
even retained WebRTC pairs against the new peers / host / ice_servers,
reject signals carrying any other generation, and connect per the new
initiate flags.
on SessionPlan(plan):
current_generation = plan.generation
if plan.transport == relay:
use GameData over the WebSocket relay # the floor
else if plan.transport == direct:
require plan.host + plan.direct_endpoint
start direct host/client P2P using plan.direct_endpoint + plan.peers
if direct path established within the timeout:
(optionally) stop sending GameData over the relay
emit ClientMessage::TransportStatus { transport, connected: true }
else (failure or timeout):
resume GameData over the WebSocket relay
emit ClientMessage::TransportStatus { transport, connected: false }
else if plan.transport == webrtc:
start WebRTC P2P using plan + plan.ice_servers
for each peer where initiate == true: send Offer
for each peer where initiate == false: await Offer, then send Answer
relay all Offer / Answer / IceCandidate via ClientMessage::Signal { to, generation: current_generation, signal }
if WebRTC path established within the timeout:
(optionally) stop sending GameData over the relay
emit ClientMessage::TransportStatus { transport, connected: true }
else (failure or timeout):
resume GameData over the WebSocket relay
emit ClientMessage::TransportStatus { transport, connected: false }
server: P2P state never disables GameData relay (delivery failures may close the socket loudly)
Key points:
initiateresolves glare. Exactly one side of each pair offers: inmeshthe lesser UUID initiates; inhosteach client initiates to the host and the host answers. The plan'speers[].initiateflag is already tailored per recipient — the client never computes it.Signalis opaque. Offer / Answer / IceCandidate payloads are forwarded verbatim by the server (matchbox-compatible by convention) and are never inspected.- Stopping relay
GameDatais optional. A client may keep dual-sending during the P2P warm-up and cut over only once the data channel is confirmed; that is a client-side latency/robustness choice, not a protocol requirement. - Fallback is always available. On any P2P failure or timeout the client
resumes
GameDataover the relay — which never stopped accepting it. - Direct endpoints are self-declared. The server validates syntax and host
eligibility, then repeats the selected host/port in the authoritative plan;
it does not probe reachability. A client that does not implement Direct must
reject the plan explicitly, report
connected: false, and use the relay fallback (the in-repo native reference client follows this contract).
Server's unconditional relay guarantee¶
The server's responsibilities are deliberately narrow:
- It relays
GameDatato room peers at all times, independent of any reported or inferred P2P state. - For a WebRTC plan it relays opaque
Signalmessages between same-room peers (subject to the same-room, negotiated-transport, and rate-limit checks). - It records each client's last-reported
TransportStatus, updates metrics, and fans the accepted state change out to the reporter's room asPeerTransportStatus(v3 recipients only — see below).
It never tears down the relay path solely because of a peer's P2P state, and it
never requires a peer to be P2P-connected. TransportStatus is purely
informational: it drives metrics and the PeerTransportStatus peer
notification (and, in the future, targeted relay for stuck peers), but reporting
connected: false does not change how the server relays for that client. The
physical WebSocket can still close loudly for slow-consumer timeout, maximum
sojourn, or failure to preserve exact accountability.
Data-channel configuration recommendation¶
For game traffic, a client should open two WebRTC data channels:
- One reliable + ordered channel for commands, chat, and critical events.
- One unreliable + unordered channel —
{ ordered: false, maxRetransmits: 0 }— for movement and frequently-overwritten state, where the latest value matters more than guaranteed delivery.
This split works browser-to-native and native-to-native. The v2 relay floor,
unclassified JSON GameData, and all raw binary game data are reliable. A v3
client can mirror the unreliable channel's intent over relay by using JSON
class: "latest" (with a key) or class: "volatile"; every resulting omission
is authorized by an exact prior DeliveryReport. Delivery class changes queue
policy, never whether the relay path remains available.
TransportStatus message (v3 only)¶
transport is one of relay, direct, or webrtc; connected is a boolean. The
message is v3 only — the server ignores it from any connection that did not
negotiate v3 (a v2 client can never legitimately send it). The reported transport
must also be present in that connection's negotiated transport set; unnegotiated
transport reports are ignored and do not update stored state or metrics. It is
purely informational and never causes the relay floor to close.
Server-side interpretation (drives the metrics below): duplicate reports of the
same (transport, connected) state in one room/spectator membership generation
are ignored; they leave stored state unchanged and do not move counters. A room
join, leave, same-room rejoin, spectator-role transition, or reconnect starts a
fresh generation. Counters move on the first report for that generation and on
later real state transitions within it.
connected: truewith a P2P transport (directorwebrtc) — a peer-to-peer data path came up; counts as P2P established when it is a first report or a transition from a different state.connected: false(for any named transport) — the client dropped back to the relay floor; counts as relay fallback when it is a first report or a transition from a different state.connected: truewithtransport: relay— "I am still on the floor"; this is neither a P2P establishment nor a fallback, so it moves no counter (only the per-connection state is updated).
PeerTransportStatus peer fan-out (v3 only)¶
When an accepted report records a real state change (the first report in a
membership generation, or a (transport, connected) transition — the same
dedup gate the metrics use), the server fans it out to the reporter's current
room as
{ "type": "PeerTransportStatus", "data": { "peer_id": "<player-uuid>", "transport": "webrtc", "connected": true } }
so peers learn, for example, that the host's WebRTC path died and relay-path
traffic from it should be expected. The reporter itself is excluded; a duplicate
report fans out nothing; a report from a room-less client is recorded but fans
out nothing. Spectators are roomless in connection routing: spectator entry and
leave reset deduplication, so the next accepted report is counted again, but
reports made while spectating do not produce PeerTransportStatus. A later seated join starts another fresh
generation, so prior roomless or spectator state never suppresses the first
in-room report. Delivery is per-recipient v3-gated (a v2 member never observes
it: recipients are filtered when the fan-out is resolved, re-checked per
delivery leg, and the socket writer fail-closed-drops any v3-only frame that
still reaches a pre-v3 queue) but deliberately not gated on the recipient's own transport
capabilities — it is informational status about a peer, useful to any v3
client, not an instruction to use that transport. Like the report it relays, it
never changes how the server relays GameData. The fan-out releases the room
event mutation gate before delivering, so backpressured (slow-consumer) legs
never delay other members' room mutations. Delivery is best-effort (like
Signal): a backpressured peer may miss a notice and re-syncs on
the next state change. A concurrent leave can finish before a pending status
delivery; clients should ignore status for a peer no longer in the room.
Metrics exposed¶
The server exposes Prometheus counters for the v3 transport surface so dashboards can see how often the relay floor is upgraded to a peer-to-peer path:
signal_fish_transport_session_plans_emitted_total— finalization-time v3 plan publications, including explicit relay-floor plans (one per finalized room with at least one v3 recipient; later refreshes are counted separately).signal_fish_transport_session_replans_emitted_total— mid-session host re-plan events (departure failover or late-join self-heal; one per event, not per recipient).signal_fish_transport_session_plans_late_join_total— finalized-room join / reconnect plan-refresh publications (one per joining actor, not per recipient).signal_fish_transport_topology_mesh_selected_total,signal_fish_transport_topology_host_selected_total,signal_fish_transport_topology_relay_selected_total— chosen topology per finalized room (including the relay floor).signal_fish_transport_webrtc_selected_total,signal_fish_transport_direct_selected_total,signal_fish_transport_relay_selected_total— chosen data-path transport per finalized room.signal_fish_transport_p2p_established_total— first reports or state transitions where clients reported P2P paths as established viaTransportStatus.signal_fish_transport_relay_fallback_total— first reports or state transitions where clients reported falling back to the relay floor viaTransportStatus.signal_fish_transport_signals_relayed_total— opaque WebRTCSignalmessages accepted for best-effort dispatch to same-room WebRTC peers.signal_fish_transport_turn_credentials_issued_total— ephemeral TURN credentials minted intoSessionPlans and pre-gatherRoomJoined/ReconnectedICE lists.signal_fish_transport_status_fanout_total—PeerTransportStatusfan-out events: acceptedTransportStatusstate changes from in-room clients fanned out to v3 room peers (one per event, not per recipient).signal_fish_transport_ice_pregather_emitted_total—RoomJoined/Reconnectedpayloads that carried a non-empty ICE pre-gather list (one per carrying payload).signal_fish_transport_seat_fills_rejected_incompatible_total— seat-fill joins rejected because the room's finalized non-relay session requires capabilities the joiner did not negotiate (ROOM_SESSION_INCOMPATIBLE; issue #421).signal_fish_transport_mixed_path_members_observed_total— seated members observed during non-relay plan publications whose capabilities exclude the session pair (an empty-peer mixed-path membership, e.g. a drifted reconnect; counted once per publication event).
Related documents¶
- Protocol v3 two-axis design (ADR-0001)
- Protocol reference
- Configuration reference
- Handoff and topologies
- Platform Integration Guide — which WebRTC stack to use per platform