The realtime gateway speaks a small JSON protocol over one WebSocket connection. This page is the wire-level specification (protocol v2) — read it to build a client for another engine, or to understand exactly what the Godot SDK sends. Transport: WebSocket text frames, UTF-8 JSON; binary frames are ignored.

Design rules

  1. One room per connection. A connection is in at most one room. Requests never carry a roomId — every room-scoped operation targets the caller's current room. Server broadcasts include roomId for context.
  2. Request → reply → broadcast. A request x gets exactly one reply (the x.…ed form) sent to the caller with the request's requestId echoed. Notifications to other members are separate broadcast types and never carry a requestId.
  3. One error shape. Every failure is a single error message, echoing the requestId whenever the failure is attributable to a request — including rate and size rejections.
  4. Lobby = public room. There is no separate lobby family; a public room is listable via room.list.
  5. kebab-case on the wire. Error codes, reasons, and enum-like values are lowercase kebab-case strings.

Envelope

Every message, in both directions, is a single JSON object:

{ "type": "message.type", "requestId": "optional-correlation-id", "payload": { } }
Field Type Required Notes
type string yes Identifies the message; unknown types are rejected
requestId string | null no Echoed on the reply (and on attributable errors)
payload object | null no Type-specific body

null fields are omitted from server-sent messages.

Authentication & identity

Connect with the project's public key:

wss://host/connect?projectKey=pk_…[&playerToken=…][&sdkVersion=…][&engine=…]
  • Without a playerToken, a new anonymous player identity is created.
  • With a valid playerToken, the same playerId is restored. A fresh token is issued on every successful connect (sliding expiration) — always store the most recent one.
  • An invalid/expired/foreign-project token rejects the handshake with HTTP 401.
  • sdkVersion and engine are optional diagnostics shown in the dashboard.

Handshake rejections: 400 not a WebSocket upgrade · 401 missing/unknown/inactive project key or bad player token · 403 Origin not allowed (when Security:AllowedOrigins is configured) · 429 per-project connection limit reached.

Shared objects

Player (roster entry)

{ "id": "player_…", "name": "Alice", "connected": true }

name is the display name given when the player created/joined (may be null). connected is false while the player is inside the disconnect grace window.

Room

{
  "id": "room_…",
  "code": "4V8772",
  "visibility": "private",
  "name": "Arena",
  "maxPlayers": 4,
  "metadata": { "mode": "ffa" },
  "hostId": "player_…",
  "players": [ { "id": "player_…", "name": "Alice", "connected": true } ],
  "state": {
    "room": { "phase": "combat" },
    "entities": [ { "id": "p1", "ownerId": "player_…", "state": { "x": 1 } } ]
  }
}
  • visibility: "private" (join by code only) or "public" (also listable/joinable via room.list).
  • name, maxPlayers, metadata are optional developer-facing attributes.
  • state is the full live-state snapshot. It is included in room.created, room.joined, match.found, and the welcome's resumedRoom; it is omitted from room.updated broadcasts (state has its own change messages).

Room summary (room.listed entries)

{ "id": "room_…", "code": "AB12CD", "name": "Arena", "visibility": "public",
  "playerCount": 2, "maxPlayers": 4, "metadata": { "mode": "ffa" } }

Session

session.welcome (server → client)

Sent once after a successful connect:

{
  "type": "session.welcome",
  "payload": {
    "connectionId": "conn_…",
    "playerId": "player_…",
    "playerToken": "player_….proj_….1750000000.<sig>",
    "tokenExpiresAtUtc": "2026-06-10T09:45:31Z",
    "serverTimeUtc": "2026-06-03T09:45:31.89Z",
    "resumedRoom": { "…": "Room, only when resuming" }
  }
}

resumedRoom is present only when the player reconnected (same playerToken) while still inside a room's grace window: the server re-attached the membership and the client should treat itself as in that room. Other members received room.player_reconnected.

pingpong

Application-level liveness; the reply echoes requestId:

{ "type": "ping", "requestId": "req_42" }
{ "type": "pong", "requestId": "req_42" }

Errors

error (server → client)

{
  "type": "error",
  "requestId": "req_7",
  "payload": {
    "code": "room-not-found",
    "message": "No room with code 'ZZZZ99'.",
    "retryable": false,
    "details": { }
  }
}

requestId is echoed whenever the offending request could be identified — including payload-too-large and rate-limited rejections (the limiter parses just far enough to recover the id). retryable: true means the same request may succeed after a short backoff.

Error codes

