Project-specific guidance for working on this codebase. General engineering principles live in ~/.claude/CLAUDE.md.
A retro CRT-aesthetic text adventure RPG. Vite + React 18 + TypeScript. Zero backend, zero audio files — sounds are synthesized via Web Audio API, saves/settings/achievements live in localStorage, all game content is static JSON.
The engine is deliberately isolated from React: src/engine/** is pure game logic and has no React imports. src/components/Game.tsx is the only place that drives the reducer and renders the terminal.
- Vite 8 + Vitest 4 are pinned intentionally. Do NOT bump toolchain majors without a dedicated PR.
- React 18.3.1 stable — not 19. Zero runtime vulns.
npm auditshould report 0 vulnerabilities after the dependency-hygiene PR that upgraded the Vite/Vitest dev-toolchain.
Pure state machine. The store (GameStore in types.ts) is the single source of truth. Everything the player sees is derived from it.
gameReducer.ts Public API: createInitialStore, gameReducer, GameAction
Orchestrates tick/key/text routing, owns enterRoom,
startCombat, startDialogue, startEnding (these entangle
across multiple handlers so they stay here).
Also owns the build*Deps wiring that injects store-bound
callbacks into pure handler/state modules.
handlers/ One file per player command. Pure functions that take
(store, target, deps) and mutate the store.
look, help, drop, use, take, attack, search, examine, talk
shop.ts Thin verb dispatcher; per-verb logic lives in
shop-buy.ts, shop-sell.ts, shop-examine.ts, and
shop-display.ts. Public exports unchanged.
info.ts Display functions: showSkills, showAchievements,
showInventory, showStats, showJournal, showScore.
This is the DISPLAY layer — do not put action logic here.
meta.ts Action handlers only (currently just handleLearn).
Re-exports display functions from info.ts for
backward compatibility.
state/ One file per GameStateKind dispatcher. Handles input
routing while the game is in that state.
menu, settings, gameover, slot-picker, exploring, combat, dialogue, shop
lifecycle.ts Game initialization: startMenu, startNewGame,
startContinue, startDungeonMode, loadDungeonFloor.
world.ts Room graph, regions, dynamic exits. createStoryWorld()
loads all 5 region JSON files.
player.ts Player stats, inventory, leveling, visited rooms.
combat.ts Pure turn-based combat with optional injected RNG.
Status effects: poison, burn, bleed (escalating),
stun. Applied by weapons (on hit) and enemies
(regular: every hit; boss: special attack only).
Cure items clear effects via cure_effects field.
save.ts Multi-slot localStorage persistence. v1→v2→v3 format
migration (v2 added gold, v3 added objectives).
Schema validated at load + integrity checks at boot.
economy.ts Shop stock management.
achievements.ts Global achievement tracking across saves.
checkAchievement(store, id) is the store-aware wrapper;
checkItemAchievements(store) checks fully_loaded/collector.
endings.ts 4 ending trigger types (boss, item, choice, exploration).
events.ts Room on_enter event triggers.
effects.ts Visual effects state (shake, flash, glitch, tint).
display.ts Room rendering (name, description, exits, contents).
objectives.ts Journal/quest tracker. Loads src/data/objectives.json,
tracks activation and completion via notifyObjectiveEvent,
writes inline notification lines to the terminal.
output.ts Terminal write helpers: addLine, addLineInstant,
clearTerminal, updateHeader, applyRegionTint,
displayAscii, emitSound.
commands.ts Command parser with aliases (go/n/north, i/inventory, etc).
matching.ts Fuzzy target matching for items/weapons/enemies/npcs.
icons.ts ASCII icon constants + iconLine() helper for
item/weapon/enemy prefixes.
descriptions.ts Dynamic room description resolution (description_cleared
fields — rooms show alternate text after challenges).
skills.ts 14-node skill tree (Warrior/Rogue/Mage branches).
dungeon.ts Procedural floor generator, seeded RNG.
rng.ts Mulberry32 PRNG.
minimap.ts BFS-based room layout computation for the minimap overlay.
frame-loop.ts Visual snapshot diffing (see "Rendering perf" below).
audio.ts Web Audio API sound effects + region ambient drones.
settings.ts Persistent settings (font, color mode, text speed, volume).
asciiArt.ts ASCII art loader for title/boss/death screens.
constants.ts Colors, boot text, menu options, ASCII art keys.
types.ts All TypeScript interfaces.
Handler and state modules are pure — they take a Deps object containing store-bound callbacks rather than importing from gameReducer.ts. This is why gameReducer.ts has buildCombatDeps, buildExploringDeps, buildShopDeps, buildDialogueDeps — they inject startCombat, enterRoom, checkAchievement, etc. into the otherwise-pure handler modules.
Do not collapse this indirection. It is what keeps handlers testable in isolation and allows enterRoom/startCombat/startDialogue/startEnding to live in one place (they're called from handlers, state dispatchers, AND initialization — extracting them further breaks the boundary).
gameReducer.ts sits at ~1050 lines after the engine-foundation extraction. The original plan targeted ~250, but further extraction isn't clean — documented in docs/superpowers/specs/2026-04-06-engine-foundation-design.md.
frame-loop.ts exports captureVisualSnapshot, didVisualSnapshotChange, and shouldRunReducerTick. Game.tsx uses these to gate React re-renders so ticks only trigger renders when something visually changed.
CRITICAL INVARIANT: every piece of store state that can mutate inside a reducer TICK AND affects what the user sees MUST appear in VisualSnapshot. If you add new tick-driven state (e.g., a new animated field or header value), you MUST:
- Add it to the
VisualSnapshotinterface inframe-loop.ts - Capture it in
captureVisualSnapshot - Compare it in
didVisualSnapshotChange - Add a test case for it in
test/unit/frame-loop.test.ts
The guard test in frame-loop.test.ts enumerates every field and fails if any mutation doesn't cause the snapshot to differ. Don't disable or weaken that test — its whole job is to catch "UI goes stale because I forgot to snapshot this" bugs before they land.
All content is JSON. There is no content DSL, but src/engine/contentValidation.ts performs runtime integrity checks on load (referential checks across rooms/items/weapons/enemies/shops/endings). Types in src/engine/types.ts are still the source of truth for shape — when changing JSON shape, update the corresponding TS type AND extend the validator.
regions/*.json Room definitions by region (manor, wilds, darkness, wastes, hidden).
Rooms have description/description_cleared, exits, items,
weapons, enemies, search_items, npcs, on_enter events.
enemies.json 16 enemies (6 bosses) with hp/atk/def/xp/gold/loot.
weapons.json 20 weapons with match_words for fuzzy targeting.
items.json Consumables, shields, key items.
npcs.json 4 NPCs with dialogue trees.
shops.json 3 merchant shops (dusty/wren/hermit) with finite stock.
endings.json 4 ending definitions.
objectives.json Hand-authored journal objectives (trigger/completion pairs).
The journal command shows hand-authored quest-style objectives defined in src/data/objectives.json. Objectives are hidden until their trigger fires in-game (e.g., talking to an NPC, entering a room). See src/data/README.md for the schema and trigger/completion type reference. Integration points: gameReducer.ts::enterRoom, handlers/talk.ts, handlers/take.ts, handlers/search.ts, and state/combat.ts all call notifyObjectiveEvent after mutating relevant state.
Game.tsxruns the animation loop, dispatches keyboard/text input to the reducer, renders the CRT terminal, and handles the typewriter effect at the component layer (NOT inside the reducer — the reducer only queues lines).Minimap.tsxis a canvas overlay.- All CRT post-processing (scanlines, vignette, chromatic aberration, flicker) is pure CSS in
src/styles/.
npm run dev # Vite dev server → http://localhost:5173/
npm run build # tsc -b && vite build
npm run preview # serve the built bundle
npm test # vitest run (unit + scenario)
npm run test:watch # vitest watch mode
npm run test:coverage # coverage report (coverage/ is gitignored)
npm run lint # eslint .Always run npm run lint and npm test after changes. Both are fast (<2s lint, <5s tests). The test count is currently 48 files / 399 passing.
Tests live in test/:
test/unit/— pure module tests. One file per engine module. These are fast and deterministic.test/scenario/— end-to-end flows that drive the real reducer with text commands. Use these when you need to verify multi-step player interactions (combat, shopping, save/load round-trips).test/fixtures/— shared helpers:freshStore()— creates a freshGameStoreready for dispatchmock-input— helpers for dispatching keys/textassert-output—expectLine/expectNoLine(the "no line" variant takes a message for better failure output);flushTypewriter()returnsvoidintentionally
combat.tsaccepts an optional injected RNG so combat tests are deterministic. Use it instead of mockingMath.random.
When adding a new engine module, add a matching unit test file. When adding a new player-facing command or flow, add a scenario test.
eslint.config.jsignoresdistandcoverage.no-explicit-anyis on. If you need a DOM-interface escape hatch (the Storage mock intest/setup.tsis the canonical example), useunknowncast first then narrow, and add a scopedeslint-disable-next-linewith a comment if the interface itself demandsany.- Strict TS. The
tsc -bin the build script will catch drift.
- Don't hardcode merchant names in shop.ts error messages. The handler is shared across Dusty/Wren/Hermit. Use neutral prose like "Not in stock" and "They won't take that".
- Don't put action logic in
handlers/info.ts. That file is display-only. Action handlers go inhandlers/meta.tsor a dedicated handler file. - Don't re-enable deleted boot/ending extraction.
updateBootandupdateEndinglive insidegameReducer.ts::handleTickdeliberately — they're small, tick-coupled, and extracting them adds indirection without benefit. - Don't touch the snapshot diff without updating the guard test. See "Rendering perf" above.
- Saves have a format version. v1 saves don't have
gold, v2 saves don't haveobjectives—save.tsmigrates them on load. Don't break forward/backward compat silently. Bump the version and add a migration. - Achievements are global. They persist across all saves and across new-game resets. That's intentional.
- The engine has no React imports. Keep it that way. If you find yourself wanting to
importfromreactinsidesrc/engine/, stop and rethink — the state change belongs on the store, andGame.tsxshould react to it.
- engine-foundation branch: 8-phase refactor (test infra, reducer modularization, dynamic descriptions, icons, gold economy, polish) + runtime perf pass (snapshot diffing). Landed as PR #1 against main.
- The reducer used to be one ~2000-line file. It's now split across
handlers/,state/, and module-specific files.gameReducer.tsis the orchestrator.
docs/superpowers/specs/2026-04-06-engine-foundation-design.md— architecture design, including the documented tradeoff for whygameReducer.tsstays at ~1050 lines instead of the original ~250 targetdocs/superpowers/plans/2026-04-06-engine-foundation.md— the 8-phase implementation plan