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.
Sequence numbers are watermarks
Section titled “Sequence numbers are watermarks”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_idchanges.hand_seqis also monotonic metadata, not a promise that every recipient sees every value.
Snapshot ownership
Section titled “Snapshot ownership”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[].statusis the seat/connection lifecycle:active,away,sitting_out,disconnected, orempty.seats[].in_handmeans the seat was mapped into the current hand, including a player that has since folded.seats[].foldedis the authoritative current-hand fold state. It is present astrueorfalseonly whenin_hand: trueand omitted for empty or out-of-hand seats.- A sequenced
player_actionwithaction: "fold"remains the authoritative event for action history.
State hash
Section titled “State hash”table_state.state_hash covers that recipient’s snapshot. To verify it:
- Remove
ts,table_seq,hand_seq, andstate_hashfrom the top-level object. - Serialize the remaining JSON with keys sorted, non-ASCII characters escaped
(
ensure_ascii=truein Python), and compact separators (,and:with no extra whitespace), then encode that string as UTF-8. - 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.
Applying resync_response
Section titled “Applying resync_response”Apply one response atomically:
- Validate
table_idand role. - Sort
replayed_eventsbytable_seq, deduplicate them, and consume them for action history, telemetry, and hand-local markers such as folds. - Install
snapshotas the final authoritative table state. Do not mutate that final state a second time with replay events that the snapshot already includes. - For any in-hand seat whose snapshot omits
folded, reapply a compatible same-hand fold marker. Do not override an explicit snapshot value. - 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.
Reference reducer
Section titled “Reference reducer”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)Conformance scenarios
Section titled “Conformance scenarios”| 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.