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 carriesprotocolVersion: 1; any other value gets anUnsupportedProtocolerror (versioning).
General rules
- Every frame is a JSON object sent as a single WebSocket text message. Binary messages are not accepted.
- The
typefield determines the frame type and isPascalCase(Authenticate,Join, …). The other field names arecamelCase. 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 thei64range. Details: numbers and encoding.protocolVersionandschemaVersionare 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
Authenticateis valid, and afterwards frames other thanAuthenticateare valid; a frame in the wrong phase getsUnexpectedFrame.
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"}
| Header | Required | Note |
|---|---|---|
x-crx-env | yes | The player’s environment. Must match the session’s environment. |
Origin | yes | Must be one of the environment’s allowed origins; the ticket is bound to this origin. |
Cookie or Authorization: Bearer <session token> | yes | If 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 cookie | only in cookie mode | Double 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:
403and the plain textpermission denied. If the module is disabled (plugins),404and{"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.v1must be requested; the server selects it. If it is not requested, the upgrade is rejected.Originis 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 textunauthorized); 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"}
| Field | Meaning |
|---|---|
connectionId | The server’s ID for this socket. |
playerId | The authenticated player. |
sessionEpoch | This 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. |
authorityGeneration | The 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"}}
| Field | Type | Meaning |
|---|---|---|
requestId | UUID | The attempt ID. |
pluginId | text | The plugin that owns the module (plugins). |
moduleId | text | The realtime module the plugin defines; it is listed in the modules section of the plugin’s schema document. |
target.kind | text | PlayerSelf, Room, Scene, or InterestSet. |
target.roomId / sceneId / interestSetId | UUID | The target’s ID (according to kind; PlayerSelf carries no ID). |
cursor | UUID, optional | An 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}}
| Field | Meaning |
|---|---|
messageType, schemaVersion | Must match exactly the input definition in the module’s schema document. |
subscriptionId | The subscription from Joined. |
sessionEpoch | The value in Ready. |
inputSeq | An 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. |
payload | An 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"}}
| Field | Meaning |
|---|---|
idempotencyKey | The required logical replay key. The same key and the same content return the same result (idempotency). |
expectedVersion | Optional optimistic version (decimal text). A mismatch is an error. |
moduleId | The module context for room-scoped commands; otherwise null. |
payload | Conforms 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
type | When | Fields |
|---|---|---|
Ready | Authenticate succeeded | protocolVersion, connectionId, playerId, sessionEpoch, authorityGeneration |
Joined | Join was authorized | requestId, subscriptionId, target |
Left | Leave was processed | requestId, subscriptionId |
CommandResult | Command was committed | requestId, messageType, schemaVersion, payload |
Ack | Input was processed | requestId, inputSeq, status, revision (optional) |
Snapshot | Transient state broadcast | subscriptionId, resourceId, messageType, schemaVersion, streamEpoch, revision, payload |
DurableRecovery | Recovery barrier for a durable stream | subscriptionId, cursor, barrierSequence, coveredEventIds, snapshot (may be empty) |
DurableEvent | A durable, ordered plugin event | subscriptionId, streamId, sequence, eventId, messageType, schemaVersion, cursor, payload |
Pong | The reply to Ping | nonce |
Error | Error | requestId (if any), code, retryable |
Ack statuses
status | Meaning | What to do |
|---|---|---|
Applied | The input advanced the state. revision is the new revision. | — |
Replayed | The same sequence number and content had already been processed. | Ignore it; a safe replay. |
Stale | The 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. |
Mismatch | The same sequence number arrived with different content. | It is a client bug; do not regenerate the sequence number, do not repeat. |
ResyncRequired | A 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
Snapshotis transient state:streamEpoch(increases when the server cache restarts) andrevision(monotonic) are ordered together. Within the samestreamEpoch, an olderrevisionmust not overwrite the new state. IfstreamEpochhas changed, a new stream has started; do not compare with the oldrevision. DurableRecoveryis the baseline in a durable stream:cursoris the new cursor tied to this point,barrierSequenceis the stream start covered by the snapshot and the replay, andcoveredEventIdsare the event IDs that the snapshot already contains (do not apply them separately).snapshotis filled only on a freshJoin; it is absent when resuming withcursor.DurableEvents arrive insequenceorder;eventIdis the end-to-end deduplication key, andcursoris the new cursor advanced up to that event. Store the cursor: when reconnecting you provide it asJoin.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.
code | When | retryable | Socket |
|---|---|---|---|
UnexpectedFrame | A frame that does not fit its phase (for example Join before authentication, or a second Authenticate) | no | closes |
UnsupportedProtocol | protocolVersion is not 1 | no | closes |
InvalidPayload | Broken 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 disabled | no | stays open on a per-frame error; closes on a parse error |
AuthenticationFailed | The ticket is invalid/used/expired, the 5 s timeout, the session was revoked | no | closes |
PermissionDenied | No authority for the target, module, or operation; a banned account | no | stays open |
PayloadTooLarge | Exceeded the frame budget (4 KiB authentication, 16 KiB afterwards) | no | closes |
CapacityExceeded | The rate or capacity limit is full | yes at the frame budget and the rate limit; no at the per-player/per-address socket count limit | closes at the frame budget and the socket count limit |
Unavailable | A dependency is temporarily unavailable or the placement is not ready | usually yes (no for the IdempotencyExpired case) | depends |
ResyncRequired | The 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
retryablealone. After anErrorframe, 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
| Code | Reason | Meaning |
|---|---|---|
4100 | idle_timeout | No player activity arrived; idle close (details). There is no typed Error frame. |
1008 | module_disabled | The plugins module was disabled. |
1013 | unavailable | The 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.