Skip to content

Adding a Game Type

This guide walks through what it takes to add a new turn-based seated game to Numero's game server. It's written with two working examples in mind: Arena (the multiplayer Numero card game — a simple case) and Poker (Texas Hold'em — a complex case with multi-phase rounds, blinds, and an adapter over an existing engine). Read both examples side-by-side as you go — they prove the framework works for two genuinely different game shapes.

The Short Version

To add a new game:

  1. Create a folder under apps/game-server/src/games/{your-game}/ with the normalized layout: engine.ts, flow.ts, state.ts, room.ts, types.ts, ledger.ts, plus an event-history file (like round-history.ts or an OHH builder).
  2. Implement the GameEngine interface in engine.ts. For simple games, write the engine directly. For games with a preexisting engine (like poker's Table), write a thin adapter.
  3. Extend BaseTable in room.ts and implement ~13 abstract methods. BaseTable handles auth, connections, seating, deposits, disconnect timers, and settlement — you handle engine wiring and state views.
  4. Put the game loop (action dispatch, round-end handling, turn notification) in flow.ts as plain functions that take a FlowRoom interface. Keep room.ts thin.
  5. Call the shared infrastructure for ledger (writeJoin/writeLeave/chainHandForPlayers), settlement (handleWithdraw), state builders (buildHandResult/buildPotInfo/buildMemberList), and position chain — don't reinvent them.
  6. Register the game type in apps/game-server/src/index.ts.

The rest of this guide explains each step in detail and points at the real code in arena and poker.

File Layout

Every game type lives in its own folder under apps/game-server/src/games/ and follows the same structure:

FilePurpose
engine.tsImplements GameEngine. Pure game logic — no WebSocket awareness, no DB writes.
flow.tsGame loop functions. Takes a {Game}FlowRoom interface, dispatches actions, handles round end, sends your_turn.
state.tsView projection — builds playerView/publicView for the client.
room.tsExtends BaseTable. Thin glue: implements abstract methods, wires engine + flow + state.
types.tsGame-specific types — settings, flow-room interface, message shapes.
ledger.tsCalls shared ledger functions and layers on game-specific records.
round-history.ts or OHH builderPer-round event document, hashed for the position chain.

Arena matches this layout exactly at apps/game-server/src/games/arena/. Poker matches at apps/game-server/src/games/poker/.

The GameEngine Interface

Every game implements the GameEngine contract, defined in apps/game-server/src/shared/types.ts.

Required methods

ts
interface GameEngine {
  getCurrentActor(): string | null;
  getValidActions(playerId: string): string[];
  executeAction(playerId: string, action: any): ActionResult;
  getPlayerView(playerId: string): any;
  getPublicView(): any;
  isRoundOver(): boolean;
  isGameOver(): boolean;
  getRoundResult(): any;
  getGameResult(): any;
}

ActionResult tells the room what happened:

ts
interface ActionResult {
  result: any;                 // game-specific payload
  roundOver: boolean;          // hand/round is done
  gameOver: boolean;           // match is done (arena-style games)
  phaseChanged?: boolean;      // sub-phase transitioned (e.g. preflop → flop)
  nextActor?: string;          // who acts next (undefined if over)
  broadcast?: any;             // game-specific WS payload to forward to clients
}

Optional methods (for games with sub-phases or pre-round setup)

ts
getCurrentPhase?(): string | null;
getPreRoundRequirements?(): PreRoundRequirement[];
  • getCurrentPhase() — poker uses this for streets ('preflop' | 'flop' | 'turn' | 'river'). Simple games (arena) omit it or return null.
  • getPreRoundRequirements() — things that must happen before a round starts: blinds, antes, bids, draft picks. The room orchestrates collection; the engine only declares what's needed. Arena has no pre-round setup and returns [].

Simple Case — Arena

Arena's engine wraps the pure-function game logic in packages/game-logic/src/numero/. One action per turn, no sub-phases, no pre-round setup. The adapter is mostly delegation.

ts
// apps/game-server/src/games/arena/engine.ts
export class ArenaEngine implements GameEngine {
  private state: GameState;

  constructor(gameId: string, players: Player[], scoreTarget: number, seed?: string) {
    this.state = createGame(gameId, players, 'p2p', seed);
  }

  getCurrentActor(): string | null {
    const pending = this.state.roundState?.pendingAction;
    if (pending) return pending.playerId;
    return getCurrentPlayerId(this.state);
  }

  executeAction(playerId: string, action): ActionResult {
    const { state: newState, result } = executeAction(this.state, playerId, action.type, action.targetPlayerId);
    this.state = newState;
    // ...
  }
  // ...
}

See apps/game-server/src/games/arena/engine.ts for the full implementation.

Arena's getCurrentActor has a twist — if there's a pending action (a freeze card waiting for a target), the same player is still the actor but needs a follow-up sub-action. The flow re-sends your_turn in that case. This is a useful pattern for any game where one "turn" might be multiple sub-actions.

Complex Case — Poker

Poker already has a mature engine (Table in packages/game-logic/src/poker/) with its own API. Rather than rewrite it, PokerEngine wraps it and implements GameEngine on top. This is the adapter pattern and it's the recommended approach whenever you have existing game logic that doesn't match the interface exactly.

The critical piece is autoAdvance:

ts
// apps/game-server/src/games/poker/engine.ts
executeAction(playerId, action): ActionResult {
  const seat = this.playerToSeat.get(playerId);
  this.table.actionTaken(ACTION_STRING_TO_ENUM[action.type], action.amount);

  const phaseResult = this.autoAdvance();

  return {
    roundOver: phaseResult.handOver,
    phaseChanged: phaseResult.phaseChanged,
    nextActor: phaseResult.handOver ? undefined : this.getCurrentActor() ?? undefined,
    broadcast: phaseResult.broadcast,
    // ...
  };
}

private autoAdvance(): { handOver: boolean; phaseChanged: boolean; broadcast?: any } {
  let phaseChanged = false;
  let lastBroadcast: any;

  while (true) {
    if (!this.table.handInProgress())       return { handOver: true, phaseChanged, broadcast: lastBroadcast };
    if (this.table.bettingRoundInProgress()) return { handOver: false, phaseChanged, broadcast: lastBroadcast };

    if (this.table.bettingRoundsCompleted()) {
      this.table.showdown();
      phaseChanged = true;
      lastBroadcast = { type: 'showdown', /* ... */ };
      continue;
    }

    this.table.endBettingRound();
    phaseChanged = true;
    lastBroadcast = { type: 'new_street', /* ... */ };
  }
}

Why autoAdvance is a loop, not a single check

This is the most important lesson from the poker adapter and it applies to any game with multi-phase rounds.

A single action can cascade through multiple phase transitions before the state stabilizes:

  • A heads-up fold ends the betting round, triggers endBettingRound, which transitions to showdown, which ends the hand.
  • An all-in on the flop completes the flop betting, deals the turn (no betting — everyone's all in), deals the river (same), then runs showdown.

If autoAdvance checks once and returns, the state ends up half-advanced and the next your_turn goes to the wrong player (or nobody). The while (true) loop keeps advancing until one of three things is true: the hand ended, a new actionable betting round started, or we're in an illegal state (which throws).

If you're writing an adapter for a game with sub-phases, you need this loop. See apps/game-server/src/games/poker/engine.ts:301.

getCurrentPhase and getPreRoundRequirements

PokerEngine implements both optional methods:

ts
getCurrentPhase(): string | null {
  if (!this.table.handInProgress()) return null;
  return this.getCurrentBettingRound();  // 'preflop' | 'flop' | 'turn' | 'river'
}

getPreRoundRequirements(): PreRoundRequirement[] {
  if (this.table.handInProgress()) return [];
  // Returns small-blind and big-blind post requirements keyed to the next SB/BB seats.
  // The room orchestrates collection via the pre-hand blind state machine.
  // ...
}

Arena implements neither — it has no sub-phases and no pre-round requirements. The interface handles both cases cleanly because they're optional.

Extending BaseTable

BaseTable is the abstract class that handles all the game-agnostic plumbing. You extend it and implement ~13 abstract methods.

What BaseTable gives you for free

  • Auth — JWT verification, agent token verification, sybil enforcement
  • Connection lifecycle — connect, disconnect, reconnect, disconnect timers
  • Message dispatch — shared types (enter, sit_down, stand_up, top_up, leave, table_info) handled centrally; game-specific messages delegated via onGameAction
  • Seat lifecycle — sit=deposit flow: client sends sit_down with ERC-2612 permit, BaseTable forwards to the bank's /settlement/deposit, seats player as sitting_out on success. Stand=withdraw flow: stand_up triggers settle + withdraw, player returns to rail. See Sit=Deposit, Stand=Withdraw decision.
  • Participant managementParticipantManager tracks the three-state lifecycle (rail → seated ↔ sitting_out, any → gone) with sit-out timers and auto-rebuy for agents. Sit-down from rail lands as sitting_out (dealt in on next hand); stand-up returns to rail after withdrawal completes.
  • Settlement infrastructureFundsManager, session tracking, hash chain plumbing
  • Lobby reportingreportToLobby() keeps the lobby in sync
  • Persistence — participant state written to room_players on every state change (enter, sit, stand, withdraw, hand-end checkpoint). Rooms reconstruct from DB on server restart via reconstructFromDb. Your game type just calls doCheckpointParticipants() after each hand/round ends. See Room State Persistence decision.

Abstract methods you implement

ts
// Identity
abstract get gameType(): string;
abstract get settings(): BaseTableSettings;

// Engine interaction
protected abstract getAvailableSeats(): (unknown | null)[];
protected abstract seatInEngine(seatIndex: number, buyIn: number): void;
protected abstract removeFromEngine(seatIndex: number): number;
protected abstract isGameInProgress(): boolean;
protected abstract autoActionOnDisconnect(playerId: string, seatIndex: number): void;

// Game lifecycle hooks
protected abstract onSeatFilled(): void;
protected abstract onAfterStandUp(): void;
protected abstract onGameAction(conn: Connection, msg: any): void;

// State views
protected abstract buildPlayerView(playerId: string | null): any;
protected abstract buildPublicView(): any;

Optional hooks

ts
protected onPlayerSeated(playerId, buyIn, userId): void {}
protected onPlayerLeaving(playerId, finalStack): void {}
protected validateSitDown(conn, participant, buyIn): string | null { return null; }

Use onPlayerSeated / onPlayerLeaving to write ledger entries (see Shared Ledger below). Use validateSitDown for game-specific seat validation (poker uses it to gate hosted-agent-only tables).

Note (post table-lifecycle-unification Phase 6): settings arrive as a constructor argument — constructor(room: Room, settings?: unknown) — sourced from tables.settings JSONB by TableManager.loadOrCreate. The legacy WS-time onFirstMemberSettings abstract hook was deleted. Add your Zod schema to packages/types/src/table-settings.ts (see the PokerTableSettings and ArenaTableSettings precedents) so validation runs at POST /api/tables write time, not at constructor time.

A minimal room.ts skeleton

ts
export class MyGameTable extends BaseTable implements MyGameFlowRoom {
  engine: MyGameEngine | null = null;
  private _settings: MyGameTableSettings;

  get gameType() { return 'mygame'; }
  get settings(): BaseTableSettings { return this._settings; }

  constructor(room: Room, settings?: unknown) {
    super(room);
    const defaults: MyGameTableSettings = { /* defaults */ };
    this._settings = {
      ...defaults,
      ...((settings as Partial<MyGameTableSettings>) ?? {}),
    };
    this.initParticipantManager(new ParticipantManager({
      ...MYGAME_LIFECYCLE_CONFIG,
      maxPlayers: this._settings.maxPlayers,
      minBuyIn: this._settings.minBuyIn,
      maxBuyIn: this._settings.maxBuyIn,
    }));
  }

  protected getAvailableSeats() { return this.seats; }
  protected seatInEngine(seatIndex, buyIn) { /* look up via PM, call engine.sitDown */ }
  protected removeFromEngine(seatIndex) { /* return final chip stack */ }
  protected isGameInProgress() { return this.engine !== null && !this.engine.isGameOver(); }
  protected autoActionOnDisconnect(playerId, seatIndex) { /* engine.executeAction('pass') or similar */ }

  protected onSeatFilled() { /* start the game if enough players */ }
  protected onAfterStandUp() { /* end if too few players */ }
  protected onGameAction(conn, msg) { /* dispatch to flow.ts functions */ }

  protected buildPlayerView(viewerId) { return buildMyGamePlayerView(this.engine, viewerId, this.participantManager); }
  protected buildPublicView() { return buildMyGamePublicView(this.engine, this.participantManager); }

  protected onPlayerSeated(playerId, wallet, buyIn, userId) {
    writeJoin(this.room.id, 'mygame', playerId, wallet, buyIn, userId, /* ... */);
  }
  protected onPlayerLeaving(playerId, wallet, finalStack) {
    writeLeave(this.room.id, 'mygame', playerId, wallet, finalStack, /* ... */);
  }

  protected handleWithdraw(conn, destinationWallet) {
    const wctx: WithdrawContext = { /* ... */ };
    _handleWithdraw(wctx, conn, destinationWallet);
  }
}

Compare with arena/room.ts (308 lines) and poker/room.ts (454 lines).

Critical: seatInEngine relies on PM being pre-populated

When BaseTable calls seatInEngine(seatIndex, buyIn), it has already called participantManager.sitDown(playerId, seatIndex, buyIn). Your implementation should recover the playerId from PM:

ts
protected seatInEngine(seatIndex: number, buyIn: number) {
  const participant = this.participantManager.getBySeat(seatIndex);
  if (!participant) throw new Error(`seatInEngine: no participant at seat ${seatIndex}`);
  this.engine.sitDown(participant.id, seatIndex, buyIn);
}

If you change the order in BaseTable, this breaks. Trust the contract and look up via PM.

The flow.ts Pattern

room.ts should be thin. All game-loop logic — action handling, round end, turn notification, auto-advance — lives in flow.ts as plain functions that take a FlowRoom interface.

The FlowRoom interface is a narrow contract your room type satisfies. This gives two benefits: flow functions are easy to test in isolation, and the room file stays small and boring.

ts
// apps/game-server/src/games/arena/flow.ts
export interface ArenaFlowRoom {
  readonly tableId: string;
  readonly engine: ArenaEngine | null;
  readonly participantManager: ParticipantManager;
  readonly roundHistory: RoundHistoryBuilder;
  doBroadcastState(): void;
  broadcast(msg: any): void;
  getConnectionForPlayer(playerId: string): Connection | null;
  sendTo(conn: Connection, msg: any): void;
  onRoundHashReady(roundNumber: number, roundHash: `0x${string}`): void;
}

export function handleArenaAction(room: ArenaFlowRoom, conn: Connection, playerId: string, action: string, targetPlayerId?: string) {
  // ...
}

export function handleReady(room: ArenaFlowRoom, playerId: string) {
  // ...
}

Your room class declares implements MyGameFlowRoom and exposes public bridge methods for flow to call:

ts
export class ArenaTable extends BaseTable implements ArenaFlowRoom {
  // ...
  doBroadcastState(): void { this.broadcastState(); }
  sendTo(conn: Connection, msg: any): void { conn.send(JSON.stringify(msg)); }
  broadcast(msg: any): void { this.broadcastToAll(msg); }
  // ...
}

See arena/flow.ts and poker/flow.ts for full examples.

Shared Infrastructure

Shared state builders

Your state.ts doesn't need to build everything from scratch. shared/state.ts provides:

  • buildHandResult(winnersData, pots, rake, seatPlayerMap, formatCard, dealerSeat, maxSeats) — builds a HandResult with winners per pot, odd-chip distribution via dealer proximity, rake deduction, and fold-win handling.
  • buildPotInfo(pots, seatPlayerMap) — pot sizes + eligible player IDs.
  • buildMemberList(participantManager) — room member list for room_state messages.

Your state.ts calls these for the universal parts and adds game-specific fields:

ts
export function buildArenaPublicView(engine, pm, readyPlayers, scoreTarget) {
  return {
    // Game-specific (yours)
    currentActor: engine.getCurrentActor(),
    roundScores: engine.getRoundScores(),
    totalScores: engine.getTotalScores(),
    scoreTarget,
    // Universal (from shared)
    members: buildMemberList(pm),
  };
}

Shared ledger

Your ledger.ts doesn't need to implement session management or hash chains. shared/ledger.ts provides:

FunctionWhat it handles
writeJoin(tableId, gameType, playerId, wallet, buyIn, userId, settings, sessionIds, sessionHashes, positionIds, isAgent)Room upsert, player record, game session creation, buy-in ledger entry, hash chain anchor/extend, position chain deposit event
writeLeave(tableId, gameType, playerId, wallet, finalStack, initialBuyIn, sessionIds, sessionHashes, positionIds)Cash-out ledger, hash chain withdrawal, session close, position chain event
chainHandForPlayers(tableId, handId, handHash, players, sessionIds, sessionHashes, positionIds)After your game computes a hand/round hash, chain it into each player's session hash and position chain
writePotWinLedger(tableId, gameType, handId, winners, rake)Write pot_win + rake ledger entries

Pure hash chain functions (no DB):

  • chainDeposit(prevHash, wallet, tableId, buyIn) → initial or top-up deposit hash
  • chainHand(prevHash, handHash) → updated session hash after a hand
  • chainWithdrawal(prevHash, finalStack) → withdrawal hash

Your game's ledger calls shared functions for the universal parts, then writes game-specific records if any:

ts
// arena/ledger.ts — arena has no per-round detail table, so it's just shared calls
export function writeArenaJoin(...) { writeJoin(tableId, 'arena', ...); }

// poker/ledger.ts — wraps shared, adds poker_hands + hand_players rows
export function writeJoinLedger(...) { writeJoin(tableId, 'poker', ...); }

Shared settlement

Withdrawal is game-agnostic. shared/settlement.ts provides the full flow:

ts
handleWithdraw(wctx: WithdrawContext, conn: Connection, destinationWallet?: string)

This handles the complete withdrawal lifecycle:

  1. Validate member state (must be standing, have funds, have wallet)
  2. Resolve destination wallet (verify linked wallets via auth-server)
  3. Multi-seat position merge (if the user has multiple agents)
  4. Execute settlement via the bank (settle P/L on-chain → withdraw tokens)
  5. Send withdraw_progress messages to the client throughout
  6. Clean up session hashes and broadcast member update

Your room constructs a WithdrawContext from its own state and calls the shared function:

ts
protected handleWithdraw(conn: Connection, destinationWallet?: string) {
  const wctx: WithdrawContext = {
    tableId: this.room.id,
    gameType: this.gameType,
    pm: this.participantManager,
    funds: this.funds,
    sessionIds: this.sessionIds,
    sessionHashes: this.sessionHashes,
    positionIds: this.positionIds,
    broadcastMemberUpdate: (playerId, action) => { /* broadcast member_update */ },
  };
  _handleWithdraw(wctx, conn, destinationWallet);
}

Both arena and poker use this exact pattern — the only game-specific part is the broadcastMemberUpdate callback.

Persistence Boundary

Every game type has a lock-in moment — the point where mid-play state stops being ephemeral and becomes part of the durable record. Poker's is hand end (pots distribute, stacks are final). Arena's is round end (points lock in; a bust-out can no longer zero them) plus game end (chips redistribute to the winner). A hypothetical turn-based game where each move is irrevocable would lock in per turn.

BaseTable provides a two-sided contract for atomic persistence at that moment:

ts
// Framework-owned — you do NOT override this
protected async persistAtBoundary(context: BoundaryContext): Promise<void> {
  // Opens a Drizzle tx, writes the universal chip-balance checkpoint,
  // then invokes persistGameBoundary(tx, context).
}

// Game-type-owned — you implement this
protected abstract persistGameBoundary(tx: BoundaryTx, context: BoundaryContext): Promise<void>;

Inside persistGameBoundary(tx, context) you write your game's per-boundary records using the provided tx:

  • Your replay record — the per-boundary JSONB document in a {game}_{boundary}s table. Poker writes its OHH to poker_hands.replay; arena writes RoundHistoryBuilder output to arena_rounds.replay; your game writes whatever shape makes sense for it.
  • Your per-participant delta rowspoker_hand_players.netChips for poker; arena_round_players.points_delta for arena. One row per seated participant.
  • The chain-of-custody hash — append to position_events with your boundary's hash (hand_hash / round_hash) via chainHandForPlayers(tx, ...) or equivalent.

All four writes run inside the same tx the framework opens. If any throws, the whole tx rolls back and persistAtBoundary propagates the error to your flow handler — which MUST NOT broadcast hand_result / round_result / game_over on the exception path.

Calling it at your lock-in moment

From your game flow:

ts
try {
  await this.room.persistAtBoundary({ boundaryType: 'hand_end', handNumber, handId });
} catch (err) {
  logger.error('hand-end persistence failed', { err });
  room.broadcast({ type: 'error', code: 'hand_persist_failed', message: (err as Error).message });
  return; // Do NOT broadcast hand_result — durable state is inconsistent.
}
room.broadcast({ type: 'hand_result', ... });

For arena the pattern is identical but called at both round_end and game_end, and at game_end the universal chip-balance checkpoint captures the pot redistribution.

Adding your game type's tables

Follow the poker_hands + poker_hand_players twin-table pattern:

  • Parent table {game}.rounds (or .hands, whatever your boundary's called) holds one row per completed boundary: replay JSONB column + the chain-hash + any game-level metadata.
  • Child table {game}.round_players holds one row per participant per boundary: the delta column (points_delta, netChips, etc.) + a running-total cache column + session_hash.

See schema.tsarenaRounds / arenaRoundPlayers / pokerHands / pokerHandPlayers follow this pattern.

Fault injection for testing

To verify your game's error-surfacing behavior, the framework provides a test-only fault-injection hook. In a harness scenario gated on ENABLE_TEST_AUTH=true:

ts
process.env.PERSIST_BOUNDARY_FAIL_NEXT = 'true';
// Trigger a boundary (play a hand / complete a round)
// Assert your flow handler emitted an `error` broadcast, not `hand_result`.

The flag is one-shot — the framework clears it after reading.

Event History — One Document per Round

Both existing games produce a progressive event document per round, hashed for the position chain:

  • Arena: RoundHistoryBuilder in arena/round-history.ts — builds a canonical JSON document with actions, scores, and sevenCardWinner. Hashed with keccak256 per round.
  • Poker: OhhBuilder (Open Hand History JSON standard) — progressive hand document, stored in poker_hands.replay. Hash of canonical OHH JSON chains into the session hash.

The pattern is identical across both games:

  1. Instantiate a builder at round start (with player list + optional seed).
  2. Append events during play (draws, passes, bets, folds).
  3. Finalize at round end → produces a canonical JSON document and a keccak256 hash.
  4. Pass the hash to chainHandForPlayers for the position chain.

Each game defines its own event shape and document shape. The shared part — canonical serialization + hashing — is currently implemented per-game. This is a candidate to lift into shared/ once a third game lands.

Lifecycle Config

Every game declares a GameLifecycleConfig in packages/types/src/ that tells ParticipantManager how players move between states:

ts
export const ARENA_LIFECYCLE_CONFIG: GameLifecycleConfig = {
  minPlayersToStart: 2,
  maxPlayers: 6,                      // overridable per-table
  allowMidGameJoin: false,            // arena blocks new seats mid-round
  sitOutOnDisconnect: true,
  disconnectTimeoutMs: 60_000,
  // ...
};

Pass it to ParticipantManager in your constructor:

ts
this.initParticipantManager(new ParticipantManager({
  ...MYGAME_LIFECYCLE_CONFIG,
  maxPlayers: this._settings.maxPlayers,
  minBuyIn: this._settings.minBuyIn,
  maxBuyIn: this._settings.maxBuyIn,
}));

POKER_LIFECYCLE_CONFIG and ARENA_LIFECYCLE_CONFIG show the extremes: poker allows mid-hand joins (sit behind the button), arena doesn't.

Testing

The test harness at scripts/test-harness/ provides a game-scoped DSL for scenario testing. Each game gets its own sub-directory with a DSL, scenario definitions, and golden files:

scripts/test-harness/
├── poker/
│   ├── dsl.ts
│   └── scenarios/
├── arena/
│   ├── dsl.ts
│   └── scenarios/
│       ├── 01-heads-up-basic.ts
│       └── 02-bust-round.ts
└── golden/

To add harness support for your game:

  1. Add a {your-game}/dsl.ts that wraps WS connections and exposes typed helpers (sit, action, waitFor).
  2. Define scenarios as pure data — the runner executes them against the running Docker stack.
  3. Run pnpm test:harness to exercise all games.

The harness uses deterministic seeding via deckSeed on table settings, gated behind ENABLE_TEST_AUTH=true. Your engine constructor should accept a seed parameter and thread it through to the randomness primitive.

Gotchas

your_turn must be re-sent for sub-actions

If a single "turn" involves multiple sub-actions (arena's action-card targeting, backgammon's multi-die moves), your_turn needs to be re-sent after each sub-action until the turn is truly over. Arena handles this via pendingAction in arena/flow.ts — after a freeze card is drawn, the same player is still the actor but needs to pick a target.

Poker doesn't need this because each poker action is a single atomic turn.

Turn timer callbacks need fresh context

If you implement a turn timer (auto-fold, auto-pass) from an async callback, be careful about stale context. Poker has a known bug where the auto-deal timer captures a stale ctx snapshot and can't be cleared by subsequent withCtx calls, causing premature auto-folds. Workaround: TURN_TIMER_ENABLED=false. Proper fix: route timer callbacks through fresh context.

notifyActingPlayer / sendYourTurn structure

Both games have their own turn-notification helper in flow.ts. The shape is similar:

  1. Find the current actor via engine.getCurrentActor().
  2. Get their connection via participantManagerroom.getConnection(connectionId).
  3. Get valid actions via engine.getValidActions(actor).
  4. Send a your_turn message with the action list and any extras (valid move targets, chip ranges, turn timer end time, OTel traceparent).

This is a strong candidate to lift into shared/flow/ once a third game confirms the shape, but for now each game keeps its own copy to avoid premature abstraction.

OTel hand spans use a module-level Map

For games with observable "round" spans (poker's poker.hand), store the active span in a module-level Map<tableId, Span>not on TableContext or room state. Ctx snapshots from withCtx lose the span reference across async boundaries. See apps/game-server/src/games/poker/game-flow.ts for the pattern.

Don't lift patterns prematurely

We have two games that share several patterns (turn notification, event history, adapter over existing engine, auto-advance loop). We deliberately did not lift any of them into shared/ — two data points isn't enough to know the right shape, and the cost of an abstraction that fits poker+arena but not the next game is higher than the cost of writing the same ~10 lines three times.

When the third game (likely backgammon) lands, revisit this list and lift what's genuinely universal.

Registering Your Game Type

Wire the new room class into apps/game-server/src/index.ts so the server accepts /games/{your-game}/{tableId} connections. Both the folder and the URL use games/.

No Wallet in Game-Server

Game-server has zero wallet awareness. Every signer address, every on-chain call, every permit-recovery step lives on the bank — see chain-of-custody-4 for the full picture. When you add a new game type, the same doctrine applies: your game module must not touch signer addresses, must not invoke on-chain viem helpers, and must not import contract ABIs.

Four grep invariants catch violations before they ship. Run these from the repo root before opening a PR that adds a game type:

bash
# 1. No literal "wallet" in your game-type module
rg -i 'wallet' apps/game-server/src/games/{your-game}/

# 2. No Hex/Address identifier types
rg ':\s*(Hex|Address)\b' apps/game-server/src/games/{your-game}/

# 3. No non-hash-primitive viem imports. keccak256 / encodePacked /
#    toBytes / toHex / hexToBytes are allowed; anything else (parseEther,
#    formatEther, createPublicClient, account helpers) is banned.
rg "from ['\"]viem" apps/game-server/src/games/{your-game}/

# 4. No @numero/contracts imports
rg '@numero/contracts' apps/game-server/src/games/{your-game}/

All four should return zero matches in your game module. If grep 3 returns hash-primitive imports only, that's fine — those are pure crypto utilities used for hand and position-chain hashes.

If you need signer data

You don't. Game-server takes { userId } from the verified JWT identity and routes everything user-keyed downstream. If a workflow genuinely needs to know which signer address backs a user's deposit (e.g., for an audit report), that lookup happens on the bank or the UI, never in game-server:

  • The bank: query deposit_intents joined on user_id, or call auth-server GET /api/internal/user-wallets/:userId with the service key.
  • UI: call GET /api/wallets with the user's JWT.

Position-chain hashes

Position-chain hashes derive from userId bytes (the 16-byte UUID), not signer bytes. If your game anchors a hand/round into the chain, call the shared queueHandEvent(userId, tableId, playerId, playerType, handId, handHash, netChips) in apps/game-server/src/shared/position-chain.ts — the chain-of-custody-4 grep invariants fall out automatically.

UI side

When you add a UI for your new game type — typically as apps/<game-type>/ — follow the Poker UI Foundation Rewrite tree-shape:

apps/<game-type>/src/
├── framework/      # game-agnostic primitives (may copy or re-export from apps/poker/src/framework/)
├── shared/         # patterns ≥ 2 game leaves can use verbatim
└── <game-type>/    # leaf game implementation

Import directionality is enforced — framework/ may NOT import from shared/ / <game-type>/ / app/; shared/ may NOT import from <game-type>/ / app/. Use a custom AST walk like apps/poker/scripts/check-import-directionality.ts (~112 lines); Biome's noRestrictedImports is module-name based and can't express directionality.

Lift criterion — a pattern lifts from <game-type>/shared/ only when at least 2 concrete consumers need the same shape. Don't speculate; wait for real second-consumer pressure. The same discipline that gates BaseTable (game-server) gates <shared> (UI).

Framework hooks (5 currently in apps/poker/src/framework/, all game-agnostic):

HookPurpose
useGameSocket<TMsg>(url, jwt, handlers, opts)Typed WS dispatcher; handlers stored in a ref so updates don't force reconnect; malformed JSON warned, not crashed
useGameState<TState, TDerived>(initial, derive)Atomic state + derived via useMemo([state, derive]) — state and derived advance in the same render cycle
useGameDispatcher<TAction>(send, opts?)Single point of ui.action.<type> OTel instrumentation; dispatchReject for pre-flight rejections; routeErrorWithSpan for routed errors
useAnimationGuard<T>(trigger, duration, onComplete?)Interruption-safe timer; trigger CHANGE mid-animation cancels in-flight cleanly
ErrorRouter (routeError + ROUTES table)Coded errors route to defined recovery surfaces; un-coded errors fall back to legacy toast

Composer hooks (one per game-type leaf, wrapping the framework primitives):

ts
// apps/<game-type>/src/<game-type>/hooks/use<GameType>GameState.ts
const { state, derived, applyMessage } = useGameState(initial, derive);

// apps/<game-type>/src/<game-type>/hooks/use<GameType>Actions.ts
const { dispatch, pending } = useGameDispatcher<<GameType>ClientMessage>(send);

// apps/<game-type>/src/<game-type>/hooks/use<GameType>TablePage.ts (orchestrator)
const auth = useGameToken();
const ws = useGameSocket(url, auth.token, handlers);
const gameState = use<GameType>GameState();
const actions = use<GameType>Actions(send);
// ... + small composer hooks for intent state machines (deposit / withdraw / etc.)

Test discipline — every framework or shared hook ships with its Tier 1 vitest unit test in a sibling __tests__/ directory in the SAME PR. Tier 2 RTL tests for components. Tier 4 Playwright e2e at project-level test/e2e/<game-type>/. See Frontend Testing.

The Poker UI rewrite is the reference implementation. When you start a new game-type UI, copy the apps/poker/src/framework/ directory verbatim (or extract it to packages/game-ui-framework/ once a second consumer exists — per the lift criterion).

Hosted-Agent Skill (LLM prompt + action parser)

If your new game type supports hosted agents (server-side LLM inference), you also need a skill under apps/hosted-agent-service/src/skills/. core/ is game-agnostic by construction (T13 grep invariant gates it); every game-specific concept — system prompt, game-state formatter, hand-history formatter, response schema, action validator — lives in a skill.

File layout

apps/hosted-agent-service/src/skills/<game-type>/<format-or-variant>/
├── prompt.ts     # SYSTEM_PROMPT + format functions
├── types.ts      # TurnContext + Action + Validation types
└── index.ts      # Skill descriptor satisfying Skill<TCtx, TAction, TValidation>

Poker NLHE is the reference: skills/poker/cash-nlhe/. For a new poker format (sit-and-go, MTT), the directory sits at skills/poker/<format>/. For a different game (<game-type>), under skills/<game-type>/<variant>/.

What each file contains

types.ts — three types:

  • TurnContext extends BaseTurnContext — the per-turn state shape your skill consumes. BaseTurnContext from core/types.ts just declares { strategy: string }; you extend it with game-specific fields (poker has holeCards / communityCards / pot / players / etc.).
  • <YourAction> — what the LLM produces and the WebSocket layer sends back.
  • <YourValidation> — inputs to your skill's parseAction.

prompt.ts — pure functions, no state:

  • A SYSTEM_PROMPT constant — your game vocabulary tutorial. Lifted verbatim across refactors so model behavior is stable.
  • formatGameStateLines(ctx) — turns the TurnContext into prompt lines for the current turn.
  • formatHandHistoryLines(ctx) — token-budgeted past-action context (rough rule: ~6000 chars max).

index.ts — the skill descriptor:

typescript
export const myGameSkill: Skill<MyTurnContext, MyAction, MyValidation> = {
  id: 'mygame:variant',
  basePrompt: MYGAME_SYSTEM_PROMPT,
  formatGameState: formatGameStateLines,
  formatHandHistory: formatHandHistoryLines,
  responseSchema: { /* JSON schema for structured output */ },
  parseAction: (rawAction, validation) => { /* validate + normalize */ },
};

What the orchestrator does for you

core/runInference(skill, ctx, validation, config) handles everything else:

  • Assembles the prompt from skill.basePrompt + [Strategy] + skill.formatGameState(ctx) + skill.formatHandHistory(ctx).
  • POSTs to the LiteLLM gateway with response_format.json_schema = skill.responseSchema.
  • Reads the cost header (x-litellm-response-cost) and the routed model id.
  • Calls skill.parseAction(rawAction, validation) to produce a typed action.
  • Emits the hosted.inference span with attributes for cost, latency, routing.

Discipline

  • Never import a skill from core/ — the dependency direction is core ← skill, not the reverse. The T13 grep gates this.
  • Never call provider APIs directly — all LLM calls go through the gateway. The T14 grep gates this.
  • Lift sparingly to core/ — if a pattern appears in two skills, then consider lifting it. One skill is too early. Follow the same BaseTable-lift discipline used across the game-server.

Reference