Code Retryable Meaning
malformed-message no Frame was not valid JSON or had no type
unknown-message-type no The type has no handler (check versions)
invalid-payload no A required field is missing or invalid
room-not-found no No such room code, or the room expired
room-full no The room is at maxPlayers
already-in-room no The connection is already in a room — leave first
not-in-room no The operation needs a current room
not-host no Only the host may do this (room.update, room state)
payload-too-large no Message exceeded Realtime:MaxMessageBytes (default 16 KB)
rate-limited yes Sending faster than allowed — back off and retry
entity-not-found no No such entity in this room's state
state-forbidden no Only the entity's owner may change it
state-limit-exceeded no Entity count cap reached (State:MaxEntitiesPerRoom)
state-too-large no Resulting state exceeds the per-entity/per-room size cap
leaderboard-invalid-board no Board name doesn't match ^[a-z0-9_.\-]{1,64}$
leaderboard-invalid-score no Score missing or outside the supported range (±2^53-1)
storage-invalid-key no Storage key is empty or too long
storage-value-too-large no Stored value exceeds the per-key size limit
storage-quota-exceeded no The player is at their stored-key quota

Rooms

Rooms are in-memory, project-scoped, identified by a short join code. Empty rooms (no connected members) expire after Realtime:EmptyRoomTtl.

room.createroom.created

{ "type": "room.create", "requestId": "c1",
  "payload": { "playerName": "Alice", "name": "Arena", "visibility": "public",
               "maxPlayers": 4, "metadata": { "mode": "ffa" } } }

{ "type": "room.created", "requestId": "c1", "payload": { "room": { "…": "Room" } } }

All payload fields optional: visibility defaults "private", maxPlayers omitted = unlimited, metadata defaults {}. The creator becomes the host. Fails with already-in-room if the connection is in a room.

room.joinroom.joined + room.player_joined

{ "type": "room.join", "requestId": "j1", "payload": { "code": "4V8772", "playerName": "Bob" } }

{ "type": "room.joined", "requestId": "j1", "payload": { "room": { "…": "Room" } } }

{ "type": "room.player_joined",
  "payload": { "roomId": "room_…", "player": { "id": "player_…", "name": "Bob", "connected": true } } }

Join by code works for public and private rooms; public rooms may also be joined by id from room.list (payload: { "id": "room_…" }). Rules:

  • room-full when maxPlayers is reached (connected + in-grace members both count).
  • already-in-room when the connection is already in a room.
  • If the same player (by playerId) is in the room but disconnected (grace), the join resumes that membership: the joiner gets room.joined, others get room.player_reconnected (not room.player_joined).

room.leaveroom.left + room.player_left

{ "type": "room.leave", "requestId": "l1" }
{ "type": "room.left", "requestId": "l1", "payload": { "roomId": "room_…" } }

{ "type": "room.player_left",
  "payload": { "roomId": "room_…", "playerId": "player_…", "reason": "left" } }

reason is "left" (explicit) or "disconnected" (grace expired). Fails with not-in-room without a room.

room.listroom.listed

Public rooms of the caller's project, most recently active first:

{ "type": "room.list", "requestId": "r1", "payload": { "limit": 20 } }
{ "type": "room.listed", "requestId": "r1", "payload": { "rooms": [ { "…": "summary" } ] } }

room.updateroom.updated (host only)

Only provided fields change; metadata replaces the whole map:

{ "type": "room.update", "requestId": "u1", "payload": { "name": "Arena II", "maxPlayers": 6 } }

{ "type": "room.updated", "payload": { "room": { "…": "Room without state" } } }

Sent to all members; the caller's copy echoes the requestId. Fails with not-host / not-in-room.

room.host_changed (server → members)

{ "type": "room.host_changed", "payload": { "roomId": "room_…", "hostId": "player_…" } }

room.closed (server → members)

Broadcast if a room is removed while it still has members in grace; rooms with zero members are removed silently:

{ "type": "room.closed", "payload": { "roomId": "room_…", "reason": "expired" } }

Disconnects (grace & resume)

When a member's socket drops, the membership is not removed immediately:

  1. The member is marked connected: false; others receive room.player_disconnected { roomId, playerId }.
  2. If the same player reconnects (token) or rejoins by code within Realtime:DisconnectGrace (default 60 s), the membership — including owned entities — is reclaimed. Others receive room.player_reconnected { roomId, player }. On token reconnect the welcome carries resumedRoom.
  3. If the grace window expires, the membership is removed: others receive room.player_left { …, "reason": "disconnected" } and the player's entities are garbage-collected (state.entity.removed { …, "reason": "owner-left" } each).

A room whose members are all disconnected expires after Realtime:EmptyRoomTtl.

