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
- 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 includeroomIdfor context. - Request → reply → broadcast. A request
xgets exactly one reply (thex.…edform) sent to the caller with the request'srequestIdechoed. Notifications to other members are separate broadcast types and never carry arequestId. - One error shape. Every failure is a single
errormessage, echoing therequestIdwhenever the failure is attributable to a request — including rate and size rejections. - Lobby = public room. There is no separate lobby family; a
publicroom is listable viaroom.list. - 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 sameplayerIdis 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.
sdkVersionandengineare 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 viaroom.list).name,maxPlayers,metadataare optional developer-facing attributes.stateis the full live-state snapshot. It is included inroom.created,room.joined,match.found, and the welcome'sresumedRoom; it is omitted fromroom.updatedbroadcasts (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.
ping → pong
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.create → room.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.join → room.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-fullwhenmaxPlayersis reached (connected + in-grace members both count).already-in-roomwhen 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 getsroom.joined, others getroom.player_reconnected(notroom.player_joined).
room.leave → room.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.list → room.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.update → room.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:
- The member is marked
connected: false; others receiveroom.player_disconnected { roomId, playerId }. - If the same player reconnects (token) or rejoins by code within
Realtime:DisconnectGrace(default 60 s), the membership — including owned entities — is reclaimed. Others receiveroom.player_reconnected { roomId, player }. On token reconnect the welcome carriesresumedRoom. - 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_changedis 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,
hostIdkeeps 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.find → match.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.cancel → match.cancelled
{ "type": "match.cancel", "requestId": "m2" }
{ "type": "match.cancelled", "requestId": "m2", "payload": { "hadTicket": true } }
Disconnecting also removes the ticket.
Events
event.send → event.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).updatedis false when the submit didn't beat the stored score.top:limit1–100 (default 10),offset≥ 0,order"desc"(default) /"asc".around: the caller's entry ±range(1–50, default 5);playerRankis 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 usualinvalid-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 (
MaxMessagesPerSecond20 sustained,MessageBurst40) →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
- Connect to
/connect?projectKey=pk_…, store the welcome'splayerToken, and re-present it (plus store each newer one) on every reconnect. - Correlate replies by
requestId; treat anerrorwith yourrequestIdas that request's failure. - 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. - Handle
resumedRoomin the welcome as "you are in this room again". - Send
pingperiodically (the Godot SDK uses 15 s) and treat prolonged silence as a dead connection. - 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.