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

Realtime protocol

This page describes the wire protocol of the /v1/realtime/ws socket: getting a ticket, connecting, authentication, and the fields of every client and server frame. If you use the TypeScript SDK you do not write this protocol by hand; but read from here to understand the behavior, write another client, or troubleshoot.

Note

The protocol version is 1. Every frame carries protocolVersion: 1; any other value gets an UnsupportedProtocol error (versioning).

General rules

  • Every frame is a JSON object sent as a single WebSocket text message. Binary messages are not accepted.
  • The type field determines the frame type and is PascalCase (Authenticate, Join, …). The other field names are camelCase. A frame that contains an unknown field is rejected.
  • Large integers (sequence numbers, epoch, revision, version) are decimal text, not JSON numbers: "sessionEpoch": "7". Canonical form: non-empty, no leading zero, digits only, non-negative, within the i64 range. Details: numbers and encoding. protocolVersion and schemaVersion are small integers and are sent as JSON numbers.
  • requestId (UUID) is the correlation ID that the client generates for every attempt; the server returns the response and the error with the same ID.
  • Before authentication only Authenticate is valid, and afterwards frames other than Authenticate are valid; a frame in the wrong phase gets UnexpectedFrame.

Connection flow

sequenceDiagram
    participant C as "Client"
    participant H as "HTTP (REST)"
    participant S as "WebSocket /v1/realtime/ws"
    C->>H: "POST /v1/realtime/tickets"
    H-->>C: "200 ticket, expiresAt"
    C->>S: "Upgrade (Sec-WebSocket-Protocol openplay.realtime.v1)"
    C->>S: "Authenticate(protocolVersion 1, ticket)"
    S-->>C: "Ready(connectionId, playerId, sessionEpoch, authorityGeneration)"
    C->>S: "Join(pluginId, moduleId, target)"
    S-->>C: "Joined(subscriptionId)"
    S-->>C: "Snapshot or DurableRecovery"
    C->>S: "Input or Command"
    S-->>C: "Ack or CommandResult"

1. Get a ticket

POST /v1/realtime/tickets HTTP/1.1
Host: game.example.com
Origin: https://game.example.com
x-crx-env: 0192a3b4-0000-7000-8000-000000000001
Cookie: __Secure-crx.session=<session>; __Secure-crx.csrf=<csrf-cookie>
x-csrf-token: <csrf-token>
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json

{"ticket":"tkt_AbC123-xyz","expiresAt":"2026-11-03T10:15:30+00:00","protocol":"openplay.realtime.v1"}
HeaderRequiredNote
x-crx-envyesThe player’s environment. Must match the session’s environment.
OriginyesMust be one of the environment’s allowed origins; the ticket is bound to this origin.
Cookie or Authorization: Bearer <session token>yesIf a cookie is present, cookie mode is always chosen; with a broken cookie it does not fall back to Bearer.
x-csrf-token + the __Secure-crx.csrf cookieonly in cookie modeDouble submit. A new CSRF value is returned on every successful request; store the x-csrf-token header and the Set-Cookie in the response (sessions and CSRF).
  • The ticket is single-use, valid for 30 seconds, and bound to the Origin. Get it right before opening the socket; do not hold on to it.
  • On this endpoint, identity, CSRF, and Origin errors get the same response: 403 and the plain text permission denied. If the module is disabled (plugins), 404 and {"code":"module_disabled",…}.

2. Open the socket

  • Address: wss://<your-game-domain>/v1/realtime/ws. There must be no query string; if there is, the upgrade is rejected.
  • Sec-WebSocket-Protocol: openplay.realtime.v1 must be requested; the server selects it. If it is not requested, the upgrade is rejected.
  • Origin is required and must be one of the environment’s allowed origins or in the global allowed list. The browser sends it itself.
  • An upgrade rejection is 401 (plain text unauthorized); if the module is disabled, 404.
  • There are per-address and per-player connection limits (limits).

3. Send Authenticate

The first frame must arrive within 5 seconds after the socket opens; if it does not, the server writes an AuthenticationFailed error and closes. A frame in the authentication phase can be at most 4 KiB.

{"type":"Authenticate","protocolVersion":1,"ticket":"tkt_AbC123-xyz"}

The server consumes the ticket (verifying the origin and the subprotocol), re-verifies the session, and replies:

