← Back to blog2026-07-05

Do we deal cooler hands on purpose? The architecture answer

A simple “no” is not enough. The real answer lives in the path from browser to session task, seed commit to flop / turn / river, and what actually recovers after disconnects or deploys.

Start with the precise question

“Do you deal cooler hands on purpose?” The shortest answer is: no.

But in poker software, “no” is not very useful by itself. The real question is whether the system has a path that can look at the players, the action, who is winning, and who might tilt, then choose a more dramatic next card. And if live game state sits in memory, what happens after a deploy or restart? Does some hidden recovery path “fill in” the missing hand?

I built BluffKing from a simple belief: Texas Hold'em is not just luck; it is a contest of intelligence, discipline, and courage. A good poker table should let players win or lose through range reading, bet sizing, pressure, and long-term decisions, not through a dealing system nobody can see or explain. So the goal is not to make the board runouts more dramatic. The goal is to make the system boundaries explicit: the client cannot choose cards, the server cannot swap cards mid-hand, and the dealing flow has to be explainable through code, logs, and verification.

The whole post has one main thread: does the code path contain the powers required to manufacture cooler hands? It follows the boundaries a backend engineer would care about — which request creates durable state, which connection advances live state, who is allowed to mutate GameHand — and asks at each one: can the system look at the players first and then choose a deck? Can it swap the seed or deck after dealing starts? Can it decide the turn or river after watching the postflop action? Every section below is about whether those powers exist.

Source entry point: BluffKing's open-source core is at github.com/CisaSettle/bluffking under AGPL-3.0. The backend dealing core, meaning the pure poker engine and Mental Poker verifiable dealing layer, is open and inspectable there; the WASM binding and solver wrapper are in the same entry point. When this post discusses the production server, I separate “the open core that owns rules and dealing” from “the room, WebSocket, and session-task orchestration that connects those core capabilities to the live table.” The former is the shared rules and dealing implementation; the latter handles connection management, authentication, room state, and event broadcasting.

Separate HTTP from WebSocket first

From the backend architecture view, a BluffKing table is not a browser independently simulating poker, and it is not a pile of HTTP requests mutating a hand directly. It has two interaction modes: HTTP handles request-response work such as room creation, joining, and lookup; WebSocket carries real-time commands and events. The only component allowed to advance a hand is the backend session task bound to the session_id.

That split matters. HTTP returns “which room you joined, which seat you own, and which session_id the table should attach to.” WebSocket carries “start this hand, fold, call, raise, push an event, send a snapshot.” The first layer writes durable room relationships. The second connects to the in-memory room actor.

HTTP: create / join room, return a recoverable session pointer Setup / Join UI config / room code lobbyApi POST create / join quick-join Axum handler auth / validate / seat Postgres room / user config HTTP response { session_id, code, seat } browser stores tc.session_id -> /table no cards, no GameHand here

The HTTP layer creates durable room relationships: game_sessions and session_users, with room configuration stored in game_sessions.session_config (a JSONB column). It returns session_id, but it does not deal cards or advance a hand.

In implementation terms, the frontend SetupView / JoinView calls lobbyApi.createRoom, joinByCode, or quickJoin. The backend handler authenticates the user, validates the room code or table choice, assigns a seat, and writes the database rows. After success, the frontend stores tc.session_id, seat, and room code locally, then navigates to the table. Up to this point, the backend has only created the durable fact that this user belongs to this room.

WebSocket: realtime commands enter the session task, backend broadcasts facts TableView reads tc.session_id WsTransport /ws?session_id=... ws.rs cookie + membership session task sole mutator Postgres load room / users lazy task spawn host clicks start startHand(true) ClientCmd kind=start_hand StartHand gate host / count / seats GameHand seed / deck / start RoomState / Snapshot / HandStarted / HoleCardsDealt / StreetRevealed

The WebSocket layer enters live play. ws.rs authenticates and routes the connection, the session task receives commands and broadcasts results, and the database supports room recovery plus completed-hand persistence. It does not store a full image of an in-progress half-hand.

The start-of-game path maps cleanly to the code. TableView reads tc.session_id, and WsTransport opens /ws?session_id=.... The backend ws.rs validates the cookie, the session_id, that game_sessions exists and is not ended, and that session_users says this user is a member. If there is no live task in memory, it lazily spawns a new session task from the persisted room config and membership rows; then it attaches this socket with JoinHuman and pushes RoomState / Snapshot.

When the host clicks “start hand,” the frontend sends a command such as {"kind":"start_hand","force_start":true}. It is not asking the server to deal particular cards. session.rs first runs the StartHand gate: multi-human session, host authority, online player count, bot-fill policy, seat state, and whether the resulting player list has at least two players. Only after that does it build players, choose the dealing provider, prepare seed / deck, and call GameHand::new_with_rng(...) plus GameHand::start().

