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.
- Get a new ticket. Every connection asks for a new ticket (single-use, 30 s); the old ticket cannot be reused (protocol).
Authenticate,Ready. A newsessionEpocharrives. 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 getAck: Stale.Joinagain for each subscription.- In durable streams: give the last
cursoryou received asJoin.cursor. The server replaysDurableEvents from where you left off and sends a newDurableRecoverybarrier (snapshotis empty). - In ephemeral state: there is no
cursor;Joinbrings a freshSnapshot.Inputs that were sent but did not get anAckare not replayed: their outcome is uncertain, and your game logic must continue according to the latestSnapshot. - In durable commands (
Command): the outcome of a command that dropped before anAckarrived is uncertain. Resend with the sameidempotencyKeyor query the result (calling plugins); do not generate a new key.
- In durable streams: give the last
- If
ResyncRequiredarrives (the cursor is missing, expired, belongs to another player, has a gap, or exceeded the replay budget), discard the cursor andJoinagain without acursor; 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
DurableRecoveryand everyDurableEventcarries 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
coveredEventIdsare 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):
| Behavior | Default | Setting |
|---|---|---|
| Automatic reconnect | on | autoReconnect |
| Attempt delay | min(reconnectBaseMs · 2^(n−1), 30 000) ms + [0, reconnectBaseMs) jitter | reconnectBaseMs: 1000 (between 100 and 10,000) |
| Maximum attempts | 8 | maxReconnectAttempts: between 0 and 20 |
| Authentication timeout | 5 s | authTimeoutMs (at most 5 s) |
Application Ping interval | 15 s | heartbeatMs: 10 ms to 60 s |
| Durable command timeout | 15 s | commandTimeoutMs: 1 ms to 120 s |
| Window of inputs awaiting a reply | 4 | maxPendingInputs: 1 to 8 |
| Frame size | 16 KiB | maxFrameBytes (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
PayloadTooLargeorCapacityExceeded, 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-effortsignal 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 reasonidle_timeout. There is no typedErrorframe; 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
| Limit | Value | When exceeded |
|---|---|---|
| Ticket lifetime | 30 s, single use, bound to Origin | AuthenticationFailed |
Authenticate wait time | 5 s | AuthenticationFailed, the socket closes |
| Authentication-phase frame | 4 KiB | PayloadTooLarge, closes |
| Frame (after authentication) | 16 KiB | PayloadTooLarge, closes |
| Transport-layer message | 64 KiB | between 16 KiB and 64 KiB gets a typed error; above 64 KiB it drops with a socket error without a typed error |
Input payload | at most 4 KiB and the module’s maxBytes value | InvalidPayload |
Command payload | at most 16 KiB | InvalidPayload |
| Per-connection frame rate | 40 frames/s average, a 4 s (160-frame) burst allowance | CapacityExceeded (retryable: true) and the socket closes |
| Open sockets per player | 4 | CapacityExceeded (retryable: false), the socket closes |
| Open sockets per address | 8 | CapacityExceeded (retryable: false), the socket closes (only counted if the operator puts it in observation mode) |
| New connections per address | 30/minute | the upgrade is rejected |
| Subscriptions per socket | 32 (server default) | Join is rejected |
| Session re-verification | every 25 s | AuthenticationFailed and close if the session was revoked |
| Module gate re-check | every 2 s | close with 1008 / 1013 |
| Events replayed in recovery | 256 | ResyncRequired |
| Cursor retention | 24 hours | ResyncRequired |
| Idle close | 5 min (default) | 4100 idle_timeout |
Notes:
- The frame rate budget is not limited to
Input; every text frame received and the protocol-levelPingcount as a frame (Pongdoes 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
Inputrate is also defined by theratePerSecondandburstvalues 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
| Symptom | Likely cause | Fix |
|---|---|---|
An immediate AuthenticationFailed on every connect | The ticket was reused or 30 s passed; Origin differs from the ticket request | Get a new ticket for every connection; connect from the origin you obtained the ticket from |
Closing with CapacityExceeded | Frame rate or connection limit | Lower the frame send rate; close old tabs (4 sockets per player) |
A ResyncRequired loop | Insisting on an old cursor | Discard the cursor, Join without cursor |
| A drop after 5 minutes | Idle close (4100) | Build a “Continue” flow or send a keep-alive input |
Upgrade 401 | There is a query string, the subprotocol was not requested, or Origin is not allowed | Protocol: open the socket |
For more, see troubleshooting.