Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Reconnect and limits

This page describes how state is recovered when the realtime socket drops, what to do when ResyncRequired arrives, the idle close (4100) behavior, and the limits the server applies. If you are writing a game that is played on a mobile or unstable network, read this page from start to finish.

Recovery after a drop

A socket can always drop (network, deployment, load balancer). In Open Play, recovery is done by re-joining with a new socket; the old socket’s state is not reused on the server.

  1. Get a new ticket. Every connection asks for a new ticket (single-use, 30 s); the old ticket cannot be reused (protocol).
  2. Authenticate, Ready. A new sessionEpoch arrives. When the new socket starts writing to the same resource, the player’s old socket is no longer the writer: the old socket’s inputs get Ack: Stale.
  3. Join again for each subscription.
    • In durable streams: give the last cursor you received as Join.cursor. The server replays DurableEvents from where you left off and sends a new DurableRecovery barrier (snapshot is empty).
    • In ephemeral state: there is no cursor; Join brings a fresh Snapshot. Inputs that were sent but did not get an Ack are not replayed: their outcome is uncertain, and your game logic must continue according to the latest Snapshot.
    • In durable commands (Command): the outcome of a command that dropped before an Ack arrived is uncertain. Resend with the same idempotencyKey or query the result (calling plugins); do not generate a new key.
  4. If ResyncRequired arrives (the cursor is missing, expired, belongs to another player, has a gap, or exceeded the replay budget), discard the cursor and Join again without a cursor; this brings a fresh snapshot. Do not insist on the same cursor.

Cursor rules

  • It is opaque and bound to the player; another player’s cursor cannot be used. Do not parse its contents.
  • Every DurableRecovery and every DurableEvent carries a new cursor; store the latest one.
  • At most 256 durable events are replayed in a single recovery; anything beyond that is turned into a fresh snapshot with ResyncRequired.
  • The cursor and delivery retention window is 24 hours; a cursor older than that gets ResyncRequired.
  • The events in coveredEventIds are already in the fresh snapshot; do not apply them separately.

What the TypeScript SDK does

The SDK runs this flow for you (RealtimeClient, client.realtime):

BehaviorDefaultSetting
Automatic reconnectonautoReconnect
Attempt delaymin(reconnectBaseMs · 2^(n−1), 30 000) ms + [0, reconnectBaseMs) jitterreconnectBaseMs: 1000 (between 100 and 10,000)
Maximum attempts8maxReconnectAttempts: between 0 and 20
Authentication timeout5 sauthTimeoutMs (at most 5 s)
Application Ping interval15 sheartbeatMs: 10 ms to 60 s
Durable command timeout15 scommandTimeoutMs: 1 ms to 120 s
Window of inputs awaiting a reply4maxPendingInputs: 1 to 8
Frame size16 KiBmaxFrameBytes (at most 16 KiB)

The state machine goes through the values idle, ticketing, connecting, authenticating, ready, recovering, live, backoff, reauthRequired, upgradeRequired, idleClosed, and closed. The important ones:

  • recovering / backoff: the SDK is reconnecting; the subscriptions’ cursors are preserved.
  • reauthRequired: the ticket or the identity was rejected (AuthenticationFailed, 401/403). Re-establish the session; the SDK does not enter an endless loop on its own.
  • upgradeRequired: the server did not recognize the protocol version or the frame (UnsupportedProtocol, 426). Update the client.
  • If the server sends PayloadTooLarge or CapacityExceeded, the SDK surfaces it as a backpressure error; it does not change the connection state.

Note

Incomplete inputs are rejected as “uncertain outcome” when the socket drops, and are not automatically resent after reconnecting. best-effort signal messages are not repeated either.

Idle close (4100)

The server closes a socket that has a subscription that accepts input (interactive) and sends no player activity for a certain time.

  • The close code is 4100, the reason idle_timeout. There is no typed Error frame; only the WebSocket close frame.
  • The duration is 5 minutes by default and can be changed or turned off by the operator. Because the check interval is min(duration/4, 15 s), the close arrives at most that much later than the threshold.
  • In the menu or before the game (when there is no subscription that accepts input), the counter does not run.
  • What counts as activity is the plugin’s definition: an input frame usually counts; the application Ping, Authenticate, and the protocol-level ping do not. If your game will stay connected on a paused screen too, send the keep-alive input that the plugin defines; sending a keep-alive input on screens such as pause or garage keeps the player from being counted as AFK.
  • An open socket does not keep the session alive: the session lifetime is extended only as the player makes REST requests (security); the server re-verifies the session on the socket every 25 seconds.
  • Do not treat this close as an error: show the player “Continue”, and on confirmation get a new ticket and reconnect; thanks to the cursors the subscriptions are recovered.

