Skip to content
Platform

State Consistency & Reducers

This guide defines how a production client should combine WebSocket events with table_state snapshots. It is part of the WebSocket V2 documentation contract, reviewed on 2026-08-31.

table_seq is a table-wide monotonic sequence, not a contiguous sequence for any one recipient. The server advances it for public broadcasts, personalized player messages, state snapshots, acknowledgements, and other table-scoped messages. A player does not receive messages private to another seat, so jumps such as 41 -> 46 are normal.

For one table_id:

  • Ignore an already-applied (table_id, table_seq) pair.
  • Treat a lower sequence as stale unless it is nested inside a resync payload.
  • Do not request resync merely because next_table_seq != previous + 1.
  • Request resync after a transport reconnect, after the application detects an impossible state, or when state-hash verification fails.
  • Reset hand-local state when hand_id changes. hand_seq is also monotonic metadata, not a promise that every recipient sees every value.

table_state is authoritative for the fields it contains at that instant: board, pot, current-street bets, stacks, actor, legal hero actions, seat occupancy, and waiting state. It is not an action-history log.

Keep these meanings separate:

  • seats[].status is the seat/connection lifecycle: active, away, sitting_out, disconnected, or empty.
  • seats[].in_hand means the seat was mapped into the current hand, including a player that has since folded.
  • seats[].folded is the authoritative current-hand fold state. It is present as true or false only when in_hand: true and omitted for empty or out-of-hand seats.
  • A sequenced player_action with action: "fold" remains the authoritative event for action history.

table_state.state_hash covers that recipient’s snapshot. To verify it:

  1. Remove ts, table_seq, hand_seq, and state_hash from the top-level object.
  2. Serialize the remaining JSON with keys sorted, non-ASCII characters escaped (ensure_ascii=true in Python), and compact separators (, and : with no extra whitespace), then encode that string as UTF-8.
  3. Compute SHA-256 and prefix the lowercase hexadecimal digest with sha256:.

A mismatch means the client and server do not agree on the snapshot bytes. Request resync. A matching hash verifies a snapshot; it does not prove that the client saw every event that preceded it.

Apply one response atomically:

  1. Validate table_id and role.
  2. Sort replayed_events by table_seq, deduplicate them, and consume them for action history, telemetry, and hand-local markers such as folds.
  3. Install snapshot as the final authoritative table state. Do not mutate that final state a second time with replay events that the snapshot already includes.
  4. For any in-hand seat whose snapshot omits folded, reapply a compatible same-hand fold marker. Do not override an explicit snapshot value.
  5. Advance the receive watermark to to_table_seq (which matches the response envelope sequence in V2).

For role: "player", snapshot.hero contains valid_actions and turn_token only when that player is the current actor. Send an action using that snapshot’s hand_id, valid_actions, and turn_token. Resync reuses the existing token and does not restart or extend the turn timer. A non-acting player’s hero has no token. For role: "spectator", the snapshot does not include hero or private cards.

on_message(message):
if message.type == "resync_response":
replay = sort_and_dedupe(message.replayed_events)
for event in replay:
record_history(event)
update_hand_markers(event)
state = replace_table_state(message.snapshot)
restore_folds_only_where_snapshot_omits_folded(state, folds_for(state.hand_id))
last_table_seq = max(last_table_seq, message.to_table_seq)
return
if stale_or_duplicate(message.table_id, message.table_seq):
return
if message.type == "player_action" and message.action == "fold":
folds_for(message.hand_id).add(message.seat)
if message.type == "table_state":
state = replace_table_state(message)
restore_folds_only_where_snapshot_omits_folded(state, folds_for(message.hand_id))
else:
apply_event(message)
if message.type in {"hand_result", "table_closed"}:
clear_finished_hand_markers(message.hand_id)
Scenario Expected reducer result
Fold event, then snapshot with folded: true Seat remains folded regardless of connection status
Legacy snapshot without folded Preserve a same-hand fold marker when available
Sequence jumps forward Accept the message; do not resync for the gap alone
Duplicate or regressed sequence Ignore it as stale
Resync with replay plus snapshot Record replay history, finish on snapshot state
Player resync during own turn Use the snapshot’s hand_id, legal actions, and restored turn_token; the deadline is unchanged
Player resync outside own turn Hero has no turn_token; wait for your_turn
Spectator resync No private hero state is created
Empty seat Preserve its seat index with status: "empty"; do not create a player
New hand_id Clear prior hand’s folds, board-derived history, and turn token

Executable captured-traffic fixtures are not yet a normative part of V2. Until they are generated and tested by the server, this table and the message catalog define the documented reducer behavior.