Host migration

Every room has a host (hostId) — including matchmade rooms, where the first matched player is host. The host is the only member who may room.update and patch room state. Rules:

  • Host leaves or their grace expires → the earliest-joined connected member becomes host; room.host_changed is broadcast.
  • Host disconnects (enters grace) → migration happens immediately (the game must not wait a minute to write room state); if the ex-host returns they are a regular member.
  • If no member is connected, hostId keeps its last value; when the next player connects or joins, migration re-runs if the current host is not connected.

Matchmaking

Players queue; when enough players wait with the same project + mode + region + size, the server creates a room and places them in it.

match.findmatch.searching, then match.found / match.timeout

{ "type": "match.find", "requestId": "m1",
  "payload": { "mode": "duel", "region": "global", "size": 2, "playerName": "Ada" } }

{ "type": "match.searching", "requestId": "m1",
  "payload": { "mode": "duel", "region": "global", "size": 2 } }

{ "type": "match.found", "requestId": "m1", "payload": { "room": { "…": "Room" } } }

{ "type": "match.timeout", "requestId": "m1", "payload": { "mode": "duel", "region": "global" } }

All fields optional: mode defaults "default", region "global", size 2 (range 2–64). mode and region must not contain |. A second match.find replaces the first ticket. Fails with already-in-room when in a room. match.found and match.timeout echo the requestId of the match.find that created the ticket, so a client can await the outcome of its own request.

match.cancelmatch.cancelled

{ "type": "match.cancel", "requestId": "m2" }
{ "type": "match.cancelled", "requestId": "m2", "payload": { "hadTicket": true } }

Disconnecting also removes the ticket.

Events

event.sendevent.received

Fire-and-forget relay to the other members of the caller's room (sender excluded). data is opaque application JSON. There is no success reply; failures come back as error (echoing requestId when one was provided):

{ "type": "event.send", "payload": { "name": "player_fired", "data": { "dir": [0, 1] } } }

{ "type": "event.received",
  "payload": { "roomId": "room_…", "name": "player_fired",
               "data": { "dir": [0, 1] }, "senderId": "player_…" } }

State sync

Room-scoped live state, always targeting the caller's current room:

  • Room state — one JSON object per room; only the host may patch it.
  • Entity state — per-entity JSON objects; an entity is owned by its creator and only the owner may set/patch/delete it.

Patches are shallow merges: provided keys overwrite, a null value removes the key. Limits (config section State): MaxEntitiesPerRoom 50 · MaxEntityStateBytes 4 KB · MaxRoomStateBytes 16 KB · MaxStateUpdatesPerSecondPerClient 10 (burst 20).

Change messages double as acks

state.room.changed / state.entity.changed / state.entity.removed are broadcast to all members including the sender. The sender's copy echoes the request's requestId — it is both the ack and the authoritative result; there is no separate success reply.

{ "type": "state.room.patch",    "requestId": "s1", "payload": { "patch": { "phase": "combat" } } }
{ "type": "state.entity.set",    "requestId": "s2", "payload": { "entityId": "p1", "state": { "x": 1, "y": 2 } } }
{ "type": "state.entity.patch",  "requestId": "s3", "payload": { "entityId": "p1", "patch": { "x": 5 } } }
{ "type": "state.entity.delete", "requestId": "s4", "payload": { "entityId": "p1" } }

{ "type": "state.room.changed",
  "payload": { "roomId": "room_…", "patch": { }, "state": { } } }
{ "type": "state.entity.changed",
  "payload": { "roomId": "room_…", "entityId": "p1", "ownerId": "player_…", "patch": { }, "state": { } } }
{ "type": "state.entity.removed",
  "payload": { "roomId": "room_…", "entityId": "p1", "reason": "deleted" } }

