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 opt-in upgrade on top of the
floor — 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 member that did not (for example a v3 relay-only client that
seat-filled a mesh + webrtc room) 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. (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. Re-run the on SessionPlan logic below against the
new peers / host / ice_servers, tearing down peer connections that are no
longer listed (e.g. the departed host) and connecting per the new initiate
flags.
on SessionPlan(plan):
if plan.transport == relay:
use GameData over the WebSocket relay # the floor
else if plan.transport == direct:
start direct host/client P2P using plan.host + 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, 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.
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 are ignored; they leave stored
per-connection state unchanged and do not move counters. Counters move on the
first report for a connection and on later real per-connection state transitions.
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 on a
connection, 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. Delivery is per-recipient v3-gated (a v2 member never observes it,
Appendix K) 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. Delivery is best-effort (like
Signal): a backpressured peer may miss a notice and re-syncs on
the next state change.
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).
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