Skip to content

Biomass Wave Spawning — Implementation Plan

Biomass Wave Spawning — Implementation Plan

Section titled “Biomass Wave Spawning — Implementation Plan”

Sub-skill: implement task-by-task with the bh-dev-chunk ritual (TDD → import → boot → full suite → count guard → determinism). One task = one commit.

Goal: Replace the cycle-22b survival ramp/boss-rotation with a wave→clear→boss→repeat loop driven by a biomass pressure score. Spec: docs/superpowers/specs/2026-06-28-biomass-wave-spawning-design.md.

Architecture: A biomass column on EnemyPool + Sim.total_biomass(); a time→type probability SpawnTable; a Sim.spawn_phase state machine (WAVES→WIND_DOWN→BOSS_PREP→BOSS→REST); waves fire when biomass < 100 and fill to ≥400 (elites first, then probabilistic). Warden/Boss2 → elites (spawn in waves); FunZo/Graviton/Eye = the BOSS-phase pool. Survival + crystal only (story == null).

Branch: build in an isolated worktree off main so the Wed iOS launch (current build) is unaffected; merge after launch.

  • /sim stays pure (RefCounted, no Node/Engine/Input/Time/OS/File/JSON). New render-read helpers are pure + excluded from snapshot_string()/state_checksum().
  • Story/tutorial untouched (all new spawn logic guarded by story == null).
  • The spawner rewrite re-pins both determinism baselines once (Task 4) — honest, documented.
  • Tunables (biomass scores, thresholds, timers, elite times, probability table) live as constants/bible.json data.

Task 1: Biomass column + scores + total_biomass()

Section titled “Task 1: Biomass column + scores + total_biomass()”

Files: sim/enemy_pool.gd (new biomass PackedInt32Array column, swapped in add/remove_at), sim/sim.gd (set biomass on spawn; total_biomass()), data/bible.json (biomass per enemy), tests/test_biomass.gd (new). Interfaces produced: EnemyPool.biomass: PackedInt32Array; Sim.total_biomass() -> int; Sim.spawn_enemy(... biomass) sets the column (0 for minions/bosses).

  • Test: spawn 3 swarmers (biomass 2 each) → total_biomass()==6; remove one → 4; a boss/minion adds 0.
  • Add the column (resize in _init, set in add, swap in remove_at); add biomass to bible enemies (swarmer 2 … accumulator 16; Warden 110, Boss2 100; bosses/minions 0).
  • _spawn_one/spawn paths pass the type’s biomass; total_biomass() sums the column.
  • Gates green (determinism UNCHANGED — column is additive, not in the checksum). Commit.

Task 2: SpawnTable (time→type probability vectors)

Section titled “Task 2: SpawnTable (time→type probability vectors)”

Files: sim/spawn_table.gd (new, RefCounted), tests/test_spawn_table.gd (new). Interfaces produced: SpawnTable.weights_at(t) -> Dictionary{type_id:weight} (lerped, sums≈1); SpawnTable.pick(t, rng) -> int (samples a type deterministically); endless tail loops.

  • Test: each authored 30s vector sums to ~1; weights_at(45) lerps the 30s/60s entries; pick is deterministic for a seed; out-of-range t loops the endless tail.
  • Author the table (early swarmer/spider → mid +shooter/scatterer/ghost/orbiter → late +lancer/bomber/ accumulator/brute/pyromancer; zapper/rusher stay out). Commit.

Files: sim/sim.gd (enum + spawn_phase, timers, _update_spawn_phase(dt), phase-change events), tests/test_spawn_phase.gd (new). Interfaces produced: Sim.spawn_phase: int (WAVES/WIND_DOWN/BOSS_PREP/BOSS/REST), Sim.phase_timer, render-read Sim.spawn_banner() -> {text, seconds}.

  • Test: starts WAVES; at WAVE_PHASE_S → WIND_DOWN; biomass 0 in WIND_DOWN → BOSS_PREP(10s) →BOSS; boss-dead → REST(10s) → WAVES. (Drive via direct state + _update_spawn_phase, story==null.)
  • Implement the machine (guarded story==null); emit banner text on transitions. Determinism: the machine only drives spawning in Task 4, so this is inert until then. Commit.

Task 4: Wave generation (THE spawner replacement) + determinism re-pin

Section titled “Task 4: Wave generation (THE spawner replacement) + determinism re-pin”

