sim.gd Module Split — Implementation Plan
sim.gd Module Split — Implementation Plan
Section titled “sim.gd Module Split — Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extract sim/sim.gd (4,784 lines) into 11 focused RefCounted director
modules, cutting the “conductor” file to roughly 1,230 lines, with zero behavior
change and a byte-identical determinism checksum at every step.
Architecture: Each module is a class_name X extends RefCounted file in sim/,
following the exact composition pattern already used by SpawnDirector/StoryDirector
in this codebase: Sim holds a persistent field (var boss_rotation: BossRotation),
instantiated once in Sim._init() (matching spawner = SpawnDirector.new()), and calls
its methods passing self explicitly where the method needs sim state (matching
story_director.update(self, dt)). All boss/drone/enemy STATE stays on Sim — only the
functions that operate on it move.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 test runner (headless).
Global Constraints
Section titled “Global Constraints”- Every file in
/simstaysextends RefCounted— no Node/Engine/Input/Time/OS/File/JSON APIs. - No behavior change. This is pure code relocation.
state_checksum()andsnapshot_string()(defined at the bottom ofsim.gd, unmoved by this plan) must be byte-identical after every single task, verified against BOTHtests/test_determinism_checksum.gdandtests/test_determinism_crystals.gd.- Full GUT suite script/test counts must match the baseline captured before Task 1
(180 scripts / 1,231 tests, captured 2026-07-02 after rebasing the worktree onto the
latest
main— a concurrent session’s commit landed and added a test file since the worktree was first created) after every task. - All work happens in
~/Claude/bullet-heaven/.claude/worktrees/sim-module-split(branchworktree-sim-module-split, forked frommain@2eeb0fb). Do not touch the primary working directory — another session is actively editing files there. - One module extraction per commit. Never batch two modules into one unverified commit.
- Godot binary:
/opt/homebrew/bin/godot. Run all commands from the worktree root.
Task 0: Shared verification script
Section titled “Task 0: Shared verification script”Every subsequent task runs the identical check sequence. Capture it once as a script so each task just runs one command instead of five.
Files:
- Create:
scripts/verify-sim-refactor.sh
Interfaces:
-
Consumes: nothing (reads the live worktree state)
-
Produces: exit 0 on success (safe to commit); non-zero + a diagnostic on any drift, for every later task to call
-
Step 1: Capture the pre-refactor baseline values
Run from the worktree root:
grep -n "assert_eq(a.snapshot_string\|assert_eq(a.state_checksum\|assert_eq(sim.snapshot_string\|assert_eq(sim.state_checksum" tests/test_determinism_checksum.gd tests/test_determinism_crystals.gdExpected output (record these — Task 1 onward compares against them):
tests/test_determinism_checksum.gd:41:assert_eq(a.snapshot_string().hash(), 2730172591, "snapshot hash baseline")tests/test_determinism_checksum.gd:42:assert_eq(a.state_checksum(), 4075578713, "state checksum baseline")tests/test_determinism_crystals.gd:29:assert_eq(a.snapshot_string().hash(), 2730172591, "crystals snapshot hash baseline")tests/test_determinism_crystals.gd:30:assert_eq(a.state_checksum(), 4075578713, "crystals state checksum baseline")(If the actual numbers differ from these — e.g. because another commit landed on main
before this worktree was forked — use whatever the grep actually prints as the baseline
instead. The point of this step is to pin the CURRENT values, not these specific digits.)
- Step 2: Write the verification script
#!/usr/bin/env bash# verify-sim-refactor.sh — run after every sim.gd module extraction task.# Fails loud (non-zero exit) on any drift from the pre-refactor baseline.set -euo pipefail
BASELINE_SCRIPTS=180BASELINE_TESTS=1231
echo "=== Import (registers any new class_name files) ==="godot --headless --path . --import >/tmp/sim-refactor-import.log 2>&1 || { echo "FAIL: import errored — see /tmp/sim-refactor-import.log"; exit 1;}if grep -qi "SCRIPT ERROR\|Parse Error" /tmp/sim-refactor-import.log; then echo "FAIL: import produced a script/parse error:"; grep -i "SCRIPT ERROR\|Parse Error" /tmp/sim-refactor-import.log exit 1fi
echo "=== Full GUT suite ==="godot --headless --path . -s addons/gut/gut_cmdln.gd -gconfig=.gutconfig.json \ >/tmp/sim-refactor-gut.log 2>&1tail -30 /tmp/sim-refactor-gut.log
scripts_ran=$(grep -oE "^Scripts +[0-9]+" /tmp/sim-refactor-gut.log | grep -oE "[0-9]+" || echo 0)tests_ran=$(grep -oE "^Tests +[0-9]+" /tmp/sim-refactor-gut.log | grep -oE "[0-9]+" || echo 0)
if [ "$scripts_ran" -ne "$BASELINE_SCRIPTS" ]; then echo "FAIL: ran $scripts_ran scripts, expected $BASELINE_SCRIPTS (stale class cache dropped a file?)" exit 1fiif [ "$tests_ran" -ne "$BASELINE_TESTS" ]; then echo "FAIL: ran $tests_ran tests, expected $BASELINE_TESTS" exit 1fiif ! grep -q "All tests passed" /tmp/sim-refactor-gut.log; then echo "FAIL: suite did not report all tests passed" exit 1fi
echo "=== PASS: $scripts_ran scripts / $tests_ran tests, all green, counts match baseline ==="- Step 3: Make it executable and run it once to confirm the current (pre-refactor) worktree passes clean
chmod +x scripts/verify-sim-refactor.sh./scripts/verify-sim-refactor.shExpected: === PASS: 180 scripts / 1231 tests, all green, counts match baseline ===
- Step 4: Commit
git add scripts/verify-sim-refactor.shgit commit -m "chore(sim-refactor): add shared verification script for module extraction tasks"Task 1: Extract boss_rotation.gd
Section titled “Task 1: Extract boss_rotation.gd”The smallest, most isolated cluster — pure “which boss is alive / which boss spawns next” orchestration with no boss-specific attack logic. Good first cut to prove the pattern.
Files:
- Create:
sim/boss_rotation.gd - Modify:
sim/sim.gd(remove the extracted functions/consts; add theboss_rotationfield + delegate calls at every call site)
Interfaces:
-
Consumes:
sim: Simpassed explicitly to every method (readssim.enemies,sim.run_time,sim.story,sim._boss_spawn_count/_next_boss_timefields — these stay onSim, unchanged) -
Produces:
BossRotation.boss_index(sim),.boss2_index(sim),.funzo_index(sim),.graviton_index(sim),.eye_index(sim),.any_boss_alive(sim),.is_boss_type(tid),.hp_scale(sim),.maybe_spawn_survival_boss(sim)— later tasks (boss modules, elemental_system) call these instead of the old_-prefixedSimmethods. -
Step 1: Locate the exact current functions to extract
grep -n "^func _boss_index\|^func _boss2_index\|^func _funzo_index\|^func _graviton_index\|^func _eye_index\|^func _any_boss_alive\|^func _is_boss_type\|^func _boss_hp_scale\|^func _maybe_spawn_survival_boss\|^const V01_WARDEN_ONLY" sim/sim.gdThis prints the CURRENT line numbers (they will differ slightly from the numbers in the design spec — the spec was written against an earlier commit). Use these fresh numbers, not the spec’s.
- Step 2: Create
sim/boss_rotation.gd
Cut the following from sim.gd (verbatim body, function names de-prefixed and given a
leading sim: Sim parameter) into a new file:
class_name BossRotationextends RefCounted
# v0.1 launch: survival only ever spawns the Warden — none of the other 4 bosses. Flip to# false to restore the full 5-boss rotation post-launch (Boss2/FunZo/Graviton/Eye code is# untouched, just unreachable while this is true). See# docs/superpowers/specs/2026-07-01-warden-only-boss-teaser-design.md.const V01_WARDEN_ONLY := true
func boss_index(sim: Sim) -> int: for i in range(sim.enemies.count): if sim.enemies.type_id[i] == EnemyPool.TYPE_BOSS: return i return -1
func boss2_index(sim: Sim) -> int: for i in range(sim.enemies.count): if sim.enemies.type_id[i] == EnemyPool.TYPE_BOSS2: return i return -1
func funzo_index(sim: Sim) -> int: for i in range(sim.enemies.count): if sim.enemies.type_id[i] == EnemyPool.TYPE_FUNZO: return i return -1
func graviton_index(sim: Sim) -> int: for i in range(sim.enemies.count): if sim.enemies.type_id[i] == EnemyPool.TYPE_GRAVITON: return i return -1
func eye_index(sim: Sim) -> int: for i in range(sim.enemies.count): if sim.enemies.type_id[i] == EnemyPool.TYPE_EYE: return i return -1
func any_boss_alive(sim: Sim) -> bool: return boss_index(sim) != -1 or boss2_index(sim) != -1 or funzo_index(sim) != -1 \ or graviton_index(sim) != -1 or eye_index(sim) != -1
func is_boss_type(tid: int) -> bool: return tid == EnemyPool.TYPE_BOSS or tid == EnemyPool.TYPE_BOSS2 \ or tid == EnemyPool.TYPE_FUNZO or tid == EnemyPool.TYPE_GRAVITON or tid == EnemyPool.TYPE_EYE
func hp_scale(sim: Sim) -> float: return 1.0 + (sim.spawner.difficulty_mult(sim.run_time) - 1.0) * sim.BOSS_HP_TIME_FRAC
func maybe_spawn_survival_boss(sim: Sim) -> void: if sim.story != null: return if sim.run_time < sim._next_boss_time: return if any_boss_alive(sim): # one boss at a time return var s := hp_scale(sim) # later bosses spawn tougher (time-based) if V01_WARDEN_ONLY: sim._spawn_boss(s) return var pick := sim._boss_spawn_count % 5 match pick: 0: sim._spawn_boss(s) 1: sim._spawn_boss2(sim.player.pos + sim.rng.rand_unit_dir() * 640.0, s) 2: sim._spawn_funzo(sim.player.pos + sim.rng.rand_unit_dir() * 640.0, s) 3: sim._spawn_graviton(sim.player.pos + sim.rng.rand_unit_dir() * 640.0, s) 4: sim._spawn_eye(sim.player.pos + sim.rng.rand_unit_dir() * 640.0, s)Note sim._spawn_boss/_spawn_boss2/_spawn_funzo/_spawn_graviton/_spawn_eye stay
as Sim methods for now (they’re extracted in Tasks 2-5) — this task only moves the
rotation/dispatch logic, not the spawn bodies. sim.BOSS_HP_TIME_FRAC stays a Sim
const (not part of this cluster).
- Step 3: Delete the extracted code from
sim.gd
Remove the const V01_WARDEN_ONLY line and its 4-line comment, and the 9 functions
listed in Step 1, from sim.gd.
- Step 4: Wire up the field and call sites in
sim.gd
Add a field near the other director fields (alongside var spawner: SpawnDirector):
var boss_rotation := BossRotation.new()(This can be a direct-initialized var x := Y.new() like spawner is NOT — spawner
is assigned in _init() instead. Match whichever pattern the line immediately above
var spawner: SpawnDirector uses in the CURRENT file; if _init() explicitly
constructs spawner, add boss_rotation = BossRotation.new() there instead of an
inline initializer, for consistency.)
Then find every remaining call site of the 9 removed functions (there will be several —
_boss_index() is called from the boss update functions still in sim.gd, from
_sweep_dead(), from elite_render_info(), etc.) and update each:
grep -n "_boss_index()\|_boss2_index()\|_funzo_index()\|_graviton_index()\|_eye_index()\|_any_boss_alive()\|_is_boss_type(\|_boss_hp_scale()\|_maybe_spawn_survival_boss()" sim/sim.gdReplace each _boss_index() → boss_rotation.boss_index(self), _any_boss_alive() →
boss_rotation.any_boss_alive(self), _is_boss_type(x) → boss_rotation.is_boss_type(x),
_boss_hp_scale() → boss_rotation.hp_scale(self), _maybe_spawn_survival_boss() →
boss_rotation.maybe_spawn_survival_boss(self), and likewise for boss2_index/
funzo_index/graviton_index/eye_index.
- Step 5: Run the verification script
./scripts/verify-sim-refactor.shExpected: === PASS: 180 scripts / 1231 tests, all green, counts match baseline ===
If it fails on script/test count: a call site was missed and the file failed to parse —
check /tmp/sim-refactor-import.log for the exact error.
- Step 6: Verify the determinism checksum by name, not just via the suite pass
grep -n "assert_eq(a.snapshot_string\|assert_eq(a.state_checksum\|assert_eq(sim.snapshot_string\|assert_eq(sim.state_checksum" tests/test_determinism_checksum.gd tests/test_determinism_crystals.gdConfirm these values are IDENTICAL to the ones captured in Task 0 Step 1. (They will be, since Step 5’s suite pass already re-ran these tests — this step is a explicit double check per the plan’s global constraint, not redundant busywork: it’s the exact byte-for-byte value comparison, not just “the assertion didn’t fail”.)
- Step 7: Commit
git add sim/boss_rotation.gd sim/sim.gdgit commit -m "refactor(sim): extract boss_rotation.gd from sim.gd
Moves boss-index lookups, any_boss_alive, is_boss_type, hp_scale, andmaybe_spawn_survival_boss into a BossRotation director, following theSpawnDirector/StoryDirector composition pattern. State stays on Sim;only the dispatch logic moved. Determinism checksum unchanged."Task 2: Extract boss_warden.gd
Section titled “Task 2: Extract boss_warden.gd”Files:
- Create:
sim/boss_warden.gd - Modify:
sim/sim.gd
Interfaces:
-
Consumes:
sim: Simpassed explicitly; reads/writessim.boss(the existingBossStateinstance field),sim.enemies,sim.player,sim.fx_events,sim.boss_missiles -
Produces:
BossWarden.spawn(sim, hp_mult),.update(sim, dt),.fire(sim, idx, bpos),.swing_hit(sim, bpos),.update_missiles(sim, dt),.render_info(sim)— called fromsim.gd’s_update_boss(dt)-equivalent dispatch (Task 1’sboss_rotationcallssim._spawn_boss; this task changes that call site toboss_warden.spawn(sim, s)instead, and updates the top-leveltick()/_update_bossorchestration point that currently calls the now-extracted functions) -
Step 1: Locate the exact current functions and consts
grep -n "^func _spawn_boss(\|^func _update_boss(\|^func _boss_fire(\|^func _boss_swing_hit(\|^func _update_boss_missiles(\|^func boss_render_info(\|^const BOSS_HP\|^const BOSS_ENRAGE_EXTRA_ARMS" sim/sim.gdRead from const BOSS_HP through const BOSS_ENRAGE_EXTRA_ARMS (inclusive) for the
Warden-specific const block, and each function from its grep line to its matching
blank-line-before-next-func boundary.
- Step 2: Create
sim/boss_warden.gd
class_name BossWardenextends RefCounted
const BOSS_HP: float = 900.0const BOSS_RADIUS: float = 70.0const BOSS_ARMOR: float = 6.0const BOSS_SPEED: float = 56.0 # approach speedconst BOSS_CONTACT_DMG: float = 26.0const BOSS_XP: float = 40.0 # the kill drops a big rewardconst BOSS_APPROACH_TIME: float = 1.5const BOSS_TELEGRAPH_TIME: float = 0.7 # fair warning before each attackconst BOSS_FIRE_TELEGRAPH_TIME: float = 3.0 # the fire (barrage) attack gets a long, angry, vibrating wind-upconst BOSS_ACTIVE_TIME: float = 0.35const BOSS_REST_TIME: float = 0.8const BOSS_SWING_RANGE: float = 240.0 # big melee sweep reach around the bossconst BOSS_SWING_DMG: float = 44.0 # the melee sweep hits HARD — dodge itconst BOSS_BARRAGE_COUNT: int = 20 # radial shot burst (reuses enemy_proj)const BOSS_BARRAGE_SPEED: float = 270.0const BOSS_MISSILE_COUNT: int = 5const BOSS_MISSILE_SPEED: float = 250.0const BOSS_MISSILE_TURN: float = 2.8 # rad/s homing turn rateconst BOSS_MISSILE_LIFE: float = 5.0const BOSS_MISSILE_DMG: float = 14.0const BOSS_MISSILE_RADIUS: float = 12.0const BOSS_SPIRAL_ARMS: int = 6 # rotating spiral arms of shotsconst BOSS_SPIRAL_PER_ARM: int = 4 # shots strung along each arm (varying speed)const BOSS_SPIRAL_SPEED: float = 300.0const BOSS_SPIRAL_STEP: float = 0.55 # radians the spiral rotates between castsconst BOSS_ENRAGE_FRAC: float = 0.40 # below this HP fraction the boss enragesconst BOSS_ENRAGE_TIME_MULT: float = 0.55 # telegraph/rest times shrink when enraged (faster cadence)const BOSS_ENRAGE_EXTRA_ARMS: int = 3 # an enraged spiral throws extra arms
func spawn(sim: Sim, hp_mult: float = 1.0) -> void: var pos := sim.player.pos + sim.rng.rand_unit_dir() * 640.0 var hp := BOSS_HP * hp_mult sim.enemies.add(pos, Vector2.ZERO, BOSS_RADIUS, hp, BOSS_ARMOR, BOSS_SPEED, BOSS_CONTACT_DMG, BOSS_XP, EnemyPool.TYPE_BOSS, sim.content.element_index("void"), EnemyPool.BEHAVIOR_BOSS) sim.boss.reset() sim.boss.max_hp = hp # enrage threshold tracks the scaled HP sim._boss_spawn_count += 1 sim.fx_events.append({"kind": "reaction", "pos": pos, "element": -1, "name": "BOSS"})
func update(sim: Sim, dt: float) -> void: update_missiles(sim, dt) var bi := sim.boss_rotation.boss_index(sim) if bi == -1: return sim.boss.timer += dt var bpos := sim.enemies.pos[bi] if not sim.boss.enraged and sim.enemies.data[bi] <= sim.boss.max_hp * BOSS_ENRAGE_FRAC: sim.boss.enraged = true sim.fx_events.append({"kind": "reaction", "pos": bpos, "element": -1, "name": "ENRAGED"}) var cadence := BOSS_ENRAGE_TIME_MULT if sim.boss.enraged else 1.0 match sim.boss.phase: BossState.PHASE_APPROACH: var to := sim.player.pos - bpos var d := to.length() if d > 0.001: sim.enemies.pos[bi] += to / d * BOSS_SPEED * dt if sim.boss.timer >= BOSS_APPROACH_TIME: sim.boss.phase = BossState.PHASE_TELEGRAPH sim.boss.timer = 0.0 sim.boss.fired = false BossState.PHASE_TELEGRAPH: var tele_time: float = BOSS_FIRE_TELEGRAPH_TIME if sim.boss.attack_idx == BossState.ATTACK_BARRAGE else BOSS_TELEGRAPH_TIME * cadence if sim.boss.timer >= tele_time: sim.boss.phase = BossState.PHASE_ACTIVE sim.boss.timer = 0.0 fire(sim, sim.boss.attack_idx, bpos) BossState.PHASE_ACTIVE: if sim.boss.attack_idx == BossState.ATTACK_SWING: swing_hit(sim, bpos) if sim.boss.timer >= BOSS_ACTIVE_TIME: sim.boss.phase = BossState.PHASE_REST sim.boss.timer = 0.0 BossState.PHASE_REST: if sim.boss.timer >= BOSS_REST_TIME * cadence: sim.boss.phase = BossState.PHASE_APPROACH sim.boss.timer = 0.0 sim.boss.attack_idx = (sim.boss.attack_idx + 1) % BossState.ATTACK_COUNT
func fire(sim: Sim, idx: int, bpos: Vector2) -> void: if idx == BossState.ATTACK_BARRAGE: for k in range(BOSS_BARRAGE_COUNT): var a := TAU * float(k) / float(BOSS_BARRAGE_COUNT) var dir := Vector2(cos(a), sin(a)) sim._boss_proj(bpos, dir * BOSS_BARRAGE_SPEED, sim.SHOOTER_PROJ_RADIUS, sim.SHOOTER_PROJ_LIFETIME, sim.SHOOTER_PROJ_DAMAGE, EnemyPool.TYPE_BOSS) sim.fx_events.append({"kind": "reaction", "pos": bpos, "element": -1, "name": ""}) elif idx == BossState.ATTACK_MISSILES: for k in range(BOSS_MISSILE_COUNT): var a := TAU * float(k) / float(BOSS_MISSILE_COUNT) var dir := Vector2(cos(a), sin(a)) sim.boss_missiles.append({"pos": bpos, "vel": dir * BOSS_MISSILE_SPEED, "life": BOSS_MISSILE_LIFE}) elif idx == BossState.ATTACK_SPIRAL: var arms := BOSS_SPIRAL_ARMS + (BOSS_ENRAGE_EXTRA_ARMS if sim.boss.enraged else 0) for arm in range(arms): var a := sim.boss.spiral_phase + TAU * float(arm) / float(arms) var dir := Vector2(cos(a), sin(a)) for s in range(BOSS_SPIRAL_PER_ARM): var spd := BOSS_SPIRAL_SPEED * (0.6 + 0.4 * float(s) / float(maxi(BOSS_SPIRAL_PER_ARM - 1, 1))) sim._boss_proj(bpos, dir * spd, sim.SHOOTER_PROJ_RADIUS, sim.SHOOTER_PROJ_LIFETIME, sim.SHOOTER_PROJ_DAMAGE, EnemyPool.TYPE_BOSS) sim.boss.spiral_phase += BOSS_SPIRAL_STEP sim.fx_events.append({"kind": "reaction", "pos": bpos, "element": -1, "name": ""})
func swing_hit(sim: Sim, bpos: Vector2) -> void: if sim.boss.fired or sim.is_invulnerable(): return var reach := BOSS_SWING_RANGE + sim.player.radius if sim.player.pos.distance_squared_to(bpos) <= reach * reach: sim._hurt_player(BOSS_SWING_DMG, "boss") sim.boss.fired = true
func update_missiles(sim: Sim, dt: float) -> void: var i := sim.boss_missiles.size() - 1 var reach := sim.player.radius + BOSS_MISSILE_RADIUS var r2 := reach * reach while i >= 0: var m: Dictionary = sim.boss_missiles[i] m["life"] = float(m["life"]) - dt if m["life"] <= 0.0: sim.boss_missiles.remove_at(i) i -= 1 continue var pos: Vector2 = m["pos"] var vel: Vector2 = m["vel"] var target := sim._nearest_drone_pos(pos) if not sim.drones.is_empty() else sim.player.pos var desired := target - pos if desired.length() > 0.001: desired = desired.normalized() * BOSS_MISSILE_SPEED var turn := clampf(vel.angle_to(desired), -BOSS_MISSILE_TURN * dt, BOSS_MISSILE_TURN * dt) vel = vel.rotated(turn) pos += vel * dt m["vel"] = vel m["pos"] = pos if not sim.drones.is_empty() and pos.distance_squared_to(target) <= r2: sim.fx_events.append({"kind": "death", "pos": pos, "element": -1}) sim.boss_missiles.remove_at(i) elif not sim.is_invulnerable() and sim.player.pos.distance_squared_to(pos) <= r2: sim._hurt_player(BOSS_MISSILE_DMG, "boss") sim.fx_events.append({"kind": "death", "pos": pos, "element": -1}) sim.boss_missiles.remove_at(i) i -= 1
func render_info(sim: Sim) -> Dictionary: var bi := sim.boss_rotation.boss_index(sim) if bi == -1: return {"alive": false} var tele := -1.0 var fire_charge := -1.0 var winding_fire := false if sim.boss.phase == BossState.PHASE_TELEGRAPH: if sim.boss.attack_idx == BossState.ATTACK_BARRAGE: winding_fire = true fire_charge = clampf(sim.boss.timer / BOSS_FIRE_TELEGRAPH_TIME, 0.0, 1.0) else: tele = clampf(sim.boss.timer / BOSS_TELEGRAPH_TIME, 0.0, 1.0) return { "alive": true, "pos": sim.enemies.pos[bi], "radius": sim.enemies.radius[bi], "hp": sim.enemies.data[bi], "max_hp": sim.boss.max_hp, "phase": sim.boss.phase, "attack": sim.boss.attack_idx, "enraged": sim.boss.enraged, "telegraph": tele, "winding_fire": winding_fire, "fire_charge": fire_charge, "fire_active": sim.boss.phase == BossState.PHASE_ACTIVE and sim.boss.attack_idx == BossState.ATTACK_BARRAGE, "swing_active": sim.boss.phase == BossState.PHASE_ACTIVE and sim.boss.attack_idx == BossState.ATTACK_SWING, "swing_range": BOSS_SWING_RANGE, }Note sim._boss_proj, sim._hurt_player, sim._nearest_drone_pos, sim.is_invulnerable
stay as Sim methods (they’re shared across multiple boss modules — _boss_proj is used
by Boss2’s artillery too — and are NOT part of this extraction’s scope per the design
spec’s module table).
-
Step 3: Delete the extracted code from
sim.gd, replacing the const block and the 6 functions identified in Step 1. -
Step 4: Wire up the field and call sites
Add var boss_warden := BossWarden.new() alongside boss_rotation. Update:
-
boss_rotation.gd’ssim._spawn_boss(s)call →sim.boss_warden.spawn(sim, s) -
Any remaining
sim.gdcall site of_spawn_boss/_update_boss/_boss_fire/_boss_swing_hit/_update_boss_missiles/boss_render_info(grep to find them — the top-leveltick()dispatch and any test-facing accessor) →sim.boss_warden.<name>(self, ...) -
Step 5: Run verification
./scripts/verify-sim-refactor.sh-
Step 6: Confirm checksum values unchanged (same grep as Task 1 Step 6)
-
Step 7: Commit
git add sim/boss_warden.gd sim/sim.gdgit commit -m "refactor(sim): extract boss_warden.gd from sim.gd
Moves the Warden boss's spawn/update/fire/swing/missiles/render_infointo a BossWarden director. Boss state (BossState instance) stays onSim. Determinism checksum unchanged."Tasks 3-11: Remaining modules
Section titled “Tasks 3-11: Remaining modules”Each follows the IDENTICAL 7-step procedure demonstrated in full in Tasks 1-2:
- Locate current functions/consts via
grep -n "^func <name>("for each name below (line numbers WILL have shifted from Tasks 1-2’s edits — always re-grep, never reuse a stale number). - Create the new file:
class_name <ClassName> extends RefCounted, the listed consts verbatim, each function de-prefixed with a leadingsim: Simparameter, every reference to aSimfield/method prefixedsim.(e.g.enemies→sim.enemies,_hurt_player(...)→sim._hurt_player(...)), every reference to another already- extracted director’s method (e.g._boss_index()) updated to the new call (sim.boss_rotation.boss_index(sim)). - Delete the extracted code from
sim.gd. - Add
var <field_name> := <ClassName>.new()tosim.gdand update every remaining call site of the old_-prefixed function name to<field_name>.<method>(self, ...). - Run
./scripts/verify-sim-refactor.sh— must print the PASS line with unchanged counts. - Re-run the checksum grep from Task 1 Step 6 — values must be byte-identical.
- Commit with a
refactor(sim): extract <file>.gd from sim.gdmessage describing what moved, following the Task 1/2 commit message format.
Do not proceed to the next task until the current one’s verification (steps 5-6)
passes. If a step fails, the most common cause is a missed call site — grep for the
OLD function name across sim/, render/, ui/, and tests/ (not just sim.gd) to
find it, since a few of these functions (e.g. boss_render_info) are called from
render code outside sim.gd.
Task 3: boss2.gd (class Boss2)
Section titled “Task 3: boss2.gd (class Boss2)”Functions: _boss2_index (already moved to boss_rotation.gd in Task 1 — skip),
_spawn_boss2, _update_boss_rockets, rocket_render_info, _update_boss2,
_boss2_active_update, _boss2_fire, boss2_render_info.
Consts: the BOSS2_* block (BOSS2_HP through BOSS2_RINGS_DMG, plus
LEECH_CAP_FRAC_PER_S if it is interleaved in that range per the current file — verify
with grep -n "^const BOSS2_\|^const LEECH_CAP" and keep LEECH_CAP_FRAC_PER_S in
sim.gd instead if it’s used elsewhere (grep its other call sites before moving it).
Task 4: funzo.gd (class FunZo)
Section titled “Task 4: funzo.gd (class FunZo)”Functions: _funzo_index (already in boss_rotation.gd — skip), _spawn_funzo,
_update_funzo, _funzo_dashes_for, _funzo_dash_speed_for, _funzo_dash_time_for,
_funzo_latch_dash, _funzo_check_threshold_summons, _spawn_funzo_zone,
_spawn_jester, _spawn_funzo_confetti, _update_funzones, funzo_render_info.
Consts: the FUNZO_* block including FUNZO_THRESHOLD_SUMMONS.
Task 5: graviton.gd (class Graviton)
Section titled “Task 5: graviton.gd (class Graviton)”Functions: _graviton_index (already in boss_rotation.gd — skip), _spawn_graviton,
_update_graviton, _spawn_graviton_blobs, _spawn_graviton_ring,
graviton_render_info.
Consts: the GRAVITON_* block.
Task 6: eye.gd (class Eye)
Section titled “Task 6: eye.gd (class Eye)”Functions: _eye_index (already in boss_rotation.gd — skip), _spawn_eye,
_update_eye, _eye_player_on_beam, eye_render_info.
Consts: the EYE_* block.
Task 7: drone_director.gd (class DroneDirector)
Section titled “Task 7: drone_director.gd (class DroneDirector)”Functions: _update_drones, _drone_behavior, _drone_logistics, _enemy_speed_scale,
_drone_disruptor, _drone_bomber, _bomber_blast, _bomber_one_blast,
_drone_interceptor, _drone_chain_strike, _drone_sentinel, _drone_kinds_for,
_nearest_enemy_of_kinds, _damage_drones_from_enemies, _drone_destroyed,
deploy_drones, set_loadout, sentinel_cfg, _current_loadout, deploy_now,
_decoy_cfg, _decoy_step, _drone_pulse, _pulse_at, decoy_positions,
drones_active, _nearest_drone_pos, decoy_render_info, drones_render_info.
Note: _nearest_drone_pos is called from boss_warden.gd (Task 2) and boss2.gd
(Task 3) as sim._nearest_drone_pos(...) — after this task, update those call sites
too (grep -rn "_nearest_drone_pos" sim/ to find every caller) to
sim.drone_director.nearest_drone_pos(sim, ...).
Task 8: enemy_behaviors.gd (class EnemyBehaviors)
Section titled “Task 8: enemy_behaviors.gd (class EnemyBehaviors)”Functions: _step_skirmish, _step_dash, _step_rush, _step_ghost,
ghost_telegraphs, _step_accumulator, _step_homing.
No consts block of its own — these read per-enemy behavior params already stored on
EnemyPool columns (verify no orphaned local consts exist in this line range before
finalizing the file).
Task 9: enemy_attacks.gd (class EnemyAttacks)
Section titled “Task 9: enemy_attacks.gd (class EnemyAttacks)”Functions: _update_tank_fire, _update_shooters, _update_ranged,
_update_custom_attacks, _update_orbiters, orbiter_shard_render, _update_lancers,
lancer_render_info, _update_bombs.
Task 10: elemental_system.gd (class ElementalSystem)
Section titled “Task 10: elemental_system.gd (class ElementalSystem)”Functions: _resolve_collisions, _spawn_split, _damage_enemy, _vuln_mult,
_weaken_mult, _reaction_burst, _apply_element, _on_reaction, _pop_primed,
_seed_primed, _spawn_zone, _update_zones, _drop_webs, _update_webs,
_web_slow_mult, _apply_status_and_decay.
This module has the widest blast radius — _damage_enemy is called from nearly
every weapon and every boss module extracted in Tasks 2-6. Before deleting it from
sim.gd, run:
grep -rn "_damage_enemy(" sim/ | grep -v "sim/elemental_system.gd"and update every call site (in boss_warden.gd, boss2.gd, enemy_attacks.gd, etc. —
all already-extracted files from prior tasks) to sim.elemental_system.damage_enemy(sim, ...).
Do this task LAST among the boss/enemy modules (as the plan order already has it) so
there are as few stray call sites as possible to chase, but expect several — this is
exactly why the plan’s global constraint says one module per commit: if this task’s
verification fails, git diff shows only this task’s changes, not several tangled
together.
Task 11: upgrade_system.gd (class UpgradeSystem)
Section titled “Task 11: upgrade_system.gd (class UpgradeSystem)”Functions: roll_upgrade_choices, _roll_crystal_grant, _active_element_count,
_has_projectile_weapon, _mod_eligible, apply_upgrade, _weapon_mod_mag,
_apply_weapon_mod, weapon_level, can_evolve, evolve_weapon, _weapon_mod_name,
_weapon_mod_label, grant_weapon, is_weapon_active, _crystal_spec_label,
upgrade_choice_display, upgrade_preview, _fmt_stat, ship_stat_preview,
effective_dps, live_dps, weapon_detail, _weapon_path_label, _parse_crystal_spec,
upgrade_effects, rank_upgrades, active_weapon_views.
Lowest behavioral risk (mostly read-only display/preview logic plus the single
apply_upgrade mutation point) — if time-boxing this plan’s execution, this task can
safely run in parallel with none of the others depending on it, though the plan’s
one-module-per-commit rule still applies sequentially.
Final step: confirm the full extraction matches the spec’s target shape
Section titled “Final step: confirm the full extraction matches the spec’s target shape”- After Task 11’s commit, verify the conductor file size:
wc -l sim/sim.gdExpected: roughly 1,230 lines (±100 for comment/whitespace differences from the
estimate) — down from 4,784. If it’s still above ~1,500, grep for any ^func still in
sim.gd that matches one of the function name lists above and finish moving it.
- Run the verification script one final time and record the result for the PR/merge description:
./scripts/verify-sim-refactor.sh- Do not merge to
mainor sync to the tvOS repo yet — per the design spec’s Rollout section, that’s a separate decision point with Chris (either one squashed merge or a couple of logical batches). Report back with the final line count and test results and wait for a merge go-ahead.