Reconnection¶
WebSocket connections are fragile. Players lose connectivity when they switch networks, when their device sleeps, when they walk through a bad Wi-Fi zone, or when a mobile carrier briefly drops a data session. Without reconnection support, any of these events would force a player to re-authenticate, rejoin the room (which may already be full), and lose every event that happened while they were away.
Signal Fish Server reduces that disruption with token-based reconnection, authoritative current-room snapshots, and bounded control-event replay. Gameplay payloads are not replayed; applications still synchronize game state after reconnect.
How Reconnection Works¶
The reconnection flow has two phases: preparation (during the initial join) and recovery (after a disconnect).
Phase 1: Save Your Identifiers¶
When a player joins a room, store the player_id and room_id from
the RoomJoined response. These identifiers are needed if you need to
reconnect later.
{
"type": "RoomJoined",
"data": {
"room_id": "550e8400-e29b-41d4-a716-446655440000",
"room_code": "HK7T3W",
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"game_name": "my-game",
"max_players": 4,
"supports_authority": true,
"current_players": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Alice",
"is_authority": true,
"is_ready": false,
"connected_at": "2025-01-15T10:30:00Z"
}
],
"is_authority": true,
"lobby_state": "waiting",
"ready_players": [],
"relay_type": "matchbox",
"current_spectators": []
}
}
The relay_type returned here and by Reconnected is an informational legacy
integration label, not a transport selector or reachability proof. Reconnect
the authenticated WebSocket first, then follow any negotiated v3 SessionPlan.
The reconnection token (a server-generated UUID bound to your player ID
and room ID) arrives ON THE WIRE at join time: v3+ clients read it from
RoomJoined.reconnection_token and store it before anything can go
wrong. It is the auth_token value the client supplies when sending a
Reconnect message.
Treat it like a password, not an identifier. The token is a bearer
seat credential: anyone who possesses it can take over the player's seat
for the whole reconnection window — receiving the player's gameplay
traffic and inheriting its budgets. Never write it to logs, never include
it in crash reports or screenshots, and redact it in any Debug output.
The string is
stable from join through a later disconnect, but it only becomes
claimable for server.reconnection_window seconds counted from the
disconnect (holding it early does not widen the window), and it rotates
on every join and every successful reconnect
(Reconnected.reconnection_token carries the replacement). A same-room
spectator join is also a join: the newer role choice discards the pending
reconnection record, so the old player token never re-seats the player. It is
discarded on
a voluntary LeaveRoom — leaving cleanly is not a disconnect. It is also not
armed for a server shutdown drain (GoingAway / close code 4000
server_shutdown); the instance is going away, so clients should join or create a
fresh room on another healthy instance.
Token rotation commits when the server queues Reconnected, before the
client receives it. If that response is lost, the old token is already spent
and the client may never learn the replacement. Retrying the old token cannot
recover that seat after the committed reconnect. The application must recover
through a fresh join; the protocol has no receipt acknowledgement for rotation.
If the server marks the socket for closure before response queue admission, it cancels the restore and preserves the old token. Open a fresh socket and retry within the reconnection window. This also applies when the response receiver closes before admission.
Phase 2: Reconnect After a Disconnect¶
When the WebSocket connection drops, open a new WebSocket to the server
and send a Reconnect message with the stored credentials:
{
"type": "Reconnect",
"data": {
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"room_id": "550e8400-e29b-41d4-a716-446655440000",
"auth_token": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
If the token is valid, the window is open, and the room has space and no
seated player with your saved name, the server restores you and sends a
Reconnected message. This
message contains the current room state and the room-uniform
control events that were broadcast while you were away (see
Event Buffer for exactly which events are replayed and
the completeness contract):
The new connection does not inherit ProvideConnectionInfo from the old
socket. If this player may host a Direct session, send its current endpoint
again after reconnect. Until then, the server will not elect it as a Direct
host.
{
"type": "Reconnected",
"data": {
"room_id": "550e8400-e29b-41d4-a716-446655440000",
"room_code": "HK7T3W",
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"game_name": "my-game",
"max_players": 4,
"supports_authority": true,
"current_players": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Alice",
"is_authority": true,
"is_ready": false,
"epoch": 1
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Bob",
"is_authority": false,
"is_ready": true,
"epoch": 2
}
],
"is_authority": true,
"lobby_state": "lobby",
"ready_players": [
"b2c3d4e5-f6a7-8901-bcde-f12345678901"
],
"relay_type": "matchbox",
"current_spectators": [],
"missed_events": [
{
"type": "PlayerJoined",
"data": {
"player": {
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Bob",
"is_authority": false,
"is_ready": false
}
}
},
{
"type": "LobbyStateChanged",
"data": {
"lobby_state": "lobby",
"ready_players": [
"b2c3d4e5-f6a7-8901-bcde-f12345678901"
],
"all_ready": false
}
}
],
"replay": "complete",
"sender_watermarks": [
{
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"epoch": 1,
"seq": 12
},
{
"player_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"epoch": 2,
"seq": 0
}
]
}
}
The replay field reports ring retention and outbound size truncation. It is sent
only to clients that negotiated protocol v3 or higher (the v2 wire is
unchanged — no replay key) and takes one of three values:
complete-- The ring evicted no event after the server's recorded disconnect cursor, and the frame limit omitted no retained event.truncated-- The replay ring evicted events you needed, or the outbound frame limit removed older retained events, somissed_eventsis only a suffix of what happened. Discard any local room-membership bookkeeping and resync from theReconnectedsnapshot fields (current_players,lobby_state,ready_players,current_spectators).unavailable-- Event replay is disabled on this deployment (event_buffer_sizeis0). Treat every reconnection as a full resync from the snapshot fields, exactly as fortruncated.
For every protocol version and replay status, replace local room state with
the snapshot fields. You can ignore missed_events. If you process this
history, process it before applying the snapshot; historical player metadata
can be older than the snapshot. The server removes your own membership
events and authority events that disagree with the current authority, even
when replay is complete. The cursor is server-side and does not confirm
receipt of the old socket's unread events.
The server keeps the full snapshot and the largest fitting suffix of filtered
history within security.max_outbound_message_size. V2 applies the same bound
without a replay key. If the snapshot alone cannot fit, the server rejects the
reconnect before queue admission and keeps the old token available for retry.
Shrink the room snapshot before retrying. Configure sufficient frame limits
before starting the server.
Queue admission remains the commit point; a later socket failure can still
lose the response and its rotated credential.
The sender_watermarks field is also v3-only. It gives one authoritative
(epoch, seq) baseline for every current room member, including the
reconnected player (seq: 0 when that member has not relayed GameData in
its current incarnation). missed_events never contains GameData; use the
snapshot plus application-level state sync for gameplay, and use
sender_watermarks only to resume relay gap accounting.
Other players in the room receive a PlayerReconnected notification so
they know you are back:
{
"type": "PlayerReconnected",
"data": {
"player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}
The Full Reconnection Timeline¶
Player disconnects
|
v
Server detects disconnect (register_disconnection)
- Arms the token issued at join (bound to player and room)
- Records authority status for potential restoration
- Starts buffering the room's control events for this player
- Starts expiration timer (default: 300 seconds)
|
| ... time passes, events happen in the room ...
|
Player reconnects (new WebSocket)
- Sends Reconnect message with player_id, room_id, auth_token
|
v
Server validates token
- Checks token matches player_id and room_id
- Checks reconnection window has not expired
- Checks player is not already connected (duplicate guard)
- Checks room still exists
- Checks room capacity and saved player name
|
v
Server restores player
- Replays missed control events from the buffer
- Restores authority role if previously held
- Notifies other players via PlayerReconnected
- Sends Reconnected with full room state + missed events
(+ v3-only replay completeness and sender watermark fields)
Reconnection Window¶
The reconnection window is the time a disconnected player's token can be used. The default is 300 seconds (5 minutes). The server does not reserve room capacity or the player's name during this window. After the window expires, the token becomes invalid.
Event Buffer¶
While at least one player of a room is disconnected (awaiting reconnection), the server buffers the room's control events so they can be replayed on reconnect. Only room-uniform events — messages every member receives identically — are buffered:
PlayerJoined/PlayerLeft/PlayerReconnectedNewSpectatorJoined/SpectatorDisconnectedLobbyStateChangedAuthorityChanged(the uniform broadcast form)
Two categories are deliberately never replayed:
GameData/GameDataBinary/Signal-- The data path is high-rate; buffering it would immediately purge the control events that matter. Reconnecting clients resync game state at the application level.GameStarting-- Itspeer_connectionsare customized per recipient, so replaying another player's copy would be wrong. A reconnect into a started session receives the room snapshot plus a fresh, tailoredSessionPlanvia the dedicated late-join flow, which fully covers session re-entry.
The buffer is a bounded ring (default 100 events per room): when it
overflows, the oldest events are evicted and the server records the
highest evicted sequence number as an eviction watermark. On reconnect,
the watermark — not a gap in sequence numbers, which can occur benignly
because sequence numbers are global across rooms — decides whether your
replay was truncated, and the replay field reports it (see above).
Each eviction also increments the
signal_fish_reconnection_events_evicted_total metric; a sustained
non-zero rate means event_buffer_size is too small for the room churn
it serves.
The buffer exists only while someone is pending: it is created when a disconnect is registered, and released when the last disconnected player of the room reconnects or their reconnection window expires (and, as a backstop, when the room itself is cleaned up).
When Reconnection Fails¶
The server responds with a ReconnectionFailed message if:
- Token expired -- The reconnection window has passed.
- Invalid token -- The token does not match the player or room.
- Room closed -- The room was cleaned up while the player was away.
- Room full -- New players filled the room while the player was away.
- Name taken -- A seated player joined with the disconnected player's
name, using the same Unicode comparison as
JoinRoom. - Already connected -- The player is already connected from another session.
- Kicked -- The room's authority removed the seat while the player was away. The pending record is refused; join again with a valid room code.
- Banned -- The room's authority banned the player id while the player was away. Every reconnection attempt is refused while the ban lives. The pending record is kept, so if the authority lifts the ban during the reconnection window, the token works again; otherwise join again after the unban.
{
"type": "ReconnectionFailed",
"data": {
"reason": "Reconnection window has expired",
"error_code": "RECONNECTION_EXPIRED"
}
}
For a full room or a taken name, the same token can be retried before the
window expires if the conflict clears. For an invalid or expired token,
the client must use a fresh JoinRoom flow.
Configuration¶
Reconnection is enabled by default. Relevant server settings:
enable_reconnection-- Set totrue(default) to enable, orfalseto disable reconnection entirely.reconnection_window-- Seconds before a disconnected player's token expires. Default:300(5 minutes).event_buffer_size-- Maximum number of control events buffered per room (the replay ring). Default:100; maximum:65,536. Values above the ceiling are rejected at startup. Set to0to disable event replay entirely; v3 clients are then toldreplay: "unavailable".
{
"server": {
"enable_reconnection": true,
"reconnection_window": 300,
"event_buffer_size": 100
}
}
Security Notes¶
The reconnection token is a bearer credential for a player's seat
(issue #523). The identity swap is authorized by the token alone by
default: anyone who learns it -- screenshare, client log, memory dump, a
leaked Custom relay blob -- can take over that player's seat for the
whole reconnection window, receive their game-data stream, and inherit
their rate-limit budget. Treat it like a password:
- Never log it, never screenshare it, never embed it in game state that leaves the process. SDK-side redaction is the client's responsibility; server-side, tokens are only echoed back to the connection that received them and never written to logs or metrics.
- Shrink the window (
reconnection_window) to the minimum your game tolerates. The 300 s default is generous for a bearer secret; a fast lobby game may only need 30-60 s. - Hosted and multi-tenant deployments should require the fingerprint
second factor: enable the mTLS token-binding subprotocol with
security.transport.token_binding.require_client_fingerprint(see Authentication), so the reconnection identity is the client certificate fingerprint rather than the token alone. An operator checklist entry reiterates this; Pre-deployment Checklist.
Token mechanics are strong:
- Tokens are validated with constant-time comparison against both the player ID and room ID, so a claim cannot be reused across sessions, and the window deadline is armed from a monotonic clock once per genuine disconnect (wall-clock steps cannot extend a window).
- A token is consumed exactly once on a successful claim; a failed restore releases the claim for retry within the window. Rotation hands the reconnecting client a fresh token.
- A duplicate-connection guard prevents a player from being connected twice simultaneously, and a kick tombstones every pending or in-flight reconnection path, so a removed seat is never resurrectable.
Next Steps¶
- Rooms and Lobbies -- Room lifecycle and states
- Authority System -- How authority interacts with reconnection
- Spectator Mode -- Read-only observers