Transport protocol (build your own client)
When your engine or language has no official SDK yet (such as Godot's GDScript, Unreal, or native iOS / Android), you can implement a game client yourself over WebSocket following this page: first join the room over HTTPS to get the game node's address and a connect ticket, then connect to the node over WebSocket and exchange binary messages. On platforms with an official SDK, use the SDK: Web, Unity.
Only the WebSocket channel is public
Game nodes also have a UDP channel (KCP + encryption) with lower latency, which only the official SDKs implement and this page doesn't describe. Use WebSocket for your own implementation: TLS handles encryption, nearly every engine ships a WebSocket client, and it usually takes a day or two.
A complete connection:
1. HTTPS POST /client/v1/sessions 登录 IM,拿到 access_token(已登录的跳过)
2. HTTPS POST /client/v1/game/rooms/{room_id}/join 带上业务给的票据,拿到 connect.ws_url 和 connect.connect_ticket
3. WSS 连接 connect.ws_url(子协议 im.game.v1)
4. WSS → 第一条二进制消息:握手(HELLO + 连接票据)
5. WSS ← WELCOME:座位、成员、当前帧号、resume_token
6. WSS ⇄ INPUT / FRAME / CUSTOM / PING / PONG … 每条二进制消息就是一条协议消息| To do this | See |
|---|---|
| Join and leave rooms, find the current room | HTTPS API |
| Connect to the node and handshake | Connection and handshake |
| Encode messages to bytes and decode bytes to messages | Message format |
| Send inputs, process frames in order, request missing frames | Frame sync |
| Heartbeats and recovering after a disconnect | Heartbeats and recovery |
| Handle errors returned by the node | Error codes |
| Check your implementation against test data | Test data |
What you need to implement
| Part | Description | Effort |
|---|---|---|
| HTTPS calls | Sign in, join (C1), leave (C2), find (C3); JSON | Small |
| Message encoding | 18 messages, big-endian integers, length-prefixed byte strings | Small, a few hundred lines |
| Frame ordering | Sort by frame number, drop duplicates, request missing frames | Small |
| Heartbeats and recovery | PING every second; after a disconnect resume with resume_token, and call C1 again if that fails | Medium |
HTTPS API
The endpoints are under $IM_API and carry the User Token from sign-in: Authorization: Bearer <access_token>. Sign-in and token refresh are described in client sign-in. The error format is the same as the server error codes, with the reason in details.reason.
Join a room (C1)
POST /client/v1/game/rooms/{room_id}/join
{"client_session_id": "join-6f2a01b7-468e", "ticket": "gtk_9f3KqZ1mXb2…", "transport": "ws"}| Field | Description |
|---|---|
client_session_id | ID of this join, 8–64 visible ASCII characters (0x21–0x7E), generated by you. It must stay the same for retries within the same join and when fetching a new connect ticket after a disconnect; use a new one after the app restarts |
ticket | The join ticket your server issued to this player. Omit it for open rooms |
transport | Pass ws |
It returns 201 (first join) or 200 (repeated by the same sign-in session; the seat stays the same):
{
"room": {"room_id": "rank-8812", "status": "open", "sync_mode": "frame", "tick_rate": 30, "max_players": 4, "reconnect_window_seconds": 60},
"member": {"seat": 0, "username": "alice", "status": "joined"},
"connect": {
"node_id": "game-e1",
"ws_url": "wss://game-e1.game.example.com:7001/game/v1/ws",
"connect_ticket": "AQ3k…",
"protocol_version": 1,
"expires_at": "2026-11-10T08:01:00.000Z"
}
}| Field | Description |
|---|---|
connect.ws_url | The game node's WebSocket URL |
connect.connect_ticket | The connect ticket, sent unchanged to the node in the handshake. Valid for 60 seconds and usable once; call C1 again for a new one when it expires or has been used |
connect.protocol_version | The protocol version, currently 1 |
room.reconnect_window_seconds | How long the seat is kept after a disconnect, which bounds how long you keep reconnecting |
The response also contains connect.udp, connect.session_key, connect.key_id and connect.transport, which only the UDP channel uses; ignore them with WebSocket. session_key is sensitive; don't write it to logs.
If the player doesn't connect to the node within 60 seconds after getting a seat, the seat is released (connect_timeout).
| Response | Cause | What to do |
|---|---|---|
403 permission_denied (no reason) | The join ticket is invalid, expired or already used | Have your server issue a new one |
403 permission_denied + game_disabled / plan_feature_disabled | The platform hasn't opened game rooms, or the plan doesn't include them | Say the match isn't available right now |
404 not_found, 409 conflict + room_not_open | The room doesn't exist or has ended | Say the match has ended |
409 conflict + room_full / seat_taken | The room is full, or the pinned seat is taken | Say the room is full |
409 conflict + joined_on_other_device | The same account already joined on another device | Say the match was entered on another device |
503 unavailable + game_node_unavailable | The game node hosting the room is temporarily unavailable | Retry later |
401 unauthenticated | The User Token has expired | Refresh it and retry once |
Leave a room (C2)
POST /client/v1/game/rooms/{room_id}/leave, no request body. It can be called repeatedly; it returns {"member": null} if the user never joined. To leave, send LEAVE on the WebSocket first, then call this endpoint.
Find the current room (C3)
GET /client/v1/game/current returns the room this sign-in session hasn't left, {"room": {"room_id", "status"}, "member": {...}}, or null for both when there is none. After the app restarts, use it to find the match, then call C1 with the same room_id (no new ticket needed; use a new client_session_id).
This requires keeping the original sign-in session: store the tokens from sign-in and keep using them after a restart. Signing in again creates a new sign-in session; C3 won't find the old room and C1 returns joined_on_other_device.
Connection and handshake
- Open a WebSocket connection to
connect.ws_urlwith the subprotocolim.game.v1. - Once connected, send the first binary message (the handshake):
| Offset | Length | Content |
|---|---|---|
| 0 | 1 | kind: 1 = HELLO (join with the connect ticket), 2 = RESUME (recover after a disconnect, see recovery) |
| 1 | 2 | Byte length of the token, big-endian |
| 3 | n | Token: for HELLO, the UTF-8 bytes of the connect_ticket string; for RESUME, the raw bytes of resume_token |
| 3+n | … | One HELLO or RESUME message (encoded as in message format) |
- The node's first reply is
WELCOME(success) orERROR(failure, see error codes); the node doesn't reply to the handshake again after that.
例:connect_ticket 为 "AQ3k",HELLO 的 client_nonce = 0x0123456789ABCDEF,sdk_version = "godot/0.1"
01 kind = HELLO
00 04 token 长度 = 4
41 51 33 6B "AQ3k"
01 消息类型 HELLO
01 23 45 67 89 AB CD EF client_nonce(u64,大端)
09 sdk_version 长度(uvarint)
67 6F 64 6F 74 2F 30 2E 31 "godot/0.1"- The node disconnects if it doesn't receive the handshake within 10 seconds.
- Fill
client_noncewith a random number; fillsdk_versionwithplatform/version, at most 64 bytes, for troubleshooting. - After the handshake, each binary frame is one message; the node ignores text frames. Don't put several messages in one frame.
Message format
The first byte of every message is its type; the fields follow back to back in the order of the table, with no separators or padding:
| Type | Encoding |
|---|---|
u8, u16, u32, u64 | Unsigned integers, big-endian, 1, 2, 4, 8 bytes |
i8 | Signed 8-bit integer (two's complement), used only for snapshot_seat |
bool | 1 byte, 0 or 1; any other value is an error |
uvarint | Unsigned variable-length integer (low 7 bits per byte, top bit 1 means more bytes follow, least significant group first; the same as the Protocol Buffers varint) |
bytes | uvarint length + bytes |
string | Same as bytes, content in UTF-8 |
frames | uvarint frame count; each frame: u32 frame number + u8 input count + each input (u8 seat + u8 length + bytes) |
When decoding, treat a message as invalid and drop it on any overrun, length over the limit, bool other than 0 or 1, or trailing bytes. A node that receives 3 invalid messages in a row disconnects with protocol_unsupported.
Messages
| Type | Name | Direction | Fields (in order) |
|---|---|---|---|
0x01 | HELLO | Client → node (handshake only) | client_nonce: u64, sdk_version: string (≤ 64) |
0x02 | WELCOME | Node → client | conv: u32, seat: u8, sync_mode: u8, tick_rate: u8, room_state: u8, start_frame: u32, frame: u32, members: uvarint count + each (seat: u8, username: string, online: bool), resume_token: bytes (≤ 256), server_time_ms: u64, snapshot_seat: i8, max_players: u8 |
0x03 | RESUME | Client → node (handshake only) | client_nonce: u64, last_frame: u32 |
0x10 | INPUT | Client → node | ack_frame: u32, data: bytes (≤ 128) |
0x11 | FRAME | Node → client | frames (≤ 3 frames: the new frame and two redundant ones) |
0x12 | FRAME_REQ | Client → node | from: u32, to: u32 |
0x13 | FRAME_BATCH | Node → client | frames (≤ 300 frames) |
0x20 | CUSTOM | Both | to: u16, from: u8, reliable: bool, data: bytes (≤ 1024) |
0x30 | READY | Client → node | None |
0x31 | START | Client → node | None |
0x32 | ROOM_STATE | Node → client | state: u8, start_frame: u32, reason: string, result: bytes |
0x33 | MEMBER | Node → client | seat: u8, username: string, kind: u8, reason: string |
0x40 | SNAPSHOT_PUT | Client → node | frame: u32, data: bytes (≤ 65536) |
0x41 | SNAPSHOT | Node → client | frame: u32, data: bytes (≤ 65536) |
0x50 | PING | Client → node | client_ms: u64, rtt_ms: u32 |
0x51 | PONG | Node → client | client_ms: u64, server_ms: u64 |
0x60 | LEAVE | Client → node | None |
0x7F | ERROR | Node → client | code: u8, message: string, reason: string, result: bytes |
Field values:
| Field | Values |
|---|---|
sync_mode | 1 frame sync (frame), 2 relay (relay) |
room_state, ROOM_STATE.state | 1 waiting for the start, 2 in a match, 3 ending |
MEMBER.kind | 1 online, 2 disconnected (reason is network or timeout; may come back), 3 left (seat released) |
WELCOME.snapshot_seat | The seat that should provide snapshots; -1 means the room has snapshots off |
WELCOME.conv | UDP only; ignore with WebSocket |
CUSTOM.to | Bitmask of recipient seats (seat n is bit n); 0 means everyone except yourself. from is filled in by the node; send 0 |
CUSTOM.reliable | Always delivered reliably over WebSocket; the field is passed through to recipients unchanged |
ROOM_STATE.result, ERROR.result | The settlement result your server gave when ending the room, as UTF-8 JSON bytes; length 0 when there is none |
Frame sync
Starting: when the room's start_mode is all_ready, each player sends READY, and the match starts once every connected player is ready; auto starts when the first player connects; manual starts when your server calls the start API. The client START isn't open yet. At the start the node broadcasts ROOM_STATE(state=2, start_frame), and the first frame's number is start_frame. If WELCOME's room_state is already 2, the match had started when you joined.
Sending inputs: send one INPUT per frame, with data being this frame's input in your own encoding (≤ 128 bytes) and ack_frame the last frame you have processed in order. The node puts the last INPUT received within a frame period into the next frame. Don't exceed twice the frame rate per second per seat; extra inputs are dropped, and exceeding it for 10 seconds in a row removes the player from the room.
Receiving frames: the node sends one FRAME per frame containing up to 3 frames: the newest plus two redundant previous ones. Order them as below before handing them to your game logic, so they are never out of order, duplicated or skipped:
- Keep
next, the next frame you expect, starting at WELCOME'sframe + 1(from 1 whenframeis 0). - On
FRAMEorFRAME_BATCH, drop frames numbered belownextand put the rest in a buffer. - While the buffer has
next, hand it to the game logic and incrementnext; repeat. - When the newest buffered frame number is at least
next + 3(redundancy can't fill the gap), sendFRAME_REQ(from=next, to=newest-1); request only once pernext. The node replies withFRAME_BATCH(at most 300 frames at a time).
Each frame's inputs contains only the seats that had input in that frame. Rooms with sync_mode relay have no frames and use only CUSTOM.
Snapshots: when WELCOME's snapshot_seat equals your seat, you provide snapshots: send SNAPSHOT_PUT(frame, game state) at the interval set for the room. When you recover after being away more than 60 seconds (the node keeps only 60 seconds of frames), the node sends SNAPSHOT first; restore your game state from it, set next to SNAPSHOT.frame + 1, and continue with the following frames.
Heartbeats and recovery
Heartbeats: once connected, send PING(client_ms=local time in ms, rtt_ms=latest round-trip time, 0 if none) every second. The node replies immediately with PONG, echoing client_ms and adding server_ms. Round-trip time = time PONG arrives − client_ms; we suggest the median of the last 10. Server time ≈ server_ms + round-trip time / 2. The node drops the connection after 10 seconds without any message, so keep the heartbeats going.
Detecting a disconnect: treat it as disconnected when nothing has arrived for 5 seconds or the WebSocket closes.
Recovering after a disconnect
- Reconnect immediately: open a new WebSocket and handshake with
kind = 2, theresume_tokenfrom WELCOME as the token, and the messageRESUME(client_nonce=random, last_frame=last frame processed). - On success the node replies
WELCOME, then resends the frames afterlast_framewithFRAME_BATCH(sendingSNAPSHOTfirst when needed). Replace the oldresume_tokenwith the one in the new WELCOME. - When the node replies
ERROR(11 resume_rejected)or an error about the connect ticket: call C1 again with the sameclient_session_idto get a newconnect_ticket, then handshake withkind = 1. In this case the node doesn't resend frames on its own; sendFRAME_REQbased on theframein the WELCOME you receive. - If you can't connect, retry with backoff of 0, 1, 2, 4, 4… seconds until
room.reconnect_window_secondshas passed; after that the seat is released and the match is over. - Don't reconnect after
kicked,room_closed,node_drainingorseat_taken; see error codes.
Leaving: when the player quits, send LEAVE, close the WebSocket, then call C2.
End of the match: when your server ends the room, the node first broadcasts ROOM_STATE(state=3, reason, result), then sends ERROR(9 room_closed, reason, result) and disconnects a second later. reason is why it ended (such as closed_by_server, expired), and result is the settlement result.
Error codes
The node reports errors with ERROR. During the handshake it replies ERROR and closes the connection; after connecting, handle ERROR as below:
code | Name | When | What to do |
|---|---|---|---|
| 1 | ticket_invalid | Handshake: the connect ticket is invalid | Call C1 again for a new ticket and handshake once more; if it still fails, have your server issue a new join ticket |
| 2 | ticket_expired | Handshake: the connect ticket expired or was already used | Same as above |
| 3 | room_not_found | Handshake: the room isn't on this node or has ended | Stop |
| 4 | room_full | Handshake: the seat isn't available | Stop and say the room is full |
| 5 | seat_taken | Handshake: a newer join holds the seat; after connecting: the same seat connected elsewhere (reason is replaced) | Stop and say the match was entered on another device |
| 6 | protocol_unsupported | Malformed handshake, or invalid messages in a row | Check your implementation and stop |
| 7 | rate_limited | After connecting: over the sending rate for 10 seconds in a row | Handle as in recovery; the seat has usually been released by then, so resuming and rejoining fail and the match is over |
| 8 | kicked | After connecting: removed by your server | Stop; don't reconnect |
| 9 | room_closed | The match ended (see above for reason and result) | Stop and show the result |
| 10 | node_draining | The game node is going down for maintenance | Stop |
| 11 | resume_rejected | Handshake: resume_token is invalid or the seat no longer exists | Call C1 again with the same client_session_id, then handshake with HELLO |
Test data
The official SDKs use the same test data to stay consistent, and you can use it to check your encoding and frame ordering: download the test data (protocol version 1) · SHA256 checksums.
| File | Content | How to use it |
|---|---|---|
messages.json | Fields and the matching hex encoding of all 18 messages, plus inputs that must be rejected | For each valid item, encoding fields must equal hex and decoding hex must equal fields; every invalid item must fail to decode |
ws_handshake.json | Complete bytes of handshakes (HELLO, RESUME) | Combining kind, token_hex and message_hex must give hex |
frames.json | Frame ordering scenarios | Feed the frame numbers of steps in order; the frames delivered and frame requests at each step must match deliver and request |
behavior.json | Constants for timeouts, heartbeats and reconnect backoff, and handling of join and node errors | Compare with the rules on this page |
In the test data, 64-bit integers are decimal strings and byte strings are hex.
Rules and details
- A seat has only one connection at a time. If the same account handshakes again for the same seat, the node drops the old connection.
- Frame numbers are
u32, incremented every frame from the start frame; the room's maximum duration keeps them from overflowing. - The node doesn't guarantee that
PONGorFRAMEreaches you (they are lost when the connection drops); frame numbers and frame requests keep the sequence complete. - A new protocol version changes
protocol_versionreturned by C1; if you get a version you don't know, ask the user to upgrade. Ignore message types you don't recognize when decoding, so the node can add new messages later. Don't send messages not listed on this page. - This page describes protocol version 1. Limits and billing are in console, billing and limits.
