Skip to content

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'):

  1. Add the code to POKER_ERROR_CODES in packages/types/src/wire/poker.ts.
  2. Add a routing entry to ErrorRouter.ROUTES in apps/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:

  1. Server-side apps/evm-bank/src/lib/<my>-progress-notifier.ts (mirror deposit-progress-notifier.ts).
  2. Game-server forwarder apps/game-server/src/routes/<my>-progress.ts (mirror deposit-progress.ts).
  3. UI consumer case 'my_progress': in usePokerSocket's message switch (mirror case 'deposit_progress':).
  4. UI slice myIntent: { intentId, status, ... } | null (mirror depositReservation).

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