Skip to content

State-Machine Driver Pattern ​

Convention: WS messages with status fields are state-machine drivers, not telemetry. UI affordances render only when the relevant intent state machine reaches a defined state.

The principle ​

When the server runs an intent state machine (deposit_intents, withdrawal_intents), the UI must consume the state changes as a driver of its own state — never as a hint, never as telemetry, never as something to log-and-ignore.

The intent IS the source of truth. The HTTP response that started the intent is not. Optimistic UI is forbidden for fund-affecting flows.

The shape ​

Each transition is a _progress WS message with status in {pending, submitted, confirmed, failed}. The UI keeps a slice (depositReservation, withdrawIntent) and updates it on every message. Affordances render branches based on slice.status:

What this rules out ​

  • ❌ "I sent the HTTP request, render complete" — the HTTP response is not the source of truth.
  • ❌ "Server broadcasts a seated event, infer the deposit confirmed" — events from one machine are not signals from another.
  • ❌ Toast-on-error-message — coded errors route through ErrorRouter (see Coded Errors below).

What this enables ​

  • Server-led correctness. UI shows confirmed only when the server has the durable confirmation. No race between optimistic UI and server reality.
  • Atomic recovery. Page reload, second tab, second device — all see consistent state via the initial broadcast on connect.
  • Structural test coverage. Tier 1 unit tests synthesize _progress messages and assert UI branches; the coverage is exhaustive over the state machine.

Worked example: sit-down ​

The deposit flow is the canonical worked example.

[1] User clicks Sit Down on empty seat
    └─ DepositFlow renders (gates: walletConnected, authToken, chainId === expectedChainId, balance >= minBuyIn)
[2] All gates pass → user clicks Sit Down inside DepositFlow
    └─ WS sit_down → server's pm.seatUnfunded() creates table_players row (state='sitting_out', chip_balance=0)
    └─ UI signs ERC-2612 permit
    └─ UI POSTs the bank's /api/deposit
[3] The bank creates deposit_intents row, submits on-chain via Engine
    └─ broadcasts deposit_progress { intentId, status: 'pending' }
[4] Engine confirms tx mined
    └─ the bank broadcasts deposit_progress { status: 'submitted', txHash }
[5] The bank's credit-chips reconciler fires once on-chain
    └─ updates table_players.chip_balance = amount
    └─ broadcasts deposit_progress { status: 'confirmed' }
[6] UI's depositReservation slice reaches 'confirmed'
    └─ player_update events propagate the new chip count
    └─ Sit In affordance renders (gated on tier=='sitting_out' && stack > 0)

At every step, the UI's depositReservation slice is updated by the WS message — never inferred.

The withdraw flow follows the same shape with withdrawal_intents + withdraw_progress.

Coded errors ​

Errors from the server arrive as error messages with a code field (hand_persist_failed, wallet_not_linked, table_not_found, etc.). These route through ErrorRouter (apps/poker/src/framework/lib/error-router.ts), which maps each code to a defined UI affordance — never a generic toast.

Un-coded errors (no code field) fall through to the legacy 3-second toast as a backward-compat path. New error codes must add a routing entry in ErrorRouter in the same PR.

Pre-flight = affordance gate ​

Invalid actions are absent from the DOM, not disabled. If the user can't sit (wrong chain, no balance, no wallet, no auth), the Sit Down button does not render — an actionable alternative renders in its place ("Switch Network", "Get Faucet Tokens", "Sign In", "Connect Wallet").

Dispatcher functions also reject with named errors as defense-in-depth. Both gates fire — the UI gate prevents the click, the dispatcher gate rejects if a programmatic caller bypasses the UI.

Table page failure states — the never-blank invariant ​

(table-page-resilience, 2026-07-13)

Navigating to /table/:tableId never renders a blank screen. The page's render is a closed union — every path terminates in exactly one of:

StateWhenSurface
loading shellauth / status read / connect still resolving (inside the budget)spinner + "loading table…"
live feltgameState arrivedthe table
empty placeholderopen + empty cash tablejoinable felt from settings
populated placeholderopen cash table with seated rows but no live room (e.g. after a restart)data-only felt with away occupants; clicking a free seat wakes the room (WS → loadOrCreate)
live-view degradedplaceholder showing for a table that HAS live players + the connect budget elapsed"live view unavailable — retry" banner over the placeholder (not a static felt masquerading as quiet)
concluded viewconcluded tournament / resultsTournamentResults
not-foundGET /tables/:id → found:false"PLAYERS WELCOME" end page
endedclosed / frozen table"CLOSED" end page
unavailablehostedHere:false — another environment's game-server hosts it"WRONG DOOR" end page
unreachableconnect budget (8s) elapsed with no definite state"NO ANSWER" + retry
error fallbackany render throwerror.tsx boundary — "something went wrong" + reset/lobby

Three structural rules make the union closed:

  1. The room-less status read carries the routing signal. A not-found or wrong-environment table rejects the WS upgrade with an HTTP 404 the browser cannot inspect — no coded error ever reaches the client. GET /tables/:id (hostedHere, found, closed…) is what the page routes on, BEFORE it attempts a connect.
  2. The felt render never receives a null view-state. A final guard renders the loading shell inside useConnectBudget and the retryable unreachable state after it. A late frame clears a stale timeout — a live table always wins. Transport-agnostic (participant WS + SSE spectate).
  3. Render throws are bounded. app/table/[tableId]/error.tsx is the App Router segment boundary; a throw anywhere in the tree renders a labeled fallback, never white.

The populated placeholder closes the third transport state: a table isn't just empty-or-live. Rooms load lazily (on WS upgrade), and a rail viewer only opens a WS at seat-click — which they can't reach if no felt renders. So a table with seated rows but no live room (every real table after a game-server restart) was a permanent "NO ANSWER". GET /tables/:id now returns occupiedSeats, and the page renders those as away occupants (buildPlaceholderSeats); the free seats stay clickable, and a click boots the room. The live-view degraded banner keeps this honest: the placeholder is only allowed to satisfy the connect budget for a table that claims no live players (isLiveResolved) — a table with live players must show real gameState or, past the budget, say the live view is unavailable (showLiveViewDegraded) rather than freeze players as away while a hand is actually running.

Adding a new reachable end condition = add a labeled state to the union (a TableEndState variant or a dedicated view) — never a fall-through.

Where to read more ​

  • apps/poker/src/poker/components/DepositFlow.tsx — the wallet-aware boundary surface; the worked example of the pattern.
  • apps/poker/hooks/usePokerSocket.ts — deposit_progress + withdraw_progress driver cases.
  • apps/evm-bank/src/lib/deposit-progress-notifier.ts — server-side broadcast.
  • apps/poker/src/framework/lib/error-router.ts — coded-error routing.