Files: sim/sim.gd (_spawn_enemies → wave logic; remove the cycle-22b ramp/_spawn_suppressed/ swarm-burst/boss-add trickle for survival), tests/test_spawn_rework.gd (rewrite), determinism tests + CLAUDE.md (re-pin).

  • Test: in WAVES, when total_biomass()<BIOMASS_TRIGGER(100), one wave call fills to >=BIOMASS_TARGET(400); no wave fires while biomass≥100; spawns are off-screen ring.
  • Implement: trigger + probabilistic fill (sample SpawnTable.pick, _spawn_one, accumulate biomass) until ≥400; elites-first hook (Task 5 fills it). Replace the old survival spawn body.
  • Re-run determinism → re-pin test_determinism_checksum + test_determinism_crystals to the new hash/checksum; note in CLAUDE.md. Verify run-twice-identical. Commit.

Files: sim/sim.gd (elite spawn-time table; spawn in-wave before fill; remove Warden/Boss2 from the boss rotation; they carry biomass + don’t clear the arena), tests/test_elites.gd (new).

  • Test: an elite due at its hard-coded time spawns in the next wave first; carries its high biomass; the arena isn’t cleared for it.
  • Implement: ELITE_SPAWN_TIMES = {warden: 90, boss2: 210} (tunable); a due-and-unspawned elite is added at wave start. Commit.

Task 6: Boss gate (FunZo / Graviton / Eye)

Section titled “Task 6: Boss gate (FunZo / Graviton / Eye)”

Files: sim/sim.gd (BOSS phase spawns one boss from the pool into the cleared arena; on death advance to REST; rotate the pool), tests/test_boss_gate.gd (new / fold into boss-rotation test).

  • Test: entering BOSS spawns exactly one pooled boss (biomass 0); boss death → REST → WAVES; pool rotates.
  • Implement; remove the old _maybe_spawn_survival_boss interval logic (the phase machine owns it now). Commit.

Files: sim/sim.gd (boss-summoned adds get biomass 0; FunZo jesters / boss adds routed as minions), tests/test_minions.gd (new / fold in).

  • Test: a boss-summoned enemy has biomass 0 (doesn’t inflate the wave trigger).
  • Implement (summon paths set the biomass column to 0). Commit.

Files: ui/hud.gd (read Sim.spawn_banner() → centered “WAVE INCOMING” / “BOSS APPROACHING N” / “AREA CLEAR”), main.gd (feed it), reuse RegionTitle-style sweep if handy.

  • Render-only (no determinism impact). Verify by boot + playtest. Commit.

Task 9: Whole-branch review + merge prep — DONE (review run 2026-06-28)

Section titled “Task 9: Whole-branch review + merge prep — DONE (review run 2026-06-28)”

All 5 load-bearing invariants verified clean by the code review (determinism + re-pin, /sim purity, story == null guard, biomass column lockstep swap-remove, bosses/minions biomass 0). Two Critical findings, both fixed: added tests/test_boss_gate.gd (the gate had no coverage); replaced the misleading test_boss_adds_spawn_small_fast_types_only with test_no_standard_waves_during_boss_phase.

Deferred pre-merge cleanup (post-launch — large, ~14 test files): the old spawn mechanism is now DEAD but still asserted by many tests (false confidence). To remove before merge:

  • Delete dead code: SpawnDirector.rate_at/pick_type/pick_boss_add_type/spawn_count_for, _maybe_spawn_survival_boss, _spawn_boss_adds, _spawn_swarm_burst, and the consts/vars SWARM_BURST_*/BOSS_ADD_RATE/BOSS_FIRST_TIME/BOSS_INTERVAL/_next_boss_time/_swarm_burst_fired/ _boss_add_accum (+ the 5 _next_boss_time updates in _sweep_dead).
  • Delete test_boss_rotation.gd (tests the dead dispatcher) + test_spawn_director.gd rate tests.
  • Rewrite the enemy spawn-band tests (test_ghost/brute/accumulator/new_enemies/dash_spider/eye/funzo/ graviton) to assert against SpawnTable.weights_at(t) instead of SpawnDirector.pick_type.
  • Update CLAUDE.md determinism baselines → survival 318766133/2760178738, crystals 545935757/2116139268. Harmless to leave for now (dead code doesn’t run); do it deliberately, not crammed in.
  • Tasks 1-3 are additive → suite + determinism stay green. Task 4 is where behavior + the baseline change (re-pin there). 5-7 build on 4. 8 is cosmetic. Each task ends green + committed.