The main backend invariant is this: the client can submit intent, not facts. It can say “I want to fold / call / raise / start hand,” but legality, turn order, pot changes, street transitions, and the next public card are decided by the engine state owned by the session task. The browser receives the result as action_applied, street_revealed, hand_finished, and snapshot.

Hole cards are not sent as one global table state to everyone. When the backend builds a snapshot, it first resolves which seat the viewer owns, then fills hole cards only for that seat — snapshots never carry other seats' hole cards, even after showdown. Showdown reveals travel on a separate event: hand_finished carries revealed_hole_cards for the contested-showdown winners only; everyone else mucks by default and can opt in to show, delivered as a follow-up cards_shown. hole_cards_dealt events are translated the same way: your event carries your two cards; other players' private cards are not included.

One common misunderstanding: session_id is not the room code, and the client does not compute it from the room code, user id, or timestamp. It is the game_sessions primary key, generated server-side by gen_random_uuid() — a routing pointer that binds the durable room row to the live session task, while the 6-digit room_code is the human-facing lookup code. It is also not a bearer token: the WS upgrade first resolves the current user_id from the HttpOnly session cookie, then checks session_users(session_id, user_id) for active membership and loads the seat from that row — so knowing a session_id alone reveals nothing, and changing local tc.session_id does not make the backend send an opponent's hole cards. The genuinely dangerous case is a stolen login cookie or same-origin script: that is account takeover, and it sees only the cards your identity was already allowed to receive.

Before dealing, fix the randomness

The default dealing path is server-authoritative: the server owns the deck and advances the game. But “the server can see the cards” is not the same as “the server picks cards for specific players.” This step happens after the start_hand command enters the session task and passes the gate. Before the hand actually starts, the system fixes the random material for that hand and sends a commitment to clients.

  1. Create the hand_id. The identity of the hand exists before the shuffle, and seed derivation is bound to it.
  2. Generate a 32-byte server_seed. The source is operating-system secure randomness, not a timestamp or simple PRNG seed.
  3. Broadcast seed_commit first. Clients receive a hash of server_seed, not the seed itself. The server locks in “this is the secret I will use for this hand” before revealing it.
  4. Let clients submit per-hand client_seed. A browser that supports the provably fair path generates a fresh 32-byte random seed only after it sees the commit, then sends it back. It is not a cached seed from connect time; it is fresh per hand so the server cannot grind the deck while already knowing the client entropy.
  5. Derive deck_seed. The backend mixes server_seed, the accepted client_seed values for this hand, and hand_id in a fixed order.

The hash here is not a short password users are supposed to “reverse.” server_seed is a 32-byte value generated from operating-system secure randomness, so the search space is 2^256. Under normal cryptographic assumptions, seeing seed_commit does not let a client recover server_seed. If someone does obtain the plaintext server_seed before the hand ends, that is not successful hash reversal; it is an early seed leak. That would be a serious bug, because once the attacker also knows the hand_id and the client_seeds actually mixed into the hand, they can recompute deck_seed and derive the deck and board in advance. That is why the protocol broadcasts only the commit before dealing, and reveals plaintext server_seed only in seed_reveal after the hand finishes, for verification rather than prediction.

The client seed window is a hard 1.5 seconds. If an older client, mobile client, bot, or flaky network does not submit one, the table does not freeze; it falls back to server-commit-only, so one disconnected client cannot block the table from dealing. The honest limit of that fallback is worth stating precisely: pre-commit deck-grinding is excluded only when at least one fresh post-commit client_seed is mixed into deck_seed. On a server-commit-only hand — mobile or old clients that never signal, a timed-out window, no participating human connected — commit-reveal still proves the deck was not changed after the commit, but it cannot prove the server did not pick a favourable deck before committing. ADR-064 records this as T3, an accepted residual on the play-money tier, where there is no chip-EV incentive to grind.

Per-hand provably fair dealing: when is the deck locked? 1 hand_id minted UUID before any shuffle 2 server_seed 32 bytes from OS randomness 3 seed_commit broadcast · hash only server_seed stays secret 4 client_seed window hard 1.5 s optional — on timeout: server-commit-only deck locked — nothing after this line can change a card commit phase consume only 5 deck_seed = hash(server_seed, client_seeds in seat order, hand_id) 6 Fisher-Yates shuffle → fixed 52-card deck 7 deal in order fixed deck only hole cards → flop → turn → river 8 hand ends then seed_reveal client recomputes seed_commit + deck_seed player profiles / win-loss history / payment status / betting action — NOT inputs

By the amber divider the deck is fully determined; everything after it is in-order consumption plus post-hand verification. The server still sees all cards on this default path — verification happens after the hand — and player data never enters the shuffle.

