Skip to content

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).

  • Every file in /sim stays extends RefCounted — no Node/Engine/Input/Time/OS/File/JSON APIs.
  • No behavior change. This is pure code relocation.
  • state_checksum() and snapshot_string() (defined at the bottom of sim.gd, unmoved by this plan) must be byte-identical after every single task, verified against BOTH tests/test_determinism_checksum.gd and tests/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 (branch worktree-sim-module-split, forked from main @ 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.

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:

Terminal window
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.gd

Expected 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=180
BASELINE_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 1
fi
echo "=== Full GUT suite ==="
godot --headless --path . -s addons/gut/gut_cmdln.gd -gconfig=.gutconfig.json \
>/tmp/sim-refactor-gut.log 2>&1
tail -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 1
fi
if [ "$tests_ran" -ne "$BASELINE_TESTS" ]; then
echo "FAIL: ran $tests_ran tests, expected $BASELINE_TESTS"
exit 1
fi
if ! grep -q "All tests passed" /tmp/sim-refactor-gut.log; then
echo "FAIL: suite did not report all tests passed"
exit 1
fi
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
Terminal window
chmod +x scripts/verify-sim-refactor.sh
./scripts/verify-sim-refactor.sh

Expected: === PASS: 180 scripts / 1231 tests, all green, counts match baseline ===

  • Step 4: Commit
Terminal window
git add scripts/verify-sim-refactor.sh
git commit -m "chore(sim-refactor): add shared verification script for module extraction tasks"

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 the boss_rotation field + delegate calls at every call site)

Interfaces:

  • Consumes: sim: Sim passed explicitly to every method (reads sim.enemies, sim.run_time, sim.story, sim._boss_spawn_count/_next_boss_time fields — these stay on Sim, 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 _-prefixed Sim methods.

  • Step 1: Locate the exact current functions to extract

Terminal window
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.gd

This 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 BossRotation
extends 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:

Terminal window
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.gd

Replace 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
Terminal window
./scripts/verify-sim-refactor.sh

Expected: === 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
Terminal window
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.gd

Confirm 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
Terminal window
git add sim/boss_rotation.gd sim/sim.gd
git commit -m "refactor(sim): extract boss_rotation.gd from sim.gd
Moves boss-index lookups, any_boss_alive, is_boss_type, hp_scale, and
maybe_spawn_survival_boss into a BossRotation director, following the
SpawnDirector/StoryDirector composition pattern. State stays on Sim;
only the dispatch logic moved. Determinism checksum unchanged."

Files:

  • Create: sim/boss_warden.gd
  • Modify: sim/sim.gd

Interfaces:

  • Consumes: sim: Sim passed explicitly; reads/writes sim.boss (the existing BossState instance 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 from sim.gd’s _update_boss(dt)-equivalent dispatch (Task 1’s boss_rotation calls sim._spawn_boss; this task changes that call site to boss_warden.spawn(sim, s) instead, and updates the top-level tick()/_update_boss orchestration point that currently calls the now-extracted functions)

  • Step 1: Locate the exact current functions and consts

Terminal window
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.gd

Read 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 BossWarden
extends RefCounted
const BOSS_HP: float = 900.0
const BOSS_RADIUS: float = 70.0
const BOSS_ARMOR: float = 6.0
const BOSS_SPEED: float = 56.0 # approach speed
const BOSS_CONTACT_DMG: float = 26.0
const BOSS_XP: float = 40.0 # the kill drops a big reward
const BOSS_APPROACH_TIME: float = 1.5
const BOSS_TELEGRAPH_TIME: float = 0.7 # fair warning before each attack
const BOSS_FIRE_TELEGRAPH_TIME: float = 3.0 # the fire (barrage) attack gets a long, angry, vibrating wind-up
const BOSS_ACTIVE_TIME: float = 0.35
const BOSS_REST_TIME: float = 0.8
const BOSS_SWING_RANGE: float = 240.0 # big melee sweep reach around the boss
const BOSS_SWING_DMG: float = 44.0 # the melee sweep hits HARD — dodge it
const BOSS_BARRAGE_COUNT: int = 20 # radial shot burst (reuses enemy_proj)
const BOSS_BARRAGE_SPEED: float = 270.0
const BOSS_MISSILE_COUNT: int = 5
const BOSS_MISSILE_SPEED: float = 250.0
const BOSS_MISSILE_TURN: float = 2.8 # rad/s homing turn rate
const BOSS_MISSILE_LIFE: float = 5.0
const BOSS_MISSILE_DMG: float = 14.0
const BOSS_MISSILE_RADIUS: float = 12.0
const BOSS_SPIRAL_ARMS: int = 6 # rotating spiral arms of shots
const BOSS_SPIRAL_PER_ARM: int = 4 # shots strung along each arm (varying speed)
const BOSS_SPIRAL_SPEED: float = 300.0
const BOSS_SPIRAL_STEP: float = 0.55 # radians the spiral rotates between casts
const BOSS_ENRAGE_FRAC: float = 0.40 # below this HP fraction the boss enrages
const 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’s sim._spawn_boss(s) call → sim.boss_warden.spawn(sim, s)

  • Any remaining sim.gd call site of _spawn_boss/_update_boss/_boss_fire/ _boss_swing_hit/_update_boss_missiles/boss_render_info (grep to find them — the top-level tick() dispatch and any test-facing accessor) → sim.boss_warden.<name>(self, ...)

  • Step 5: Run verification

Terminal window
./scripts/verify-sim-refactor.sh
  • Step 6: Confirm checksum values unchanged (same grep as Task 1 Step 6)

  • Step 7: Commit

Terminal window
git add sim/boss_warden.gd sim/sim.gd
git commit -m "refactor(sim): extract boss_warden.gd from sim.gd
Moves the Warden boss's spawn/update/fire/swing/missiles/render_info
into a BossWarden director. Boss state (BossState instance) stays on
Sim. Determinism checksum unchanged."

Each follows the IDENTICAL 7-step procedure demonstrated in full in Tasks 1-2:

  1. 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).
  2. Create the new file: class_name <ClassName> extends RefCounted, the listed consts verbatim, each function de-prefixed with a leading sim: Sim parameter, every reference to a Sim field/method prefixed sim. (e.g. enemiessim.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)).
  3. Delete the extracted code from sim.gd.
  4. Add var <field_name> := <ClassName>.new() to sim.gd and update every remaining call site of the old _-prefixed function name to <field_name>.<method>(self, ...).
  5. Run ./scripts/verify-sim-refactor.sh — must print the PASS line with unchanged counts.
  6. Re-run the checksum grep from Task 1 Step 6 — values must be byte-identical.
  7. Commit with a refactor(sim): extract <file>.gd from sim.gd message 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.

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).

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.

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.

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:

Terminal window
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:
Terminal window
wc -l sim/sim.gd

Expected: 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:
Terminal window
./scripts/verify-sim-refactor.sh
  • Do not merge to main or 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.