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
- Join Relay over
POST /v1/client/relay/queuefirst. That response returnsrelayToken,sessionId,slot, andstatus. - Queue join defaults to
fillPolicy=REQUIRE_ALL_PLAYERS. For delegated fills, sendfillPolicy=ALLOW_DELEGATED_SLOTSwithdelegatedFillAfterSeconds. - Use that
relayTokenwhen opening/v1/client/relay/ws. - Authenticate with either
Authorization: Bearer <relayToken>or?token=<relayToken>.
| Player auth policy | Queue call | WebSocket call |
|---|---|---|
NO_AUTH | Authorization: Bearer <client-key> plus X-Player-Id | relayToken |
AUTH_REQUIRED | Exchange auth first, then queue with Authorization: Bearer <player-jwt> | relayToken |
relayToken. See Player Authentication for the email OTP, backend proof, and UGS exchange flows.POST /v1/client/relay/queue
Authorization: Bearer <client-key>
X-Player-Id: player-123
Content-Type: application/json
{
"slug": "tic-tac-toe",
"fillPolicy": "REQUIRE_ALL_PLAYERS"
}POST /v1/client/relay/queue
Authorization: Bearer <player-jwt>
Content-Type: application/json
{
"slug": "tic-tac-toe",
"fillPolicy": "ALLOW_DELEGATED_SLOTS",
"delegatedFillAfterSeconds": 15
}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
Match starts when queue policy conditions are met, then each player receives MATCH_STARTED. If turn enforcement is round robin, the current player timer starts immediately.
Delegated Timeout State
With onTurnTimeout=DELEGATE_MOVE, session enters WAITING_FOR_DELEGATED_MOVE after timeout.
Heartbeat
The server checks heartbeats every 15 seconds. Missing 2 ping windows disconnects that socket.
Reconnect
Reconnect accepts only move gaps allowed by reconnectMoveHistorySize.
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 or resume. |
MOVE Shape
{
"type": "MOVE_MADE",
"actingPlayerId": "p1",
"moveNumber": 1,
"json": {
"move": "alpha"
},
"changes": [
{
"type": "MOVE",
"fromList": "deck",
"toList": "hand",
"items": [
{
"id": "card-1",
"slug": "fireball",
"creatorSlot": 1
}
],
"actingPlayerSlot": "1"
}
],
"statChanges": [
{
"statName": "score",
"playerId": "p1",
"oldValue": 0.0,
"value": 3.0
}
]
}
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_STARTED | Sent when the active player changes in round robin mode. |
| MOVE_REQUESTED_FOR_PLAYER | Timeout delegation request. Includes optional revealed private lists for other connected players. |
| 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 }
],
"delegatedSlots": [],
"yourTurn": true,
"activePlayerId": "p1",
"lists": [
{
"name": "hand",
"ownerPlayerIds": ["p1"],
"visibleToPlayerIds": ["p1"]
}
],
"listContents": {
"hand": [
{ "id": "c_17", "slug": "fireball", "creatorSlot": 0 }
]
},
"randomSeed": 918221,
"serverMoveNumber": 0,
"serverNowUtcMs": 1714600000000,
"timerEndUtcMs": 1714600120000
}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. - Reconnect is accepted only when move gap is either
0or between1andreconnectMoveHistorySizeinclusive. - If gap exceeds
reconnectMoveHistorySize, reconnect is rejected withRECONNECT_MOVE_GAP_TOO_LARGE.
{ "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. |
| DELEGATED_MOVE_REQUIRED | Session is waiting for delegated move; non-delegated move was rejected. |
| RECONNECT_MOVE_GAP_TOO_LARGE | Reconnect move gap exceeded configured reconnectMoveHistorySize. |
| RECONNECT_EXPIRED | Saved reconnect state is no longer valid and the match cannot be resumed. |
| 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
- Treat Relay as a two-stage auth flow: queue with your current client credential, then open the socket with the returned
relayToken. - 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.