Skip to content

sim.gd module split — design

sim/sim.gd is 4,784 lines — roughly 63% of all code in sim/, while every other file in that directory is under 260 lines. It grew this way because every gameplay system added since M1 (five set-piece bosses, drones/decoy, elemental reactions, upgrades, enemy behaviors) had to live somewhere that stays pure RefCounted with no Node/Engine APIs (the rule that makes the sim headless-testable and deterministic), and the path of least resistance was always “add it to sim.gd.” That’s now the single biggest drag on development speed: any session touching gameplay logic has to load and reason about the whole file, and it will only keep growing as content keeps landing.

Split sim.gd into focused modules, each mapping to one clear gameplay concern, without changing behavior and without moving any determinism-relevant state off Sim. This is a pure refactor — the determinism checksum, pinned in BOTH tests/test_determinism_checksum.gd (survival) and tests/test_determinism_crystals.gd (crystals mode; the two modes’ baselines converged as of BUILD 97), must not move by a single bit at any point in the process.

  • No behavior changes, balance changes, or new features bundled into this refactor.
  • No change to the checksum/snapshot format or the pinned baseline values.
  • No change to /sim’s architecture rules (pure RefCounted, no Node/Engine/Input/Time/ OS/File/JSON APIs, one-way Input → Sim → Render data flow).

Extract each self-contained function cluster into its own class_name X extends RefCounted file, following the pattern already established by SpawnDirector and StoryDirector in sim/: the new module takes sim: Sim as an explicit parameter on its methods and never holds a back-reference to it. This keeps the dependency direction one-way (director → sim, never the reverse) and each module independently readable.

Move methods, not state. All boss/drone/enemy state (positions, HP, timers, phase flags) stays as fields on Sim exactly where it is today — only the functions that operate on that state move into director objects. state_checksum() and snapshot_string() read Sim’s fields directly and require no changes. The one precedent to watch: StoryDirector already holds some private state of its own (_last_room, _wave_index, etc.) that isn’t part of Sim. Before extracting any module, we verify none of its private state is read by state_checksum() / snapshot_string() — new director modules default to holding zero private state unless a specific function genuinely requires it, and if it does, the checksum/snapshot inclusion question is resolved explicitly before that module is extracted.

Identified from the full 199-function inventory of sim.gd (line numbers as of commit 2eeb0fb, the base of the worktree-sim-module-split branch — will drift as later modules are extracted first, so each extraction step re-locates its own target lines rather than trusting these line numbers literally):

New file Extracted from (approx. current lines) ~Lines Contents
sim/boss_warden.gd 40–83, 2641–2895 (partial) ~250 Boss (Warden) spawn/update/fire/render_info
sim/boss2.gd 84–129, 2896–3034 ~280 Boss2 (Sentinel) spawn/update/fire/render_info
sim/funzo.gd 130–193, 3035–3274 ~300 FunZo spawn/update/zones/jesters/confetti/render_info
sim/graviton.gd 194–237, 3275–3448 ~220 Graviton spawn/update/pull/blobs/collapse/render_info
sim/eye.gd 238–270, 3449–3684 ~270 Eye spawn/update/laser/blink/dash/render_info
sim/boss_rotation.gd _boss_index/_boss2_index/_is_boss_type/_maybe_spawn_survival_boss/_boss_hp_scale ~60 Cross-cutting “which boss spawns next” orchestration — stays separate from the 5 boss modules since it decides between them
sim/drone_director.gd 1879–2284 ~400 Drone/decoy deploy, behavior, logistics, disruptor/bomber/interceptor/sentinel, chain-strike, pulse
sim/enemy_behaviors.gd 1522–1784 ~260 Movement steppers: skirmish/dash/rush/ghost/accumulator/homing
sim/enemy_attacks.gd 2315–2641 ~330 Shooter/ranged/orbiter/lancer/bomb attack loops
sim/elemental_system.gd 3685–3914 ~230 Collision resolve, damage/vuln/weaken, reactions, primed pop, zones, webs, status decay
sim/upgrade_system.gd 4227–4753 ~530 Upgrade rolling/preview/apply, evolutions, weapon mod display, DPS calc
remains in sim.gd everything else ~1,230 _init, tick(), spawn-phase state machine, top-level movement/collision dispatch, projectile movement, gem/powerup/wormhole collection, player hit/regen/dash, checksum/snapshot

This takes the “conductor” file from 4,784 lines to roughly 1,230 (a ~74% cut), while every extracted module stays under ~530 lines and maps to one clear concern.

This is mechanical extraction (cut function bodies, paste into a new file, change func _foo( call sites to take sim: Sim as the first param) — no behavior change is intended anywhere. The existing determinism checksum is the ground truth for “did this move break anything”: if it still matches after each module extraction, that move was safe.

Process, enforced one module at a time:

  1. Extract one module in the isolated worktree (~/Claude/bullet-heaven/.claude/worktrees/sim-module-split, branch worktree-sim-module-split).
  2. Run the full GUT suite (godot --headless --path . -s addons/gut/gut_cmdln.gd -gconfig=.gutconfig.json) and confirm the script/test counts match the pre-refactor baseline (179 scripts / 1,221 tests, captured 2026-07-01) — a smaller-but-green suite is exactly the “hidden drift” failure mode already burned once on the tvOS sync (see the tvOS port memory).
  3. Confirm state_checksum() / snapshot_string() values in BOTH tests/test_determinism_checksum.gd and tests/test_determinism_crystals.gd are unchanged.
  4. Commit that module’s extraction as its own bh-dev-chunk-style commit before starting the next module.
  5. Never batch multiple module moves into one unverified step.

Order (safest/most isolated first): boss modules (5 files) → boss_rotationdrone_directorenemy_behaviors / enemy_attackselemental_system (most other code depends on this, so it’s done after the verification rhythm is well-established) → upgrade_system (mostly read-only display logic, lowest behavioral risk — could be moved earlier if preferred, order isn’t load-bearing here).

No new tests are needed — this refactor’s correctness is entirely covered by the existing GUT suite + the pinned determinism baseline. If any extraction reveals a gap (e.g. a function whose behavior was implicitly relying on sim.gd’s private scope in a way a test didn’t catch), a regression test is added at that point before proceeding.

Each module extraction lands on worktree-sim-module-split as its own commit. Once all modules are extracted and the full suite + checksum are green, the branch merges to main as a single reviewed change (or in a few logical batches — e.g. “boss modules” as one PR-equivalent chunk, “drone/enemy/elemental/upgrade” as another). The tvOS repo sync (bh-deploy step A) happens after the merge to main, copying the new/changed sim/*.gd files across — no special handling needed since the tvOS repo already gitignores sim/ and takes it wholesale from main.