WebSocket Protocol
Authoritative turn relay for active matches. This page reflects the current server implementation in com.turnkit.platform.relay.
Endpoint
wss://api.turnkit.dev/v1/client/relay/wsAuth
Authorization: Bearer <relayToken>?token=<relayToken>
Format
Text frames with JSON. Every message uses a top-level type.
Handshake
POST /v1/client/relay/queuereturnsrelayTokenalongsidesessionIdandslot.- Use that
relayTokenwhen opening/v1/client/relay/ws. - Authenticate with either
Authorization: Bearer <relayToken>or?token=<relayToken>.
const ws = new WebSocket(
"wss://api.turnkit.dev/v1/client/relay/ws",
[],
{
headers: {
Authorization: `Bearer ${relayToken}`
}
}
)const ws = new WebSocket(
`wss://api.turnkit.dev/v1/client/relay/ws?token=${encodeURIComponent(relayToken)}`
)?token=<relayToken>.Session Lifecycle
Connect Phase
Sessions start in CONNECTING. All players must connect within 30 seconds or the match ends with CONNECT_TIMEOUT.
Active Phase
When all players are present, each receives MATCH_STARTED. If turn enforcement is round robin, the current player timer starts immediately.
Heartbeat
The server checks heartbeats every 15 seconds. Missing 2 ping windows disconnects that socket.
Reconnect
Disconnected players enter a grace period defined by match config waitReconnectSeconds.
Client to Server
| Type | Payload | Rules |
|---|---|---|
| PING | { "type": "PING" } | Records heartbeat and returns PONG. |
| MOVE | json, shouldEndMyTurn, actions | Accepted only while session is active. Round robin sessions reject moves from non-active players. Optional json is capped at 1024 bytes. |
| VOTE | moveNumber, isValid | Only meaningful when sync voting is enabled and that move is currently pending vote. |
| END_GAME | { "type": "END_GAME" } | Session ends with END_GAME only after every player has sent it. |
| RECONNECT | lastMoveNumber | Requests delta replay or full resync after reconnect. |
MOVE Shape
{
"type": "MOVE",
"json": {
"cardId": "c_17",
"targetLane": "discard"
},
"shouldEndMyTurn": true,
"actions": [
{
"action": "MOVE",
"selector": "BY_ITEM_IDS",
"itemIds": ["c_17"],
"fromList": "hand",
"toList": "discard",
"repeat": 1,
"ignoreOwnership": false
}
]
}Action Variants
| Action | Fields |
|---|---|
| SPAWN | items, toList |
| MOVE | selector, fromList, toList, repeat, ignoreOwnership |
| REMOVE | selector, fromList, repeat, ignoreOwnership |
| SHUFFLE | list |
Selector Variants
TOP, BOTTOM, RANDOM, ALL, BY_ITEM_IDS, BY_SLUGS
{
"action": "REMOVE",
"selector": "BY_SLUGS",
"slugs": ["poison", "bleed"],
"fromList": "status",
"repeat": 1,
"ignoreOwnership": true
}Server to Client
| Type | Purpose |
|---|---|
| MATCH_STARTED | Full session snapshot for that player, including visible list contents, active player, seed, and current move number. |
| MOVE_MADE | Committed move delta broadcast to connected players. |
| TURN_CHANGED | Sent when the active player changes in round robin mode. |
| VOTE_FAILED | Sync vote failed. Includes failed move number and configured fail action. |
| GAME_ENDED | Terminal state. Socket is closed after broadcast. |
| PONG | Heartbeat acknowledgement. |
| SYNC_COMPLETE | Reconnect catch-up finished for the current server move number. |
| ERROR | Request rejected. Includes machine code, message, and current serverMoveNumber. |
MATCH_STARTED
{
"type": "MATCH_STARTED",
"sessionId": "6c151663-94a8-4f85-a7a4-a6c58d0f8fa1",
"players": [
{ "playerId": "p1", "slot": 0 },
{ "playerId": "p2", "slot": 1 }
],
"yourTurn": true,
"activePlayerId": "p1",
"lists": [
{
"name": "hand",
"ownerPlayerIds": ["p1"],
"visibleToPlayerIds": ["p1"]
}
],
"listContents": {
"hand": [
{ "id": "c_17", "slug": "fireball", "creatorSlot": 0 }
]
},
"randomSeed": 918221,
"serverMoveNumber": 0
}MOVE_MADE
{
"type": "MOVE_MADE",
"actingPlayerId": "p1",
"moveNumber": 4,
"json": {
"cardId": "c_17"
},
"changes": [
{
"type": "MOVE",
"fromList": "hand",
"toList": "discard",
"items": [
{ "id": "c_17", "slug": "fireball", "creatorSlot": 0 }
],
"actingPlayerSlot": "0"
}
]
}Reconnect Behavior
- If the client sends
RECONNECTwithlastMoveNumberequal to the server move number, the server only returnsSYNC_COMPLETE. - If the client is behind but still within the last 10 moves, the server replays missed
MOVE_MADEmessages. - If the client is further behind, the server sends a fresh
MATCH_STARTEDsnapshot instead. - After reconnect sync, the player enters a 2 second sync window. Moves from that player during that window are rejected with
SYNC_WINDOW.
{ "type": "RECONNECT", "lastMoveNumber": 12 }Error Codes
| Code | Meaning |
|---|---|
| NOT_ACTIVE | Move was sent before the session became active. |
| SYNC_WINDOW | Player is still inside the post-reconnect sync delay. |
| NOT_YOUR_TURN | Round robin mode rejected a move from a non-active player. |
| PAYLOAD_TOO_LARGE | MOVE.json exceeded 1024 bytes. |
| INVALID_JSON | The optional json payload could not be serialized. |
| ACTION_FAILED | An action was invalid for the current authoritative state. |
| STALE_SOCKET | Message or disconnect came from a superseded socket. |
| SUPERSEDED_CONNECTION | An older socket was closed because a newer one connected. |
Terminal Reasons
END_GAME, VOTE_FAIL, TIMEOUT, ALL_DISCONNECTED, CONNECT_TIMEOUT, ONE_PLAYER_LEFT
The current session package emits all of these reasons, with ALL_DISCONNECTED reserved in the protocol enum for relay-level terminal handling.
Client Guidance
- Send
PINGon an interval shorter than 15 seconds. - Treat
serverMoveNumberas the authoritative cursor for reconnects. - Do not assume hidden lists contain real slugs. Invisible items arrive with empty
slugvalues in snapshots. - Expect the socket to close after
GAME_ENDEDand after stale socket rejection.