Appearance
Adding a New Action
A "new action" means a new client-initiated WebSocket message type — something a UI surface can dispatch (Sit Down, Stand Up, Sit In, Post Blind, etc.). This page walks the full stack of what you change and where.
The Six Edits
Worked Example — request_top_up
Hypothetical: add an action that lets a seated player open a top-up flow without standing up first.
1. Wire schema
packages/types/src/poker.ts — extend the PokerClientMessage discriminated union:
ts
export type PokerClientMessage =
| { type: 'enter'; playerId: string; name: string; agentToken?: string }
| { type: 'sit_down'; seatIndex: number }
// ... existing branches
| { type: 'request_top_up'; amount: number };After the wire-schema edit, dispatch({ type: 'made_up_action' }) is a TypeScript error against any strongly-typed dispatcher. The discriminated union is the contract.
2. Server handler
apps/game-server/src/games/poker/table.ts — extend the onGameAction switch:
ts
protected onGameAction(conn: Connection, msg: PokerClientMessage) {
switch (msg.type) {
case 'sit_down': /* ... */ break;
case 'request_top_up':
await this.handleRequestTopUp(conn, msg.amount);
break;
// ...
}
}3. Server logic
Implement handleRequestTopUp in the table class. For a top-up that fronts on-chain work, this would call the bank's /api/deposit (mirrors the sit-down deposit pattern) and broadcast deposit_progress updates as the intent state machine ticks.
4. UI dispatcher
apps/poker/hooks/usePokerSocket.ts — add a callback that goes through the dispatcher hook:
ts
const requestTopUp = useCallback((amount: number) => {
rumUserAction('request_top_up', tableId, { amount });
dispatchAction({ type: 'request_top_up', amount });
}, [dispatchAction, tableId]);
// expose in the hook return:
return { /* ...existing */, requestTopUp };The dispatcher automatically emits a ui.action.request_top_up OTel span with action.type, table.id, user.id, and the filtered payload. No per-action span code.
5. UI affordance
The component renders the affordance ONLY when the action is currently valid (per the affordance discipline — invalid actions are absent from DOM, not disabled):
tsx
{isSeated && walletConnected && tokenBalance > 0n && (
<button onClick={() => requestTopUp(50)}>Top Up</button>
)}If the precondition can fail at the dispatcher level too (defense-in-depth), use dispatchReject:
ts
const requestTopUp = useCallback((amount: number) => {
if (!isSeated || tokenBalance < BigInt(amount)) {
dispatchReject('request_top_up', 'top_up_precondition_unmet', dispatcherContext);
return;
}
dispatchAction({ type: 'request_top_up', amount });
}, [dispatchAction, isSeated, tokenBalance, dispatcherContext]);6. Tier 1 test
apps/poker/hooks/__tests__/usePokerSocket-request-top-up.test.tsx — synthesize the user click and assert the wire shape:
ts
import { setupSocketMock } from '../../__tests__/lib/socket-mock';
test('requestTopUp sends WS message with amount', () => {
const { sendSpy } = setupSocketMock();
const { result } = renderHook(() => usePokerSocket({ tableId: 'T1', playerName: 'p' }));
act(() => { result.current.requestTopUp(50); });
expect(sendSpy).toHaveBeenCalledWith(
JSON.stringify({ type: 'request_top_up', amount: 50 })
);
});Edge Cases
Coded errors
If the server can reject the action with a coded error (e.g., code: 'top_up_amount_invalid'):
- Add the code to
POKER_ERROR_CODESinpackages/types/src/wire/poker.ts. - Add a routing entry to
ErrorRouter.ROUTESinapps/poker/src/framework/lib/error-router.ts.
T11 (error-router.test.ts) parameterizes over POKER_ERROR_CODES and asserts every code has a route — adding the code without the route fails CI.
Intent state machine
If the action triggers a long-running on-chain flow (deposit, withdraw, settle), follow the existing _progress notifier pattern:
- Server-side
apps/evm-bank/src/lib/<my>-progress-notifier.ts(mirrordeposit-progress-notifier.ts). - Game-server forwarder
apps/game-server/src/routes/<my>-progress.ts(mirrordeposit-progress.ts). - UI consumer
case 'my_progress':inusePokerSocket's message switch (mirrorcase 'deposit_progress':). - UI slice
myIntent: { intentId, status, ... } | null(mirrordepositReservation).
The state-machine driver pattern is the canonical reference — see State-Machine Driver Pattern.
Tier 4 e2e
If the action is part of a user-facing flow (not just an internal handshake), add a Playwright scenario at test/e2e/poker/pregame/<my-action>.spec.ts. Reuse lib/wallet.ts, lib/auth.ts, lib/setup.ts, lib/cleanup.ts — the helpers cover wallet injection, auth cookie, setup + teardown.
What You Do NOT Edit
- The dispatcher hook itself (
useGameDispatcher.ts). It's generic over the action union; new types just work. - A central "action manifest" file. There isn't one. The wire-schema discriminated union IS the manifest.
- OTel span configuration. Spans auto-emit as
ui.action.<type>. No registration. - Routing tables. The server has a
switch; the UI has typed callbacks. No middleware, no decorators, no manifest.
Companion Reads
- State-Machine Driver Pattern — the convention for actions that fan out into intent state machines.
- Frontend Testing — the four-tier discipline for what to test where.
apps/docs/decisions/bulletproof-state-machine.md— why the dispatcher pattern + ErrorRouter exhaustion exist.