{"type":"Ready","protocolVersion":1,"connectionId":"0192a3b4-0000-7000-8000-0000000000a1","playerId":"0192a3b4-0000-7000-8000-0000000000b2","sessionEpoch":"7","authorityGeneration":"42"}
FieldMeaning
connectionIdThe server’s ID for this socket.
playerIdThe authenticated player.
sessionEpochThis socket’s write epoch. It is given back unchanged in Input frames. If a newer socket of the same player starts writing to the same resource, the old socket’s inputs get Stale.
authorityGenerationThe environment’s authority generation; it is for server-side limiting, the client only stores it.

A ticket that is invalid, used, expired, or belongs to another origin is rejected with AuthenticationFailed; the server closes the socket. Get a new ticket and reconnect.

Client frames

They can be sent after Ready. The frame size is at most 16 KiB; a frame that exceeds it gets PayloadTooLarge.

Join: join a subscription

{"type":"Join","protocolVersion":1,"requestId":"0192a3b4-0000-7000-8000-000000000010","pluginId":"package-ride","moduleId":"package-ride.street","target":{"kind":"Scene","sceneId":"0192a3b4-0000-7000-8000-000000000031"}}
FieldTypeMeaning
requestIdUUIDThe attempt ID.
pluginIdtextThe plugin that owns the module (plugins).
moduleIdtextThe realtime module the plugin defines; it is listed in the modules section of the plugin’s schema document.
target.kindtextPlayerSelf, Room, Scene, or InterestSet.
target.roomId / sceneId / interestSetIdUUIDThe target’s ID (according to kind; PlayerSelf carries no ID).
cursorUUID, optionalAn opaque resume cursor from a previous DurableRecovery.

The server authorizes the target; a raw Redis key or topic name is not given. Success: Joined. After that, a Snapshot or DurableRecovery arrives depending on the module’s type.

Leave: leave a subscription

{"type":"Leave","protocolVersion":1,"requestId":"…","subscriptionId":"…"}

The response is Left; a repeated Leave is harmless.

Input: transient input

Player input (movement, steering) in server-authoritative modules. Loss is not acceptable, a sequence number is required.

{"type":"Input","protocolVersion":1,"requestId":"…","pluginId":"arena","moduleId":"arena.movement","messageType":"arena.movement.input","schemaVersion":1,"subscriptionId":"…","sessionEpoch":"7","inputSeq":"42","payload":{"delta":1}}
FieldMeaning
messageType, schemaVersionMust match exactly the input definition in the module’s schema document.
subscriptionIdThe subscription from Joined.
sessionEpochThe value in Ready.
inputSeqAn increasing sequence number (decimal text). If the same number and the same content are sent again you get Replayed; the same number with different content gets Mismatch.
payloadAn object that conforms to the plugin’s input schema. Its encoded size is at most 4 KiB and the module’s maxBytes value; exceeding it gets InvalidPayload.

The input rate is defined by the ratePerSecond and burst values in the module’s schema document; a general per-connection frame budget is applied separately (limits).

Command: durable command

A command that writes durably with a database transaction (reward, delivery); the result returns only after the transaction is committed. It is subject to the same rules as the HTTP command endpoint (calling plugins).

{"type":"Command","protocolVersion":1,"requestId":"…","pluginId":"package-ride","moduleId":null,"messageType":"package-ride.progression-missions.claim.input","schemaVersion":1,"idempotencyKey":"claim-7f3a","expectedVersion":"12","payload":{"missionId":"daily-1"}}
FieldMeaning
idempotencyKeyThe required logical replay key. The same key and the same content return the same result (idempotency).
expectedVersionOptional optimistic version (decimal text). A mismatch is an error.
moduleIdThe module context for room-scoped commands; otherwise null.
payloadConforms to the plugin’s command schema; its encoded size is at most 16 KiB, exceeding it gets InvalidPayload.

Ping

{"type":"Ping","protocolVersion":1,"nonce":"a1"}

The response is {"type":"Pong","nonce":"a1"}. nonce is optional. The application-level Ping measures connection liveness; it does not count as player activity and does not postpone idle close (idle close).

Server frames

typeWhenFields
ReadyAuthenticate succeededprotocolVersion, connectionId, playerId, sessionEpoch, authorityGeneration
JoinedJoin was authorizedrequestId, subscriptionId, target
LeftLeave was processedrequestId, subscriptionId
CommandResultCommand was committedrequestId, messageType, schemaVersion, payload
AckInput was processedrequestId, inputSeq, status, revision (optional)
SnapshotTransient state broadcastsubscriptionId, resourceId, messageType, schemaVersion, streamEpoch, revision, payload
DurableRecoveryRecovery barrier for a durable streamsubscriptionId, cursor, barrierSequence, coveredEventIds, snapshot (may be empty)
DurableEventA durable, ordered plugin eventsubscriptionId, streamId, sequence, eventId, messageType, schemaVersion, cursor, payload
PongThe reply to Pingnonce
ErrorErrorrequestId (if any), code, retryable

