Player Reconnection & Session Resumption: Tokens & Grace Windows

Design reconnect tokens, grace windows, seat holds, state resynchronization, retry safety and backfill protection after temporary multiplayer disconnects.

A player's phone switches from WiFi to cellular, the train enters a tunnel, the app gets backgrounded for thirty seconds. None of these are the player quitting, but a naive backend treats every dropped socket as a leave: the slot frees up, the character despawns, the inventory write half-commits, and when the player comes back they load into a fresh session having lost the round. Reconnection is the difference between "my connection blipped" and "I lost my progress and rage-quit." This guide covers the backend design that makes mid-match drops survivable.

Scope: session-based and persistent multiplayer where a dropped player should be able to resume the same session within a window. Not the cold-start case where a player just opens the game and loads their save - that is covered in persistent data and shared state.

Why a Dropped Socket Is Not a Leave

The core mistake is treating transport-level disconnect as an application-level intent to leave. TCP and WebSocket connections drop for dozens of reasons that have nothing to do with the player: network handoff, NAT timeout, a backgrounded mobile app, a flaky hotel router, a server-side load-balancer recycle. If your server's onDisconnect handler immediately destroys session state, every one of those becomes a lost game.

The fix is to introduce a third state between connected and gone. A player is connected, disconnected-but-reservable (the grace window), or left (grace expired or an explicit quit). Almost all the design work in reconnection is about that middle state: how long it lasts, what you hold open during it, and how you let the right player back in.

The Five Pieces of a Reconnect Path

Piece Job Failure if missing
Reconnect token Prove the returning client is the same player + same session Anyone can hijack the slot, or the player spawns as a stranger
Grace window How long the slot stays reserved after a drop Slot frees instantly (lost game) or never (zombie slots)
Slot hold Keep the seat, entity, and ownership reserved during grace Matchmaker backfills the seat before the player returns
State resynchronization Re-establish an authoritative baseline before normal incremental updates resume Client resumes from stale state or applies updates against the wrong baseline
Idempotency Replayed in-flight actions must not double-apply Duplicate purchases, double-spent currency, duplicated durable rewards

Reconnect Tokens, Not Connection IDs

The identity you reconnect against must outlive the socket. The most common bug here, and it shows up across every engine, is keying session state on a transport-assigned connection ID that is generated on connect and destroyed on disconnect. Unity's Netcode for GameObjects session-management docs are blunt about it: the clientId "generates when a player connects and is disposed of when they disconnect," so you need a separate persistent identifier (a GUID stored client-side) to map a returning player back to the character, position, and ownership they had before.

So you need two distinct identifiers:

  • A stable player identity from your auth layer (account ID, or a device-scoped guest ID). This survives across sessions and is what you key persistent data on.
  • A per-session reconnect token, issued when the player joins a match, that proves "I am the holder of this specific seat in this specific session." It is short-lived, single-session, and ideally rotated.

The reconnect token is a capability, not just an identifier, so treat it like a bearer credential: high entropy, bound to one session, and valid only while that seat is resumable. Frameworks implement this differently. In current Colyseus, the server holds the seat with allowReconnection(); a running client can retry automatically, while an app restart can call client.reconnect() with the cached private reconnectionToken. Colyseus regenerates that token after a successful connection, so persist the newest value rather than treating it as a permanent credential. Photon Fusion uses a ConnectionToken on StartGameArgs; its disconnect/reconnect sample uses that token to associate a returning connection with the player objects it controlled in the previous connection.

The Grace Window: Pick a Number, Then Defend It

The grace window is the single most important tuning knob. Too short and a subway tunnel costs the player their game. Too long and seats sit empty, matches stall waiting on a player who is never coming back, and your concurrency math inflates.

Colyseus makes the seat-hold policy explicit. In current Colyseus, an unexpected disconnect can be handled in onDrop; call allowReconnection(client, seconds) there to reserve the seat for a fixed window, or use "manual" and reject it from your own game logic. onReconnect fires when the client returns, while onLeave is the permanent-leave path after a timeout, failed reconnect, explicit leave, or disconnect you chose not to resume:

onDrop(client) {
  const player = this.state.players.get(client.sessionId);
  if (player) player.connected = false;

  // Hold the seat for 30 seconds. Colyseus handles the timeout.
  this.allowReconnection(client, 30);
}

onReconnect(client) {
  const player = this.state.players.get(client.sessionId);
  if (player) player.connected = true;
}

onLeave(client) {
  // Permanent leave: consented exit, timeout, failed reconnect, or rejection.
  this.state.players.delete(client.sessionId);
}

Reasonable starting points, tune with telemetry rather than guessing:

Game type Grace window Why
Competitive FPS / MOBA round 30 to 90s Match integrity matters; a long absence ruins the round for everyone else anyway
Co-op / PvE session 2 to 5 min Friends will wait; no fairness clock forcing a quick free
Persistent world / survival Seconds to hold the seat, save state regardless The world persists; the character can re-enter freshly from the save
Turn-based / async Hours to days There is no realtime tick to keep alive; resume is just re-fetching turn state

Holding the Slot Without Stalling Everyone Else