state.entity.removed reasons: "deleted" (owner deleted it) · "owner-left" (owner's membership ended; the entity was garbage-collected).

Late joiners need no snapshot message — the full state arrives inside room.joined (room.state), match.found, and resumedRoom.

Leaderboards

Project-scoped ranked score boards. A board springs into existence on first submit; a player holds at most one entry per board. Board names match ^[a-z0-9_.\-]{1,64}$.

{ "type": "leaderboard.submit", "requestId": "b1",
  "payload": { "board": "highscore", "score": 4200, "mode": "max", "playerName": "Ada" } }
{ "type": "leaderboard.submitted", "requestId": "b1",
  "payload": { "board": "highscore", "score": 4200, "best": 4200, "rank": 17, "updated": true } }

{ "type": "leaderboard.top", "requestId": "b2",
  "payload": { "board": "highscore", "limit": 10, "offset": 0, "order": "desc" } }
{ "type": "leaderboard.entries", "requestId": "b2",
  "payload": { "board": "highscore", "total": 812,
               "entries": [ { "rank": 1, "playerId": "player_…", "playerName": "Ada",
                              "score": 99120, "updatedAtUtc": "…" } ] } }

{ "type": "leaderboard.around", "requestId": "b3", "payload": { "board": "highscore", "range": 5 } }
{ "type": "leaderboard.entries", "requestId": "b3",
  "payload": { "board": "highscore", "total": 812, "playerRank": 17, "entries": [ "…" ] } }
  • mode: "max" (default — keep the higher score), "min" (keep the lower; ranks read ascending), "latest" (always overwrite). updated is false when the submit didn't beat the stored score.
  • top: limit 1–100 (default 10), offset ≥ 0, order "desc" (default) / "asc".
  • around: the caller's entry ± range (1–50, default 5); playerRank is omitted when the caller has no entry (then the top of the board is returned).
  • Ties share a rank; ordering within a tie is oldest-entry-first.
  • Errors: leaderboard-invalid-board, leaderboard-invalid-score, plus the usual invalid-payload / rate-limited.

Player storage

Persistent per-player key-value storage, scoped to the connection's project and player identity. Values are arbitrary JSON, persisted across sessions (the same data as the HTTP storage API, which also accepts the playerToken as a bearer token).

{ "type": "storage.set", "requestId": "k1", "payload": { "key": "save", "value": { "level": 3 } } }
{ "type": "storage.saved", "requestId": "k1", "payload": { "key": "save", "updatedAtUtc": "…" } }

{ "type": "storage.get", "requestId": "k2", "payload": { "key": "save" } }
{ "type": "storage.value", "requestId": "k2",
  "payload": { "key": "save", "value": { "level": 3 }, "updatedAtUtc": "…" } }

{ "type": "storage.delete", "requestId": "k3", "payload": { "key": "save" } }
{ "type": "storage.deleted", "requestId": "k3", "payload": { "key": "save", "existed": true } }

{ "type": "storage.list", "requestId": "k4", "payload": { "prefix": "sav" } }
{ "type": "storage.listed", "requestId": "k4", "payload": { "keys": ["save"] } }

Missing keys read back as { "key": "save", "value": null }. Failures: storage-invalid-key, storage-value-too-large, storage-quota-exceeded.

Connection limits

Per-connection inbound limits (config section Realtime):

  • Size — messages over MaxMessageBytes (default 16 KB) → payload-too-large.
  • Rate — token bucket (MaxMessagesPerSecond 20 sustained, MessageBurst 40) → rate-limited.

Both rejections echo the request's requestId when it can be recovered.

Message type catalog

Client → server Reply (requestId echoed) Broadcast to other members
ping pong
room.create room.created
room.join room.joined room.player_joined / room.player_reconnected
room.leave room.left room.player_left
room.list room.listed
room.update room.updated (to all; caller's copy echoes id) room.updated
match.find match.searching, then match.found | match.timeout
match.cancel match.cancelled
event.send — (errors only) event.received
state.room.patch state.room.changed (to all; sender's copy echoes id) state.room.changed
state.entity.set / state.entity.patch state.entity.changed (same rule) state.entity.changed
state.entity.delete state.entity.removed (same rule) state.entity.removed
storage.get / storage.set / storage.delete / storage.list storage.value / storage.saved / storage.deleted / storage.listed
leaderboard.submit leaderboard.submitted
leaderboard.top / leaderboard.around leaderboard.entries
session.welcome (on connect) room.player_disconnected, room.host_changed, room.closed

Writing a custom client: checklist

  1. Connect to /connect?projectKey=pk_…, store the welcome's playerToken, and re-present it (plus store each newer one) on every reconnect.
  2. Correlate replies by requestId; treat an error with your requestId as that request's failure.
  3. Apply broadcasts (room.player_*, state.*, event.received, room.updated, room.host_changed, room.closed) to your local room model — including the requestId-echoing ack copies of state changes.
  4. Handle resumedRoom in the welcome as "you are in this room again".
  5. Send ping periodically (the Godot SDK uses 15 s) and treat prolonged silence as a dead connection.
  6. Respect the rate budget (20 msg/s, burst 40) and back off on rate-limited.

Versioning & stability

A breaking change to the wire shape updates the backend, the SDK, the tests, and the protocol spec together. Unknown message types are rejected with unknown-message-type — clients should log and ignore reply types they don't recognize to stay forward-compatible with additive changes.