Skip to content

Bullet Heaven — enemies, bosses & survival pacing

Bullet Heaven — enemies, bosses & survival pacing

Section titled “Bullet Heaven — enemies, bosses & survival pacing”

Extracted from CLAUDE.md on 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. See CLAUDE.md § “Subsystem architecture — read on demand”.

Enemy bestiary — silhouette lineup by archetype

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 live deploy-demo.sh and mirror the change in the site changelog (~/Claude/bullet-heaven-site/index.html .changelog section + 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 columns behavior/dash_phase/dash_timer; the lunge velocity reuses the base vel column (enemies otherwise move toward the player and ignore vel). Per-type dash params (charge_s/dash_s/dash_speed) come from the bible; enemy.speed is the slow charge drift. Sim._step_dash runs the state machine; Sim.enemy_charge_frac(i) feeds render/dash_telegraph_renderer.gd which 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 reaction zones); _drop_webs adds a web on fresh ground (no new column — checks distance to existing webs), _update_webs expires them, _web_slow_mult counts webs the player stands in → player.move_mult (set BEFORE integrate each tick; webs stack the slow). Rendered as cobweb discs in ZoneRenderer (under the reaction zones). SpawnDirector.pick_type now blends types (Toby: never send one type alone).
  • Stand-still heal: holding no move input for STILL_HEAL_DELAY=0.8s regenerates STILL_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 power now → after from Sim.upgrade_preview(id) (reads StatEffects.TABLE/SimMods.TABLE field+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 ramp 1.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.gd BossState, 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 on Sim (approach→telegraph→swing/barrage/missiles→rest), found each tick by _boss_index() (scan — robust to swap-remove). Homing missiles are Sim.boss_missiles (a dict array, not the pool). Spawns at 40s then every 80s; _move_enemies SKIPS BEHAVIOR_BOSS (moved by _update_boss). HUD boss HP bar via boss_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 a edt enemy time-scale computed in tick() and passed to _move_enemies/_update_boss/_move_enemy_proj/_update_shooters (real dt still drives player/weapons/gems).
  • Decoy (sim/decoy_state.gd DecoyState, render/decoy_renderer.gd): LB/Q-triggered replica that pulls walk-enemy aggro (the WALK branch of _move_enemies targets decoy.pos when active), pulses AoE at ~70%, recharges over time + faster on damage (via an hp-delta check at end of tick). Input edge via InputState.decoy.
  • Build-craft: damage is now PER-WEAPON. Each weapon has its own damage_mult (was global player.damage_mult); the global “damage” upgrade is filtered out of the offer and replaced by WEAPON_MODS (per-owned-weapon upgrades, ids wm:<weapon>:<kind>: power / orbit shard / nova radius), routed in apply_upgrade/upgrade_preview/upgrade_choice_display. Orbit shard count is now var shards (cap MAX_SHARDS=6), rendered dynamically in main.gd.
  • Armor (PlayerState.armor, StatEffects "armor", bible “Hull Plating”): all player damage routes through Sim._hurt_player (6%/point reduction, cap 75%).
  • Determinism baseline was UNMOVED through all four (was 4152236597/1267954985 at the time; now re-pinned to 4152236597/2325839371 by 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.dash edge → Sim._update_dash gives a short committed burst + i-frame window (is_invulnerable() while player.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 upgrade thrusters (StatEffects → player.dash_cooldown_mult) + a meta thrusters upgrade. 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_proj is now this pool with a per-shot damage column (add(p,v,r,life,dmg), swap-removed in lockstep). _check_player_hit deals enemy_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 + telegraph render/bomb_renderer.gd; explosion uses the existing reaction fx), TYPE_ORBITER=13 (cold spinning shards = moving contact hazard; spin phase in the free dash_timer column; render/orbiter_renderer.gd), TYPE_LANCER=14 (telegraphed light beam-line; state in _lancer_state keyed by entity_id; render/lancer_telegraph_renderer.gd; fire uses the beam fx — keys MUST be pos/dir/length/element to match FxManager). Ranged firing is a type-driven _update_armed_enemies pass keyed by entity_id.
  • New 5-attack boss Boss2State (TYPE_BOSS2=15): picks ONE of 5 attacks at random each cycle — Cutter (sweeping beam), Artillery (boss_rockets arc up via a render-only altitude trick → 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.gd draws 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 from rng in the baseline window → moved state_checksum to 2325839371.
  • Visual archetype silhouettes (render/archetype_renderer.gd): replaced the single-mesh swarm with per-shape MultiMesh partitions (distinct silhouette per TYPE_*); shape_for/_TYPE_SHAPE sized to TYPE_BOSS2+1. _polygon_mesh fans from the CENTRE (0,0) so star-shaped/non-convex silhouettes render correctly. Replaced SwarmRenderer for 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; discrete level_up/game_over/victory/dash/hurt hooks in main.gd. ui_nav/ui_buy exist 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 responsive GridContainer (_columns_for clamp 3-5) spread across the screen with procedural per-upgrade icons + entrance/focus/buy animations + 2D grid nav (tvOS-safe). show_shop/restart_requested unchanged.
  • 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_GOLD 25→40 + heavy-kill +2 gold; meta cost_growth capped 1.6; DMGNUM_MIN 7→2 (damage numbers now show for ordinary hits).
  • Telemetry DPS fix (parallel-session commit a6194c0, accepted): dmg_dealt_total now caps each credit at the enemy’s remaining HP (the dashboard avg_dps was 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 dt not edt during freeze; BOSS2_RINGS_SPEEDS should 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 within BOSS_QUIET_LEAD (30s) of the next boss. Fewer/deadlier/quicker: SOFT_ENEMY_CAP 340→140, SpawnDirector.rate_at cut ~45%, tanky HP CUT for faster TTK (tank 85→55, brute 160→110, elite 60→45) + LETHALITY_MULT 1.6× contact at spawn (deadly via damage/behaviour, NOT HP). Arena-wide movement: new EnemyPool.flank column (signed angular offset from rng at spawn) rotates the BEHAVIOR_WALK heading by flank × 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 distinct ghost_eye fx on its body so it’s never confused with the ghost_warn endpoint silhouette; offset keyed by entity_id, gated ≥30s. Accumulator (TYPE_ACCUMULATOR=17, reuses BEHAVIOR_DASH + a grow_t column) — grows radius/speed/contact over its lifetime, forcing a priority kill; gated ≥50s, rare. 2026-07-08: the old flat ACCUMULATOR_MAX_SCALE cap is gone (for every Accumulator, not just elites) — growth is capped by RADIUS instead, at ACCUMULATOR_MAX_RADIUS (= the Ice-dimension ice-chunk hazard’s radius, “ridiculously big” tops out there); MAX_ENEMY_RADIUS was bumped from 72→80 to keep covering it so projectiles don’t tunnel. Elite Accumulators grow ~5x faster (ELITE_ACCUMULATOR_GROW_MULT) and hit the cap in seconds.

  • Tank rework: killable homing missiles (TYPE_TANK_MISSILE=18, BEHAVIOR_HOMING) — real pooled enemies (weapons + _sweep_dead kill them free), limited turn rate = dodgeable, reuse dash_timer as an age counter, no gem/gold on death, tank fire keyed by entity_id + gated run_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_dead death branch advancing _next_boss_time + gold + gems and a nuke-exemption entry):

    FunZo, Graviton, and The Eye Graviton mid-fight — the gravity-pull vortex

    • 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_STRENGTH 200→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 from last_player_pos delta) with a telegraph window that shrinks as HP drops but never below EYE_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 keys pos/dir/length/element.
  • Lessons baked in: (1) a new zone/projectile mechanic that should hurt the PLAYER can silently be copy-pasted from the enemy-damaging reaction zones and hurt ENEMIES instead — FunZo shipped this Critical (zone DoT queried the enemy hash); always verify a “damages the player” mechanic routes through _hurt_player (respecting is_invulnerable()), not _damage_enemy. (2) A new pooled boss needs FIVE things or the rotation/economy breaks: a _sweep_dead death branch (gold + _next_boss_time advance — 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_enemies must skip its BEHAVIOR_BOSS. (3) the gravity pull must be applied AFTER player.integrate so 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_accumulator dedup 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”

The swarm crescendo — enemy count building toward a boss

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 ONCE3428105085/1447851215 (the early ramp + removed quiet-lead change the 0–10s window; sim still deterministic — run twice = identical).

  • 3-min ramp: SpawnDirector.rate_at now ramps base = 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_CAP 140→200 (more on-screen).
  • Swarm crescendo at 3:00: Sim._spawn_swarm_burst() dumps SWARM_BURST_COUNT=70 of the current blend at once, ONE-time (_swarm_burst_fired guard), at SWARM_BURST_TIME=180.
  • First boss at 3:30: BOSS_FIRST_TIME 40→210. Subsequent bosses keep the BOSS_INTERVAL=80 cadence after each dies.
  • Adds during boss (not quiet): _spawn_suppressed/BOSS_QUIET_LEAD are GONE. _spawn_enemies now: if _any_boss_alive()_spawn_boss_adds(dt) (trickle small/fast types — swarmer/spider/zapper/rusher via SpawnDirector.pick_boss_add_type, at BOSS_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_rework A1 rewritten (boss-adds + swarm crescendo + _any_boss_alive instead of suppression), cap/boss-time asserts updated; test_boss_rotation suppression tests → _any_boss_alive; the eye/funzo/graviton death-rearm tests set run_time = _next_boss_time first (a boss only dies after it spawned, so run_time + BOSS_INTERVAL advances past the now-210 first-boss time). All tuning is constants at the top of spawn_director.gd / sim.gd — retune there.

Centurion — Nova Drift-style multiply-tint enemy (DONE, pre-deploy)

Section titled “Centurion — Nova Drift-style multiply-tint enemy (DONE, pre-deploy)”

Spec docs/superpowers/specs/2026-07-06-centurion-enemy-design.md, plan docs/superpowers/plans/2026-07-06-centurion-enemy.md. First enemy built on genuinely colorless base art, adapting Nova Drift’s custom-skins technique (player color applied multiplicatively onto pure-white art) — proves it out with a procedural VECTOR shape instead of a sprite, so there’s no texture to keep neutral.

  • SHAPE_ROSETTE (render/archetype_renderer.gd): a scalloped/segmented disc built by reusing the existing generalized _star_pts(points, outer, inner) function (the same one SHAPE_STAR uses), just with far more points and a much shallower valley. Reusable pattern for future enemies: any new procedural shape that should support clean per-instance multiply-tint should stay off the sprite-quad path entirely (no _SPRITE_FOR_SHAPE entry) — a pure vector polygon has no baked-in hue to fight, unlike every current sprite (Orbiter/Warden/Spider/Ghost), which are all pre-colored concept art and only support a 1-2-state color swap, not arbitrary tint.
  • Color is free, spawn eligibility is not. main.gd’s _base_color_for_type already had a generic per-element branch every ordinary type falls through — Centurion needed zero new color code. But spawn eligibility is a SEPARATE gate, discovered mid-plan: sim/area_defs.gd curates a per-area enemy_types allow-list that every spawn path (SpawnTable.pick’s allowed param, Sim._remap_to_dimension) filters against BEFORE color is ever considered — and home counts as a curated area too (it has a boss field, same as Fire/Void/Light). A new enemy type absent from an area’s roster simply never spawns there, silently, regardless of every other wiring step. Any future enemy meant to appear broadly must be added to area_defs.gd’s enemy_types explicitly — it is not automatic.
  • Two independent spawn paths, not one: SpawnTable (sim/spawn_table.gd) drives the primary per-wave spawner (Sim._spawn_wave); SpawnDirector.pick_type (sim/spawn_director.gd) only feeds the rare one-time swarm-burst event. A new type needs wiring into both to appear everywhere a similar existing type does.
  • Determinism: gated to the latest time-bucket/wave-unlock/RNG-bucket in all three spawn-gating files, verified unchanged (4217109746/2666143677) rather than assumed.
  • Visual verification (tools/glow_bake/live_check_centurion.gd, 2026-07-06): a dev-only harness boots the real game, forces each of Home/Fire/Void/Light in turn, dev-spawns a Centurion, and screenshots it (tools/glow_bake/out/centurion_{home,fire,void,light}.png). Visually confirmed against concept-art/gemini/14_enemy-centurion-concept.png: the _star_pts(12, radius, radius * 0.80) polygon reads clearly as a 12-point scalloped/segmented disc (a simplified, procedural-vector take on the concept art’s much more detailed layered-plate sphere — expected, since this shape carries no texture), and the 4 captures land as visibly distinct hues (home = blue/indigo, fire = orange/red, void = purple/magenta, light = gold/yellow), none close to a plain white/grey baseline and none matching each other. No retuning of the point count/valley depth was needed — first pass read well.