Skip to content

Refactor: split the 4.7k-line game.ts into single-responsibility modules - #17

Merged
FiddlyDigital merged 23 commits into
mainfrom
claude/refactor-game
Jul 26, 2026
Merged

Refactor: split the 4.7k-line game.ts into single-responsibility modules#17
FiddlyDigital merged 23 commits into
mainfrom
claude/refactor-game

Conversation

@FiddlyDigital

@FiddlyDigital FiddlyDigital commented Jul 25, 2026

Copy link
Copy Markdown
Owner

game.ts had grown to ~4,700 lines — a god-class that's hard to navigate. This PR splits it into focused, single-responsibility modules, with no behaviour change (300 tests pass at every step).

Result: game.ts 4,686 → 2,095 lines. tsc + oxlint clean, data valid, 300 tests pass (including the fidchell and causeway-duel save/resume round-trips), build + smoke green — verified before every commit.

Approach

Each larger feature becomes a class that owns its logic and is composed onto Game as a field (new Foo(this)), reached through thin delegating methods so callers, content-hook wiring, and tests are unchanged. Run state stays on Game (exposed where a module needs it) to keep save/resume simple; only logic moves. Modules that hold their own mid-run state (Fidchell, Causeway Duel) serialize/restore explicitly and are re-linked by SaveGame. Done one module per commit, fully verified — this file drives combat, save/resume, and the tick loop, where regressions have historically been subtle.

Modules extracted

Module Responsibility
src/fidchell.ts The brandub/tafl board challenge (state, AI, save/restore)
src/causewayDuel.ts The boss-floor Causeway Duel (turn-based bridge-building, save/restore, live boss re-link)
src/waystation.ts The sídhe-mound rest floor + every mound/floor NPC-tile bump verb
src/bossEncounters.ts The Bres/Gorgoth finale + per-boss hooks (shards, gravity surge)
src/vendorOffers.ts Altar / tattoo artist / Fear Dearg shop / rescue services
src/spawning.ts Monster spawning + vault/den dungeon rooms
src/runSetup.ts Start-of-run pickers: class, difficulty, heat, modifier
src/saveGame.ts Mid-run serialize / restore (the SavedRun contract)
src/pact.ts An Draoi's deity-pact ceremony
src/npcEncounters.ts Wandering-NPC / seanchaí / ghost dialogs
src/smithQuest.ts Lugh's Spear questline (the three smiths)
src/systems/abilities.ts Ranged-ability dispatch (class kits, patron spells, Spear)
src/views/inspect.ts, charSheet.ts, uiState.ts Read-only projections of Game state (tap-inspect, char sheet, HUD UIState)
src/gameMath.ts Pure tetromino/timing/scoring math (also breaks a circular import)

What stays in game.ts

The core engine only: the shared run state (grid, falling piece, player, monsters) and the block/floor loop it drives — spawnBlock/lockBlock/checkLineClears, gravity, floor transition/descent, and combat orchestration in handleHeroMove. Those are load-bearing and tightly interwoven, so they were intentionally left in place.

README's Project-structure and Core-systems sections are updated to match.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 18 commits July 25, 2026 07:18
First step of splitting the ~4700-line game.ts god-class into single-
responsibility pieces. The whole Fidchell board game — state, rook-slide move
generation, custodial capture, the alpha-beta AI, win/loss resolution, and its
save snapshot — now lives in a self-contained Fidchell class that holds a
back-reference to Game and reaches out only for shared services (the grid it
clears, the callback stream, floor descent, the reward it grants).

Game keeps a `fidchell` instance and thin delegations: the `inFidchell` getter,
the renderer's board getters, `startFidchell`/`handleFidchellTap`, the HUD
payload (`fidchell.uiState()`), and save/resume (serialized explicitly, since
the module holds a back-ref; added to SAVE_SKIP). A few engine helpers the
module needs (emptyMap/emptyColors/spawnMonster/descendFloor/pushUI, tattooTiles)
are now public.

