Building a Client¶
This is the client-author contract for Signal Fish Server: the message flow you must implement, the parts that are optional, and the rules you must not re-derive yourself. It is protocol-level and language-agnostic. For a concrete Rust walkthrough see the Rust Client Guide; for engine integration see the Platform Integration Guide.
The protocol is JSON over WebSocket. Every message is a type-discriminated
envelope:
A message with no payload (such as PlayerReady, Ping, StartGame, Pong,
RoomLeft) is sent as { "type": "PlayerReady" } with no data field.
The full, codegen-ready description of every message lives in the machine-readable spec — see Generating client code at the end of this guide.
The two protocol levels¶
Signal Fish has a mandatory floor and an optional upgrade. You can ship a fully working client implementing only the floor.
- v2 — the relay floor (mandatory). The server relays every
GameDatamessage reliably through the WebSocket to the other players in the room. No peer-to-peer, no WebRTC, no capability negotiation. Every client MUST implement this and it always works. - v3 — capability negotiation + classified delivery (optional). A client may
advertise transports/topologies for a peer-to-peer plan and may classify JSON
relay data as reliable, keyed-latest, or volatile. Exact
DeliveryReportranges account for every intentional sequence omission. P2P status never disables the relay path; the physical WebSocket can still close loudly when its delivery contract fails. Raw binary game data remains reliable.
See Protocol Versions for how the negotiated version is chosen and clamped.
Mandatory v2 relay-floor flow¶
This is the lifecycle every client must implement. Connect a WebSocket to the
server's /v2/ws (or /v3/ws) endpoint, then:
Authenticate -> JoinRoom -> PlayerReady -> StartGame -> GameStarting -> GameData ... -> LeaveRoom
- Authenticate — MUST be the first message. Send your public
app_id(it is an identifier, not a secret — safe to embed in builds). The server repliesAuthenticatedfollowed byProtocolInfo. On failure you getAuthenticationErrorwith anerror_code. - JoinRoom — create or join a room. Omit
room_codeto create a new room (the server generates one); provide aroom_codeto join an existing room, or to create one with that specific code if none exists yet. The server repliesRoomJoined(with yourplayer_id, the current players, and the lobby state). On failure you getRoomJoinFailed. While in the room you receivePlayerJoined/PlayerLeftas others come and go. - PlayerReady — toggle your readiness. The server broadcasts
LobbyStateChangedafter each toggle, withall_ready: trueonce every current player is ready. Readiness alone does not start the game. - StartGame — explicitly finalize and start. See StartGame authorization below — this is the most common source of client bugs.
- GameStarting — the server broadcasts this once the lobby finalizes. It
carries legacy
peer_connectionsmetadata. For a relay-only room this is your signal that gameplay traffic may begin. - GameData — send
{ "type": "GameData", "data": { "data": <anything> } }. The server relays it to the other players, who receive{ "type": "GameData", "data": { "from_player": <uuid>, "data": <anything> } }. The innerdatais opaque to the server. - LeaveRoom — leave cleanly; the server replies
RoomLeftand tells the othersPlayerLeft.
Throughout the session, send Ping periodically to keep the connection alive
(the server replies Pong); an idle connection is closed with
CONNECTION_IDLE_TIMEOUT.
See the worked walkthrough in v2 two-player relay.
StartGame authorization and readiness¶
StartGame is accepted only when every current player is ready (all_ready
was true in the most recent LobbyStateChanged). max_players is a ceiling,
not a required count — a room with a single ready player may start (solo is
allowed). The server rejects a premature start with the error code
GAME_START_NOT_READY.
Authorization:
- If the room has a designated authority player, only that authority may
send
StartGame. Anyone else getsGAME_START_FORBIDDEN. - If no authority is set, any player in the room may start.
So: track all_ready from LobbyStateChanged, track whether you are the
authority (from RoomJoined.is_authority / AuthorityChanged), and only enable
your "Start" affordance when both conditions allow it.
Mandatory vs optional¶
| Message / feature | Status |
|---|---|
Authenticate, JoinRoom, PlayerReady, StartGame, GameStarting, GameData, LeaveRoom |
Mandatory (v2 floor) |
Ping / Pong heartbeat |
Mandatory (avoid idle timeout) |
Handling Error and the *Failed messages with error_code |
Mandatory |
Reconnect after a drop |
Optional (see Reconnection) |
JoinAsSpectator / LeaveSpectator |
Optional (see Spectator Mode) |
AuthorityRequest |
Optional (see Authority System) |
ProvideConnectionInfo |
Optional legacy peer metadata |
Signal, SessionPlan, NewPeer, TransportStatus, PeerTransportStatus |
Optional (v3 only) |
DeliveryReport |
Mandatory for negotiated v3; peers may send latest / volatile even when you do not |
RelayStats, GoingAway |
Optional v3 diagnostics / shutdown handling |
Optional v3 upgrade¶
To use peer-to-peer or classified delivery, negotiate v3 during Authenticate.
Advertise only the peer-to-peer capabilities you actually implement:
{
"type": "Authenticate",
"data": {
"app_id": "mb_app_abc123",
"protocol_version": 3,
"supported_transports": ["relay", "direct", "webrtc"],
"supported_topologies": ["relay", "host", "mesh"]
}
}
Omitting supported_transports / supported_topologies keeps you relay-only
even on the /v3/ws endpoint. The server echoes the negotiated
protocol_version, accepted range, and current server message transports
(["websocket"] today) in ProtocolInfo.
After GameStarting, every v3 member receives a per-recipient
SessionPlan describing the chosen topology, transport, optional
host, peers with per-peer initiate flags, ice_servers, and universal
fallback. Relay-resolved rooms send relay/relay with an empty peer list,
which is an authoritative instruction to clear stale P2P state.
The signaling rules you must follow:
- Latest
SessionPlanwins. A newSessionPlansupersedes the previous one (e.g. on host failover, join, or reconnect). Apply the most recent one, remove peers no longer listed, and use its latest ICE credentials. - The glare rule is server-driven. Each
SessionPlanpeer'sinitiatetells you whether you send the WebRTC offer. Exactly one side of every pair is the offerer. Do not recompute this yourself from UUID ordering or topology — just obey the flag. - Relay
Signalverbatim. Send{ "type": "Signal", "data": { "to": <peer>, "signal": <opaque> } }; you receive the peer's signal as{ "type": "Signal", "data": { "from": <peer>, "signal": <opaque> } }. The server never inspectssignal; by convention it is matchbox-compatible ({"Offer":..}/{"Answer":..}/{"IceCandidate":..}). - Report transport state (optional). Send
TransportStatuswhen your WebRTC path comes up or dies; peers are told viaPeerTransportStatus. This is purely informational — the relay floor stays open regardless.
Classified relay delivery and exact gaps¶
Only JSON GameData on a negotiated-v3 connection can be classified:
- Omit
class(or sendreliable) with no key for commands and critical events. Reliable delivery waits for queue capacity and closes a slow recipient loudly rather than omitting a message. - Send
latestwith a requiredu32key for replaceable state. Reuse a stable key for each independent state stream; values with other keys do not supersede one another. - Send
volatilewith no key for opportunistic data. It never backpressures. - Never attach class/key metadata to a raw binary frame; binary is reliable.
Received v3 GameData includes epoch and seq, and echoes class/key only
when supplied by the sender. Within one sender epoch, delivered sequences are
strictly increasing but can skip. Before accepting a skip on a continuing
connection, your receive loop must already have consumed enough exact
DeliveryReport ranges that their non-overlapping union covers every missing
sequence for that sender and epoch. Reports carry at most 256 ranges and may
roll over into multiple priority frames. Aggregate RelayStats, per-class
counter deltas, and a later report are not proof.
Treat baselines separately from gaps. For every v3 PlayerInfo, require paired
epoch and seq; the pair is the exact recipient-visible baseline and the next
delivery or exact gap begins at seq + 1. Peer lifecycle control has priority and can overtake
already-queued old-epoch data. Keep accounting for that trailing data, but do
not apply it after PlayerLeft or a newer incarnation announcement. Accept a
future epoch only if PlayerJoined or PlayerReconnected announced that exact
epoch; once data advances, reject older epochs. Your own room/spectator
transition is a generation barrier and resets room-scoped cursors while keeping
physical-connection counters. After your own reconnect, replace every
expectation with Reconnected.sender_watermarks, reset connection-scoped report
counters, and resynchronize application state. If an unexplained same-epoch
hole appears, stop applying dependent deltas and surface a protocol error.
Worked v3 sessions: mesh + WebRTC, host topology, host failover.
Common pitfalls¶
- Don't recompute glare. The single most common P2P bug is deriving the
offerer from UUID order or topology. The server already did it; obey
you_initiate/initiate. - Always handle relay fallback. WebRTC may never connect (NAT, firewalls).
The
fallbackis alwaysrelay, and the server never disables it because of P2P state. A correct v3 client keeps working over relay when P2P fails -- never block gameplay on a WebRTC connection succeeding. Delivery failures can still close the physical WebSocket loudly. StartGameis explicit and authorized. Readiness does not auto-start. Gate the action onall_readyand on authority (see StartGame authorization).- Treat
signalas opaque. Forward it verbatim; do not parse or rewrite it. - Honor the negotiated version. If
ProtocolInfo.protocol_versioncomes back as 2, do not send v3 messages (Signal,TransportStatus) or classifiedGameData. Well-typed but illegal class/key pairings returnINVALID_DELIVERY_CLASS; malformed metadata, including explicitnull, returnsINVALID_INPUT. Unnegotiated WebRTC signaling returnsUNSUPPORTED_TRANSPORT. - Do not infer gaps from totals. Only the union of causally prior exact
DeliveryReportranges authorizes a continuing-connection sequence hole. Process priority control before later data, retain old-epoch accounting while suppressing stale application payloads, and reset baselines after reconnect. - Treat
4002 slow_consumeras authoritative. A finalSLOW_CONSUMERerror is best effort. Queue timeout, maximum sojourn, or failure to preserve exact accountability can all produce the close. - Keep the connection alive. Send
Pingon an interval or you will be dropped withCONNECTION_IDLE_TIMEOUT. - Authenticate first. Any other message before
Authenticateis an error. - Rejoining by code re-creates a vanished room — carry your original
max_players. If a room no longer exists (the server restarted, or every member left and its reconnection window elapsed) and your party rejoins by room code, the first rejoiner re-creates the room. Omitmax_playersand it falls back to the server's per-room default (8), so a larger party strands its overflow withROOM_FULL. Always re-supply the originalmax_playerson a rejoin-by-code so the whole party fits. Reconnectis windowed and single-winner. The reconnectionauth_tokenis only claimable within the reconnection window (default 300 s); after it,Reconnectfails withRECONNECTION_EXPIRED/RECONNECTION_TOKEN_INVALID, so fall back to a freshJoinRoom. Exactly one of two concurrent claims on the same token wins. If a reconnect races the old connection's teardown you may getPLAYER_ALREADY_CONNECTED— the token is not consumed, so wait briefly and retry. See Reconnection.
Testing checklist¶
A client is conformant when it passes these scenarios:
- Auth + join + relay: authenticate, create a room, send and receive a
GameDataround-trip, thenLeaveRoom. - Two-player lobby + start: two clients join, both
PlayerReady, observeall_ready, the authorized client sendsStartGame, both seeGameStarting. - StartGame rejection: sending
StartGamebeforeall_readyreturnsGAME_START_NOT_READY; a non-authority sending it in an authority room returnsGAME_START_FORBIDDEN. - Error handling: join a full room (
ROOM_FULL), use a bad room code (ROOM_NOT_FOUND), and surfaceerror_codevalues to the user. See error handling and the error code reference. - Heartbeat: the connection survives an idle period because
Pingis sent. - Reconnect (if implemented): drop and
Reconnectwith the joinauth_token, replay control-onlymissed_events, resync gameplay state at the application layer, and for v3 applysender_watermarksas per-sender(epoch, seq)baselines while resetting report counters. - v3 negotiation (if implemented): advertise WebRTC, receive a
SessionPlan, complete the offer/answer/ICE exchange followingyou_initiate, and fall back to relay when WebRTC fails. - v3 dynamics (if implemented): apply superseding
SessionPlans for host failover and finalized membership changes, including the empty-peer relay reset. - v3 delivery classes (if implemented): validate every class/key
combination, keep binary reliable, and verify keyed-latest and volatile
omissions are covered by the union of prior exact
DeliveryReportranges, including rollover beyond 256 ranges. - v3 gap lifecycle (if implemented): distinguish an initial sender baseline, an epoch reset, a recipient reconnect watermark, and an authorized same-epoch gap; account but suppress lifecycle-overtaken stale payloads, require future-epoch announcements, and reject an unexplained hole or backward epoch.
Generating client code from the spec¶
The protocol is described in a machine-readable AsyncAPI 3.0 document:
spec/signal-fish-protocol.asyncapi.yaml.
It models every ClientMessage (send) and ServerMessage (receive) variant as a
message with a JSON-Schema payload, including the type discriminator, every
field's name/type/optionality, and the enum tokens for transports, topologies,
and error codes. A Rust test
(tests/protocol_spec_consistency.rs)
fails the build if it drifts from the Rust source, so you can trust it.
Generate models or a client scaffold with the AsyncAPI generator:
npx @asyncapi/generator spec/signal-fish-protocol.asyncapi.yaml \
@asyncapi/typescript-template -o ./generated
Or feed the self-contained components/schemas subtree to any JSON-Schema model
generator (quicktype, json-schema-to-typescript, schemafy, …). The spec's
header comment lists concrete invocations. The canonical wire examples to test
your generated types against live in
.llm/code-samples/protocol/
and the full prose reference is Protocol Reference.