Ack statuses

statusMeaningWhat to do
AppliedThe input advanced the state. revision is the new revision.—
ReplayedThe same sequence number and content had already been processed.Ignore it; a safe replay.
StaleThe sequence number is behind the current writer or a newer socket took over.Drop this input; if there is a new socket, this socket is no longer the writer.
MismatchThe same sequence number arrived with different content.It is a client bug; do not regenerate the sequence number, do not repeat.
ResyncRequiredA sequence gap or an unrecoverable state.Join again for a fresh snapshot (without cursor).

revision may be absent other than for Applied and Replayed. When the field is absent, null is not sent; the field is simply not there.

Snapshot and DurableRecovery

  • A Snapshot is transient state: streamEpoch (increases when the server cache restarts) and revision (monotonic) are ordered together. Within the same streamEpoch, an older revision must not overwrite the new state. If streamEpoch has changed, a new stream has started; do not compare with the old revision.
  • DurableRecovery is the baseline in a durable stream: cursor is the new cursor tied to this point, barrierSequence is the stream start covered by the snapshot and the replay, and coveredEventIds are the event IDs that the snapshot already contains (do not apply them separately). snapshot is filled only on a fresh Join; it is absent when resuming with cursor.
  • DurableEvents arrive in sequence order; eventId is the end-to-end deduplication key, and cursor is the new cursor advanced up to that event. Store the cursor: when reconnecting you provide it as Join.cursor (reconnect).

Error frame

{"type":"Error","requestId":"0192a3b4-0000-7000-8000-000000000010","code":"InvalidPayload","retryable":false}

The error frame carries no reason text: only the code and retryable. Even a plugin rejection (for example a business rule) appears as InvalidPayload; the reason is not given to the client over the wire.

codeWhenretryableSocket
UnexpectedFrameA frame that does not fit its phase (for example Join before authentication, or a second Authenticate)nocloses
UnsupportedProtocolprotocolVersion is not 1nocloses
InvalidPayloadBroken JSON, unknown field, a payload that does not conform to the schema, a plugin/business rule rejection, no such resource, a version conflict, the module is disablednostays open on a per-frame error; closes on a parse error
AuthenticationFailedThe ticket is invalid/used/expired, the 5 s timeout, the session was revokednocloses
PermissionDeniedNo authority for the target, module, or operation; a banned accountnostays open
PayloadTooLargeExceeded the frame budget (4 KiB authentication, 16 KiB afterwards)nocloses
CapacityExceededThe rate or capacity limit is fullyes at the frame budget and the rate limit; no at the per-player/per-address socket count limitcloses at the frame budget and the socket count limit
UnavailableA dependency is temporarily unavailable or the placement is not readyusually yes (no for the IdempotencyExpired case)depends
ResyncRequiredThe cursor is missing, expired, mismatched, has a gap, or exceeded the replay budget—stays open

Warning

Do not infer whether the socket will close from retryable alone. After an Error frame, the client must learn whether the socket is still open from the connection state (the close event). On long-lived sockets, per-frame errors (InvalidPayload, PermissionDenied) do not drop the connection.

Close codes

CodeReasonMeaning
4100idle_timeoutNo player activity arrived; idle close (details). There is no typed Error frame.
1008module_disabledThe plugins module was disabled.
1013unavailableThe module gate could not be verified; retry later.

Example: connecting by hand (summary)

In the browser, use the SDK; the skeleton below shows the protocol order.

const res = await fetch("/v1/realtime/tickets", {
  method: "POST",
  credentials: "include",
  headers: { "x-crx-env": environmentId, "x-csrf-token": csrfToken },
});
const { ticket } = await res.json();
const ws = new WebSocket(`wss://${location.host}/v1/realtime/ws`, ["openplay.realtime.v1"]);
ws.onopen = () => ws.send(JSON.stringify({ type: "Authenticate", protocolVersion: 1, ticket }));
ws.onmessage = (event) => {
  const frame = JSON.parse(event.data as string);
  if (frame.type === "Ready") {
    // Send Join, store frame.sessionEpoch for Input frames.
  }
};

Next step

For how to resume with the cursor and epoch when the connection drops, limits, and compression, see reconnect and limits.