sim.gd module split — design
sim.gd module split — design
Section titled “sim.gd module split — design”Problem
Section titled “Problem”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.
Non-goals
Section titled “Non-goals”- 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 (pureRefCounted, no Node/Engine/Input/Time/ OS/File/JSON APIs, one-wayInput → Sim → Renderdata flow).
Approach
Section titled “Approach”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.
Module boundaries
Section titled “Module boundaries”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.
Verification strategy
Section titled “Verification strategy”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:
- Extract one module in the isolated worktree (
~/Claude/bullet-heaven/.claude/worktrees/sim-module-split, branchworktree-sim-module-split). - 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). - Confirm
state_checksum()/snapshot_string()values in BOTHtests/test_determinism_checksum.gdandtests/test_determinism_crystals.gdare unchanged. - Commit that module’s extraction as its own
bh-dev-chunk-style commit before starting the next module. - Never batch multiple module moves into one unverified step.
Order (safest/most isolated first): boss modules (5 files) → boss_rotation →
drone_director → enemy_behaviors / enemy_attacks → elemental_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).
Testing
Section titled “Testing”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.
Rollout
Section titled “Rollout”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.