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 (capped ACCUMULATOR_MAX_SCALE so it stays dodgeable + max radius < MAX_ENEMY_RADIUS so 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_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.