Skip to content

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.

JSON
{
  "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:

JSON
{
  "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.

JSON
{
  "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, so missed_events is only a suffix of what happened. Discard any local room-membership bookkeeping and resync from the Reconnected snapshot fields (current_players, lobby_state, ready_players, current_spectators).
  • unavailable -- Event replay is disabled on this deployment (event_buffer_size is 0). Treat every reconnection as a full resync from the snapshot fields, exactly as for truncated.

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:

JSON
{
  "type": "PlayerReconnected",
  "data": {
    "player_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

The Full Reconnection Timeline

Text Only
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 / PlayerReconnected
  • NewSpectatorJoined / SpectatorDisconnected
  • LobbyStateChanged
  • AuthorityChanged (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 -- Its peer_connections are 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, tailored SessionPlan via 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.
JSON
{
  "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 to true (default) to enable, or false to 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 to 0 to disable event replay entirely; v3 clients are then told replay: "unavailable".
JSON
{
  "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