If you provide the onIdleClose callback in the SDK, the client moves to the idleClosed state and does not reconnect on its own; call connect() on the user’s confirmation. If you do not provide the callback, the close is handled like an ordinary drop and the SDK reconnects itself.

import { createPlayerClient, type PlayerClient } from "@ferdiunal/open-play-ts";

/**
 * Sunucu boşta kapanışında (`4100`, `idle_timeout`) SDK kendiliğinden yeniden bağlanmaz; oyuncuya "Devam et"
 * gösterin ve onayında `connect()` çağırın. Abonelikler imleçleriyle kurtarılır.
 */
export function createClientWithIdlePrompt(
  baseUrl: string,
  environmentId: string,
  showContinueButton: (onContinue: () => void) => void,
): PlayerClient {
  const client: PlayerClient = createPlayerClient({
    baseUrl,
    environmentId,
    realtime: {
      reconnectBaseMs: 250, // ilk yeniden bağlanma denemesi 250 ms (varsayılan 1000)
      onIdleClose: () => showContinueButton(() => void client.realtime.connect()),
    },
  });
  return client;
}

Limits

LimitValueWhen exceeded
Ticket lifetime30 s, single use, bound to OriginAuthenticationFailed
Authenticate wait time5 sAuthenticationFailed, the socket closes
Authentication-phase frame4 KiBPayloadTooLarge, closes
Frame (after authentication)16 KiBPayloadTooLarge, closes
Transport-layer message64 KiBbetween 16 KiB and 64 KiB gets a typed error; above 64 KiB it drops with a socket error without a typed error
Input payloadat most 4 KiB and the module’s maxBytes valueInvalidPayload
Command payloadat most 16 KiBInvalidPayload
Per-connection frame rate40 frames/s average, a 4 s (160-frame) burst allowanceCapacityExceeded (retryable: true) and the socket closes
Open sockets per player4CapacityExceeded (retryable: false), the socket closes
Open sockets per address8CapacityExceeded (retryable: false), the socket closes (only counted if the operator puts it in observation mode)
New connections per address30/minutethe upgrade is rejected
Subscriptions per socket32 (server default)Join is rejected
Session re-verificationevery 25 sAuthenticationFailed and close if the session was revoked
Module gate re-checkevery 2 sclose with 1008 / 1013
Events replayed in recovery256ResyncRequired
Cursor retention24 hoursResyncRequired
Idle close5 min (default)4100 idle_timeout

Notes:

  • The frame rate budget is not limited to Input; every text frame received and the protocol-level Ping count as a frame (Pong does not). An honest client sends roughly 30 to 35 frames/s; stay under 40/s and do not send frames that accumulated after a pause in a batch.
  • The numbers are server defaults; the operator can change some of them (idle duration, connection limits, frame budget) with the environment configuration. If you use a hosted Open Play, ask your provider for the values.
  • The Input rate is also defined by the ratePerSecond and burst values in the module’s schema document.

Compression

  • The server supports permessage-deflate (RFC 7692) compression, and it takes effect only if the client offers it. Browsers offer it on their own; you do not need to do anything separately. It gives a noticeable reduction on similar JSON frames.
  • The operator can turn compression off; if it is off, the socket works without compression and the protocol does not change.
  • There is no compression yet in the Unity native WebSocket client; this is a known limitation. In WebGL the browser takes it on (Unity SDK).

Server-side liveness probe

The server sends a protocol-level WebSocket Ping about every 5 seconds to measure connection latency. Browsers and standard client libraries answer Pong on their own; if you write a WebSocket by hand, do not forget the Pong reply. This probe does not count as player activity.

Troubleshooting

SymptomLikely causeFix
An immediate AuthenticationFailed on every connectThe ticket was reused or 30 s passed; Origin differs from the ticket requestGet a new ticket for every connection; connect from the origin you obtained the ticket from
Closing with CapacityExceededFrame rate or connection limitLower the frame send rate; close old tabs (4 sockets per player)
A ResyncRequired loopInsisting on an old cursorDiscard the cursor, Join without cursor
A drop after 5 minutesIdle close (4100)Build a “Continue” flow or send a keep-alive input
Upgrade 401There is a query string, the subprotocol was not requested, or Origin is not allowedProtocol: open the socket

For more, see troubleshooting.