Appearance
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:
- 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 (likeround-history.tsor an OHH builder). - Implement the
GameEngineinterface inengine.ts. For simple games, write the engine directly. For games with a preexisting engine (like poker'sTable), write a thin adapter. - Extend
BaseTableinroom.tsand implement ~13 abstract methods. BaseTable handles auth, connections, seating, deposits, disconnect timers, and settlement — you handle engine wiring and state views. - Put the game loop (action dispatch, round-end handling, turn notification) in
flow.tsas plain functions that take aFlowRoominterface. Keeproom.tsthin. - Call the shared infrastructure for ledger (
writeJoin/writeLeave/chainHandForPlayers), settlement (handleWithdraw), state builders (buildHandResult/buildPotInfo/buildMemberList), and position chain — don't reinvent them. - 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:
| File | Purpose |
|---|---|
engine.ts | Implements GameEngine. Pure game logic — no WebSocket awareness, no DB writes. |
flow.ts | Game loop functions. Takes a {Game}FlowRoom interface, dispatches actions, handles round end, sends your_turn. |
state.ts | View projection — builds playerView/publicView for the client. |
room.ts | Extends BaseTable. Thin glue: implements abstract methods, wires engine + flow + state. |
types.ts | Game-specific types — settings, flow-room interface, message shapes. |
ledger.ts | Calls shared ledger functions and layers on game-specific records. |
round-history.ts or OHH builder | Per-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 returnnull.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 viaonGameAction - Seat lifecycle — sit=deposit flow: client sends
sit_downwith ERC-2612 permit, BaseTable forwards to the bank's/settlement/deposit, seats player assitting_outon success. Stand=withdraw flow:stand_uptriggers settle + withdraw, player returns to rail. See Sit=Deposit, Stand=Withdraw decision. - Participant management —
ParticipantManagertracks the three-state lifecycle (rail → seated ↔ sitting_out, any → gone) with sit-out timers and auto-rebuy for agents. Sit-down from rail lands assitting_out(dealt in on next hand); stand-up returns torailafter withdrawal completes. - Settlement infrastructure —
FundsManager, session tracking, hash chain plumbing - Lobby reporting —
reportToLobby()keeps the lobby in sync - Persistence — participant state written to
room_playerson every state change (enter, sit, stand, withdraw, hand-end checkpoint). Rooms reconstruct from DB on server restart viareconstructFromDb. Your game type just callsdoCheckpointParticipants()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 fromtables.settingsJSONB byTableManager.loadOrCreate. The legacy WS-timeonFirstMemberSettingsabstract hook was deleted. Add your Zod schema topackages/types/src/table-settings.ts(see the PokerTableSettings and ArenaTableSettings precedents) so validation runs atPOST /api/tableswrite 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 aHandResultwith 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 forroom_statemessages.
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:
| Function | What 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 hashchainHand(prevHash, handHash)→ updated session hash after a handchainWithdrawal(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:
- Validate member state (must be standing, have funds, have wallet)
- Resolve destination wallet (verify linked wallets via auth-server)
- Multi-seat position merge (if the user has multiple agents)
- Execute settlement via the bank (settle P/L on-chain → withdraw tokens)
- Send
withdraw_progressmessages to the client throughout - 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}stable. Poker writes its OHH topoker_hands.replay; arena writesRoundHistoryBuilderoutput toarena_rounds.replay; your game writes whatever shape makes sense for it. - Your per-participant delta rows —
poker_hand_players.netChipsfor poker;arena_round_players.points_deltafor arena. One row per seated participant. - The chain-of-custody hash — append to
position_eventswith your boundary's hash (hand_hash / round_hash) viachainHandForPlayers(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:replayJSONB column + the chain-hash + any game-level metadata. - Child table
{game}.round_playersholds one row per participant per boundary: the delta column (points_delta,netChips, etc.) + a running-total cache column +session_hash.
See schema.ts — arenaRounds / 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:
RoundHistoryBuilderin arena/round-history.ts — builds a canonical JSON document with actions, scores, andsevenCardWinner. Hashed withkeccak256per round. - Poker:
OhhBuilder(Open Hand History JSON standard) — progressive hand document, stored inpoker_hands.replay. Hash of canonical OHH JSON chains into the session hash.
The pattern is identical across both games:
- Instantiate a builder at round start (with player list + optional seed).
- Append events during play (draws, passes, bets, folds).
- Finalize at round end → produces a canonical JSON document and a keccak256 hash.
- Pass the hash to
chainHandForPlayersfor 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:
- Add a
{your-game}/dsl.tsthat wraps WS connections and exposes typed helpers (sit,action,waitFor). - Define scenarios as pure data — the runner executes them against the running Docker stack.
- Run
pnpm test:harnessto 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:
- Find the current actor via
engine.getCurrentActor(). - Get their connection via
participantManager→room.getConnection(connectionId). - Get valid actions via
engine.getValidActions(actor). - Send a
your_turnmessage 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_intentsjoined onuser_id, or call auth-serverGET /api/internal/user-wallets/:userIdwith the service key. - UI: call
GET /api/walletswith 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 implementationImport 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):
| Hook | Purpose |
|---|---|
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.BaseTurnContextfromcore/types.tsjust 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_PROMPTconstant — 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.inferencespan 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
- GameEngine interface — apps/game-server/src/shared/types.ts
- BaseTable — apps/game-server/src/shared/room-handler.ts
- Shared state builders — apps/game-server/src/shared/state.ts
- Shared ledger — apps/game-server/src/shared/ledger.ts
- Shared settlement — apps/game-server/src/shared/settlement.ts
- Arena (simple example) — apps/game-server/src/games/arena/
- Poker (complex example with adapter) — apps/game-server/src/games/poker/
- Decision (UI side) — Poker UI Foundation Rewrite
- Test harness — scripts/test-harness/