Protocol Versioning¶
The Signal Fish Client SDK speaks two generations of the signaling protocol: v2 relay and v3 delivery/mesh. v3 negotiation is additive, opt-in, and backward-compatible — a client that opts into nothing sends the same v2 authentication bytes and remains on the relay floor. Version 0.8 separately made protocol-v2 game start explicit. This page explains both changes.
Server 0.7 token binding is a transport negotiation
The opt-in native token-binding feature supports disabled, optional, and
required signalfish.tokenbinding.v2 modes. It is negotiated and completed
before either protocol driver sends Authenticate, so it is separate from
v2/v3 application capability negotiation. See
WebSocket Token Binding.
Just want peer-to-peer?
If you have a WebRTC stack and want full mesh, jump to the Mesh Guide. This page covers the versioning model that makes it safe.
The relay-floor guarantee¶
The single most important compatibility invariant:
A client's default
Authenticatemessage is byte-identical to the old v2 client.
SignalFishConfig::new("app") leaves the v3 negotiation fields unset. Because
each is Option (skipped when None), they vanish from the wire and the
Authenticate bytes equal v2. The server relays all traffic through itself
(the "relay floor"), emits no v3 messages, and every existing flow is unchanged.
// v2 relay floor — the default. Byte-identical to the old client.
let config = SignalFishConfig::new("mb_app_abc123");
This protects relay-floor negotiation from accidental v3 opt-in. It does not
remove the 0.8 game-start migration: applications that relied on readiness
auto-starting the game must call start_game().
What's new in v3¶
v3 is purely additive on v2. It introduces:
- New wire types:
Topology,TransportKind,IceServer,SessionPeer,SessionPlanPayload,PeerSignal,RoomOperationId,RoomOperationRequest, andRoomOperationResult. - New client messages:
Signal,TransportStatus, and negotiatedRoomOperation. - New server messages:
Signal,NewPeer,SessionPlan,PeerTransportStatus,DeliveryReport,RelayStats,GoingAway, and negotiatedRoomOperationResult. - Classified relay delivery: reliable, keyed-latest, and volatile JSON messages with exact gap accountability; strict physical binary envelopes.
- Reconnect accountability: rotating tokens, replay status, sender watermarks, and lifecycle epoch/sequence metadata.
- New events: mesh events plus typed delivery reports, relay statistics,
graceful-drain advisories, categorized protocol violations, and attributed
RoomOperationFailedoutcomes. - New optional fields on existing messages:
Authenticate(protocol_version/supported_transports/supported_topologies/requested_capabilities),ProtocolInfo(protocol_version/min_protocol_version/max_protocol_version), andice_serversonRoomJoined/Reconnected(ICE "pre-gather"). - Six new signaling/lifecycle error codes plus two v2
GameStartcodes — see Errors.
A v2 client safely ignores any v3 field it doesn't recognize: the protocol
types never use deny_unknown_fields, so unknown additive fields deserialize
without error.
Opting in¶
Opt into the portion of v3 you can fulfill:
// v3 relay/accountability, without claiming WebRTC support.
let relay_config = SignalFishConfig::new("mb_app_abc123").enable_v3();
// v3 mesh — use only with a WebRTC driver.
let mesh_config = SignalFishConfig::new("mb_app_abc123").enable_mesh();
enable_v3() sets the protocol version and advertises relay-only capability.
enable_mesh() calls it and additionally advertises
the [WebRtc, Relay] transports and [Mesh, Host, Relay] topologies.
Never advertise what you can't fulfill
The SDK is signaling-only — it bundles no WebRTC stack. Only call
enable_mesh() when you actually bridge the resulting signaling events to a
WebRTC implementation (or use MeshController). Advertising
webrtc/mesh with no driver would make the server build a SessionPlan
you can't honor.
Power-user escape hatches exist for finer control:
with_protocol_version(v), with_transports([...]), with_topologies([...]).
Capability negotiation¶
Negotiation is a single round trip layered onto the existing handshake:
- The client advertises what it can fulfill in
Authenticate(protocol_version,supported_transports,supported_topologies, and additiverequested_capabilities). - The server caps the advertised version at its configured maximum and echoes
the negotiated
protocol_version(plus min/max) back inProtocolInfo. If the client's maximum is below the server minimum, authentication fails withUNSUPPORTED_PROTOCOL_VERSION; the server never upgrades the client above what it advertised. A v2 negotiation omits these fields entirely. - If the negotiation is below v3 (or transports/topologies were omitted), the server keeps the room on the relay floor and emits no v3 messages.
Read the result on the client:
// After ProtocolInfo has arrived:
match client.negotiated_protocol_version() {
Some(v) => println!("negotiated protocol v{v}"),
None => println!("relay floor (v2) — or not negotiated yet"),
}
if client.supports_mesh() {
// This client negotiated v3 after advertising WebRTC plus Host or Mesh.
// The server may still select an explicit Relay plan.
}
let snapshot = client.snapshot();
println!("selected path: {:?} / {:?}", snapshot.session_topology, snapshot.session_transport);
| Accessor | Returns |
|---|---|
negotiated_protocol_version() |
Option<u16> — None before ProtocolInfo, or for a v2 negotiation. |
supports_mesh() |
bool — negotiated local capability: v3 plus advertised WebRTC and at least one P2P topology (Host or Mesh). It does not mean the active plan is P2P. |
session_topology() / session_transport() |
The topology and transport selected by the latest authoritative plan, or None outside a plan. Read both from snapshot() when they must describe one instant. |
is_p2p_active() |
bool — whether the selected topology is currently Host or Mesh, independent of local capability. |
0.11 migration: capability versus active plan¶
supports_mesh() is retained for source compatibility, but its meaning is now
precise: it requires advertised WebRTC, an advertised Host or Mesh topology,
and negotiated v3. A custom WebRTC + relay-only configuration therefore changes
from true to false. It never guarantees that the server selected P2P.
ClientSnapshot is exhaustive and gains session_topology and
session_transport, so 0.11 consumers constructing snapshots must initialize
both fields. Routing code should read those fields from one snapshot() (or use
is_p2p_active() for a one-value query); do not substitute supports_mesh().
The fail-fast guard¶
After connection and room-role validation, the v3-only send methods —
classified non-reliable JSON sends, binary sends,
send_signal, send_offer, send_answer,
send_ice_candidate, send_raw_signal, and report_transport_status — check
the negotiated version before sending. If v3 has not been negotiated, they
return SignalFishError::ProtocolUnsupported immediately rather
than letting the server reject the message asynchronously (an unattributed
Error event would be much harder to debug).
This ordering is intentional: a player-only command attempted outside a room
returns NotInRoom (or WrongRoomRole for a spectator) even if negotiation is
still in flight. Once player membership is valid, ProtocolUnsupported
describes the remaining version failure precisely.
Signaling has a second guard: every send requires an authoritative WebRTC
SessionPlan that authorizes its target. Otherwise it returns
SignalFishError::SessionPlanUnavailable without queuing a frame. This covers
no plan, a non-WebRTC plan, self, and peers absent from either the authoritative
session peer set (the replacement plan plus valid compatibility NewPeer
updates) or current room roster. Accepted signals use the plan's generation.
The mode field tells you why:
mode |
Meaning |
|---|---|
"pre-negotiation" |
No ProtocolInfo has arrived yet — negotiation is still in flight; retry once it completes. |
"relay-only" |
A ProtocolInfo arrived but negotiated v2 (the relay floor) — waiting will not help; enable v3 and reconnect. |
match client.send_offer(peer, sdp) {
Ok(()) => {}
Err(SignalFishError::ProtocolUnsupported { mode }) => {
eprintln!("protocol v3 was not negotiated ({mode})");
}
Err(e) => eprintln!("send failed: {e}"),
}
start_game() is not guarded
Explicit game start is the one universal v2 change (the game no longer
auto-starts on readiness). client.start_game() works on every connection
and is not gated behind the mesh opt-in.
Migrating from v2 to v3¶
Adopting v3 capabilities is additive, but upgrading an older SDK still requires the explicit-start audit:
- Relay negotiation stays v2 by default. Don't call
enable_v3()orenable_mesh()and the authentication bytes stay on the relay floor. - One v2 behavior change: the game now starts explicitly. If you relied
on the game auto-starting when everyone was ready, call
client.start_game()(typically onLobbyStateChanged { all_ready: true, .. }). Rejections surface asGameStartNotReady/GameStartForbiddenerror codes. Use a one-shot latch and, in authority rooms, require current authority; see Migrating 0.7 to 0.8. - To adopt mesh, add
.enable_mesh(), wire aWebRtcDriver(or useMeshController), and handle the four mesh events. - To adopt v3 relay only, add
.enable_v3(), choose delivery classes withGameDataDelivery, persistsnapshot().reconnection_token, and handleProtocolViolationaccording to your recovery policy. See Delivery Contract.
See also¶
- Mesh Guide — implementing WebRTC mesh end to end.
- Core Concepts — the conceptual overview.
- Protocol Types — the v3 wire types in detail.
- Events — the v3 events.
- Errors —
ProtocolUnsupportedand the v3 error codes.