How hole cards, flop, turn, and river are dealt

Once deck_seed exists, the backend initializes the poker RNG from it and shuffles a standard 52-card deck. The deck is an ordered structure: after shuffling, dealing always consumes the next card from the top.

deck_seed
  -> PokerRng::from_seed_bytes(...)
  -> Deck::new(...)        // Fisher-Yates shuffle of 52 cards
  -> GameHand::new_with_rng(...)
  -> GameHand::start()

Preflop: GameHand::start() records blinds, then deals two passes by seat order. The first pass gives one card to each still-in seat; the second pass gives each one more. That produces two hole cards per player. The engine emits HoleCardsDealt internally, but the server redacts it per viewer before sending it over the wire.

Flop: when preflop betting closes, the engine moves to the next street, takes three cards from the top of the same deck, writes them to the board, and broadcasts street_revealed with those three new_cards.

Turn: after flop betting closes, the engine takes one more card from the same deck, writes it as the turn, and broadcasts street_revealed.

River: after turn betting closes, the engine takes one more card, writes it as the river, and broadcasts street_revealed. If two or more non-folded players remain, the hand reaches showdown; if only one remains, the pot is awarded immediately.

There is no entry point here for “watch the action, then reshuffle.” The deck is fixed by deck_seed before the hand starts, and streets only consume the next cards from that same deck. Betting can change who wins, who folds, and who reaches showdown, but it does not choose the next card.

Why this can be verified

After the hand finishes, the backend broadcasts seed_reveal: only then does it reveal server_seed, the client_seeds actually mixed into this hand, and deck_seed. The browser recomputes two checks locally:

  1. Hash the revealed server_seed again and confirm it equals the pre-deal seed_commit.
  2. Derive deck_seed again from server_seed + client_seeds + hand_id and confirm it equals the revealed deck_seed.

If either check fails, the seed chain was tampered with or internally inconsistent, and the client marks it as a mismatch. Note what each check catches: the commitment check catches a swapped server_seed, and the derivation check catches an inconsistent deck_seed. Catching a deck or dealt-card substitution takes a third, offline step: rebuild the deck from the same deck_seed and confirm that the persisted hole cards and board came from it in order.

What goes into deck_seed is equally specific: server_seed, the client_seeds actually collected for this hand, and hand_id. Not player profiles, historical win/loss data, payment status, tilt state, action pressure, or a parameter that says “make these two players collide.”

So the default mode proves two specific things. First, there is no interface for choosing turn or river after watching player reactions. Second, a seed swapped after the commit leaves a mismatch in the post-hand checks, and a substituted deck fails the card-level reconstruction. The limit from the previous section applies here too: on server-commit-only hands, those checks prove the deck was not changed after the commit — not that the server did not pick a favourable deck before committing (ADR-064 T3). And the mode does not claim that “the server is blind while the hand is live”: in the default path the server is still the authoritative dealer and holds the plaintext deck, board, and completed-hand data for live execution, persistence, review, and coaching.

The codebase also contains an Advanced encrypted dealing implementation (engine-blind / Mental Poker) that moves the trust boundary further, so on that hand the server does not hold your unshown hole-card plaintext. Under ADR-101 (trusted-single-operator scope) that mode is now available: the host enables it on all-human, no-AI tables. Required disclosure: this secrecy holds only for that hand in this mode; if any player drops, the whole table visibly downgrades to standard (server-visible) dealing; practice chips only. Default tables stay server-visible commit-reveal dealing, unchanged.

In this mode the implementation uses encrypted dealing and local client-side decryption, is limited to all-human tables, disables AI / bots, normal Coach review, and full plaintext history, and requires every player to remain online. Its stronger confidentiality design also creates a liveness problem when terminal actions or reveal shares are unavailable — handled by the visible downgrade above, not a silent one. It is not trustless-against-the-operator: on the single-coordinator channel, delivery of a player's accepted final actions and reveal messages cannot be independently proven, so settlement voids and returns stakes rather than confiscating an honest player — a disclosed residual (ADR-101 §8). Fully removing that ambiguity — independently authenticated delivery, or reveal material escrowed before the outcome — is ADR-090's separate v2 exit.

What recovers after a deploy

This has to be split into three recovery cases, plus one architecture note on why memory and the database are not lock-stepped — otherwise “WebSocket reconnect” and “server process restart” get blurred together.

Direct answer: a server restart does not delete the whole room. It drops the in-progress hand's memory state. If the room has not expired or ended, players reconnect to the same room; that interrupted hand does not continue from the previous second, and the table re-enters the waiting / next-hand flow.

1. WebSocket drops, session task stays alive