During grace the seat is reserved, but the game should not freeze waiting on a ghost. The two things you hold are the seat reservation (so the matchmaker does not backfill it) and the player entity and its ownership (so the character, inventory, and authority survive). What you do not do is pause the simulation. For a survival or persistent server the world keeps ticking; for a competitive round most designs either AI-fill the missing player or let the team play a man down with a visible "reconnecting" indicator rather than halting the match.

Server-authoritative frameworks give you the hooks to make this distinction. In Nakama's authoritative model the match handler is explicit about re-join: a client whose connection dropped must explicitly re-join the same match, and MatchJoinAttempt is your gate to decide whether that returning presence is allowed back into the in-progress match, per the Nakama authoritative multiplayer docs. Your handler can check the returning user against the seat reservation, accept the rejoin, and skip the normal "new player" spawn path. The same docs note that during a graceful server shutdown the grace period is used to migrate players to a new match, which is a useful generalization: reconnection logic and server-drain logic are the same problem from two directions.

Resynchronization: Establish a Fresh Authoritative Baseline

After a reconnect, the client must not blindly continue from whatever incremental sequence it last saw. How you restore the baseline depends on the networking stack. Some frameworks automatically synchronize current room or replicated state when the connection is re-established; custom protocols often send a fresh snapshot or checkpoint plus a new sequence cursor. Replaying every missed transient delta is usually unnecessary unless your protocol is specifically designed around an ordered retained log. The invariant is simpler: before normal incremental traffic resumes, both sides must agree on the current authoritative state and update sequence.

A practical resynchronization order that avoids visible glitches:

  • Authenticate the rejoin with the reconnect token, before touching any state.
  • Reattach ownership: bind the returning connection to the held entity, restore Input Authority.
  • Re-establish the baseline: let the framework synchronize current replicated state, or send a fresh snapshot/checkpoint for the player's interest set, then resume incremental updates.
  • Replay nothing the client already committed: this is where idempotency matters.

Idempotency: The Quiet Killer

The bug that survives QA and shows up in production is the double-applied action. A player taps "buy the upgrade," the client sends the request, the socket drops before the ack arrives, and on reconnect the client retries because it never saw confirmation. Without protection you charge twice, grant twice, or apply damage twice.

Every retryable durable mutation that can be in flight across a disconnect needs replay protection. Purchases, currency grants, inventory mutations, progression writes, and match-result submissions are the obvious cases. High-frequency gameplay input usually uses tick/sequence semantics instead of storing an idempotency record per movement or shot. For durable commands, a client-generated idempotency key lets the server return the stored result instead of executing the same mutation twice:

// Server: apply a mutating action exactly once per idempotency key.
function applyAction(playerId, key, action) {
  const seen = store.get(playerId, key);
  if (seen) return seen.result;        // replay: return cached outcome

  const result = mutateState(playerId, action);
  store.put(playerId, key, { result }); // commit result + key atomically
  return result;
}

Store the key and its result in the same transaction or atomic boundary as the durable state change so a crash between the two cannot leave you in an "applied but not recorded" state. Retain keys for at least the maximum retry horizon for that command. This is the same replay-safety discipline you would apply to a payment webhook; it is separate from transient realtime input sequencing.

Watch out: reconnect is an attack surface. A reconnect token is a bearer credential, so an attacker who steals one can seize a live seat. Bind tokens to the session, expire them at the grace boundary, rotate on each use if your framework allows it, and never log them. Rate-limit rejoin attempts the same way you rate-limit login.

Engine and Framework Cheat Sheet

Stack Reconnect primitive You still own
Colyseus allowReconnection(), automatic retry, onReconnect(), and rotating private reconnectionToken for manual resume Grace/gameplay policy, durable-command replay safety, persistent identity behind the room
Photon Fusion ConnectionToken + same Session ID reclaims Input Authority Token issuance/storage, what counts as a real leave
Unity Netcode for GameObjects Manual: ephemeral clientId, so map a persistent GUID to owned objects Almost all of it - NGO gives hooks, not a managed grace window
Nakama (authoritative) Explicit re-join; MatchJoinAttempt gates the returning presence Seat reservation logic, state sent on rejoin, grace policy

Testing Reconnection (Most Teams Skip This)

Reconnection bugs hide because the happy path never exercises them. Build these into your test harness before launch:

  • Kill the socket, not the client: drop the TCP connection at the OS or proxy level mid-action and confirm the client reconnects within grace.
  • Reconnect at the boundary: return exactly at grace-expiry minus one second, and exactly at grace plus one second, and assert the right outcome (resumed vs fresh).
  • Replay an in-flight mutation: drop after send, before ack, retry on reconnect, assert the action applied once.
  • Two clients, same token: confirm the second presentation is rejected, not silently allowed to steal the seat.
  • Backfill race: drop a player in a match the matchmaker wants to fill, confirm the held seat is not backfilled during grace.

Crux boundary: Crux currently provides player identity, persistent/shared state, presence leases, a simple matchmaking queue, a server registry, and a capacity-gated Runtime surface. It does not currently provide a managed reconnect-token, room grace-window, seat-hold, or snapshot-on-rejoin service. If your networking stack needs those semantics, implement them in the authoritative game/session layer and use the backend for the durable identity/state that must survive that session.

Related Guides