Appearance
State-Machine Driver Pattern
Convention: WS messages with
statusfields 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
seatedevent, 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
confirmedonly 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
_progressmessages 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:
| State | When | Surface |
|---|---|---|
| loading shell | auth / status read / connect still resolving (inside the budget) | spinner + "loading table…" |
| live felt | gameState arrived | the table |
| empty placeholder | open + empty cash table | joinable felt from settings |
| populated placeholder | open 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 degraded | placeholder 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 view | concluded tournament / results | TournamentResults |
not-found | GET /tables/:id → found:false | "PLAYERS WELCOME" end page |
ended | closed / frozen table | "CLOSED" end page |
unavailable | hostedHere:false — another environment's game-server hosts it | "WRONG DOOR" end page |
unreachable | connect budget (8s) elapsed with no definite state | "NO ANSWER" + retry |
| error fallback | any render throw | error.tsx boundary — "something went wrong" + reset/lobby |
Three structural rules make the union closed:
- 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. - The felt render never receives a null view-state. A final guard renders the loading shell inside
useConnectBudgetand the retryableunreachablestate after it. A late frame clears a stale timeout — a live table always wins. Transport-agnostic (participant WS + SSE spectate). - Render throws are bounded.
app/table/[tableId]/error.tsxis 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_progressdriver cases.apps/evm-bank/src/lib/deposit-progress-notifier.ts— server-side broadcast.apps/poker/src/framework/lib/error-router.ts— coded-error routing.