Appearance
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 typeEvery 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
| Arena | Poker | |
|---|---|---|
| Engine style | Direct — wraps game-logic/numero pure functions | Adapter — wraps existing Table class |
| Actions per turn | One | Multi-phase (preflop → flop → turn → river) |
| Pre-round setup | None | Manual blind posting |
getCurrentPhase | Not implemented | 'preflop' | 'flop' | 'turn' | 'river' |
getPreRoundRequirements | Not implemented | Small blind + big blind posts |
| Event history | RoundHistoryBuilder per round | OhhBuilder (Open Hand History) per hand |
| Room line count | 308 lines | 454 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:
actionTaken(FOLD)— one player left, betting round endsendBettingRound()— all betting rounds complete (fold win)showdown()— determines winner- 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/:
- Turn notification —
sendYourTurn/notifyActingPlayer - Event history builder —
RoundHistoryBuildervsOhhBuilder - 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
Tabledirectly, not going throughGameEngine. 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.