Bullet Heaven — enemies, bosses & survival pacing
Bullet Heaven — enemies, bosses & survival pacing
Section titled “Bullet Heaven — enemies, bosses & survival pacing”Extracted from
CLAUDE.mdon 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. SeeCLAUDE.md§ “Subsystem architecture — read on demand”.

Dash enemies, spider/webs, QoL + UX (M2 cycle 14, DONE)
Section titled “Dash enemies, spider/webs, QoL + UX (M2 cycle 14, DONE)”- Build number convention:
Sim_Const.BUILD(currently 14), shown bottom-left in-game (hud.gd) and in the F2 overlay. Bump it on every livedeploy-demo.shand mirror the change in the site changelog (~/Claude/bullet-heaven-site/index.html.changelogsection + the play-bar “Build N” text). The changelog lists what’s new AND how it interacts with existing systems — maintain it each deploy (Chris’s standing request). - Dash movement primitive (
EnemyPool.BEHAVIOR_DASH): charge (telegraph) → fast locked lunge → recharge. New columnsbehavior/dash_phase/dash_timer; the lunge velocity reuses the basevelcolumn (enemies otherwise move toward the player and ignorevel). Per-type dash params (charge_s/dash_s/dash_speed) come from the bible;enemy.speedis the slow charge drift.Sim._step_dashruns the state machine;Sim.enemy_charge_frac(i)feedsrender/dash_telegraph_renderer.gdwhich draws the brightening lunge line + endpoint marker (Toby’s fairness rule: telegraph everything). Elite is now the Dasher. - Spider (
EnemyPool.TYPE_SPIDER) + webs: a fast small dasher that lays a slowing web trail.Sim.webs(separate from reactionzones);_drop_websadds a web on fresh ground (no new column — checks distance to existing webs),_update_websexpires them,_web_slow_multcounts webs the player stands in →player.move_mult(set BEFOREintegrateeach tick; webs stack the slow). Rendered as cobweb discs inZoneRenderer(under the reaction zones).SpawnDirector.pick_typenow blends types (Toby: never send one type alone). - Stand-still heal: holding no move input for
STILL_HEAL_DELAY=0.8sregeneratesSTILL_HEAL_DPS=7/s (Sim._stand_still_heal,player.still_timer). Determinism-neutral for the baseline (the moving-input run never heals). - Perf: F2 overlay now shows fps / sim-ms / render-ms / draw-calls / primitives / zone+web counts (CPU-vs-GPU diagnosis). F4 toggles low-fx (
main._toggle_low_fx): disables the WorldEnvironment bloom + the additive halo layer (SwarmRenderer.set_halo_visible) — the main overdraw sources; the mitigation for weaker Macs (Toby reported lag). - Level-up overhaul (
ui/level_up_panel.gd): dimmed field + opaque colour-coded cards (cyan=weapon, gold=transformative, steel=stat) with hover glow, and each shows the RESULTING powernow → afterfromSim.upgrade_preview(id)(readsStatEffects.TABLE/SimMods.TABLEfield+op to project the value). Replaced the translucent default buttons that were lost against the busy field. - ⚠️ Reaction lethality was tuned down (
REACTION_DAMAGE_SCALE=0.3,ZONE_DPS=2,REACTION_COOLDOWN=0.45, spawn ramp1.2+0.30t) so reactions are crowd-control + chip, not instant-clears — the swarm now builds and dashers/spiders pressure the player (auto-player drops below 100 HP). Baselines re-pinned through these (current:1998629725/261923457; dash/spider/QoL added later spawn-gated so they didn’t move it).
Content systems (M2 cycle 16, DONE) — boss, mini-boss, powerups, decoy, build-craft, armor
Section titled “Content systems (M2 cycle 16, DONE) — boss, mini-boss, powerups, decoy, build-craft, armor”Four feature chunks added 2026-06-24, each TDD’d in main then copied to the tvOS repo. All tuning is constants near the top of sim/sim.gd (BOSS_, SKIRMISH_, POWERUP_, DECOY_) + per-enemy data in bible.json — retune there.
- Boss (
sim/boss_state.gdBossState,render/boss_renderer.gd): a pooled enemy (EnemyPool.TYPE_BOSS/BEHAVIOR_BOSS) so all player weapons + projectile collisions damage it for FREE (no per-weapon code). Its behaviour is a separate attack-pattern state machine onSim(approach→telegraph→swing/barrage/missiles→rest), found each tick by_boss_index()(scan — robust to swap-remove). Homing missiles areSim.boss_missiles(a dict array, not the pool). Spawns at 40s then every 80s;_move_enemiesSKIPSBEHAVIOR_BOSS(moved by_update_boss). HUD boss HP bar viaboss_render_info(). - Skirmisher mini-boss (
TYPE_SKIRMISHER/BEHAVIOR_SKIRMISH,bible.json): mid-range strafing AI that fires aimed shots; reuses the dash columns (dash_timer=fire clock,dash_phase=strafe dir) — no new columns. Drops a random powerup on death. - Powerups (
Sim.powerups,render/powerup_renderer.gd): slow / freeze / nuke / heal, collected on contact, immediate. Global slow/freeze is aedtenemy time-scale computed intick()and passed to_move_enemies/_update_boss/_move_enemy_proj/_update_shooters(realdtstill drives player/weapons/gems). - Decoy (
sim/decoy_state.gdDecoyState,render/decoy_renderer.gd): LB/Q-triggered replica that pulls walk-enemy aggro (the WALK branch of_move_enemiestargetsdecoy.poswhen active), pulses AoE at ~70%, recharges over time + faster on damage (via an hp-delta check at end oftick). Input edge viaInputState.decoy. - Build-craft: damage is now PER-WEAPON. Each weapon has its own
damage_mult(was globalplayer.damage_mult); the global “damage” upgrade is filtered out of the offer and replaced byWEAPON_MODS(per-owned-weapon upgrades, idswm:<weapon>:<kind>: power / orbit shard / nova radius), routed inapply_upgrade/upgrade_preview/upgrade_choice_display. Orbit shard count is nowvar shards(capMAX_SHARDS=6), rendered dynamically inmain.gd. - Armor (
PlayerState.armor, StatEffects"armor", bible “Hull Plating”): all player damage routes throughSim._hurt_player(6%/point reduction, cap 75%). - Determinism baseline was UNMOVED through all four (was
4152236597/1267954985at the time; now re-pinned to4152236597/2325839371by task 10) — boss/skirmisher/powerups are time-gated past the 10s baseline window, and decoy/armor/per-weapon-mult default to no-ops.
Combat depth, new content & balance (M2 cycle 20, DONE) — builds 47-48
Section titled “Combat depth, new content & balance (M2 cycle 20, DONE) — builds 47-48”A 13-task cycle (spec docs/superpowers/specs/2026-06-25-combat-depth-content-balance-design.md, plan docs/superpowers/plans/2026-06-25-combat-depth-content-balance.md), each TDD’d + reviewed. Shipped to the Apple TV @ BUILD 48. Re-pinned the determinism baseline to 4152236597/2325839371 (Task 10 per-enemy variation; see the keystone note). All other tasks were determinism-neutral (time-gated / input-gated / non-blade weapons / armor-0-safe).
- Player thruster/dodge:
InputState.dashedge →Sim._update_dashgives a short committed burst + i-frame window (is_invulnerable()whileplayer.iframe_timer > 0). EVERY player-damage site is i-frame-guarded (contact, enemy_proj, shooter, boss swing/missiles, boss2 cutter/charge, bomb/artillery blast, orbiter shards, lancer beam). In-run upgradethrusters(StatEffects →player.dash_cooldown_mult) + a metathrustersupgrade. Determinism-safe (no dash input in the baseline → all guards always-true). - New enemy behavior
BEHAVIOR_RUSH=4(Rusher,TYPE_RUSHER=9): re-aim → fast straight burst → immediately re-aim, NO recharge pause; dodgeable by moving perpendicular (it overshoots). The counter-play to thrusters. EnemyProjPool extends EntityPool(sim/enemy_proj_pool.gd):enemy_projis now this pool with a per-shotdamagecolumn (add(p,v,r,life,dmg), swap-removed in lockstep)._check_player_hitdealsenemy_proj.damage[ep], not a constant.- 5 weapon-mirroring enemies (spawn-gated ≥20-50s, determinism-safe):
TYPE_ZAPPER=10(fast lightning bolts),TYPE_SCATTERER=11(blood pellet fan),TYPE_BOMBER=12(slow; lobs a delayed AoE —Sim.bombs+_update_bombs+ telegraphrender/bomb_renderer.gd; explosion uses the existingreactionfx),TYPE_ORBITER=13(cold spinning shards = moving contact hazard; spin phase in the freedash_timercolumn;render/orbiter_renderer.gd),TYPE_LANCER=14(telegraphed light beam-line; state in_lancer_statekeyed by entity_id;render/lancer_telegraph_renderer.gd; fire uses thebeamfx — keys MUST bepos/dir/length/elementto match FxManager). Ranged firing is a type-driven_update_armed_enemiespass keyed byentity_id. - New 5-attack boss
Boss2State(TYPE_BOSS2=15): picks ONE of 5 attacks at random each cycle — Cutter (sweeping beam), Artillery (boss_rocketsarc up via a render-onlyaltitudetrick → land → radial shrapnel into enemy_proj), Shockwave Rings (concentric, with a dodge gap), Charge slam, Summon. Pooled like the Warden (weapons damage it free). Survival ALTERNATES Warden/boss2 (_boss_spawn_count, one boss at a time).render/boss2_renderer.gddraws body + all telegraphs + rockets. Coexists with the original 4-attack Warden. - Per-enemy variation + escorts (THE re-pin, Task 10):
Sim._vary_stats(radius,hp,speed,contact)jitters every spawn (one size roll: bigger=tankier+slower, smaller=faster+weaker, +noise, ~8% stronger “variant”); tank/brute spawns bring a 3-5 escort cluster. Applied in_spawn_enemies+ story randomized-replay spawns (NOT tutorial-authored). Draws fromrngin the baseline window → movedstate_checksumto2325839371. - Visual archetype silhouettes (
render/archetype_renderer.gd): replaced the single-mesh swarm with per-shapeMultiMeshpartitions (distinct silhouette perTYPE_*);shape_for/_TYPE_SHAPEsized toTYPE_BOSS2+1._polygon_meshfans from the CENTRE (0,0) so star-shaped/non-convex silhouettes render correctly. ReplacedSwarmRendererfor enemies only (gems/projectiles still SwarmRenderer). - Audio (
audio/audio_manager.gd+audio/*.wav): first audio in the game — reuses chess-defense SFX (placeholder).consume(fx_events)maps fx kinds → sounds with a per-frame cap; discretelevel_up/game_over/victory/dash/hurthooks in main.gd.ui_nav/ui_buyexist but unwired. Render-side (NOT /sim). - Modes: the start menu is now TUTORIAL / STORY / SURVIVAL. The main STORY campaign no longer pauses on dialogue (lines float by); only TUTORIAL freezes the sim to read. The level-up picker pause is unchanged (genre-core).
- Meta shop overhaul (
ui/meta_shop_panel.gd+ui/shop_icons.gd): was a single VBox that ran off-screen; now a responsiveGridContainer(_columns_forclamp 3-5) spread across the screen with procedural per-upgrade icons + entrance/focus/buy animations + 2D grid nav (tvOS-safe).show_shop/restart_requestedunchanged. - Balance: armor floor
amount*0.1→*0.25(chip/ranged weapons stop doing ~nothing to armored foes; armor-0 enemies unaffected → baseline-safe); non-blade weapon damage buffs (blade stays 3.0 to hold the baseline);BOSS_GOLD25→40 + heavy-kill +2 gold; metacost_growthcapped 1.6;DMGNUM_MIN7→2 (damage numbers now show for ordinary hits). - Telemetry DPS fix (parallel-session commit a6194c0, accepted):
dmg_dealt_totalnow caps each credit at the enemy’s remaining HP (the dashboardavg_dpswas reading millions from AoE/overkill); HP subtraction unchanged → determinism-neutral. - DEFERRED: web demo R2 redeploy (the start menu changed the public demo’s first screen auto-attract→menu — needs Chris’s OK); site landing is otherwise behind on builds 32-47 content (storyline/telemetry/decoy not yet in the changelog/legend); polish nits (boss rockets use
dtnotedtduring freeze;BOSS2_RINGS_SPEEDSshould derive its loop bound from the array size; lancer/cutter beams ignore story walls). Playtest-feel items: enemy silhouettes, new-boss telegraphs, thruster-vs-Rusher dodging, audio.
“The Chasm” content + survival rework (M2 cycle 21, DONE) — build 51
Section titled ““The Chasm” content + survival rework (M2 cycle 21, DONE) — build 51”Ported the best unbuilt enemies/bosses from Chris’s earlier game The Chasm + reworked survival pacing. Spec docs/superpowers/specs/2026-06-25-chasm-content-survival-rework-design.md, plan …/plans/2026-06-25-chasm-content-survival-rework.md. 8 TDD’d tasks, each per-task reviewed + a final whole-branch review (READY TO MERGE, no Critical/Important). Survival-mode only (story integration deferred). Re-pinned the determinism baseline ONCE in Part A → 1432233777/2300319179 (see keystone note); Tasks 2–8 hold it byte-identical (new enemies spawn-gated, tank-missile fire gated ≥20s, bosses story==null+≥40s, every boss _update_* early-returns no-op when absent).
-
Part A — survival spawn & balance rework: clean arena around EVERY boss —
_spawn_suppressed()pauses normal spawns when any of the 5 boss indices is active OR withinBOSS_QUIET_LEAD(30s) of the next boss. Fewer/deadlier/quicker:SOFT_ENEMY_CAP340→140,SpawnDirector.rate_atcut ~45%, tanky HP CUT for faster TTK (tank 85→55, brute 160→110, elite 60→45) +LETHALITY_MULT1.6× contact at spawn (deadly via damage/behaviour, NOT HP). Arena-wide movement: newEnemyPool.flankcolumn (signed angular offset fromrngat spawn) rotates theBEHAVIOR_WALKheading byflank × falloff(distance)— walkers fan out / circle far away, converge close in. -
New enemies (survival pool, spawn-gated): Ghost (
TYPE_GHOST=16,BEHAVIOR_GHOST) — DRIFT→TELEGRAPH→STRIKE teleport-strike; the silhouette endpoint tracks the player-relative offset each tick (running doesn’t escape); the REAL ghost emits a distinctghost_eyefx on its body so it’s never confused with theghost_warnendpoint silhouette; offset keyed byentity_id, gated ≥30s. Accumulator (TYPE_ACCUMULATOR=17, reusesBEHAVIOR_DASH+ agrow_tcolumn) — grows radius/speed/contact over its lifetime (cappedACCUMULATOR_MAX_SCALEso it stays dodgeable + max radius <MAX_ENEMY_RADIUSso projectiles don’t tunnel), forcing a priority kill; gated ≥50s, rare. -
Tank rework: killable homing missiles (
TYPE_TANK_MISSILE=18,BEHAVIOR_HOMING) — real pooled enemies (weapons +_sweep_deadkill them free), limited turn rate = dodgeable, reusedash_timeras an age counter, no gem/gold on death, tank fire keyed byentity_id+ gatedrun_time>=20. -
Three new bosses → survival rotation extended 2→5 (
_maybe_spawn_survival_boss()dispatches_boss_spawn_count % 5: Warden/Boss2/FunZo/Graviton/Eye; one boss at a time; each_spawn_*increments the counter once; each has a_sweep_deaddeath branch advancing_next_boss_time+ gold + gems and a nuke-exemption entry):

- FunZo (
FunZoState,TYPE_FUNZO=19) — zone-flooding clown: summons growing fuchsia DoT zones beneath itself that damage the PLAYER (Sim.funzones, stack with overlap, decay fast on death), slow-drift/fast-dash alternation, body grows as HP drops; enrichments: jester one-hit adds, confetti ring on dash landing, enrage spikes zone/dash cadence. - Graviton (
GravitonState,TYPE_GRAVITON=20) — radial dark-blob fire with safe gap-lanes + a telegraphed gravity pull (ADDITIVE displacement applied after the player integrate, so input still steers;GRAVITON_PULL_STRENGTH200→480 as HP drops); enrichments: Singularity Collapse enrage ultimate (charge→strong pull→REVERSE push + shockwave ring) + orbiting satellite blobs. - The Eye (
EyeState,TYPE_EYE=21) — predictive-aim lasers that LEAD the player’s velocity (derived fromlast_player_posdelta) with a telegraph window that shrinks as HP drops but never belowEYE_LASER_WINDOW_FLOOR=0.30s(always dodgeable); lazy edge dash (huge damage + time-bounded afterimage line); enrichments: blink reposition, multi-beam fan below 50% HP, pupil tracks its next endpoint. Beam fx uses the required keyspos/dir/length/element.
- FunZo (
-
Lessons baked in: (1) a new zone/projectile mechanic that should hurt the PLAYER can silently be copy-pasted from the enemy-damaging reaction
zonesand hurt ENEMIES instead — FunZo shipped this Critical (zone DoT queried the enemy hash); always verify a “damages the player” mechanic routes through_hurt_player(respectingis_invulnerable()), not_damage_enemy. (2) A new pooled boss needs FIVE things or the rotation/economy breaks: a_sweep_deaddeath branch (gold +_next_boss_timeadvance — without the advance the whole rotation STALLS after that boss dies — + gems + “BOSS DOWN”), a nuke-exemption entry, a renderer + color override +_TYPE_SHAPE/LUT sized to the new max type id,boss_render_info().max_hp= its OWN spawn HP (not a constant), and_move_enemiesmust skip itsBEHAVIOR_BOSS. (3) the gravity pull must be applied AFTERplayer.integrateso it ADDS to input (escapable); tune pull < player move speed at full HP. -
DEFERRED (Chris’s call): the web demo R2 redeploy + the site landing changelog/legend for this content (outward-facing public page). Deferred Minors:
_step_accumulatordedup vs_step_dash; a stronger additive-pull composition test; Eye telegraph beam-fx per-tick churn;test_suppressed_while_boss2_alive. Story-mode integration of the new content is a later data-authoring pass.
Survival pacing rework — 3-min buildup → swarm → boss-with-adds (M2 cycle 22b, DONE) — build 55
Section titled “Survival pacing rework — 3-min buildup → swarm → boss-with-adds (M2 cycle 22b, DONE) — build 55”
Reshaped survival into a deliberate load curve (Chris’s design; also the controlled scenario for the fps measurement). REPLACES the cycle-21 “clean arena around every boss” — bosses now fight amid adds. Sim-touching; re-pinned the determinism baseline ONCE → 3428105085/1447851215 (the early ramp + removed quiet-lead change the 0–10s window; sim still deterministic — run twice = identical).
- 3-min ramp:
SpawnDirector.rate_atnow rampsbase = 1.0 + clampf(t/180,0,1)*8(1/s → 9/s by 3:00) with a wave-texture floor of 0.4 (never fully dry) — normal enemies BUILD UP over the first 3 minutes instead of the cycle-21 cut curve.SOFT_ENEMY_CAP140→200 (more on-screen). - Swarm crescendo at 3:00:
Sim._spawn_swarm_burst()dumpsSWARM_BURST_COUNT=70of the current blend at once, ONE-time (_swarm_burst_firedguard), atSWARM_BURST_TIME=180. - First boss at 3:30:
BOSS_FIRST_TIME40→210. Subsequent bosses keep theBOSS_INTERVAL=80cadence after each dies. - Adds during boss (not quiet):
_spawn_suppressed/BOSS_QUIET_LEADare GONE._spawn_enemiesnow: if_any_boss_alive()→_spawn_boss_adds(dt)(trickle small/fast types — swarmer/spider/zapper/rusher viaSpawnDirector.pick_boss_add_type, atBOSS_ADD_RATE=1.8/s); else swarm-crescendo check + the normal ramp. So a boss fight stays busy with light adds. - Refactor: extracted
Sim._spawn_one(tid, pos)(vary+flank+add) used by the ramp, boss-adds, and burst — its rng draw order (_vary_stats→ flank sign → flank magnitude) MATCHES the old inline spawn, so the extraction itself is determinism-neutral (only the rate/quiet-lead changes moved the baseline)._any_boss_alive()replaces the repeated 5-index boss check. - Tests:
test_spawn_reworkA1 rewritten (boss-adds + swarm crescendo +_any_boss_aliveinstead of suppression), cap/boss-time asserts updated;test_boss_rotationsuppression tests →_any_boss_alive; the eye/funzo/graviton death-rearm tests setrun_time = _next_boss_timefirst (a boss only dies after it spawned, sorun_time + BOSS_INTERVALadvances past the now-210 first-boss time). All tuning is constants at the top ofspawn_director.gd/sim.gd— retune there.