This is the common recovery path. A phone backgrounds the app, the network blips, or the browser reopens. The WebSocket dies, but the backend session task still holds GameHand in memory. When the client reconnects to the same session_id, the WebSocket layer rebinds it to the original task and the backend sends a fresh per-user snapshot. The frontend does not guess by replaying local state; it redraws the table from the authoritative snapshot.

2. Process restarts, room rows remain in the database

The room itself is not memory-only. game_sessions stores the room, session_users stores membership, and completed hands are written to hands with the action log, hole cards, community cards, result, deck_seed, server_seed, client_seeds, and which seats were dealt in. After a deploy, the new process does not close every unexpired multi-human room. When a player next connects with the same session_id, the backend can lazily start a new session task from the room and membership rows.

3. A hand in progress is lost, not continued

This is the critical part. The current implementation does not event-source the full mid-hand engine state into the database. GameHand, current street, current actor, in-memory seat stacks / buy-in totals, and any unfinished settlement state belong to the live session task. If the process dies in the middle of a hand, that hand's runtime state is what is lost: the current board, unfinished betting round, action timer, and unpersisted result are not reconstructed to the previous second.

The system preserves the room, membership, successfully persisted completed hands, and their verifiable seeds — a crash in the short window between hand completion and the async persist loses that hand like any other mid-hand state. If a hand never reached completion and never persisted a result, the system does not invent a board or winner to fill the gap. Players return to the existing room and re-enter the waiting / next-hand flow. In short: the current hand is lost, the next hand can start normally, and the room record itself is not lost.

The symmetric honest limit belongs here too: a hand with no persisted row is a hand that never happened for history and stats, so a malicious server could in principle void a hand it disliked by faking a crash — never revealing, never persisting — and that would leave no verification mismatch. It is logged but not cryptographically prevented: ADR-064 records it as T10, an accepted residual on the play-money tier. A regulated tier would need to bind reveal and settlement atomically, a heavier design ADR-064 deliberately dropped.

True lossless mid-hand recovery would require a different design: every client command, engine event, action sequence number, and timer boundary would need to be event-sourced to the database, then replayed idempotently into the same GameHand after restart. That is not something the current default table secretly does.

4. Memory and the database are not strongly synchronized on every step

The live-table hot path is closer to an actor model. The WS task receives frames, parses ClientCmd, and forwards them over a Rust mpsc channel to the session task. The session task is the component that validates actions, mutates GameHand, and broadcasts action_applied / street_revealed / snapshot. In other words, fold / call / raise / reveal-next-street does not wait for a database write before the hand can continue.

The database sits on lifecycle boundaries. HTTP room creation, seating, membership, and room config write game_sessions / session_users. WS upgrade checks the cookie and session_id against membership. If no live task exists in memory, the backend lazily starts a new session task from those persisted room rows. Once a hand completes, hands / hand_actions are persisted asynchronously. A multi-human hand is written as one logical transaction, and success or failure is reported back to the session task as SessionCtrl::HandPersisted or PersistFailed.

A natural follow-up: if the hot path does not wait for the database, why not put every write behind an in-memory queue and go faster still? Because that would require a durable outbox with idempotency, ordering, backpressure, and crash recovery — and a pure in-memory queue loses queued-but-not-persisted facts when the process dies. The current design chooses a memory-owned session task for live play, persisted room / membership / completed-hand boundaries, and no fake lossless recovery for mid-hand state.

Does this answer the cooler-hands question?

From an engineering point of view, deliberately dealing cooler hands would require at least one of these powers: include player profile, win/loss history, payment state, or current betting pressure in the shuffle input; re-derive the deck after seeing hole cards / flop / actions; let the engine call a “strategy service” for the next card instead of consuming a fixed deck; or fabricate a board and result during restart recovery for a hand that did not actually complete.

For completed, revealed, persisted hands, the current default path removes those powers. deck_seed is derived before the first hole card from random material and hand_id, not from business/player fields. GameHand consumes one already-shuffled deck in order; betting events change pot state, actor state, and settlement, not the next card. The client can submit action intent, not card facts. After completion, seed_reveal lets clients and offline tools check that the seed, deck, and actual cards match.

So the more technical answer is not “trust us.” It is: in the current product, the backend is the authoritative, server-visible dealer, but it does not have an execution path that dynamically chooses cards by player or situation. For any hand that completed, revealed, and persisted, changing the seed, swapping the deck, or fabricating a result after the commit would no longer line up with the verifiable chain, persistence record, or event sequence. The honest residuals are the two named above: a zero-client-entropy hand's pre-commit deck choice (T3), and a faked crash that voids a hand before it persists (T10). The engine-blind implementation targets a stronger boundary and is now available under ADR-101's trusted-operator scope (card-secrecy on that hand, not trustless-against-the-operator settlement).