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

  • Join Relay over POST /v1/client/relay/queue first. That response returns relayToken, sessionId, slot, and status.
  • Queue join defaults to fillPolicy=REQUIRE_ALL_PLAYERS. For delegated fills, send fillPolicy=ALLOW_DELEGATED_SLOTS with delegatedFillAfterSeconds.
  • Use that relayToken when opening /v1/client/relay/ws.
  • Authenticate with either Authorization: Bearer <relayToken> or ?token=<relayToken>.
Player auth policyQueue callWebSocket call
NO_AUTHAuthorization: Bearer <client-key> plus X-Player-IdrelayToken
AUTH_REQUIREDExchange auth first, then queue with Authorization: Bearer <player-jwt>relayToken
Relay queue auth now depends on your configured player auth policy. Only the WebSocket hop itself uses the short-lived 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)}`
)
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

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

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

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_STARTEDSent when the active player changes in round robin mode.
MOVE_REQUESTED_FOR_PLAYERTimeout delegation request. Includes optional revealed private lists for other connected players.
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 }
  ],
  "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 RECONNECT with lastMoveNumber equal to the server move number, the server only returns SYNC_COMPLETE.
  • Reconnect is accepted only when move gap is either 0 or between 1 and reconnectMoveHistorySize inclusive.
  • If gap exceeds reconnectMoveHistorySize, reconnect is rejected with RECONNECT_MOVE_GAP_TOO_LARGE.
{ "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.
DELEGATED_MOVE_REQUIREDSession is waiting for delegated move; non-delegated move was rejected.
RECONNECT_MOVE_GAP_TOO_LARGEReconnect move gap exceeded configured reconnectMoveHistorySize.
RECONNECT_EXPIREDSaved reconnect state is no longer valid and the match cannot be resumed.
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

  • Treat Relay as a two-stage auth flow: queue with your current client credential, then open the socket with the returned relayToken.
  • 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.
WebSocket Protocol - TurnKit Docs