game.ts: 4686 → 4390 lines; new src/fidchell.ts is 351. No behaviour change —
300 tests pass (incl. the fidchell save/resume round trip), build + smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Pure tetromino/timing/scoring math, moved out of game.ts so feature modules
(the Causeway Duel next) can use rotateMatrix without a circular import back
into game.ts. Re-exported from game.ts so existing importers (main.ts, tests)
are unaffected. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The rules were only in the dungeon-log, easy to miss on mobile. Now a match
opens a titled "how to play" modal (side dealt, the goal, movement, capture, and
the win/lose stakes) with a single Begin button. Opened deferred on the first
safe tick via Fidchell.maybeShowRules() (driven from autoTick) so it never
stacks on the descent dialog that entered the floor. Verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The ~380-line spellbook (handleRangedAttack dispatch + every activate* handler,
plus findTarget/trail/param helpers) moves into a stateless AbilitySystem class
of static methods taking Game — matching the existing systems/*.ts pattern
(CombatSystem, HazardSystem, …). No own state, so no save/resume impact.

Game keeps a one-line handleRangedAttack that delegates to AbilitySystem.cast.
Three engine helpers the spells use are now public: advanceTurn,
updateVisibility, and the activeBossOnDeath hook.

game.ts: 4367 → 3991 lines (now under 4k). No behaviour change — 300 tests pass,
build + smoke green, and casting verified live (a bolt kills its target and
consumes ammo; Time Dilation applies its slow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
getInspectInfo becomes an InspectView class composed onto Game (constructor
takes the game; read-only projection, no mutation). Game.getInspectInfo now
delegates. getHazardAt/isTattooTile made public for the view; SpriteService
import dropped from game.ts (no longer used there). InspectView added to
SAVE_SKIP so its back-ref isn't swept into the save snapshot. No behaviour
change — 300 tests pass, build + smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
buildCharacterSheet becomes a CharacterSheetView class composed onto Game
(read-only stat projection). pushUI now calls characterSheetView.build().
Added to SAVE_SKIP; unused CharacterSheetSection import dropped from game.ts.
No behaviour change — 300 tests pass, build + smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
pushUI's ~85-line UIState assembly moves into a UiStateBuilder class composed
onto Game. The private-heavy sub-objects stay encapsulated behind two cohesive
Game query methods — duelUiState() and floorProgressState() — so the builder
composes only public state (no reaching into duel/floor internals). This also
sets up the duel card to later delegate to the Causeway Duel module cleanly.
difficultyTuning + characterSheetView exposed; builder added to SAVE_SKIP.
No behaviour change — 300 tests pass, build + smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The pact flow — offering two patrons, swearing one (passive + level-gated
spellbook, each paid a toll), and unlocking spells on level-up — becomes a
PactCeremony class composed onto Game. The describePatronSpell/describeToll
display helpers move with it. Game keeps thin delegators (maybeOfferPact,
applyPatron, syncSpellUnlocks); activePatronId stays on Game (it's saved).
Now-unused PATRONS/EffectResolver/PatronDef/EffectSpec imports dropped.
No behaviour change — 300 tests pass (incl. the applyPatron suite), build +
smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
…ters.ts

triggerNpcEncounter (bounty/trade/flavor), the mound seanchaí + your-own-tale,
and the ghost-of-a-past-run encounter move into an NpcEncounters class composed
onto Game. Call sites in handleHeroMove/waystation now use
npcEncounters.triggerEncounter/triggerSeanchai/triggerGhost. previewBossForFloor,
activeGhost, and metFlavorNpcIds made public for the module. No behaviour change
— 300 tests pass, build + smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The three smith-quest methods — announceFloor (the anvil heads-up), next (the
smith due), and triggerEncounter (grant a part; reforge the Spear on Goibniu's
third meeting) — move into a SmithQuest class composed onto Game. Game keeps
thin delegators (the rider-injection in spawnBlock and the two descent paths
still call maybeAnnounceSmithFloor/nextSmith). Run state (pendingSmithFloor,
smithsMetCount, spearPartsHeld, spearForged) stays on Game and is saved.
No behaviour change — 300 tests pass, build + smoke green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move monster spawning and dungeon-room generation into a new
src/spawning.ts Spawner class (compose-onto-Game pattern). Game keeps
thin delegating methods (spawnMonster, getRandomMonsterKey,
maybeSpawnDungeonRoom) so all internal callers, fidchell, and tests are
unchanged. heatAdd/heatMult made public for the module; spawner added to
SAVE_SKIP (stateless instance, back-ref to Game must not serialize).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the run-configuration pickers — starting class, difficulty preset,
New Game+ heat, and Rift Curse modifier (getRandomClasses/applyClass,
applyDifficulty, applyHeat, getRandomModifiers/applyModifier) — into a
new src/runSetup.ts RunSetup class. Game keeps thin delegating methods so
main.ts and tests are unchanged. Live scaling queries the rest of the run
reads (difficultyTuning, heatMult/heatAdd, gravity getters) stay on Game.
runSetup added to SAVE_SKIP; unused MODIFIERS import dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the mid-run persistence pair (serialize + applySave, plus the
SAVE_SKIP scalar-sweep exclusion set) into a new src/saveGame.ts SaveGame
class, keeping the SavedRun contract's two halves together. Game keeps
thin delegating serialize()/applySave() methods so callers and tests are
unchanged. The persistence layer legitimately touches deep run state, so
rescueGuards, duelBoss, activeBossOnHalfHp, bossHalfHpTriggered,
lastLineClearMs and makeGorgothOnHalfHp are made public (consistent with
this refactor's "state on Game, exposed where a module needs it"
approach); saveGame added to SAVE_SKIP; unused SAVE_VERSION import dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the in-world vendors and reward stalls — the altar (boon pick), the
tattoo artist (Ogham brands), the Fear Dearg's shop, and the rescued-NPC
services (Gobán Saor / Fedelm / Airmed / Abcán / Bricriu) — into a new
src/vendorOffers.ts VendorOffers class. Each builds a modal payload and
hands it to the host via cb. Game keeps thin delegating methods
(openAltar, openTattooArtist, openPeddler, openRescueService) so all bump
call sites and tests are unchanged. portionAtkBonus made public;
vendorOffers added to SAVE_SKIP; unused Brand/BODY_PARTS/ShopItem imports
dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the boss-encounter set pieces into a new src/bossEncounters.ts
BossEncounters class: the Bres/Gorgoth finale (summonGorgoth, its
half-HP roar factory, triggerVictory), the per-boss hook helpers other
bosses fire (Cailleach's shard adds, Balor's Herald gravity surge), the
near-ceiling "top out to win" nudge, and the boss-floor ambush toast.
Game keeps thin delegating methods so all call sites, boss-hook wiring,
and tests are unchanged. blockBuildingSuspended getter, stackTopRow,
gorgothHintShown, gorgothHalfTriggered and pendingBossFloor made public;
bossEncounters added to SAVE_SKIP.

game.ts is now under 3000 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the whole boss-floor Causeway Duel — ~30 methods and its state — into
a new src/causewayDuel.ts CausewayDuel class (mirrors the Fidchell module).
It owns its duel state (owner grid, switches, wall, boons, boss ref, home,
resolution flags) and its own serialize()/restore(); Game holds the
instance and delegates.

- inCausewayDuel becomes a getter/setter proxying the module's `active`.
- Input handlers, the switch/boon bump branch, isValidMove's wall check,
  and the UI/HUD builder call through game.causewayDuel.
- SaveGame gains a causewayDuel snapshot and re-links the live boss ref on
  restore (the stale duelBoss skip entry is gone); SavedRun grows a
  causewayDuel field; causewayDuel added to SAVE_SKIP.
- randomShapeKey and openStairsChoice made public for the module.
- The duel test's private-accessor now reaches into game.causewayDuel.

game.ts is down to ~2440 lines (from 4686 at the start of the split).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the sídhe-mound rest floor and every mound/floor NPC-tile bump-verb
into a new src/waystation.ts Waystation class: enterWaystation() becomes
enter(), and the ~230-line npcTile dispatch buried in handleHeroMove
(An Dagda, the pact emissary, nexus rescues, ogham stone, Sídhe coffer,
Well of Segais, held floor event, hearth-fire, Fear Dearg's stall,
seanchaí, ghost, smiths, wandering NPCs) becomes interact(npcTile). The
mound layout constant moves too; Game.MOUND now aliases Waystation.MOUND
so tests keep resolving positions by name.

handleHeroMove's npc branch collapses to a single delegate call. The
module collaborates with sibling subsystems directly, so pact,
npcEncounters, smithQuest and vendorOffers become public readonly and the
now-dead Game delegators (openRescueService, maybeOfferPact,
triggerSmithEncounter) are removed; the smith test calls
game.smithQuest.triggerEncounter directly. pactPending and ritualComplete
made public; waystation added to SAVE_SKIP; unused RescueDef import dropped.

game.ts is down to ~2095 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Update Project structure to list the feature modules now composed onto
Game (fidchell, causewayDuel, waystation, bossEncounters, vendorOffers,
spawning, runSetup, saveGame, pact, npcEncounters, smithQuest, gameMath,
views/), add systems/abilities.ts, and repoint the Core-systems /
data-file file references (Waystations, Fidchell, Gorgoth, save-resume,
Lugh's Spear, patron pacts) at their new homes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
@FiddlyDigital FiddlyDigital changed the title Refactor: start splitting the 4.7k-line game.ts into single-responsibility modules Refactor: split the 4.7k-line game.ts into single-responsibility modules Jul 25, 2026
claude added 5 commits July 25, 2026 20:45
Now that the feature modules are separated, add focused unit tests for the
biggest gaps and wire them into coverage:

- vitest.config: add the extracted modules (fidchell, causewayDuel,
  waystation, bossEncounters, vendorOffers, spawning, runSetup, saveGame,
  pact, npcEncounters, smithQuest, gameMath, views/) to the coverage
  include list — they were previously unmeasured.
- npcEncounters (37→89%): bounty/trade/flavor dialogs, the seanchaí tale
  chain, ghost lay-to-rest vs turn-away.
- runSetup (63→93%): class/modifier pickers, validation throws, effect apply.
- vendorOffers (68→84%): peddler buys (afford/refuse/once-per-visit), altar
  commit, all five rescue services incl. Bricriu's one-helping rule.
- bossEncounters (75→93%): crystal-shard adds, gravity surge, win-nudge
  once-only, summon idempotence, single victory.
- views (inspect 52→94%, charSheet branches): every inspect branch;
  char-sheet placeholders vs a sworn draoi's patron/spellbook.

347 tests (was 300). Overall coverage 74.5→79.9% stmts / 67→71% branch;
thresholds raised to 77/68/80/80.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Add focused unit tests for the parts the module split left thinnest:

- gameCore.test.ts (new): the core block/floor loop on Game — line clears
  (gold/XP, the perfect-4 Dagda gift once-only, line-clear-damage &
  Annihilation-Rune AoE, cursed no-heal, combos, a captive lost to a
  cleared row), spell cycling, hero-move combat (guaranteed crit, kill,
  stun), Bealtaine ritual completion, stack collapse, veil upkeep.
- pact.test.ts (new): pact gating, applying each patron, the modal's
  spell/toll descriptions (shriek/veil/drain, mul & add tolls), unlock sync.
- waystation.test.ts (new): mound entry layout/residents and every bump
  verb driven through handleHeroMove (hearth, stall, ogham stone, ghost,
  smith, wanderer, well).
- vendorOffers: altar/tattoo reroll paths + the tattoo artist.
- causewayDuel: the gold boon-island payout and restore-from-nothing.

Coverage 79.9→81% stmts, 71→72.4% branch (game.ts 69.5→73.7% branch);
386 tests (was 347). Thresholds raised to 79/70/82/82.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Refactor (behaviour-preserving):
- AbilitySystem: extract two duplicated idioms into shared helpers —
  reap() (filter-dead + killMonster, was inlined in shriek/overload/
  spearBolt) and slayWithHooks() (killMonster + biome death-hook, was
  inlined in bolt/drain). Five copies collapse to two helpers.

Tests:
- abilities.test.ts (new): every ability path through handleRangedAttack —
  cast guards (no-ability / stunned / cooldown / lethal HP-pact), bolt
  (hit/ammo/no-target/kill), drain, spear column, shriek, blight, overload,
  gravity well, veil, blink, time dilation, consecrate.
- entities.test.ts (+Particle/ParticlePool): reset/update lifetime, draw
  (headless ctx), pool pre-fill/spawn/burst/exhaustion/recycle.

entities 65→94% stmts, abilities 77→92%; overall 81→84.3% stmts,
72.4→75.5% branch. 411 tests (was 386). Thresholds raised to 82/73/82/85.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Refactor (behaviour-preserving): extract the "adjacent → strike, else chase
within range → pursue" skeleton shared by melee/berserker/swift into a
single chaseOrStrike(m, game, chaseRange, strike, chase) helper. The three
handlers now express only what differs — berserker's enraged second hit,
swift's double-move — with one source of truth for the contact/chase gate.

Tests (monsterAI.test.ts, new): every behaviour path —
- ranged: fire in range+LOS, kite back when crowded, advance when far
- healer: mend the hurt ally, else fall back to melee
- berserker: double-strike enraged vs single at full HP
- swift: two tiles per chase turn
- Gorgoth: descend/phase, strike on contact, ignore the veil
- movement: ice-slide until it runs out
- line of sight: clear across floor, blocked by a void gap
- plus the dead-hero early-out and the god-mist veil skip

monsterAI 53→92.6% stmts, 43→84.6% branch. Overall 84.3→85.1% stmts,
75.5→77% branch. 426 tests (was 411). Thresholds raised to 83/74/83/86.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Final duplication pass. Three copy-pasted idioms are consolidated into
documented Game helpers, each the single source of truth:

- clearBoardEntities(): wipe terrain/colors + every entity & tile-feature
  list to bare rock (was duplicated in Fidchell, Causeway Duel, Waystation).
- revealAll(): light the whole arena, no fog (was in those three plus the
  Gorgoth finale — four copies of the nested loop).
- presentChoice(event, char, onClosed?): open a one-off floor-event choice,
  apply the pick, log under `char`, resume, run onClosed (was the same
  ~7-line close-out in NpcEncounters ×2, VendorOffers, SmithQuest, Pact).

Behaviour-preserving: 426 tests still pass, typecheck + oxlint clean, build
+ smoke green. Net −26 lines and one place to change each behaviour.
Dropped a now-unused GameConfig import in waystation.ts.

A full-repo sweep found no dead code; a handful of internal-only `export`s
(config/shape types) were left as intentional API surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
@FiddlyDigital
FiddlyDigital merged commit 1cb3d25 into main Jul 26, 2026
1 check passed
@FiddlyDigital
FiddlyDigital deleted the claude/refactor-game branch July 26, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants