Skip to content

Arena Generalization — One Framework, Multiple Games

Decision

Extract a game-agnostic framework (shared/) from poker and build Arena (multiplayer Numero) as the second game type using only the shared layer. Prove the abstraction holds by requiring zero shared/ modifications during Arena build.

Then stress-test the GameEngine interface by wrapping the existing poker Table class in an adapter (PokerEngine) — not rewriting poker — so the interface gets validated against a genuinely complex existing system, not against a clean-sheet port that would fit the interface by construction.

What Lives Where

apps/game-server/src/
├── shared/                    # Game framework — never modified per game type
│   ├── room-handler.ts        # BaseRoom abstract class
│   ├── types.ts               # GameEngine interface + ActionResult + PreRoundRequirement
│   ├── state.ts               # buildHandResult, buildPotInfo, buildMemberList
│   ├── ledger.ts              # writeJoin, writeLeave, chainHandForPlayers, writePotWinLedger
│   ├── settlement.ts          # handleWithdraw via WithdrawContext
│   ├── participant-manager.ts # Five-state player lifecycle
│   ├── position-chain.ts      # Hash chain per (user, table, player)
│   └── agent-auth.ts          # Ed25519 token verification
└── games/                     # Per-game implementations, normalized file layout
    ├── poker/                 # Production PokerRoom (unchanged — pre-framework)
    ├── poker/              # PokerRoom using PokerEngine adapter
    ├── arena/                 # ArenaRoom — the generalization proof
    ├── dealer/                # Solo Numero card game
    └── pokerlobby/            # Lobby party type

Every game type follows the same file layout: engine.ts, flow.ts, state.ts, room.ts, types.ts, ledger.ts, plus an event-history file.

The GameEngine Interface

Nine required methods (every game), three optional (only games with sub-phases or pre-round setup):

ts
interface GameEngine {
  // required
  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;

  // optional — Arena ignores all three, PokerEngine implements all three
  getCurrentPhase?(): string | null;
  getPreRoundRequirements?(): PreRoundRequirement[];
}

Optional methods are how simple and complex games share a contract without forcing simple games to implement no-op stubs.

Two Validated Game Types

ArenaPoker
Engine styleDirect — wraps game-logic/numero pure functionsAdapter — wraps existing Table class
Actions per turnOneMulti-phase (preflop → flop → turn → river)
Pre-round setupNoneManual blind posting
getCurrentPhaseNot implemented'preflop' | 'flop' | 'turn' | 'river'
getPreRoundRequirementsNot implementedSmall blind + big blind posts
Event historyRoundHistoryBuilder per roundOhhBuilder (Open Hand History) per hand
Room line count308 lines454 lines (was 819 pre-Phase-7)

Critical Pattern: autoAdvance Must Be a Loop, Not a Single Check

The PokerEngine adapter wraps Table.actionTaken() and auto-advances through phase transitions internally. The first implementation used a single if check to advance once. It broke on heads-up folds because one action cascades through multiple transitions:

  1. actionTaken(FOLD) — one player left, betting round ends
  2. endBettingRound() — all betting rounds complete (fold win)
  3. showdown() — determines winner
  4. Hand ends

A single check stops after step 1, leaving the state mid-cascade. The fix is a while (true) loop that advances until either the hand ends or a new actionable betting round starts. This pattern applies to any adapter over a multi-phase state machine.

See poker/engine.ts:301.

Deliberate Non-Lift

Three patterns appear in both Arena and Poker but were not extracted into shared/:

  1. Turn notificationsendYourTurn / notifyActingPlayer
  2. Event history builderRoundHistoryBuilder vs OhhBuilder
  3. Auto-advance loop — currently only in PokerEngine

Two games is enough to see a pattern but not enough to know the shape. Two games can share a shape for reasons that don't generalize. Wait for game #3 (backgammon) before extracting — otherwise the abstraction will fit poker+arena and fight the next game. That third data point will validate or invalidate these candidates.

Tradeoffs

  • Phase 7 was added retroactively. The original Phase 4 "validate by reimplementing poker on the framework" passed its harness but left PokerRoom calling Table directly, not going through GameEngine. Adding the adapter phase cost calendar time but caught three interface gaps (getCurrentPhase, getPreRoundRequirements, phaseChanged) that would have bitten the next game type.
  • Verification gates need adversarial framing. Phase 4's gate ("Poker harness passes") passed for the wrong reason. The fix — a grep-based negative check — was only written in Phase 7. Every framework-use gate now pairs a positive assertion with a negative grep.
  • URL paths changed. The WebSocket and HTTP base paths renamed from /parties/{type}/{roomId} to /games/{type}/{roomId} to match the folder rename. Breaking for any external bot that had hardcoded the old path — none did since nothing external was in production yet.
  • FundsManager is still under games/poker/. Move-to-shared was deferred because of type dependencies; ArenaRoom imports it via the same path. Follow-up for a future cleanup.

See also

Developer guide: guide/adding-a-game-type — uses Arena (simple) and Poker (complex) as dual examples.