This document covers everything you need to develop, test, and build the Tableau Card Engine (TCE) project. For a high-level overview, see the README.
- Environment Setup
- Running Locally
- Building for Production
- Testing
- ToneForge Audio Generation
- Project Structure
- Path Aliases
- Adding an Example Game
- Example Games
- Transcript Persistence
- Replay Tool
- Managing Assets
- SVG Rendering & Migration
- HUD Layer
- Shared HUD Components
- Card Upgrade Rendering Pipeline
- Shared Renderer
- Screen Layout Language (SLL)
- Keeping Docs Up to Date
- Work-Item Tracking
- Troubleshooting
Prerequisites:
- Node.js 18+ (LTS recommended)
- npm 9+ (ships with Node.js 18+)
- Git
Install dependencies:
npm installThis installs Phaser 4.0.0-rc.7 as a runtime dependency and TypeScript, Vite, and Vitest as dev dependencies.
npm run devStarts the Vite dev server at http://localhost:3000 with hot module replacement (HMR). The root index.html loads the Game Selector landing page, which displays all available example games as clickable cards. Click a game to launch it.
If you plan to use Main Street's ToneForge-backed audio synthesis, generate the synth module first:
npm run tf:generateIf the synth module is missing, loadMainStreetTfModule() logs a clear warning and gracefully degrades (returns null). Synthesis-based audio will be unavailable but WAV-based sound effects continue to work normally.
The project uses a unified entry point (main.ts at the project root) that registers a GameSelectorScene as the initial Phaser scene alongside all example game scenes. Navigation works as follows:
- Game Selector -> Game: Clicking a game card calls
scene.start(sceneKey)to transition to the selected game's scene. - Game -> Game Selector: Each game scene has a
[ Menu ]button in its title bar (top-left) and in its end-game overlays that callsscene.start('GameSelectorScene')to return to the selector.
The game catalogue is stored in the Phaser registry (key: gameSelector.games) via a preBoot callback, so game scenes don't need to know about the catalogue to return to the selector.
Each example game also retains its own standalone main.ts entry point and createXxxGame.ts factory function for independent testing and browser test use.
npm run buildThis runs two steps:
tsc --noEmit-- TypeScript type-checking (strict mode, no output files)vite build-- production bundle todist/
To preview the production build locally:
npm run previewNote: The Phaser library produces a ~2.0 MB chunk with the current Phaser 4 RC bundle. This is expected and can be addressed with code-splitting when needed.
See RELEASE.md for the full release workflow, checklist, and verification steps. The CI workflow is .github/workflows/deploy.yml.
npm test # run all tests once (unit + browser, no tracked-asset restore step)
npm run monte-carlo # run Main Street Monte Carlo harness (JSON + CSV outputs)
npm run tf:generate # generate tf audio artifacts (out-of-repo build/tf-synths)npm test is intentionally non-destructive and must not mutate tracked source assets such as public/assets/games/main-street/svg/cards. If asset regeneration is needed, run the dedicated generation scripts explicitly.
The Main Street balance guardrail (tests/main-street/monte-carlo-balance.test.ts) and harness
script (scripts/monte-carlo.ts) are configurable via environment variables so that PR CI runs
quickly while the main branch retains full, strict checks:
| Variable | Default | PR value | Main value | Description |
|---|---|---|---|---|
MONTE_SEEDS |
20 | 20 | 200 | Number of deterministic seeds to simulate |
MONTE_MIN_WIN_RATE |
0.20 | 0.20 | 0.30 | Minimum acceptable win rate |
MONTE_MAX_WIN_RATE |
0.80 | 0.80 | 0.60 | Maximum acceptable win rate |
Detailed pacing metrics (median score, grid fill timing, loss-reason dominance) are only asserted
when MONTE_SEEDS >= 50, since they are not statistically meaningful for small sample sizes.
Examples:
# Fast local run (default — same as PR CI):
npm test
# Reproduce main branch CI conditions locally:
MONTE_SEEDS=200 MONTE_MIN_WIN_RATE=0.30 MONTE_MAX_WIN_RATE=0.60 npm test
# Run the harness script with a custom seed count:
MONTE_SEEDS=50 npm run monte-carlo
# Fully explicit override:
MONTE_SEEDS=200 MONTE_MIN_WIN_RATE=0.20 MONTE_MAX_WIN_RATE=0.80 npm testTests use Vitest with projects configured inline in vite.config.ts:
| Project | Environment | File Pattern | Purpose |
|---|---|---|---|
unit |
Node.js | tests/**/*.test.ts (excludes replay-*.test.ts) |
Logic, data, and integration tests — runs in parallel |
replay-e2e |
Node.js (fork pool) | tests/e2e/replay-*.test.ts |
Playwright-driven replay e2e tests. Runs in its own fork (singleFork: true) after unit tests to avoid Vite cold-start CPU contention |
browser |
Chromium (Playwright) | tests/**/*.browser.test.ts (excludes tutorial E2E) |
Phaser UI and rendering tests |
tutorial-part1..6 |
Chromium (Playwright, one per part) | tests/e2e/main-street-tutorial-e2e-part{1-6}.browser.test.ts |
Main Street tutorial E2E tests (each in own browser instance) |
All projects run via npm test. The browser and tutorial projects run in headless Chromium using @vitest/browser with the Playwright provider.
The tutorial E2E tests are split into 6 part files (1-6 tests per file). Each part is a separate Vitest project with its own uniquely-named browser instance (t1 through t6) to prevent the Phaser 4 RC GPU/Canvas context exhaustion that occurs after ~8 game create/destroy cycles in a single browser process. The runner script scripts/run-tutorial-tests.sh invokes each project sequentially.
The replay E2E tests live in tests/e2e/replay-*.test.ts and use a dedicated Node.js project (replay-e2e) with pool: 'forks' + singleFork: true. This isolates them from the parallel unit test pool, ensuring the Vite dev server started by scripts/replay.ts has uncontested CPU for its initial cold compilation. The replay tests start and stop their own dev server per run via scripts/dev-server-utils.ts.
The helper module at tests/helpers/main-street-tutorial-e2e.ts contains shared game lifecycle utilities (bootGameWithTutorial, destroyGame with CanvasPool drain), diagnostic error messages, and click helpers for tutorial step advancement.
During Vitest runs, the dev-only transcript persistence middleware (POST /api/transcripts) is intentionally disabled even though Vitest browser mode uses an internal Vite server. This prevents file-system side effects and reduces harness noise/flakiness during test execution.
- Place test files in
tests/following the*.test.tspattern - Import from
vitestdirectly:import { describe, it, expect } from 'vitest' - Vitest globals are enabled --
describe,it,expectare available without imports in test files
Browser tests verify Phaser UI rendering and interactions in a real browser environment. Phaser requires WebGL/Canvas and cannot run in JSDOM or happy-dom.
- Use the
*.browser.test.tspattern to mark tests for the browser project - Tests run in headless Chromium via Playwright -- no visible browser window
- Import
createGolfGamefrom the game's factory module to boot Phaser inside the test - Wait for the scene to become active before making assertions
- Clean up the game instance in
afterEachto avoid resource leaks - Access Phaser game objects via
game.scene.getScene('SceneKey').children.list
Example:
import { describe, it, expect, afterEach } from 'vitest';
import Phaser from 'phaser';
import { createGolfGame } from '../../example-games/golf/createGolfGame';
describe('MyScene browser tests', () => {
let game: Phaser.Game | null = null;
afterEach(() => {
if (game) game.destroy(true, false);
game = null;
});
it('should render a canvas', async () => {
const container = document.createElement('div');
container.id = 'game-container';
document.body.appendChild(container);
game = createGolfGame();
// wait for scene, then assert...
});
});The Main Street tutorial E2E tests are defined in tests/e2e/main-street-tutorial-e2e-part{1-6}.browser.test.ts. Each part tests a subset of the tutorial flow. They use shared helpers from tests/helpers/main-street-tutorial-e2e.ts.
Key design decisions:
- Per-file browser isolation: Each tutorial part is a separate Vitest project with its own browser instance. This avoids Phaser 4 RC's canvas/GPU context exhaustion from sequential game create/destroy cycles.
- Enhanced cleanup: The
destroyGamehelper drains Phaser's CanvasPool after each test, force-releases canvas contexts by resetting canvas dimensions to 0, and removes orphaned canvases from the DOM. - Diagnostic tracking:
bootGameWithTutorialtracks boot cycles and provides detailed error messages if canvas context is null, including the cycle number, remaining canvas count, and CanvasPool state. - New project:
scripts/run-ci-tests.shorchestrates the full CI test suite (unit → browser → tutorial E2E).
Browser test dependencies:
@vitest/browser(matches vitest version)playwright(provides Chromium browser)- Install Chromium:
npx playwright install chromium
ToneForge-generated synth artifacts are integrated via a thin adapter and are not committed to source control.
npm run tf:generateThis runs scripts/tf-generate-synths.sh and writes generated outputs under build/tf-synths/, including a runtime synth module (main-street-runtime-synth.mjs) used for on-the-fly synthesis.
Missing module handling: If the runtime synth module is absent,
loadMainStreetTfModule()inmainStreetTfModule.tslogs a clearconsole.warnmessage with instructions to runnpm run tf:generate, then gracefully returnsnullwithout triggering a Chromium module-loading error. Synthesis-based audio degrades silently; WAV-based SFX and game logic are unaffected.
See docs/the-build/audio.md for full details (module shape, mapping, runtime wiring, CI guidance).
All sound effects use the sfx- prefix with no game identifier. Common cross-game
keys are defined in COMMON_SFX_KEYS (exported from src/core-engine/SoundManager.ts).
Audio assets are organized in public/assets/audio/<game>/ with a fallback to
public/assets/audio/default/. See docs/SFX_CONVENTION.md for the full convention.
src/
├── core-engine/ Game loop, state management, turn sequencing, utilities
│ ├── GameState.ts GameState<T>, createGameState (deprecated for setup — use SetupOptions)
│ ├── SetupOptions.ts BaseSetupOptions, MultiplayerSetupOptions, resolveSetupOptions
│ ├── SeededRng.ts createSeededRng — deterministic PRNG (LCG) for shuffles and AI
│ ├── ActiveEffect.ts Duration-based modifier system (create, decay, apply, query)
│ ├── CheckpointManager.ts Checkpoint save-and-resume abstraction (save, load, clear, checkAndResume)
│ ├── CheckpointResumeOverlay.ts Built-in default resume overlay component
│ ├── TranscriptRecorder.ts BaseTranscript interface, TranscriptRecorderBase<T> abstract base class
│ ├── TurnSequencer.ts advanceTurn, getCurrentPlayer, startGame, endGame
│ └── index.ts Barrel file / public API
├── card-system/ Card, Deck, Pile abstractions
│ ├── Card.ts Rank, Suit, Card type, createCard
│ ├── Deck.ts createStandardDeck, shuffle, draw, drawOrThrow
│ ├── Pile.ts Pile class (push, pop, peek, isEmpty, size)
│ └── index.ts Barrel file / public API
├── rule-engine/index.ts Rule definitions (stub -- game-specific rules live with games)
├── ai/ Shared AI strategy abstractions and utilities
│ ├── AiStrategy.ts AiStrategyBase interface, AiPlayer<TStrategy> generic base class
│ ├── AiUtils.ts pickRandom<T>, pickBest<T> utility functions
│ └── index.ts Barrel file / public API
└── ui/
├── GameSelectorScene.ts Game selector landing page (GameEntry, REGISTRY_KEY_GAMES)
├── HelpPanel.ts Reusable help panel component
├── HelpButton.ts Help button component
└── index.ts Barrel file / public API
example-games/
├── gym/
│ ├── README.md Gym documentation and quick-start instructions
│ ├── GymRegistry.ts Scene key constants and catalogue
│ ├── index.ts Barrel file / public API
│ ├── layouts/ SLL sample layout JSON documents for GymSllScene
│ └── scenes/
│ ├── GymRouterScene.ts Landing page with navigation cards
│ ├── GymSceneBase.ts Shared base class for all Gym scenes
│ ├── GymDeckRngScene.ts Deck lifecycle & seeded RNG demo
│ ├── GymHandPileScene.ts Hand/pile interaction demo (bottom-anchored hand arc + live arc/spacing/rotation/raise sliders)
│ ├── GymOverlayUiScene.ts Overlay & UI configuration demo
│ ├── GymUndoRedoScene.ts Undo/redo workflow demo
│ ├── GymTranscriptScene.ts Transcript recording demo
│ ├── GymSaveLoadScene.ts Save/load state demo
│ ├── GymAudioFeedbackScene.ts Audio & feedback configuration demo
│ ├── GymGraphicsShaderSpikeScene.ts Shader & blend mode spike
│ ├── GymGraphicsLightingSpikeScene.ts Lighting spike
│ └── GymSllScene.ts Screen Layout Language demo (schema+mapping+overlay)
├── golf/
│ ├── main.ts Game entry point (Phaser.Game config)
│ ├── createGolfGame.ts Factory function (used by main.ts and tests)
│ ├── GolfGrid.ts 3x3 grid type and utilities
│ ├── GolfRules.ts Turn legality, move application, round-end detection
│ ├── GolfScoring.ts Card point values, grid scoring, column matching
│ ├── GolfGame.ts Game orchestration (session setup, turn execution)
│ ├── AiStrategy.ts AI players with configurable skillRating (default 80) and
│ │ CardMemoryTracker for discard-pile memory across turns
│ ├── GameTranscript.ts Transcript recording (TranscriptRecorder)
│ └── scenes/
│ └── GolfScene.ts Phaser scene (full visual interface)
├── beleaguered-castle/
│ ├── main.ts Game entry point
│ ├── createBeleagueredCastleGame.ts Factory function (used by main.ts)
│ ├── BeleagueredCastleState.ts State types, move types, constants
│ ├── BeleagueredCastleRules.ts Pure game logic (deal, moves, win/loss)
│ ├── GameTranscript.ts Transcript recording (BCTranscriptRecorder)
│ ├── help-content.json Help panel content (rules, controls, tips)
│ └── scenes/
│ └── BeleagueredCastleScene.ts Phaser scene (full visual interface)
├── sushi-go/
│ ├── main.ts Game entry point
│ ├── createSushiGoGame.ts Factory function (used by main.ts and tests)
│ ├── SushiGoCards.ts Card types, deck creation, card-back texture generation
│ ├── SushiGoGame.ts Game orchestration (drafting rounds, scoring)
│ ├── SushiGoScoring.ts Set-collection scoring rules (Maki, Tempura, etc.)
│ ├── AiStrategy.ts AI strategies (RandomStrategy, GreedyStrategy)
│ ├── help-content.json Help panel content
│ └── scenes/
│ └── SushiGoScene.ts Phaser scene (drafting UI, card picking)
├── feudalism/
│ ├── main.ts Game entry point
│ ├── createFeudalismGame.ts Factory function (used by main.ts and tests)
│ ├── FeudalismCards.ts Development cards, nobles, gem types, tier data
│ ├── FeudalismGame.ts Game orchestration (token collection, purchases, nobles)
│ ├── AiStrategy.ts AI strategies (RandomStrategy, GreedyStrategy)
│ ├── help-content.json Help panel content
│ └── scenes/
│ └── FeudalismScene.ts Phaser scene (gem tokens, card market, purchases)
└── lost-cities/
├── LostCitiesCards.ts Card types, deck factory, 5 expedition colors, card helpers
├── LostCitiesRules.ts Two-phase turn model, ascending-play validation, legality checks
├── LostCitiesScoring.ts Expedition scoring (-20 base, investments, 8-card bonus)
├── LostCitiesGame.ts Match manager (3-round session, executeAction, state queries)
├── AiStrategy.ts AI strategies (RandomStrategy, GreedyStrategy)
├── GameTranscript.ts Transcript recording (LCTranscriptRecorder)
├── help-content.json Help panel content (rules, scoring, controls)
└── scenes/
├── LostCitiesMockScene.ts Static layout mockup (development aid)
└── LostCitiesScene.ts Phaser scene (interactive play with animations)
scripts/
├── replay.ts Replay CLI (Playwright-driven transcript replay + screenshots)
├── generate-thumbnail.ts Thumbnail generator (midpoint frame -> 120x68 PNG)
├── refresh-thumbnails.sh Batch thumbnail refresh for all games
├── generate-*-fixture-transcript.ts Per-game fixture transcript generators
└── adapters/
├── ReplayAdapter.ts ReplayAdapter interface (contract for all adapters)
├── AdapterRegistry.ts Singleton adapter registry
├── index.ts Barrel file (imports and registers all adapters)
├── BeleagueredCastleReplayAdapter.ts
├── LostCitiesReplayAdapter.ts
├── SushiGoReplayAdapter.ts
├── FeudalismReplayAdapter.ts
├── MainStreetReplayAdapter.ts
└── GolfReplayAdapter.ts (structural detection fallback -- registered last)
public/assets/
├── cards/ 52 standard card SVGs + card_back.svg (140x190px, CC0)
│ └── lost-cities/ 60 Lost Cities expedition card SVGs + lc-back.svg (140x190px)
├── games/ Per-game assets (thumbnails)
│ ├── golf/thumbnail.png
│ ├── beleaguered-castle/thumbnail.png
│ ├── lost-cities/thumbnail.png
│ ├── sushi-go/thumbnail.png
│ └── feudalism/thumbnail.png
└── CREDITS.md Asset attribution
tests/
├── fixtures/transcripts/ Fixture transcripts for replay tests (one per game)
├── ai/ AiPlayer, pickRandom, pickBest, barrel export tests
├── card-system/ Card, Deck, Pile unit tests
├── core-engine/ GameState, TurnSequencer, UndoRedoManager, SeededRng, TranscriptRecorder unit tests
├── golf/ Golf game unit + integration + browser tests
├── beleaguered-castle/ Beleaguered Castle unit + integration tests
├── sushi-go/ Sushi Go! cards, scoring, game, AI tests
├── feudalism/ Feudalism cards, game, AI tests
├── lost-cities/ Lost Cities cards, scoring, rules, game, AI, transcript tests
└── replay/ Replay CLI validation tests
Each src/ module has a barrel file (index.ts) that serves as its public API. Import engine modules using path aliases (see below).
The project defines path aliases in both tsconfig.json and vite.config.ts:
| Alias | Resolves To |
|---|---|
@core-engine/* |
src/core-engine/* |
@card-system/* |
src/card-system/* |
@rule-engine/* |
src/rule-engine/* |
@ai/* |
src/ai/* |
@ui/* |
src/ui/* |
Usage in code:
import { ENGINE_VERSION } from '@core-engine/index';The app version (from package.json's version field) is injected at build time
via Vite's define in vite.config.ts. The global constant __APP_VERSION__ is
replaced with the version string during the Vite transform phase (both dev server
and production builds).
The version is displayed as v<version> (e.g. v0.1.7) in two locations:
- The GameSelectorScene menu screen (bottom-left corner)
- The SettingsPanel overlay (shown on the game canvas when the panel opens)
Both use the shared factory createVersionLabel() from src/ui/versionDisplay.ts,
which provides consistent styling (11px font, muted grey, 60% opacity, bottom-left
positioning).
// src/ui/versionDisplay.ts provides the factory and style constants:
import { createVersionLabel, VERSION_LABEL_TEXT } from '@ui/versionDisplay';
// Usage in a scene:
createVersionLabel(this); // creates a non-interactive version label at bottom-leftThe version string can also be referenced directly in code as a string:
console.log(`App version: ${__APP_VERSION__}`);All move validation across the Tableau Card Engine should use the canonical LegalityResult type from @rule-engine/*. This ensures consistent validation semantics across games and enables generic tooling (AI, replay, transcripts) to work with a uniform contract.
import type { LegalityResult } from '@rule-engine/index';
// Discriminated union:
// { legal: true } — action is permitted
// { legal: false, reason } — action is forbidden with an explanationTwo convenience constructors are provided:
| Function | Returns |
|---|---|
legalAction() |
{ legal: true } |
illegalAction(reason: string) |
{ legal: false, reason } |
import { legalAction, illegalAction } from '@rule-engine/index';
function validateMove(card: Card): LegalityResult {
if (!card) return illegalAction('No card provided');
return legalAction();
}Callers should use the legal discriminant to check the result:
const result = validateMove(someCard);
if (!result.legal) {
// result.reason is a string
showError(result.reason);
}
// When result.legal is true, result.reason is not presentThe following games use LegalityResult for move validation:
- Golf —
GolfRules.checkMoveLegality(),checkInitialReveal() - Lost Cities —
LostCitiesRules.checkPhase1Legality(),checkPhase2Legality() - Main Street —
MainStreetMarketimports via market validation - Sushi Go —
SushiGoGame.validatePick()(migrated from{ valid, reason }) - Feudalism —
FeudalismGame.validateAction()and sub-validators (migrated fromstring | null) - Beleaguered Castle —
BeleagueredCastleRules.isLegalFoundationMove(),isLegalTableauMove()(migrated fromboolean)
When migrating an existing game to the canonical pattern:
- Import
LegalityResult(as type-only) from@rule-engine/index - Change the validation function's return type to
LegalityResult - Replace
return true/return null→return { legal: true }(orreturn legalAction()) - Replace
return false/return 'error string'/throw Error(...)→return { legal: false, reason: '...' }(orreturn illegalAction('...')) - Update all callers to check
result.legalinstead of the old pattern - Run
npm testandnpm run buildto verify
Note: For engine feature demonstrations (not full games), add a demo scene to the Gym instead of creating a new example game. See Gym documentation and Gym scene index.
- Create a directory:
example-games/<game-name>/ - Add a standalone entry point:
example-games/<game-name>/main.ts - Add a factory function:
example-games/<game-name>/createXxxGame.ts(for browser tests) - Add scenes:
example-games/<game-name>/scenes/<SceneName>.ts(extendPhaser.Scene) - Place assets in
public/assets/<game-name>/and document attribution inpublic/assets/CREDITS.md - Add game-specific tests under
tests/<game-name>/ - Register the game in the unified entry point (
main.tsat the project root):- Import the scene class
- Add it to the
scenearray in the Phaser config - Add a
GameEntryto theGAMEScatalogue array (includethumbnailonce available)
- Add a
[ Menu ]button to the game scene that callsthis.scene.start('GameSelectorScene')for navigation back to the selector - Add transcript recording:
- Create
example-games/<game-name>/GameTranscript.tswith transcript types and aTranscriptRecorderextendingTranscriptRecorderBase<T>fromsrc/core-engine/TranscriptRecorder.ts - Integrate recording into the scene: create the recorder after game setup, record each turn/action, finalize on game over, and auto-save to
TranscriptStore
- Create
- Add replay support:
- Add
loadBoardState(stateJson: string)to the scene to reconstruct visual state from a transcript snapshot - Emit a
state-settledevent (viaGameEventEmitter) afterloadBoardState()completes rendering - Handle
?mode=replayURL parameter in the scene to skip normal game initialization - Expose
window.__GAME_EVENTS__in replay mode for adapter communication
- Add
- Create a replay adapter:
- Create
scripts/adapters/<GameName>ReplayAdapter.tsimplementing theReplayAdapterinterface - Register the adapter in
scripts/adapters/index.ts(before Golf, which uses structural detection) - Include a
gameTypefield in the transcript for explicit adapter matching
- Create
- Generate fixture and thumbnail:
- Create a fixture generator script at
scripts/generate-<game>-fixture-transcript.ts - Generate and commit the fixture transcript at
tests/fixtures/transcripts/<game-name>/fixture-game.json - Generate and commit the thumbnail at
public/assets/games/<game-name>/thumbnail.pngusing./scripts/refresh-thumbnails.sh <game-name>
- Create a fixture generator script at
Follow the Golf (original reference) and Sushi Go (most recent) examples as reference implementations.
All example games are playable via the Game Selector after running:
npm run devOpen http://localhost:3000 and click the desired game card. Each game also has a standalone entry point (main.ts) and factory function (create<Game>Game.ts) for independent testing.
| Game | Location | Key engine features demonstrated | Tests |
|---|---|---|---|
| 9-Card Golf | example-games/golf/ |
Card/Deck/Pile abstractions, GameState/TurnSequencer, scoring rules (A=1, 2=-2, K=0, column-of-three=0), Random/Greedy AI strategies, transcript recording, Phaser UI with 3x3 grid | tests/golf/ (8 files) |
| Beleaguered Castle | example-games/beleaguered-castle/ |
Single-player solitaire, UndoRedoManager (Command pattern), drag-and-drop + click-to-move, auto-move heuristics, auto-complete, win/loss detection, HelpPanel component, checkpoint autosave after each move with startup recovery | tests/beleaguered-castle/ (2 files) |
| Sushi Go! | example-games/sushi-go/ |
Card drafting (pick-and-pass hands), custom card types with set-collection scoring, multi-round match, procedural card-back textures | tests/sushi-go/ (4 files) |
| Feudalism | example-games/feudalism/ |
Resource management (gem tokens), tiered development cards with costs/bonuses, noble attraction, multi-action turns (take/reserve/purchase), checkpoint autosave after each turn (human + AI) with startup recovery | tests/feudalism/ (4 files) |
| Lost Cities | example-games/lost-cities/ |
Two-player expeditions, two-phase turn model (play/discard then draw), ascending-play rules, investment multipliers (x2/x3/x4), multi-round match scoring, procedurally generated SVG card assets | tests/lost-cities/ (6 files) |
| Main Street | example-games/main-street/ |
Single-player tableau builder, responsive 2x5 grid layout, SLL integration, ToneForge audio adapter, Monte Carlo balance testing, tutorial scene | tests/main-street/ |
The 61 SVG card images are generated by scripts/generate-lost-cities-cards.ts:
npx tsx scripts/generate-lost-cities-cards.tsAssets are output to public/assets/cards/lost-cities/ and documented in public/assets/CREDITS.md.
Game transcripts are automatically recorded by the engine's TranscriptStore and saved to the browser's IndexedDB. Two additional mechanisms allow transcripts to be persisted to disk for debugging, replay, and analysis.
When running npm run dev, a Vite plugin intercepts POST /api/transcripts requests and writes each transcript as a timestamped JSON file:
data/transcripts/<gameType>/<gameType>-<ISO-timestamp>.json
This happens via a fire-and-forget POST from TranscriptStore.save(). If the POST fails (e.g. the production build is being served instead of the dev server), a console.warn is emitted but gameplay is not disrupted.
The data/ directory is gitignored, so persisted transcripts remain local to your machine.
To export all transcripts currently stored in IndexedDB to disk, use:
npm run transcripts:export -- <game>For example:
npm run transcripts:export -- golfThis launches a headless Chromium browser via Playwright, navigates to the game, reads all transcripts from IndexedDB, and writes them to data/transcripts/<game>/. The dev server is started automatically if it is not already running.
Requirements: Playwright's Chromium must be installed (npx playwright install chromium).
Each game has a fixture transcript used by replay tests and thumbnail generation:
tests/fixtures/transcripts/<game-name>/fixture-game.json
All example games have fixture transcripts checked into version control:
| Game | Fixture Path |
|---|---|
| Golf | tests/fixtures/transcripts/golf/fixture-game.json |
| Beleaguered Castle | tests/fixtures/transcripts/beleaguered-castle/fixture-game.json |
| Lost Cities | tests/fixtures/transcripts/lost-cities/fixture-game.json |
| Sushi Go | tests/fixtures/transcripts/sushi-go/fixture-game.json |
| Feudalism | tests/fixtures/transcripts/feudalism/fixture-game.json |
| Main Street | tests/fixtures/transcripts/main-street/fixture-game.json |
These are generated by game-specific fixture generator scripts (e.g. scripts/generate-golf-fixture-transcript.ts) that run deterministic AI-vs-AI games using a fixed seed.
Games can auto-save their run state after each turn and offer a resume/fresh-game
choice on startup via the shared CheckpointManager in @core-engine. This
pattern is game-agnostic — the manager works with any game state type and
SaveSerializer.
import { CheckpointManager } from '@core-engine';
const manager = new CheckpointManager(store, 'my-game', 'run-checkpoint', mySerializer);| Method | Description |
|---|---|
save(state) |
Fire-and-forget checkpoint save after each game state change. |
load() |
Returns the saved state, or null if none exists. |
clear() |
Removes the checkpoint (e.g. on game end or New Game). |
checkAndResume(freshStartFn, resumeFn, createResumeOverlay?) |
Checks for a saved checkpoint. If found, shows a resume overlay via the optional callback. If not, calls freshStartFn immediately. |
The createResumeOverlay callback lets each game provide its own overlay UI.
A built-in createDefaultResumeOverlay is also available for quick integration:
import { createDefaultResumeOverlay } from '@core-engine';
manager.checkAndResume(
() => startFreshGame(),
(state) => restoreFromCheckpoint(state),
(state, onResume, onNewGame) =>
createDefaultResumeOverlay(scene, state, onResume, onNewGame),
);| Game | When checkpoint is saved | Startup behaviour |
|---|---|---|
| Beleaguered Castle | After deal completes + after each player move | Shows "Resume Saved Game?" overlay with [Resume] and [New Game] buttons |
| Feudalism | After each human turn + after each AI turn | Shows resume overlay with [Resume] and [New Game] buttons |
| Main Street | (planned) | (planned) |
The CheckpointManager delegates all storage to SaveLoadStore (IndexedDB
with localStorage fallback). See src/core-engine/CheckpointManager.ts for
full API documentation.
The ActiveEffect module (src/core-engine/ActiveEffect.ts) provides a
duration-based modifier system that tracks ongoing effects over multiple turns.
ActiveEffect– interface witheffectType,multiplier,turnsRemaining,sourceEventId, anddescription.DecayResult– result of a decay operation withactive,expired, andeffectsarrays.
All functions are exported from @core-engine/index:
| Function | Purpose |
|---|---|
createActiveEffect(type, mult, turns, sourceId, desc) |
Create a new effect |
decayActiveEffects(effects) |
Decrement all effects, return active/expired sets |
applyActiveEffectMultiplier(effects, type, baseValue) |
Apply matching multipliers (rounded) |
hasActiveEffectOfType(effects, type) |
Check if any effect of given type exists |
Duration-based Event cards (e.g. evt-flu-outbreak) extend EventCard with
duration, effectType, and multiplier fields. The engine's resolveEvent()
function detects DurationEventCard instances via the isDurationEventCard()
type guard and creates an ActiveEffect instead of applying one-shot deltas.
Income-modifier effects are applied per-slot during applyIncome() before
the reputation coin multiplier. Effects decay at the end of each turn during
EndCheck in processEndOfTurn().
MainStreetState.activeEffectsstores the active effects array- Serialization/deserialization includes
activeEffectswith migration for old saves (missing field defaults to[]) - Duration computation for
evt-flu-outbreakscans the street grid for Clinic/Medical Center cards
The replay tool (scripts/replay.ts) replays a fixture transcript through the game's Phaser scene in a headless browser, capturing per-turn screenshots. It is the foundation for thumbnail generation and visual regression testing.
npm run replay -- <transcript-path> [--output <dir>] [--stop-at <turn>]transcript-path-- Path to a fixture transcript JSON file--output <dir>-- Output directory for screenshots (defaults todata/screenshots/<game-type>/)--stop-at <turn>-- Stop replay at a specific turn number (for interactive takeover in headed mode)
Examples:
# Replay Golf fixture and capture all screenshots
npm run replay -- tests/fixtures/transcripts/golf/fixture-game.json
# Replay Feudalism with custom output directory
npm run replay -- tests/fixtures/transcripts/feudalism/fixture-game.json --output data/screenshots/feudalism-test
# Replay Main Street fixture and generate thumbnail source frames
npm run replay -- tests/fixtures/transcripts/main-street/fixture-game.json --game main-street --output data/screenshots/main-streetScreenshots are written as turn-000.png, turn-001.png, etc. in the output directory. A replay-summary.json is also written with metadata.
- The replay tool parses the transcript and resolves a
ReplayAdapterfrom the adapter registry (scripts/adapters/index.ts) - It launches a headless Chromium browser via Playwright and navigates to the game with
?mode=replay&game=<game-type> - For each turn in the transcript, it calls
adapter._injectBoardState()which usespage.evaluate()to call the scene'sloadBoardState(stateJson)method - The scene reconstructs visual state from the snapshot and emits a
state-settledevent when rendering is complete - The tool captures a screenshot of the canvas after each
state-settledevent
After a replay completes, a contact sheet image is automatically generated showing all per-turn screenshots arranged in a grid. The contact sheet is written to contact-sheet.png in the output directory.
- Thumbnails are 225x175px arranged in 4 columns
- Each thumbnail is labeled with its turn number
- Generated using
sharp(MIT-licensed, already a dependency) - The contact sheet path is included in
replay-summary.jsonascontactSheetPath
During gameplay, an Export Transcript button appears on the end-of-round results screen, allowing you to download the current game transcript as a JSON file directly from the browser.
- End-of-round screen: After the game ends, click
[ Export Transcript ]to download the transcript asgolf-transcript-<timestamp>.json - Error-triggered export: If an unhandled JavaScript error occurs during gameplay, an overlay appears with an
[ Export Transcript ]button so the transcript can be saved for debugging before reloading
Each game has a ReplayAdapter implementation in scripts/adapters/ that bridges the replay tool to the game's scene:
| Game | Adapter | Game Type |
|---|---|---|
| Beleaguered Castle | BeleagueredCastleReplayAdapter |
beleaguered-castle |
| Lost Cities | LostCitiesReplayAdapter |
lost-cities |
| Sushi Go | SushiGoReplayAdapter |
sushi-go |
| Feudalism | FeudalismReplayAdapter |
feudalism |
| Main Street | MainStreetReplayAdapter |
main-street |
| Golf | GolfReplayAdapter |
(structural detection) |
Adapters are registered in scripts/adapters/index.ts. Registration order matters: adapters with explicit gameType fields are registered before Golf, which uses structural shape-matching as a fallback.
- Create
scripts/adapters/<GameName>ReplayAdapter.tsimplementing theReplayAdapterinterface fromscripts/adapters/ReplayAdapter.ts - Implement all 14 interface methods (see
SushiGoReplayAdapteras the most recent reference) - Register the adapter in
scripts/adapters/index.tsbefore the Golf adapter - Ensure the game scene implements
loadBoardState()and emitsstate-settledevents - Test with:
npm run replay -- tests/fixtures/transcripts/<game>/fixture-game.json
The core engine provides a typed event system for turn lifecycle events. It consists of two parts:
GameEventEmitter(src/core-engine/GameEventEmitter.ts) — A type-safe event emitter that works in both Node.js and browser environments. Events are defined with typed payloads.PhaserEventBridge(src/core-engine/PhaserEventBridge.ts) — BridgesGameEventEmitterevents to Phaser's scene event system and vice versa, allowing Phaser-based consumers (scenes, UI components) to subscribe to engine events using Phaser's nativescene.events.
| Event | Payload | Fires When |
|---|---|---|
turn-started |
{ turnNumber: number, playerIndex: number, phase: string } |
A player's turn begins |
turn-completed |
{ turnNumber: number, playerIndex: number } |
A move is applied and recorded |
animation-complete |
{ turnNumber: number } |
All tween animations for a turn finish |
state-settled |
{ turnNumber: number, phase: string } |
The board is visually stable and safe to screenshot |
game-ended |
{ finalTurnNumber: number, winnerIndex: number, reason: string } |
The game ends after scoring |
resume-replay |
(none) | Signals the replay tool to resume after takeover |
import { GameEventEmitter } from '@core-engine';
const emitter = new GameEventEmitter();
// Subscribe with full type safety
emitter.on('state-settled', (payload) => {
console.log(`Turn ${payload.turnNumber} settled, phase: ${payload.phase}`);
});
// Unsubscribe
const handler = (p: StateSettledPayload) => {};
emitter.on('state-settled', handler);
emitter.off('state-settled', handler);emitter.emit('state-settled', { turnNumber: 5, phase: 'draw' });During gameplay, the emitter is exposed globally as window.__GAME_EVENTS__ so that tools (replay, testing) can subscribe from outside the Phaser scene:
const emitter = (window as any).__GAME_EVENTS__;
emitter.on('state-settled', (payload) => {
// e.g., capture screenshot
});When using Phaser scenes, the PhaserEventBridge forwards engine events to Phaser's scene events and vice versa:
import { GameEventEmitter, PhaserEventBridge } from '@core-engine';
const emitter = new GameEventEmitter();
const bridge = new PhaserEventBridge(emitter, scene.events);
// Now scene.events receives forwarded engine events:
this.events.on('state-settled', (payload) => { /* ... */ });
// Destroy on scene shutdown:
bridge.destroy();- All assets go in
public/assets/and are served by Vite at the/assets/URL path - Assets must be CC0, MIT, Apache 2.0, or similarly permissive -- no restrictive licenses
- Document every asset source and license in
public/assets/CREDITS.md - Prefer SVG for card art (resolution-independent, small file size)
Each game can have a thumbnail image displayed on its card in the Game Selector. Thumbnails are committed to the repo at:
public/assets/games/<game-name>/thumbnail.png
Generating a thumbnail from replay screenshots:
npx tsx scripts/generate-thumbnail.ts <game-name> [source-dir]game-name-- The game identifier (e.g.golf)source-dir-- Optional path to a directory containingturn-NNN.pngreplay screenshots. Defaults todata/screenshots/<game-name>/
The script selects the midpoint frame from the replay output, resizes it to 120x68 PNG, and writes it to public/assets/games/<game-name>/thumbnail.png.
Wiring up a thumbnail:
After generating the thumbnail PNG, add a thumbnail field to the game's entry in main.ts:
{
sceneKey: 'GolfScene',
title: '9-Card Golf',
description: '...',
thumbnail: 'games/golf/thumbnail', // asset key (no .png extension)
}The GameSelectorScene will preload and display the thumbnail automatically. Games without a thumbnail field fall back to the text-only card layout.
Refreshing all thumbnails at once:
Use the scripts/refresh-thumbnails.sh script to replay fixture transcripts and regenerate thumbnails for all supported games in a single command:
bash scripts/refresh-thumbnails.shThe script processes all supported games (golf, beleaguered-castle, lost-cities, sushi-go, feudalism, main-street). For each game it runs the replay tool to capture screenshots, then invokes the thumbnail generator. Games that lack a fixture transcript or replay adapter are skipped with a warning (not a failure). The gym is excluded -- it has no replay transcript. A summary table is printed at the end showing which games were refreshed and which were skipped. The script exits non-zero if any supported game fails during replay or thumbnail generation.
Main Street rendering policy is Phaser-native only for runtime card visuals. Do not add new svgDom visibility toggles for overlays; validate layering through canvas-native assertions.
Use these commands when validating Main Street visual polish changes:
# Replay canonical fixture and capture screenshots
npm run replay -- tests/fixtures/transcripts/main-street/fixture-game.json --game main-street --output data/screenshots/main-street
# Regenerate Main Street thumbnail
npx tsx scripts/generate-thumbnail.ts main-street
# Execute dedicated visual/replay smoke checks
npx vitest run --project unit tests/e2e/replay-main-street.e2e.test.ts tests/e2e/generate-thumbnail.main-street.test.tsUse commit-level reverts on the feature branch if a rendering regression is discovered:
git checkout <feature-branch>
git log --oneline -- example-games/main-street/scenes src/ui tests/main-street
git revert <commit-hash>
npm test
npm run buildWhen to regenerate thumbnails:
Thumbnails are static assets. Regenerate them when a game's visual appearance changes significantly. Use scripts/refresh-thumbnails.sh to regenerate all thumbnails at once, or use the individual commands above for a single game.
The engine now provides shared SVG raster helpers from src/core-engine/SvgHelpers.ts (exported via src/core-engine/index.ts).
Rasterisation policy (project choice): lazy rasterisation on first use. In practice this means scenes should preload SVG source text (via this.load.text) and only rasterise to a texture when the texture is first required for rendering. This keeps preload fast and memory usage reasonable while ensuring visual fidelity when textures are needed.
- Preload SVG source text (not
this.load.svg) so you can rasterise through shared helpers:
this.load.text('svg:icon-tempura', 'assets/sushi-go/icon-tempura.svg');- Mark scene validity during lifecycle:
import { markSceneValid, markSceneInvalid } from '@core-engine/index';
markSceneValid(this);
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => markSceneInvalid(this));
this.events.once(Phaser.Scenes.Events.DESTROY, () => markSceneInvalid(this));- Generate textures lazily or in a preload-to-render bridge:
import { makeTextureKey, getOrCreateTexture, rasteriseSvgToTexture } from '@core-engine/index';
const svgText = this.cache.text.get('svg:icon-tempura') as string;
const key = makeTextureKey('icon-tempura', 128, 128, window.devicePixelRatio || 1);
// Option A: fully explicit
await rasteriseSvgToTexture(this, key, svgText, 128, 128);
// Option B: lazy helper (recommended)
const texture = getOrCreateTexture(this, 'icon-tempura', svgText, 128, 128);
if (!texture.ready && texture.promise) await texture.promise;- Replace direct
this.load.svg(...)calls in scenes withthis.load.text(...)for SVG source text. - Import shared helpers from
src/core-engine(markSceneValid,markSceneInvalid,makeTextureKey,rasteriseSvgToTexture,getOrCreateTexture). - Ensure scene lifecycle invalidates helper operations on shutdown/destroy.
- Add or update browser smoke tests with pixel-sample assertions (non-solid texture checks).
- Keep per-test runtime at or below 10 seconds for SVG smoke checks.
The following pattern is used when migrating a game from scene.load.svg to SvgHelpers lazy rasterisation. Key changes:
- Preload: SVG assets are registration-only in browser runtimes (call
markSceneValid(scene)); SVG source text is loaded viathis.load.text()for later rasterisation. The Node/test preload path populates a module-levelsvgTextCachefor headless access. - Texture adapter: A new module provides a stable, DPR-aware API for callers, with
resolveTemplateId(),getCanonicalTextureKey(), andensureTexture()wrappers replacing legacy template IDs. - Scene callers: All scene code imports from the texture adapter instead of using legacy keys or direct texture lookups.
- Texture keys: Lazy rasterisation via
SvgHelpers.getOrCreateTextureproduces DPR-aware keys. Legacy template IDs should not be used for sprite texture lookups. - Tests: Unit and integration tests assert DPR-aware key format. A headless integration smoke test verifies the full preload → ensure → key resolution pipeline.
Pattern for migrating other games:
- Create a texture adapter module with
resolveTemplateId(),getCanonicalTextureKey(), andensureTexture()wrappers. - Replace
this.load.svg(...)withmarkSceneValid(this)in preload; populate SVG text cache viathis.load.text(...)or module-level cache for Node. - Replace direct texture key strings with adapter calls in scene code.
- Update tests to assert DPR-aware key format and add headless integration checks.
Lost Cities was the third example game migrated from scene.load.svg to SvgHelpers lazy rasterisation. Key changes:
- LostCitiesTextureHelpers.ts: New co-located helper module providing
preloadLostCitiesAssets(),getLcTextureKey(),ensureLcCardTexture(),ensureLcCompactTexture(), andensureLcBackTexture(). The preload function is registration-only in browser runtimes (marks scene valid viamarkSceneValid); the Node/test path reads all 121 SVGs into a module-level cache. - LostCitiesScene.ts: Removed 5
this.load.svg()blocks (121 SVG files) frompreload(). Replaced withpreloadLostCitiesAssets(this)and addedmarkSceneInvalid(this)on shutdown. - LostCitiesRenderer.ts: All sprite creation now starts with a card-back fallback texture and uses
applyEnsuredTextureto lazily update to the DPR-aware texture when rasterisation completes. Expedition cards, discard pile cards, hand cards, and the draw pile all use the same lazy pattern. - Texture keys: Lazy rasterisation via
SvgHelpers.getOrCreateTextureproduces DPR-aware keys (e.g.ms_card_lc-blue-2_95x130@2). Legacy template IDs (lc-blue-2,lc-back) are still returned bycardAssetKey()/compactAssetKey()but should not be used for sprite texture lookups. - Tests: Focused unit tests added at
tests/lost-cities/texture-helpers.test.tscovering key generation, SVG text caching, and texture retrieval for representative card samples.
Pattern for migrating other games:
- Create a co-located helper module with
preload*Assets(),ensure*Texture(),get*TextureKey(), andsvgTextCache. - Replace
this.load.svg(...)withmarkSceneValid(this)in preload; populate SVG text cache in Node. - Replace direct texture key strings with helper calls in scene/renderer code.
- Update tests to assert DPR-aware key format and add focused unit tests.
No remaining games use scene.load.svg.
The project provides a shared HUD (Heads-Up Display) layer abstraction that ensures help/settings panels, buttons, and game-state overlays render consistently above gameplay content across all example games.
-
CardGameScene.initHUDContainer()— Creates a shared container (this.hudContainer) at depth1000. Call this early increate()beforeinitHelpPanel()andinitSettingsPanel(). -
OverlayManager(src/ui/OverlayManager.ts) — A reusable class that manages game-state overlay lifecycle. Supports types:'game-over','win/loss','round-end','custom'.
| Layer | Depth | Purpose |
|---|---|---|
| HUD container | 1000 | Help/settings panels, buttons |
| Game-state overlays | 2000 | Win, loss, game-over, round-end overlays |
For comprehensive documentation covering all shared HUD components (HelpPanel, SettingsPanel, HelpButton, SettingsButton, Overlay Manager, Parameterized Overlay, CardGameScene base class, undo/redo buttons, and HUD container patterns) see the Shared HUD Components section below.
For detailed migration steps, see docs/HUD-LAYER-MIGRATION.md.
The project now includes a reusable Screen Layout Language for viewport-aware scene layout.
- Schema + types:
src/ui/screen-layout-schema.ts - Runtime mapping:
src/ui/screen-layout.ts - Composition helper:
src/ui/screen-layout-compose.ts - Visibility / ownership helper:
src/core-engine/VisibilityOwnership.ts - Public exports:
src/ui/index.ts,src/core-engine/index.ts
The following games have been migrated to use SLL layout helpers:
| Game | Layout file | Adapter |
|---|---|---|
| Golf | example-games/golf/layouts/golf.layout.json |
example-games/golf/scenes/GolfLayoutAdapter.ts |
| Beleaguered Castle | example-games/beleaguered-castle/layouts/beleaguered-castle.layout.json |
example-games/beleaguered-castle/scenes/BeleagueredCastleLayoutAdapter.ts |
| Main Street | example-games/main-street/layouts/main-street.layout.json |
example-games/main-street/scenes/MainStreetLayoutAdapter.ts |
Games with layout files and adapters ready for renderer integration:
| Game | Layout file | Adapter |
|---|---|---|
| Feudalism | example-games/feudalism/layouts/feudalism.layout.json |
example-games/feudalism/scenes/FeudalismLayoutAdapter.ts |
| Sushi Go | example-games/sushi-go/layouts/sushi-go.layout.json |
example-games/sushi-go/scenes/SushiGoLayoutAdapter.ts |
| Lost Cities | example-games/lost-cities/layouts/lost-cities.layout.json |
example-games/lost-cities/scenes/LostCitiesLayoutAdapter.ts |
- Layout file:
example-games/main-street/layouts/main-street.layout.json - Adapter:
example-games/main-street/scenes/MainStreetLayoutAdapter.ts - Renderer integration:
example-games/main-street/scenes/MainStreetRenderer.ts(computeLayout()applies SLL first, then falls back)
- Scene:
example-games/gym/scenes/GymSllScene.ts - Layout documents:
example-games/gym/layouts/gym-shell.layout.json(shell-only and composed shell source),example-games/gym/layouts/gym-scene.layout.json(scene-only source),example-games/gym/layouts/gym-sll-pixel-override.layout.json - Browser verification:
tests/gym/GymSllScene.browser.test.ts - Unit verification:
tests/core-engine/VisibilityOwnership.test.ts,tests/ui/screen-layout-compose.test.ts,tests/gym/GymSllLayout.test.ts - Shared ownership helper:
src/core-engine/VisibilityOwnership.ts
Use composeResolvedLayouts(baseLayout, sceneLayout, viewport, dpr, { policy: 'sceneWins' }) to combine a shared shell layout (header/menu/toolbar/help) with a scene-specific layout without duplicating placement math. Collision handling follows the project default: scene wins, with a warning reported on collision for local dev visibility.
Register scene objects into ownership groups so visibility is managed automatically per layout mode:
import { VisibilityOwnershipController } from '@core-engine/VisibilityOwnership';
const controller = new VisibilityOwnershipController({
groupRules: {
shell: { 'shell-only': true, composed: true },
scene: { 'scene-only': true, composed: true },
shared: { 'shell-only': true, 'scene-only': true, composed: true },
},
});
controller.register(headerText, 'shell');
controller.register(sceneContent, 'scene');
controller.setMode('scene-only'); // hides shell, shows scene+sharedTypical use cases: shared app chrome across scenes, scene-specific overrides, browser tests asserting merged anchor positions across DPR/viewports, and debug overlays needing both source layout IDs and resolved pixels.
Main Street uses a code-based overlay rendering pipeline to display upgrade state on Business cards. This section documents how the pipeline works, why it was designed this way, and how to extend it.
When a player upgrades a Business card in Main Street, the game state updates correctly (level increases, income bonus applies, card name changes) but the visual display must reflect these changes. Creating separate SVG assets for every level variant of every card would cause asset explosion and make maintenance difficult.
Instead of generating separate SVG templates for each card level, the system renders the base SVG card once (cached texture) and draws Phaser text/graphics objects on top as overlays. This approach provides:
- Performance: No per-level SVG re-rasterization. Base textures are cached and reused.
- Texture caching simplicity: One texture key per base card, regardless of upgrade state.
- Backward compatibility: Non-upgraded cards (level 0) render identically to before; no visual changes to existing rendering paths.
- Testability: The overlay spec builder is a pure function with no Phaser dependencies.
The pipeline has two layers:
Location: example-games/main-street/scenes/UpgradeOverlaySpec.ts
This is a pure data module with no Phaser or runtime dependencies. It defines three interfaces:
OverlayTextSpec– Describes a text overlay withtext,x,y,fontSize,color, andfontStyleproperties.OverlayBorderSpec– Describes a border/glow overlay withcolor(hex number) andstrokeWidth(pixels).UpgradeOverlaySpec– Combines all overlay elements:levelBadge,incomeText,nameText, andupgradeBorder.
The key function is buildUpgradeOverlaySpec(biz: BusinessCard, width: number, height: number): UpgradeOverlaySpec:
BusinessCard state ──► buildUpgradeOverlaySpec() ──► UpgradeOverlaySpec
(level, name, (pure function, (positioned text
baseIncome, no Phaser deps) specs + border
incomeBonus) spec)
Logic:
- Base cards (
level === 0): All overlay fields arenull— nothing extra is rendered. - Upgraded cards (
level > 0): All four overlay specs are populated:- Level badge —
"Lvl N"in gold (#ffdd44), top-right corner, 10px bold. - Income text —
"+N"(combinedbaseIncome + incomeBonus) in green (#44ff44), bottom-center, 12px bold. - Name overlay — Upgraded card name in white (
#ffffff), top-center, 10px bold, with a dark semi-transparent background rectangle for readability. - Upgrade border — Golden stroke (
0xffaa22), 3px width, around the card perimeter.
- Level badge —
Location: example-games/main-street/scenes/MainStreetRenderer.ts — applyUpgradeOverlays() method.
This method reads the UpgradeOverlaySpec and creates Phaser game objects as children of the card's container:
UpgradeOverlaySpec ──► applyUpgradeOverlays() ──► Phaser text/graphics objects
(from Layer 1) (reads spec, creates (added to card container)
Phaser objects)
Rendering order (back to front within the container):
- Upgrade border (transparent fill, golden stroke) — drawn behind text but on top of card image.
- Name overlay background (dark semi-transparent rectangle) — for readability.
- Name text (white bold).
- Level badge text (gold, top-right).
- Income text (green, bottom-center).
Call site: drawBusinessSlot() in MainStreetRenderer.ts:
// Render base card SVG
mainStreetRenderCardSvg(s, cardContainer, biz.id, renderW, renderH);
// Apply upgrade overlays on top
this.applyUpgradeOverlays(cardContainer, biz, renderW, renderH);┌─────────────────────────────────────────────────────────────────┐
│ Game State Update │
│ Player upgrades Bookshop → Reader's Café (level 1→2, income +3→+8) │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ refreshStreetGrid() │
│ Iterates over street grid, calls drawBusinessSlot() per card │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ drawBusinessSlot() │
│ 1. mainStreetRenderCardSvg() → base SVG texture (cached) │
│ 2. applyUpgradeOverlays() → Phaser overlays on top │
└──────────────────────────┬──────────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────┐
│ Base SVG texture │ │ buildUpgradeOverlaySpec() │
│ (cached, reused) │ │ → levelBadge: "Lvl 2" │
│ │ │ → incomeText: "+8" │
│ │ │ → nameText: "Library" │
│ │ │ → upgradeBorder: gold 3px │
└─────────────────────┘ └──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ applyUpgradeOverlays() │
│ Creates Phaser objects: │
│ - Rectangle (golden border)│
│ - Graphics (name bg) │
│ - Text (name, level, income)│
└─────────────────────────────┘
To add or modify upgrade overlays:
- Modify
buildUpgradeOverlaySpec()inUpgradeOverlaySpec.tsto compute new overlay specs. Keep it pure — no Phaser imports. - Modify
applyUpgradeOverlays()inMainStreetRenderer.tsto create the corresponding Phaser objects. - Add unit tests for
buildUpgradeOverlaySpec()intests/main-street/UpgradeOverlaySpec.test.ts(this file tests the pure spec builder, not Phaser rendering).
Example: Adding a star icon for level 3+ cards:
// In UpgradeOverlaySpec.ts — add to the spec interface:
export interface UpgradeOverlaySpec {
// ... existing fields ...
/** Star icon overlay for level 3+ cards, null otherwise. */
starIcon: OverlayTextSpec | null;
}
// In buildUpgradeOverlaySpec():
const starIcon: OverlayTextSpec | null = biz.level >= 3
? { text: '\u2605', x: 4, y: Math.round(height / 2), fontSize: '16px', color: '#ffdd44' }
: null;
// In applyUpgradeOverlays() in MainStreetRenderer.ts:
if (spec.starIcon) {
const star = this.scene.add.text(spec.starIcon.x, spec.starIcon.y, spec.starIcon.text, {
fontSize: spec.starIcon.fontSize, color: spec.starIcon.color,
});
container.add(star);
}An alternative approach would be to generate composite SVGs with embedded level/income text and re-rasterize them per card state. This was considered but rejected because:
- Asset duplication: Each card would need SVG variants for each level (Bookshop L1, L2, L3, etc.), multiplying asset count.
- Cache complexity: Texture cache keys would need to encode card state, increasing cache miss rates.
- SVG text rendering: SVG text positioning and font rendering can be inconsistent across browsers, making precise overlay placement harder.
- Performance: Re-rasterizing SVGs on every state change is more expensive than drawing Phaser text objects on top of a cached texture.
The code-based overlay approach keeps the texture cache simple (one key per base card) and leverages Phaser's reliable text rendering.
The engine provides a shared rendering API under src/ui/Renderer/ that supplies common rendering helpers so game scenes stay small and focused without duplicating boilerplate patterns. The module exports container creation, HUD text, tooltip zones, action buttons, and an SVG card rendering wrapper — all designed to work with a standard Phaser Scene.
All exports are available via @ui/Renderer (which resolves to src/ui/Renderer/index.ts).
import {
createHudContainer,
createGameZone,
} from '@ui/Renderer';createHudContainer(scene: Phaser.Scene): Phaser.GameObjects.Container
Creates a HUD container with depth 1000, intended for transient overlay elements that are rebuilt each HUD refresh cycle. Children should be tagged with _hudTransient: true so they can be selectively destroyed on the next refresh.
const hud = createHudContainer(this);
const scoreText = createHudText(this, 10, 10, 'Score: 0', '#ffcc44');
(scoreText as any)._hudTransient = true;
hud.add(scoreText);createGameZone(scene: Phaser.Scene, x: number, y: number, w: number, h: number, name?: string): Phaser.GameObjects.Container
Creates a named zone container for grouping related game objects. Stores logical width/height as __zoneWidth and __zoneHeight custom properties.
const streetZone = createGameZone(this, 100, 200, 600, 300, 'street');import { createHudText, attachHudTooltipZone } from '@ui/Renderer';createHudText(scene: Phaser.Scene, x: number, y: number, text: string, color: string, options?: { fontSize?: string; fontFamily?: string; originX?: number; originY?: number }): Phaser.GameObjects.Text
Creates a styled text object using FONT_FAMILY by default, bold style, and origin (0, 0.5).
const label = createHudText(this, 200, 50, 'Turn 1', '#ffffff', { fontSize: '18px' });attachHudTooltipZone(scene: Phaser.Scene, textObj: Phaser.GameObjects.Text, ariaLabel: string, contentBuilder: () => string): void
Attaches an interactive tooltip zone to a HUD text element. On desktop, tooltip shows on hover; on mobile, toggles on tap. Sets ARIA labels for accessibility.
attachHudTooltipZone(
this,
scoreText,
'Current score',
() => `Your score is ${gameState.score}`,
);import { createActionButton, type ActionButtonOptions } from '@ui/Renderer';createActionButton(scene: Phaser.Scene, x: number, y: number, width: number, text: string, callback: () => void, options?: ActionButtonOptions): Phaser.GameObjects.Container
Creates a styled button with background, label, hover/click effects, and optional disabled state.
createActionButton(
this,
100, 500, 120, 'Buy',
() => buyCard(),
{ fillColor: 0x224455, textColor: '#88ccff' },
);import { applyEnsuredTexture, type EnsureTextureResult } from '@ui/Renderer';applyEnsuredTexture(sprite: Phaser.GameObjects.Image, ensureOp: Promise<EnsureTextureResult>, stillMounted: () => boolean, displayWidth?: number, displayHeight?: number): Promise<void>
Applies an ensured texture to a sprite, awaiting async generation if needed. Encapsulates the pattern: await the texture operation, check sprite is still mounted, swap texture, and re-apply display size.
await applyEnsuredTexture(
cardImage,
ensureCardTexture(this, cardId, width, height),
() => cardImage.active,
width,
height,
);import { renderCardSvg, type RenderCardSvgOptions } from '@ui/Renderer';renderCardSvg(scene: Phaser.Scene, parentContainer: Phaser.GameObjects.Container, templateId: string, width: number, height: number, options?: RenderCardSvgOptions): Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle
Renders an SVG card into a parent container. Checks for an existing texture; if found creates an Image, otherwise starts async generation and draws a fallback Rectangle.
renderCardSvg(this, cardContainer, 'card-42', 95, 130, {
fallbackFill: 0x333333,
fallbackStroke: 0x666666,
});The Renderer module exports several TypeScript interfaces used by the public API functions.
Passed to createActionButton to customise button appearance and behaviour.
| Field | Type | Default | Description |
|---|---|---|---|
height |
number |
32 |
Button height in pixels. |
fillColor |
number |
0x554422 |
Background fill colour. |
fillAlpha |
number |
0.8 |
Background fill alpha. |
strokeColor |
number |
0xaa8855 |
Stroke colour. |
textColor |
string |
'#ffcc88' |
Label text colour. |
fontSize |
string |
'14px' |
Label font size. |
disabled |
boolean |
false |
When true, the button is visually dimmed and non-interactive. |
Passed to createHudText (merged with an inline fontSize option) to customise text styling.
| Field | Type | Default | Description |
|---|---|---|---|
fontFamily |
string |
FONT_FAMILY |
Override the default font family. |
originX |
number |
0 |
Horizontal origin (default 0). |
originY |
number |
0.5 |
Vertical origin (default 0.5). |
The createHudText options parameter accepts { fontSize?: string } & HudTextOptions, so you can pass fontSize alongside the fields above.
Returned by async texture-ensure operations; consumed by applyEnsuredTexture.
| Field | Type | Description |
|---|---|---|
key |
string |
The texture key that will be (or is) available. |
ready |
boolean |
true if the texture is already registered and ready to use. |
promise |
Promise<void> (optional) |
Resolves when async generation completes. |
Passed to renderCardSvg to customise card rendering behaviour.
| Field | Type | Default | Description |
|---|---|---|---|
makeKey |
MakeTextureKeyFn |
makeTextureKey from SvgHelpers |
Derives a texture cache key from template ID and dimensions. |
requestTexture |
RequestTextureFn |
wrapper around getOrCreateTexture |
Initiates async texture generation when the texture is missing. |
fallbackFill |
number |
0x333333 |
Fill colour for the fallback rectangle. |
fallbackStroke |
number |
0x666666 |
Stroke colour for the fallback rectangle. |
Type alias: (templateId: string, width: number, height: number) => string
Derives a texture cache key. Games with custom texture pipelines provide their own implementation via RenderCardSvgOptions.makeKey.
Type alias: (scene: Phaser.Scene, templateId: string, width: number, height: number) => void
Initiates asynchronous texture generation. Games with custom texture pipelines provide their own implementation via RenderCardSvgOptions.requestTexture.
Each game provides a thin adapter module under src/ui/Renderer/adapters/ that re-exports shared helpers and wires game-specific texture pipelines. This keeps scene code importing from a single adapter while the shared API remains stable.
Re-exports createActionButton and attachHudTooltipZone unchanged. Provides mainStreetRenderCardSvg that wires the scene's templateKeyForCard and requestCardTexture methods:
import {
createActionButton,
attachHudTooltipZone,
mainStreetRenderCardSvg,
createMainStreetHintButton,
} from '@ui/Renderer/adapters/MainStreetAdapter';
// Button and tooltips use the shared helpers directly
const buyBtn = createActionButton(this, x, y, 120, 'Buy', () => buy());
attachHudTooltipZone(this, costText, 'Card cost', () => `Cost: ${card.cost}`);
// Card rendering uses the Main Street–specific wrapper
mainStreetRenderCardSvg(this, slotContainer, card.id, CARD_W, CARD_H);
// Hint button with game-specific styling
createMainStreetHintButton(this, x, y, 80, 32, hintUsed, () => showHint());The following table lists helpers that were extracted from individual game scenes into the shared Renderer module.
| Old location (scene) | Old name | New location | New name |
|---|---|---|---|
example-games/main-street/scenes/MainStreetScene.ts |
Inline HUD container creation | @ui/Renderer |
createHudContainer |
example-games/main-street/scenes/MainStreetScene.ts |
Inline HUD text styling | @ui/Renderer |
createHudText |
example-games/main-street/scenes/MainStreetScene.ts |
Inline tooltip zone setup | @ui/Renderer |
attachHudTooltipZone |
example-games/main-street/scenes/MainStreetScene.ts |
Inline action button creation | @ui/Renderer |
createActionButton |
example-games/main-street/scenes/MainStreetRenderer.ts |
renderCardSvg (local) |
@ui/Renderer |
renderCardSvg |
Before (Main Street — inline in scene):
// Old pattern: duplicated in every scene
const hudContainer = this.add.container(0, 0);
hudContainer.setDepth(1000);
const scoreText = this.add.text(10, 10, 'Score: 0', {
fontSize: '16px',
fontStyle: 'bold',
color: '#ffcc44',
fontFamily: 'system-ui, sans-serif',
}).setOrigin(0, 0.5);
const buyBtn = this.add.container(x + 60, y + 16);
const bg = this.add.rectangle(0, 0, 120, 32, 0x554422, 0.8);
bg.setStrokeStyle(1, 0xaa8855);
buyBtn.add(bg);
const label = this.add.text(0, 0, 'Buy', {
fontSize: '14px', fontStyle: 'bold', color: '#ffcc88',
}).setOrigin(0.5);
buyBtn.add(label);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => buyCard());After (using shared Renderer via adapter):
import {
createHudContainer,
createHudText,
createActionButton,
} from '@ui/Renderer/adapters/MainStreetAdapter';
const hud = createHudContainer(this);
const scoreText = createHudText(this, 10, 10, 'Score: 0', '#ffcc44');
hud.add(scoreText);
createActionButton(this, x, y, 120, 'Buy', () => buyCard());PR: Merged as part of Shared Renderer epic (GitHub #568)
| Commit | Work Item | Description |
|---|---|---|
42a3916 |
CG-0MPOLH2U9001P7BC | Shared Renderer API scaffold and core helpers |
7d80ec1 |
CG-0MPOLHCAN004D753 | Card rendering SVG wrapper helper (renderCardSvg) |
9f1272f |
CG-0MPOLHCAN0037UUS | Main Street adapter and migration |
f173bfd |
CG-0MPOLHCBV005SD2L | Migration documentation in DEVELOPER.md |
- Shared Renderer (CG-0MP12VWO1003YL55) — parent epic
- Shared Renderer API scaffold and core helpers (CG-0MPOLH2U9001P7BC)
- Card rendering SVG wrapper helper (CG-0MPOLHCAN004D753)
- Main Street adapter and migration (CG-0MPOLHCAN0037UUS)
- Unit test specification for shared Renderer helpers (CG-0MPOLGVTH009NSTM)
- Browser integration smoke tests for Main Street (CG-0MPOLGZ70000Q9J1)
See the Gym SLL demo (example-games/gym/scenes/GymSllScene.ts) for a working example with shell toggling.
When adding a new example game, follow this pattern:
-
Create a layout JSON file in
example-games/<game>/layouts/<game>.layout.jsonwith position-only normalized zone rectangles (x,y) and anchors. UsebaseViewportof 1280x720 (matching the sharedGAME_W/GAME_Hconstants).Important: Layout zones define positioning only (
x,y). Card dimensions come entirely from per-game constants (e.g.,CARD_W,CARD_H), not from layout zones. ThepixelOverridefield supports exact pixel-position overrides forxandyonly — no dimensions. -
Create a layout adapter in
example-games/<game>/scenes/<Game>LayoutAdapter.tsthat:- Parses the layout JSON using
parseScreenLayoutDocument - Defines a typed
GameLayoutinterface with the positions your renderer needs - Exports a
compute<Game>Layout()function that maps SLL zones to the game-specific shape, falling back to legacy values if the SLL document is unavailable
- Parses the layout JSON using
-
Update the renderer to:
- Import
compute<Game>Layout()and call it in the constructor - Replace hardcoded position constants with
this.layout.<property>references - Card dimensions always come from per-game constants (e.g.,
CARD_W,CARD_H); never derive card sizes from layout zones
- Import
-
Update the Constants file to:
- Remove layout position constants (e.g.
PILE_X,HAND_Y) - Keep card dimensions, timing constants, audio keys, and game-logic constants
- Add a comment noting that layout positions are now defined via SLL
- Remove layout position constants (e.g.
-
Update tests that mock renderers to include a
layoutproperty matching theGameLayoutinterface.
- Author/update a
*.layout.jsonfile with position-only normalized zone rectangles (x,y— nowidth/height) and anchors. - Validate schema + parse behavior via:
npx vitest run tests/ui/screen-layout-schema.test.ts --project unit
- Validate mapping behavior via:
npx vitest run tests/ui/screen-layout-mapping.test.ts --project unit
- Validate SLL composition and Gym integration via:
npx vitest run tests/ui/screen-layout-compose.test.ts --project unit npx vitest run tests/gym/GymSllScene.browser.test.ts --project browser
- Validate Main Street layout integration via:
npx vitest run tests/main-street/MainStreetLayoutAnchors.browser.test.ts --project browser npx vitest run tests/main-street/MainStreetScene.browser.test.ts --project browser
- Use
adaptLayoutWithFallback(...)for incremental scene migration. - If a layout document is missing or mapping fails, fallback layout code remains active.
- Runtime issue hooks (
ScreenLayoutIssue) can be wired to telemetry/logging without changing scene logic.
- If schema validation fails, inspect
pathandmessagein validation errors fromvalidateScreenLayoutDocument. - If zones/anchors are missing at runtime, look for
UNKNOWN_ZONE/UNKNOWN_ANCHORissues. - If scene behavior unexpectedly matches legacy coordinates, verify that the adapter sees a valid layout document and that the relevant zone names exist.
Main Street uses a tutorial-specific layout file that complements the base layout with bounding-box zones for tutorial highlight areas. This pattern allows the tutorial to define zones that don't exist in the base scene layout (HUD strip, help button, investments row) while reusing base layout zones through composition.
| File | Purpose |
|---|---|
example-games/main-street/layouts/main-street.layout.json |
Canonical base layout (8 zones, position-only) |
example-games/main-street/layouts/main-street-tutorial.layout.json |
Tutorial-specific layout (7 zones, position + dimensions) |
example-games/main-street/scenes/MainStreetTutorialHints.ts |
Tutorial overlay manager |
example-games/main-street/TutorialFlow.ts |
T1-T13 unified step definitions with TutorialHighlightZone / TutorialActionType types |
The tutorial layout is composed with the base layout using composeResolvedLayouts():
import { composeResolvedLayouts } from '@ui';
import type { ScreenLayoutDocument } from '@ui';
// Load both layout documents
const baseDoc = parseScreenLayoutDocument(baseLayoutJson) as ScreenLayoutDocument;
const tutorialDoc = parseScreenLayoutDocument(tutorialLayoutJson) as ScreenLayoutDocument;
// Compose with sceneWins policy (tutorial zones override base zones on collision)
const resolved = composeResolvedLayouts(
baseDoc,
tutorialDoc,
{ width: 1280, height: 720 }, // viewport
1, // DPR
{ policy: 'sceneWins' },
);
// Access tutorial-specific zones
const hudRect = resolved.zones.hud.rect; // { x, y, width, height }
const streetRect = resolved.zones.streetGrid.rect;
// Access base zones alongside tutorial zones
const marketRect = resolved.zones.market.rect; // still available from baseThe tutorial layout defines these zones (all use normalized coordinates with optional w/h dimensions):
| Zone ID | Description | Uses dimensions |
|---|---|---|
hud |
HUD strip (top bar with coins, reputation, score) | Yes (full-width bounding box) |
marketBusinessRow |
Business card row in the market area | Yes |
streetGrid |
The 2×5 street grid for placing businesses | Yes (stops before right column) |
endTurnButton |
End Turn action button area | Yes |
incidentQueue |
Scrollable incident cards queue | Yes |
investmentsRow |
Investment/upgrade card row | Yes |
helpButton |
Help/settings button area | Yes |
Zones that return null for highlighting (no bounding box needed):
center-modal— centered overlaycompletion-modal— centered completion dialog
The NormalizedRect type and JSON Schema were extended with optional w (width) and h (height)
fields. These are fully backward-compatible — existing position-only zones continue to work
without modification. When w and h are present, getZoneRect() returns a PixelRect with
width and height set.
// Position-only (existing pattern)
interface PositionOnlyRect {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
}
// Dimensioned (new pattern for bounding boxes)
interface DimensionedRect {
x: number;
y: number;
w?: number; // optional width (0-1 normalized)
h?: number; // optional height (0-1 normalized)
}When creating a new tutorial layout file:
- Copy the base layout structure (
version,id,baseViewport,requiredZones) - Define only the zones needed for tutorial highlights (you don't need all base zones)
- Include
wandhfor all zones that need bounding-box dimensions - Use normalized coordinates (0-1) — resolution is handled at runtime by
normalizedToPixels() - Add anchors for each zone (used for tooltip positioning relative to the zone)
- Validate with
validateScreenLayoutDocument()andcomposeResolvedLayouts()before committing
See example-games/main-street/layouts/main-street-tutorial.layout.json for a complete example.
- Tutorial-specific layout migration remains tracked separately in work item Adapt tutorial system to use layout description (CG-0MP7IZ4RK008065O).
The engine provides a collection of reusable HUD (heads-up display) components under src/ui/ that standardise overlay, sidebar, and button UI across all example games. These components are exported via the core-engine public API (src/ui/index.ts) and are consumed through adapter modules in each game.
The HelpPanel class provides a slide-in left sidebar that displays game rules, controls, and tips. It accepts an array of HelpSection objects, each with a heading and either body (plain text) or render (custom Phaser renderer) for rich content.
import { HelpPanel, type HelpSection } from '@ui';
const helpPanel = new HelpPanel(this, {
sections: [
{ heading: 'How to Play', body: 'Select cards and build sets...' },
{ heading: 'Scoring', body: 'Each card contributes...' },
],
});
helpPanel.open(); // Slide in from the left
helpPanel.close(); // Slide out
helpPanel.toggle(); // Toggle open/closedDepth conventions:
- Input blocker: 900
- Panel background: 901
- Panel content: 902
- Close button: 903
- Help button: 1101
Input blocking: When open, the panel creates a full-screen transparent interactive rectangle that captures pointer events. Closing the panel removes this blocker.
The HelpButton class renders a circular "?" toggle button that opens/closes the associated HelpPanel. It renders at depth 1101 (above all gameplay and HUD content).
import { HelpButton } from '@ui';
const helpButton = new HelpButton(this, helpPanel);The SettingsPanel class provides a slide-in right sidebar with controls for:
- Sound mute toggle
- Volume slider
- Tooltip visibility toggle
- Reduced motion toggle
- Configurable End Turn keybind
- Difficulty selector (when
difficultyNamesprovided)
import { SettingsPanel } from '@ui';
const settingsPanel = new SettingsPanel(this, {
soundManager: this.soundManager,
difficultyNames: ['Easy', 'Medium', 'Hard'],
});
settingsPanel.open(); // Slide in from the right
settingsPanel.close(); // Slide out
settingsPanel.toggle(); // Toggle open/closedDepth conventions: Same as HelpPanel (blocker 900, background 901, etc.). Settings button at depth 1102.
The SettingsButton class renders a circular gear icon (\u2699) toggle button that opens/closes the associated SettingsPanel. It renders at depth 1102.
import { SettingsButton } from '@ui';
const settingsButton = new SettingsButton(this, settingsPanel);The shared overlay system provides full-screen modal overlays with input-blocking backgrounds.
import { createOverlayBackground, dismissOverlay } from '@ui';
// Create an overlay with a dark background and a visible centered box
const { background, box, objects } = createOverlayBackground(
scene,
{ depth: 10, alpha: 0.75 }, // full-screen dark overlay
{ width: 500, height: 300, alpha: 0.95 }, // centered content box
);
// Later, dismiss the overlay
dismissOverlay(objects);The OverlayManager class provides a lifecycle wrapper around the overlay background system.
import { OverlayManager } from '@ui';
const overlayManager = new OverlayManager(scene);
const overlay = overlayManager.create({ depth: 10 }, { width: 500, height: 300 });
overlayManager.dismiss(); // Cleans up all managed objectsThe createOverlayButton factory creates interactive text buttons with hover effects, suitable for use in modal overlays (win screens, pause menus, etc.).
import { createOverlayButton } from '@ui';
const playAgainBtn = createOverlayButton(
scene,
GAME_W / 2, GAME_H / 2 + 50,
'[ Play Again ]',
11, // depth
);
playAgainBtn.on('pointerdown', () => scene.scene.restart());The createOverlayMenuButton factory creates a "[ Menu ]" button that navigates to the GameSelectorScene when clicked.
import { createOverlayMenuButton } from '@ui';
const menuBtn = createOverlayMenuButton(scene, GAME_W / 2, GAME_H / 2 + 50, 11);The createParameterizedOverlay factory combines overlay background, title text, detail text, and action buttons into a single convenient call.
import { createParameterizedOverlay, overlayCenterY } from '@ui';
const objects = createParameterizedOverlay(scene, {
title: 'You Win!',
titleColor: '#88ff88',
detailText: 'Score: 100',
titleY: overlayCenterY(-60),
detailY: overlayCenterY(-15),
titleDepth: 11,
detailDepth: 11,
background: { depth: 10, alpha: 0.75 },
box: { width: 460, height: 280, alpha: 0.9 },
buttons: [
{ label: '[ Play Again ]', x: GAME_W / 2 - 90, y: GAME_H / 2 + 60, onClick: () => scene.scene.restart() },
],
});The CardGameScene abstract class (at src/ui/CardGameScene.ts) provides shared boilerplate for all card game scenes:
- Event system setup (
GameEventEmitter+PhaserEventBridge) - Sound system setup (
SoundManager+ SFX registration) - Help and Settings panel initialization via
initHelpPanel()andinitSettingsPanel() - Undo/redo button creation via
initUndoRedoButtons()with resolution-independent positioning - Undo/redo button state updates via
refreshUndoRedoButtons(canUndo, canRedo) - Replay mode detection
- Standard shutdown/cleanup via
shutdownBase()
import { CardGameScene, type HelpSection } from '@ui';
export class MyGameScene extends CardGameScene {
constructor() { super({ key: 'MyGameScene' }); }
create(): void {
this.detectReplayMode();
this.initEventSystem();
if (!this.replayMode) {
this.initHelpPanel(helpContent as HelpSection[]);
this.initSettingsPanel();
this.initUndoRedoButtons(
() => this.turnController.performUndo(),
() => this.turnController.performRedo(),
);
}
// ... game-specific setup ...
}
shutdown(): void {
this.shutdownBase();
}
}The initHelpPanel() method creates both HelpPanel and HelpButton. The initSettingsPanel() method creates both SettingsPanel and SettingsButton. These are accessed via this.helpPanel, this.helpButton, this.settingsPanel, and this.settingsButton respectively.
The initUndoRedoButtons(onUndo, onRedo) method creates standard undo/redo
action buttons positioned to avoid overlap with the settings and help toggle
buttons. The positioning is resolution-independent — computed dynamically from
the scene viewport using the same formula as the settings button's default
position.
- Undo button is placed to the left of the settings button
- Redo button is placed to the right of the undo button
- Both buttons are parented into
hudContainerfor consistent depth ordering - Use
refreshUndoRedoButtons(canUndo, canRedo)to update enabled/disabled state (alpha 1.0 when enabled, 0.5 when disabled) - Both buttons are destroyed in
shutdownBase() - This method is opt-in: only scenes that explicitly call it get undo/redo buttons (games without undo/redo are unaffected)
Games that need to separate persistent overlay elements (help/settings buttons, panel input blockers) from transient HUD elements (score text, status bars) should use a two-container pattern:
hudOverlayContainer– Persistent container for help/settings buttons and panel input blockers. Not rebuilt during HUD refresh cycles.hudContainer– Transient container for HUD text and elements that need to be rebuilt each refresh. Children should be tagged with_hudTransient: true.
If no hudOverlayContainer exists on the scene, the HelpPanel and SettingsPanel will fall back to hudContainer, and if neither exists, they use standard depth layering.
The GymButtonBar class (at src/ui/GymButtonBar.ts) provides a reusable full-width button bar with left, center, and right zones and automatic row wrapping. It is designed for Gym demo scenes to replace the manual addButton(x, y, ...) pattern with a declarative API.
import { GymButtonBar } from '@ui';
const bar = new GymButtonBar(scene, {
y: 60, // Y position of first row
zone: 'center', // default zone for buttons (optional)
padding: 20, // horizontal padding from screen edges
buttonGap: 16, // gap between buttons within a zone
rowSpacing: 28, // vertical gap between wrapped rows
width: GAME_W, // total bar width (defaults to 1280)
});
bar.addButton('[ Draw ]', () => this.drawCard(), { zone: 'center' });
bar.addButton('[ Discard ]', () => this.discardCard(), { zone: 'right' });
bar.addButton('[ Reset ]', () => this.resetGame(), { zone: 'left' });Each zone occupies one-third of the bar width:
'left'— Buttons align to the left edge of the left zone'center'— Buttons are centered in the center zone'right'— Buttons align to the right edge of the right zone
Buttons that overflow their zone width automatically wrap to a new row below. Multiple rows (1..n) are supported.
bar.addButton('[ Custom ]', () => { /* ... */ }, {
zone: 'left',
fontSize: '16px',
color: '#ff8888', // text color
hoverColor: '#ffbbbb', // hover color
});| Method | Description |
|---|---|
addButton(label, callback, opts?) |
Add a button to the bar. Returns the Phaser.GameObjects.Text instance for further manipulation (e.g., setVisible(), setText()). |
refresh() |
Re-layout all buttons (call after modifying button visibility or text). |
destroy() |
Remove all buttons and clean up. |
Gym scenes call initButtonBar() once per button row/section. Each call creates a new GymButtonBar at the given Y position and appends it to an internal registry — previously created bars are kept (no destroy-and-recreate). this.buttonBar always points at the most recently created bar:
// Controls row 1
this.initButtonBar(60);
this.buttonBar!.addButton('[ Draw ]', () => this.drawToHand(), { zone: 'center' });
this.buttonBar!.addButton('[ Discard ]', () => this.discardSelected(), { zone: 'center' });
// Controls row 2 — a SECOND bar; row 1 is NOT destroyed
this.initButtonBar(112);
this.buttonBar!.addButton('[ Disable Drag ]', () => this.toggleDrag(), { zone: 'center' });initButtonBar(y, opts?) returns the created bar (also exposed as this.buttonBar), and accepts the same GymButtonBarConfig overrides as the GymButtonBar constructor (e.g. { zone: 'left' }, { rowSpacing: 30 }).
All registered bars are destroyed automatically when the scene shuts down or is destroyed, so scene restarts are leak-free. GymSceneBase wires this cleanup to the Phaser scene shutdown/destroy events on the first initButtonBar() call.
The GymButtonBar is exported from the UI barrel (src/ui/index.ts) and can be used by any scene, not just Gym scenes.
| Component | Depth |
|---|---|
| Gameplay containers | 0–999 |
| HUD container (transient) | 1000 |
| Help panel button | 1101 |
| Settings panel button | 1102 |
| Panel input blocker | 900 |
| Panel background | 901 |
| Panel content | 902 |
| Panel close button | 903 |
| Overlay background | 10–2000 (game-specific) |
| Overlay buttons | overlay depth + 1 |
See the Doc-Update Policy in AGENTS.md for the canonical policy. In summary: any change that alters developer workflows must include a corresponding documentation update in both docs/DEVELOPER.md and AGENTS.md, or a child work item tracking the doc update must be created.
This project uses Worklog (wl) for all task tracking. See the Worklog section in AGENTS.md for full documentation on creating, updating, closing, and querying work items.
Quick reference:
wl next --json # what should I work on?
wl create --title "..." --json # create a work item
wl update <id> --status in_progress --json # claim a task
wl close <id> --reason "..." --json # close when doneThe Tableau Card Engine includes a suite of debug tools that appear only when
running in developer mode (npm run dev). In production builds (npm run build),
the entire debug infrastructure is tree-shaken from the bundle using Vite's
import.meta.env.DEV build-time constant.
-
Dev mode detection: A shared
isDevMode()function (insrc/ui/debug/DebugToolsRegistry.ts) returns the value ofimport.meta.env.DEV. Duringnpm run dev, this istrue. In production builds, Vite replaces it withfalseand tree-shakes all dead code gated behindif (isDevMode())— no debug code leaks into the production bundle. -
Debug Tools section: When
import.meta.env.DEVistrueand at least one debug tool is registered, a "Debug Tools" section appears at the bottom of the Settings panel (below all other sections). Each tool is displayed as a clickable label with a short description. -
Opening the tools: Press the Settings button (gear icon) in any game scene, scroll to the bottom of the panel, and click a debug tool to open its overlay.
- Label: "Export Session"
- Location: Debug Tools section of the Settings panel
- Function: Downloads the current game transcript as a JSON file. If the
active scene has a
recorderwith agetTranscript()method, it serializes the full transcript. Otherwise, it produces an empty transcript with metadata. - Use case: Developers can export session data for debugging, regression testing, or replay without needing to finish the game or use CLI tools.
- Implementation:
src/ui/debug/SessionExportTool.ts
- Label: "State Inspector"
- Location: Debug Tools section of the Settings panel
- Function: Opens a scrollable overlay showing the current game state as a
collapsible tree view. Features include:
- Collapsible tree: Click ▶/▼ icons to expand or collapse objects.
- Text filter: Type in the filter field to show only matching fields (matched against key names and string values).
- Refresh button: Re-reads the scene's state and redraws the tree.
- Close button: Dismisses the overlay.
- State detection: The inspector automatically detects common state patterns
(
state,gameState,session,recorder). Falls back to enumerating all scene properties. - Use case: Inspect runtime game state to debug AI decisions, rule validation, and rendering issues.
- Implementation:
src/ui/debug/StateInspectorOverlay.ts
- Label: "Game Events"
- Location: Debug Tools section of the Settings panel
- Function: Opens a scrollable overlay showing a live feed of events
emitted by the
GameEventEmitterduring gameplay. Features include:- Live feed: Each event displays an ISO timestamp, event name (e.g.,
turn-started,turn-completed,state-settled,card-drawn), and a truncated view of the event payload. - Auto-scroll: Newest events appear at the bottom and are shown automatically.
- Clear button: Removes all entries from the feed.
- Pause/Resume: Toggles whether new events are added to the feed.
- Live feed: Each event displays an ISO timestamp, event name (e.g.,
- Event source: Subscribes to the
GameEventEmitterinstance exposed onwindow.__GAME_EVENTS__(set up automatically byCardGameScene). - Use case: Monitor event flow during gameplay for debugging event-driven interactions or replays.
- Implementation:
src/ui/debug/GameEventLogOverlay.ts
- Label: "AI Decisions"
- Location: Debug Tools section of the Settings panel
- Function: Opens a scrollable overlay showing per-turn AI decision
records. Features include:
- Decision records: Each entry shows turn number, AI strategy name, and a description of the chosen action.
- Clear button: Removes all entries.
- Pause/Resume: Toggles whether new decisions are recorded.
- Recording: Game scenes push decision data to the global
AiDecisionRecordersingleton at decision points. Golf'sGolfAiControlleris instrumented out of the box; other games can add recording by importing and callingAiDecisionRecorder.getInstance().record(...). - Use case: Debug AI behavior, verify strategy selection, and inspect decision patterns across turns.
- Implementation:
src/ui/debug/AiDecisionRecorder.ts— Recording singletonsrc/ui/debug/AiDecisionOverlay.ts— Display overlay
Adding a new debug tool requires minimal code:
-
Create a tool factory in a new file under
src/ui/debug/that exports a function returning aDebugToolsEntryobject:import type { DebugToolsEntry } from './DebugToolsRegistry'; export function createMyTool(): DebugToolsEntry { return { label: 'My Tool', description: 'What my tool does', activate: (scene: Phaser.Scene) => { // Your tool logic here }, }; }
-
(Optional) Export from the barrel by adding to
src/ui/debug/index.ts. -
Register the tool by adding it to the default debug tools array in
CardGameScene.initSettingsPanel()(insrc/ui/CardGameScene.ts):import { createMyTool } from './debug/MyTool'; // ... const effectiveDebugTools = debugTools ?? [ createSessionExportTool(), createStateInspectorTool(), createGameEventLogTool(), createAiDecisionViewerTool(), createMyTool(), // <-- add yours here ];
Alternatively, pass a custom
debugToolsarray directly toinitSettingsPanel()from any game scene to override the defaults. -
Write tests (at minimum, verify the factory returns a valid entry).
All debug code is gated behind if (isDevMode()) (or direct if (import.meta.env.DEV)), which Vite evaluates at build time. During npm run build:
import.meta.env.DEVis replaced withfalse.- All code inside
if (false) { ... }blocks is eliminated by Vite's tree-shaking (dead code elimination). - No debug strings, imports, or logic appear in the production bundle.
To verify production safety:
- Build the project:
npm run build - Check the output bundle for any debug-related strings:
This should produce no matches.
grep -i "debug\\|state inspector\\|export session\\|game events\\|ai decisions" dist/assets/*.js
| File | Purpose |
|---|---|
src/ui/debug/DebugToolsRegistry.ts |
isDevMode() function and DebugToolsEntry type |
src/ui/debug/SessionExportTool.ts |
Session export debug tool |
src/ui/debug/StateInspectorOverlay.ts |
State inspector overlay |
src/ui/debug/GameEventLogOverlay.ts |
Game event log overlay |
src/ui/debug/AiDecisionRecorder.ts |
AI decision recording singleton |
src/ui/debug/AiDecisionOverlay.ts |
AI decision viewer overlay |
src/ui/debug/index.ts |
Debug tools barrel file |
src/ui/CardGameScene.ts |
Default debug tool registration |
src/ui/SettingsPanel.ts |
Debug section rendering in Settings panel |
Vite dev server won't start:
- Check port 3000 is not already in use:
lsof -i :3000 - Try
npm run dev -- --port 3001for an alternate port - Stale lock file / orphaned Vite process: The dev server utilities now auto-clean stale processes and lock files when starting. If port 3000 is stuck, manually clean with:
rm -f tmp/dev-server-lock.json && kill -9 $(lsof -t -i :3000) 2>/dev/null; true
TypeScript errors on build:
- Run
npx tsc --noEmitto see detailed errors - Check that path aliases match between
tsconfig.jsonandvite.config.ts
Tests fail to find modules:
- Ensure Vitest config in
vite.config.tsincludes thetest.projectsblock - Verify unit test files match
tests/**/*.test.ts - Verify browser test files match
tests/**/*.browser.test.ts
Browser tests fail or time out:
- Ensure Playwright's Chromium is installed:
npx playwright install chromium - Check that
@vitest/browserversion matchesvitestversion - Browser tests boot a real Phaser game and may take 8-10 seconds each
- If tests hang, check for unresolved game instances (ensure
afterEachdestroys the game) - Process/resource leak cleanup: All browser tests should clean up Phaser.Game instances in
afterEachusinggame.destroy(true, false)and remove the game container div. The dev server utilities (scripts/dev-server-utils.ts) use a simplified start-stop-per-call pattern with no reference counting.ensureDevServer()kills any existing process on port 3000 before starting a fresh server.killDevServer()unconditionally kills the child process and any remaining process on port 3000. SIGTERM/SIGINT handlers provide additional cleanup for forced exits.
Large bundle warning:
- The Phaser library is ~1.4 MB minified -- this is expected
- Code-splitting can be added later via
build.rollupOptions.output.manualChunksinvite.config.ts
Replay tool: Dev server not running:
- The replay tool (
npm run replay) and transcript export (npm run transcripts:export) auto-start the dev server iflocalhost:3000is not responding - If auto-start fails, start the dev server manually:
npm run dev - Check port 3000 availability:
lsof -i :3000 - Port conflict detection / stale server cleanup: Before starting,
ensureDevServer()kills any process on port 3000 usingfuser(Linux) orlsof(macOS/Linux). This ensures a clean slate even if a previous server was orphaned by a crash or SIGKILL.killDevServer()also runs the same port-based cleanup as a belt-and-suspenders measure.
Replay tool: Unsupported transcript version error:
- The transcript schema includes a
versionfield; the replay tool validates this and exits with a clear error if the version is unsupported - Re-record the game to generate a transcript with the current version
- Transcripts evolve independently per game type; check the game's adapter for supported versions
Transcript persistence: IndexedDB storage quota:
- The
TranscriptStoreuses IndexedDB with a rolling window of the last 10 transcripts per game type - If IndexedDB is unavailable (private browsing, storage quota exceeded), it falls back to localStorage with a console warning
- Individual large transcripts can exceed localStorage's ~5-10MB limit; a size warning is logged to console
- Use
npm run transcripts:export -- <game>to offload transcripts to disk
Playwright not installed:
- The replay tool and transcript export use Playwright's Chromium browser
- Install it:
npx playwright install chromium - Verify installation:
npx playwright install --list