Docs/WebSocket Protocol
TurnKit Relay

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/ws

Auth

Authorization: Bearer <relayToken>
?token=<relayToken>

Format

Text frames with JSON. Every message uses a top-level type.

Handshake

  • POST /v1/client/relay/queue returns relayToken alongside sessionId and slot.
  • Use that relayToken when 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)}`
)
Note: Browser WebSocket APIs do not allow custom headers. In browsers, use ?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

TypePayloadRules
PING{ "type": "PING" }Records heartbeat and returns PONG.
MOVEjson, shouldEndMyTurn, actionsAccepted only while session is active. Round robin sessions reject moves from non-active players. Optional json is capped at 1024 bytes.
VOTEmoveNumber, isValidOnly 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.
RECONNECTlastMoveNumberRequests 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

ActionFields
SPAWNitems, toList
MOVEselector, fromList, toList, repeat, ignoreOwnership
REMOVEselector, fromList, repeat, ignoreOwnership
SHUFFLElist

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

TypePurpose
MATCH_STARTEDFull session snapshot for that player, including visible list contents, active player, seed, and current move number.
MOVE_MADECommitted move delta broadcast to connected players.
TURN_CHANGEDSent when the active player changes in round robin mode.
VOTE_FAILEDSync vote failed. Includes failed move number and configured fail action.
GAME_ENDEDTerminal state. Socket is closed after broadcast.
PONGHeartbeat acknowledgement.
SYNC_COMPLETEReconnect catch-up finished for the current server move number.
ERRORRequest 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 RECONNECT with lastMoveNumber equal to the server move number, the server only returns SYNC_COMPLETE.
  • If the client is behind but still within the last 10 moves, the server replays missed MOVE_MADE messages.
  • If the client is further behind, the server sends a fresh MATCH_STARTED snapshot 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

CodeMeaning
NOT_ACTIVEMove was sent before the session became active.
SYNC_WINDOWPlayer is still inside the post-reconnect sync delay.
NOT_YOUR_TURNRound robin mode rejected a move from a non-active player.
PAYLOAD_TOO_LARGEMOVE.json exceeded 1024 bytes.
INVALID_JSONThe optional json payload could not be serialized.
ACTION_FAILEDAn action was invalid for the current authoritative state.
STALE_SOCKETMessage or disconnect came from a superseded socket.
SUPERSEDED_CONNECTIONAn 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 PING on an interval shorter than 15 seconds.
  • Treat serverMoveNumber as the authoritative cursor for reconnects.
  • Do not assume hidden lists contain real slugs. Invisible items arrive with empty slug values in snapshots.
  • Expect the socket to close after GAME_ENDED and after stale socket rejection.