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/:idfound: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.tsdeposit_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.