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) — every web is a CAPSULE since 2026-09-30 (Toby): {pos, dir, half_len, radius, remaining}, the segment pos ± dir*half_len inflated by radius; half_len == 0 is a plain circle web, byte-identical to the pre-capsule model (that’s what protects the determinism baselines). ElementalSystem.drop_webs acts on fresh ground (dedup: no web within WEB_SPACING, point-to-segment, via a per-call coarse grid pinned equal to a full scan by tests/test_web_drop_grid.gd). Capsule trails (Web v2, 2026-10-01, spec docs/superpowers/specs/2026-10-01-web-v2-design.md): passive trail webs carry "trail": true; on fresh ground a spider first STRETCHES a trail web (_trail_to_extend / _extend_trail) whose END is within TRAIL_JOIN_DIST (1.5×WEB_SPACING), whose stretched half-length stays ≤ TRAIL_MAX_HALF_LEN (85 → ≤170 px), and — for a capsule — where end→spider bends ≤ 30° (TRAIL_MAX_TURN_COS; a circle accepts any direction). Nearest end wins, ties by lowest sequence number, no RNG. The far end stays fixed, remaining resets to WEB_DURATION, the web is re-filed in the grid under its existing sequence number (so the evicted prefix still maps to sim.webs order). Otherwise a new circle trail web is laid. Measured (tests/test_web_trails.gd): a 680 px straight walk lays 4 webs instead of 20; a scripted 60 s 24-spider chase at the Weaver’s real 230 px/s walk speed averages 24.3 live webs instead of 94.6 (peak 108 vs 400), and at its 600 px/s dash speed 21.7 vs 91.4 (peak 97 vs 400). Under WEB_CAP pressure pop_front evicts the lowest INDEX (insertion order), which for a stretched trail capsule is not its age. Elite Spider webs have no trail flag and are never stretched. update_webs expires webs; web_slow_mult (point-to-capsule-segment) → pilot.move_mult (set BEFORE integrate each tick): non-trail webs (Elite pad/corridors) stack at weight 1 each; passive trail webs do not stack with each other — standing in one or more adds a single TRAIL_SLOW_WEIGHT = 1.7 → pow(WEB_SLOW_PER, total) floored at WEB_SLOW_MIN. 1.7 keeps today’s feel by AREA average (spec: Chris’s “keep today’s feel”): the old circle chain averaged ~0.42 over its webbed area (0.6 fringe / 0.36 pair overlaps / 0.22 triple overlaps), the capsule trail is a uniform 0.6^1.7 = 0.42 (pinned within ±0.03 on straight, gentle- and tight-curve walks by test_area_averaged_trail_slow_matches_old_circle_chain); a trail alone can no longer floor you at 0.15 at capsule junctions. A pilot on a trail AND an Elite corridor gets 0.6^2.7. ElementalSystem.add_web_capsule is the shared append (WEB_CAP backstop + WEB_DURATION lifetime). The Elite Spider’s landing drops exactly 4 webs: a big circular pad (ELITE_SPIDER_WEB_RADIUS) + the dash line back along the lunge + two rays at ELITE_SPIDER_RAY_ANGLE=120° off the backward dash dir (EnemyBehaviors._elite_spider_landing) — one capsule corridor each instead of the old ~33-blob chain (phantom emitters / staggered pending spawns removed). Rendered in ZoneRenderer’s batched MultiMesh web layer (web_instance sizes+rotates the quad to the grown capsule; growth over WEB_GROW_S — except trail capsules, which render at full length via _web_grown because every stretch resets their remaining; INSTANCE_CUSTOM.a = 1 flags a stretched trail capsule so the shader drops the centre cross strand, whose position would jump ~17 px per stretch), procedural pattern in render/web_field.gdshader (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.
  • ⚠️ HISTORICAL (2026-08-12): the tuning constants named below (REACTION_DAMAGE_SCALE, REACTION_COOLDOWN) no longer exist — reactions mode was archived 2026-07-09 (see _archive/reactions-mode/), and apply_element() now only sets the aura plus a single status stack (burn DoT / shock vulnerability, since 2026-09-23) — no reactions. ZONE_DPS did survive, but now powers the unrelated Wildfire weapon’s terrain-zone DoT, not a reaction burst. The still-relevant pacing lesson (kept for context, not as live tuning knobs): spawn ramp 1.2+0.30t plus dashers/spiders now provide the swarm’s real pressure (auto-player drops below 100 HP), which is why reaction lethality no longer needs separate taming. Baselines re-pinned through this era (then-current: 1998629725/261923457) — see tests/test_determinism_survival.gd for the actual pinned value now.

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=7; pre-evolve cap MAX_SHARDS - EVOLVE_SHARDS = 4, evolve adds +3), rendered dynamically in main.gd.
  • Armor (PlayerState.armor, StatEffects "armor", bible “Armor 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.
  • Warden attack selection (rebuild 2026-10-01, Task 1): the tutorial/story Warden (sim.story != null) keeps the fixed (idx+1) % TUTORIAL_ATTACK_COUNT cycle. The Survival Warden picks its next attack at the end of each REST from a range-aware deck, sim/warden_deck.gd (WardenDeck.pick), by distance to _nearest_pilot: bands close < BossWarden.DECK_CLOSE (300) / mid / far > DECK_FAR (600), weights in WardenDeck.WEIGHTS_CLOSE/MID/FAR (attack_id → weight; bundled-only, the tunables registry can’t carry Dictionaries). Never repeats BossState.last_attack; ATTACK_SLAM only when enraged; ATTACK_SUMMON only when since_summon >= DECK_SUMMON_GAP (3) and fewer than SUMMON_MAX_ADDS summoned elites live. One sim.rng.randf() draw per pick, weights walked in ascending attack-id order. ATTACK_FAN/ATTACK_LUNGE/ATTACK_SLAM (ids 6-8) are Survival-only (story never selects them).
  • Survival Warden attacks (rebuild Task 2): all tunables are BossWarden statics with registry rows. Aimed fan: FAN_VOLLEYS (enraged FAN_VOLLEYS_ENRAGED) volleys of FAN_SHOTS bolts across ±FAN_HALF_ANGLE, FAN_VOLLEY_GAP apart, each volley re-aimed by BossWarden.predict_aim(sim, from, speed) (pilot pos + velocity * min(dist/speed, FAN_LEAD_MAX)). Barrage: Survival adds a second ring offset by PI / count, BARRAGE_RING2_DELAY after the first (story stays one ring). Lunge: aim re-predicted every TELEGRAPH tick, latched at fire; ACTIVE charges LUNGE_LENGTH at LUNGE_SPEED (clamped to ±ARENA_HALF), swept-segment hit once (boss.fired) for LUNGE_DMG, then LUNGE_BURST radial shots. Slam (enraged-only pick): the swing hit, then a second swing SLAM_SECOND_DELAY later with its own guard (boss.second_fired) and its own BOSS_ACTIVE_TIME hit window. ACTIVE is held open past BOSS_ACTIVE_TIME while BossWarden.active_pending() (volleys left, ring 2 pending, lunge charging, slam’s second window open). Fx: boss_swing_start at each Survival swing/slam swing start, boss_swing_hit per landed swing. render_info adds lane (fan cone during its telegraph; lunge path in telegraph and while charging, progress = travel), double_swing_t (-1 unless a slam’s second swing is counting down) and telegraph_kind (-1 outside TELEGRAPH).
  • Survival Warden movement + phase 2 (rebuild Task 3): walk speed is the bible warden speed (120; spawn() writes it into enemies.speed, which movement reads, so a bible overlay tunes it; SV_SPEED is only the fallback/test reference). APPROACH walks (first tick: SV_DASH_SPEED dash when the pilot is past SV_DASH_MIN_DIST AND beyond the target range) along the pilot line to BossWarden.PREFERRED_RANGE[home_band(attack_idx)] (close 200 for SWING/SLAM, mid 450 for FAN/SPIRAL/BARRAGE/MISSILES/SWARM, far 700 for LUNGE/SUMMON) — backing away for a ranged pick. TELEGRAPH of a ranged attack (is_ranged) orbits the pilot at the current distance at speed * STRAFE_MULT * boss.strafe_sign (strafe_sign flips every deck pick); SWING/SLAM/LUNGE wind-ups stay planted. All movement clamps to ±ARENA_HALF (clamp_arena). Enraged: the barrage wind-up is BOSS_FIRE_TELEGRAPH_TIME * BOSS_ENRAGE_TIME_MULT, and every Survival telegraph (calm or enraged, all 9 attacks) is floored at TELE_MIN (0.45 s, the spec fairness guard; registry min 0.45 so a patch can’t lower it) — BossWarden.telegraph_time(sim) is the single source for both the sim and render_info’s fire_charge/telegraph; MISSILES also fires a SPIRAL cast. Survival missiles home on the pilot and are never soaked by drones; story missiles still chase/soak on drones. render_info["strafing"]. Story Warden movement/telegraphs unchanged.
  • Warden telemetry + FX (rebuild Tasks 4-8): every _hurt_player from the Warden carries a source tag (swing, slam, lunge, missile, shot, swarm, or contact for body contact), recorded per hit in the run timeline so telemetry shows which attack lands (sim.gd enemy-projectile hit site; boss_warden.gd for the melee/lunge/missile sites). Look: the Warden is an ArchetypeRenderer Nova bucket (eye + hub core) plus render/warden_body_renderer.gd’s two counter-rotating ring layers. FX live in fx/boss/: warden_fx.gd (WardenFx, fed by main.gd every frame) owns one WeaponFxLayer look each for bolt / drone / missile (persistent, sync_projectiles), swing / lane / charge (persistent telegraphs from render_info, update_boss) and burst / swing crescent (events from sim.render_fx_events, on_events). Every telegraph kind has a visible warning: attacks with no look of their own (MISSILES, SPIRAL, SWARM, SUMMON) get the charge layer’s generic pulse (mode 2). The whole new look is behind the VisualLook.warden_v2 A/B toggle (Debug Settings “Warden Look (A/B)”, default on); off = the old sprite and primitive FX. Roster entries warden_* in tools/vfx_library/library_manifest.gd.
  • 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). Boss spawn/enrage cues are per-boss slots (AudioManager.BOSS_CUE_FALLBACK); every boss enrage reaction (ENRAGED, FunZo’s ENRAGE) carries "boss": "<warden|boss2|funzo|graviton|eye>" so audio can pick enrage_<boss> (a dictionary field only, no RNG/state).
  • 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 (superseded by cycle 22b below; _spawn_suppressed() / BOSS_QUIET_LEAD no longer exist). 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 (FunZo/Graviton/Eye). Current state (2026-09-29): the original _maybe_spawn_survival_boss() 5-way rotation (_boss_spawn_count % 5) was removed 2026-07-09, and its leftover _boss_spawn_count / _next_boss_time bookkeeping was deleted 2026-09-29. Survival bosses now spawn from the phase-driven wave loop (WAVE_PHASE_S → wind-down → BOSS_PREP_S → Sim._spawn_phase_boss()); one boss at a time. Each boss still has a _sweep_dead death branch (gold + dimension crystals + “BOSS DOWN”) 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 economy/render breaks: a _sweep_dead death branch (gold + crystals + “BOSS DOWN”; the old _next_boss_time advance is gone, boss timing is phase-driven now), 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”

Current state (2026-09-29): the swarm crescendo and the fixed boss clock described below are gone. _spawn_swarm_burst() lost its caller and was deleted with SWARM_BURST_TIME/SWARM_BURST_COUNT/_swarm_burst_fired; BOSS_FIRST_TIME/BOSS_INTERVAL/_next_boss_time were deleted too. Boss timing is the phase-driven wave macro-loop (WAVE_PHASE_S → wind-down until the arena is clear → BOSS_PREP_S countdown → _spawn_phase_boss() → rest → waves), matching docs/architecture/remote-config.md. The boss-adds trickle (_spawn_boss_adds, BOSS_ADD_RATE) and _spawn_one are still live; “any boss alive” is boss_rotation.any_boss_alive(sim).

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 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_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: (deleted, see note above) Sim._spawn_swarm_burst() dumped 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: (deleted, see note above) BOSS_FIRST_TIME 40→210, then a BOSS_INTERVAL=80 cadence.
  • Adds during boss (not quiet): _spawn_suppressed/BOSS_QUIET_LEAD are GONE. _spawn_enemies now: if boss_rotation.any_boss_alive(sim) → _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). boss_rotation.any_boss_alive(sim) 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 the elemental dimensions). 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.

Elemental Dimensions — current roster (source of truth: sim/area_defs.gd, 2026-07-16)

Section titled “Elemental Dimensions — current roster (source of truth: sim/area_defs.gd, 2026-07-16)”

The v2 pass (2026-07-05) initially built only Fire/Void/Light; all 7 non-Home elemental dimensions are now defined and eligible. After any boss dies, Sim._roll_dimension_choice() offers 3 of the 7 via the dimension-choice popup (deterministic Fisher-Yates over upgrade_rng, same pattern as UpgradeSystem.roll_upgrade_choices). main freezes the sim on the kill tick but shows the popup only after DimensionChoicePanel.OPEN_DELAY_S (1.0 s wall time, Remote Console tunable) so the boss’s death FX play out first; after a level-up chain it opens at once. Once a run is in a Dimension, the HUD’s DimensionChip (ui/dimension_chip.gd) shows a bottom-centre arrival card for ~5 s, then a one-line chip under the boss-bar row. Live roster + bosses (each reuses an existing archetype — no bespoke dimension bosses have been written yet):

Dimension (id) Element Boss Native / stand-in
fire fire Warden stand-in
ice cold Warden stand-in (Nautilus* not built)
void_dim void Graviton native
blood blood Warden stand-in (The Queen* not built)
electricity lightning Warden stand-in (The Last Computer* not built)
light light Eye native (reused as “Sentinel”)
poison poison FunZo native
home — (no element) Warden→FunZo→Graviton→Eye rotation — (not a dimension: is_dimension(HOME) is false)

Each dimension additionally restricts the enemy roster to on-theme enemy_types, reflavors some types to its element via enemy_elements, applies an enemy_buffs stat set, a weapons allow-list, and (most) a persistent hazard. Warden standing in everywhere * is unbuilt is a deliberate v2 shortcut — the dimensions ship live now; their bespoke bosses/mechanics land later (Ice/Blood/Electricity/Poison were previously mislabeled “deferred” — they are NOT; only their bespoke bosses are). Confirmed live in soak (electricity/poison/ice all visited).

Boss2 — hidden from play (2026-07-16, Chris’s call)

Section titled “Boss2 — hidden from play (2026-07-16, Chris’s call)”

Boss2State/TYPE_BOSS2=15 (the “New 5-attack boss” from M2 cycle 20 above) was never part of the Home or Dimension boss rotations — its only live spawn path was the Aurora/Ember Reach mid-wave “elite” trigger (_spawn_due_elites(), ELITE_BOSS2_TIME=210s), and those two areas aren’t offered by the real dimension-choice popup (AreaDefs.DIMENSION_IDS excludes them) — only reachable via dev tooling. It carried two disputed player-facing names (“OVERSEER” on the HUD, “Sentinel” in the codex — the latter also colliding with the shop’s Sentinel drone class). Rather than pick one, it’s now disabled outright: Sim.BOSS2_ENABLED := false gates the one trigger, the HUD label is a neutral “BOSS2” placeholder, and its codex key is blanked (same pattern as the dev-only TYPE_CUSTOM slot). All code (boss2.gd, boss2_director, boss2_renderer.gd, BossState, the Remote Console "boss2" dev-spawn) stays intact and fully functional for manual/dev use — only the automatic trigger is off. To reactivate: flip BOSS2_ENABLED, pick one final name, restore the HUD label + TYPE_KEYS[15] codex entry.

Mite — tutorial-only swarm enemy (2026-09-27)

Section titled “Mite — tutorial-only swarm enemy (2026-09-27)”

TYPE_MITE=31 (bible id mite, display “Mite”, nano element): 3 HP (one Blaster bolt or one Nova pulse at 5.2), speed 95, radius 7, contact 3, biomass 1, plain BEHAVIOR_WALK chaser, and in EnemyChipFire._OWN_ATTACK so it never shoots. It exists to make the tutorial’s Nova lesson show Nova’s edge: the kill step (sim/tutorial_lessons.gd, lesson nova) keeps up to 30 alive, dropped 10 at a time every 1.0 s as a clustered burst (the lesson spawn dict’s burst/gap keys, placed on a golden-angle disc of BURST_SPREAD around one deterministic spawn point, no RNG). Measured in tests/test_tutorial_nova_swarm.gd: Nova ~8 kills/s vs ~2.2/s for the Blaster alone, which lets the swarm pile up to the cap. Not in any survival spawn path (SpawnTable, SpawnDirector, area_defs), so both determinism baselines are untouched; adding it to survival later is a normal spawn-table change that must stay gated past the 10 s baseline window. Nova-look art: mite() in tools/nova_units/nova_unit_designs.py (a tiny quad-rotor drone), exported to render/enemy_sprites/nova/mite_{body,glow}.png; classic-look fallback is SHAPE_DIAMOND.

Tackler / “Interceptor” — EVE-style tackle elite (overnight T2, 2026-10-07)

Section titled “Tackler / “Interceptor” — EVE-style tackle elite (overnight T2, 2026-10-07)”

TYPE_TACKLER=32 (bible id tackler, display “Interceptor”: the id differs because the drone class interceptor exists), BEHAVIOR_TACKLE=10, all logic in sim/tackler.gd (Sim.tackler). State machine in the reused dash_phase column (APPROACH 0 / TACKLE 1 / DISENGAGE 2), dash_timer = lock clock, coil_angle = orbit angle; no new pool columns, no rng anywhere (spawn placement is a golden-angle walk, so the flag on never reshuffles sim.rng). APPROACH at APPROACH_SPEED_MULT × bible speed → TACKLE orbits the nearest pilot at ORBIT_RADIUS; after LOCK_S it holds the web (PlayerState.tackle_timer, TACKLE_SLOW_MULT 0.45 in integrate) and scram (dash_cd + warp_scramble_timer held at HOLD_S, the EliteKit warp-strike mechanism) while within TACKLE_RANGE → below DISENGAGE_HP_FRAC it flees, repairs REGEN_FRAC_PER_S of max HP only beyond REGEN_SAFE_DIST (holding just past it), and re-engages at REENGAGE_HP_FRAC. Excluded from EliteKit and chip fire (the tackle is its kit).

Spawning: Tackler.update (top of the enemy phase in Sim.tick), survival only, only while sim.tackler_spawns — main mirrors DevFlags.tackler_spawns (default off; Debug Settings row + console dev_flag tackler_spawns) into it at the top of _physics_process. Honors dev_suppress_spawns. One at SPAWN_START_S (75 s), then per SPAWN_INTERVAL_S while below MAX_ALIVE: solo (one alive) until PACK_START_S (300 s), then packs of PACK_SIZE (2; PACK_SIZE_LATE 3 from PACK_LATE_S 480 s) up to MAX_ALIVE (3) alive, bunched on the ring (spawn_pack, still rng-free). Elite tier (is_elite=1: named HP bar), HP × run difficulty, pays bible biomass (30) as XP and gold (3, the heavy tier). Every number is a tunable (group Elites).

Boost + packs (overnight T3). Speed boost (“MWD”): BOOST_S at BOOST_MULT × the phase speed, BOOST_CD_S cooldown, fired to close in (APPROACH beyond BOOST_DIST), to settle into the orbit (the striker beyond BOOST_SLOT_DIST from its slot, i.e. a swap-in) and to escape (entering DISENGAGE); boost seconds/cooldown live in the Orbiter-only orbit_accel/orbit_friction columns. Tackler.plan() runs in update() before any tackler moves: 2+ healthy tacklers form one pack with a shared, sticky target_pilot (first the pilot nearest the striker), slots spread evenly (pack_angle + k·TAU/n, order fixed by angle when membership changes, turning at PACK_ANG_SPEED) and ONE sticky striker_eid (the healthiest) that orbits at ORBIT_RADIUS and locks; holders orbit at HOLD_RADIUS (300, outside TACKLE_RANGE) and never lock (their lock clock stays 0). A striker under DISENGAGE_HP_FRAC drops out of the plan the next tick, the healthiest holder is named and boosts in, re-running the lock-on telegraph. One healthy tackler = the T2 solo behaviour. Camera: main._drive_camera_zoom sets camera_zoom.tackler_zoom_out = CameraZoom.tackler_extra( Tackler.engaged_count(sim)) (0.4 solo / 0.6 pack, tunables), combined with extra_zoom_out as the MAX; only live under DevFlags.dynamic_zoom. Not yet in state_checksum() (plan state, boost columns): needed before networked lockstep with the flag on.

Render: Nova unit tackler (tools/nova_units tackler(), swept-wing frigate) + render/tackler_renderer.gd (lock reticle, double-strand web tether, web wrap + broken scram hexagon on the ship, retreat streaks and a green repair ring, afterburner trail from Tackler.boosts, dashed pack hold ring + slot ticks from TacklerRenderer.formation) fed by Sim.tackler.links (rebuilt every tick) and TacklerRenderer.retreat_marks. VFX library card tackler_tackle. Under VisualLook.tackle_v2 (default ON, Debug Settings “Interceptor Tackle Look (A/B)”) the lock reticle, tether and wrap move to the main-hosted WebLinkLook (fx/web_link_look.gd, hostile magenta-red: reticle + dashed line over the real lock, a barbed cable whose strain is distance / TACKLE_RANGE, a net on the ship exactly while PlayerState.tackle_timer > 0); TacklerRenderer keeps retreat, repair, boost and pack. Tests: tests/test_enemy_tackler.gd, tests/test_tackler_packs.gd (all through Sim.tick_single / real main._physics_process).

Player counters: stasis web (overnight T4). sim/stasis_web.gd (StasisWeb, static). One effect: EnemyPool.stasis_timer (new column, 0 by default, decays in ElementalSystem.apply_status_and_decay, not in the checksum). While > 0, DroneDirector.enemy_speed_scale moves the enemy at SLOW_MULT (0.2; the stronger of it and the Disruptor’s 0.5 wins, no stacking); a webbed Tackler cannot burst (Tackler._try_boost returns, StasisWeb.apply zeroes a burst in progress) and, while BREAKS_TACKLE (bool tunable, on), its lock clock is held at 0 (Tackler._held), so its web + scram on the pilot release after HOLD_S. Targeting (StasisWeb.pick_target, nearest-first, no rng): any Tackler, else any elite, else (drone only) any non-boss enemy. Two sources:

  • Web drone (DroneDirector.DRONE_WEB = "web", drone_web): hovers DRONE_STANDOFF off the target nearest its PILOT and holds the web while within DRONE_RANGE × cfg radius (DroneState.target_eid/webbing are render reads). Fielded as a bonus drone (beyond the slot count) on every launch while sim.web_drone_grant, which main mirrors from DevFlags.web_drone (default off; Debug Settings “Dev: Web drone joins every launch” + console dev_flag web_drone). Not in MetaState.KNOWN_CLASSES and no shop defs yet: the unlock price is Chris’s call.
  • Stasis Web card (StasisWeb.CARD_ID = "stasis_web", code-only, no bible entry): added to the level-up mod pool by UpgradeSystem.roll_upgrade_choices only while StasisWeb.card_offerable (sim.tackler_spawns, no story/tutorial, below CARD_MAX_LEVEL 3), so the flag-off pool and every upgrade_rng draw are unchanged. apply_upgrade bumps PlayerState.stasis_web_level; StasisWeb.update_pilot (in the weapon loop of Sim.tick, per living pilot) casts on the nearest Tackler/elite within CARD_RANGE for card_web_seconds(lvl) and then waits card_cooldown(lvl) (the cooldown starts on a cast, so it fires as soon as a target comes in), emitting fx stasis_web.

Render: render/stasis_web_renderer.gd (main.stasis_web_renderer): a net wrapped round every webbed enemy (StasisWebRenderer.wraps), the Web drone’s tether (tethers, dashed while closing in), and the card’s net shot + snap ring from stasis_web events (main forwards the drained render_fx_events via on_events). The drone hull is DecoyRenderer’s, class colour white-cyan, wearing the Disruptor sprite (DroneSprites.ALIAS). VFX library card stasis_web. Under VisualLook.stasis_web_v2 (default ON) StasisWebRenderer steps aside and WebLinkLook draws the friendly glass net (countdown arc of the real stasis_timer), the silk drone tether and the card’s shot + snap (main forwards render_fx_events to web_link_look.note_events). 12 tunables (StasisWeb.*, group Elites). Tests: tests/test_stasis_web.gd.

Suppressor / “Blight” — anti-heal aura elite (overnight T5, 2026-10-07)

Section titled “Suppressor / “Blight” — anti-heal aura elite (overnight T5, 2026-10-07)”

TYPE_SUPPRESSOR=33 (bible id suppressor, display “Blight”, decay, #c8d84a), BEHAVIOR_SUPPRESS=11, all logic in sim/suppressor.gd (Sim.suppressor). A counter to sustain builds: it closes on the nearest pilot at APPROACH_SPEED_MULT × bible speed (75 → 150 px/s, slower than the hull), then hovers at STANDOFF (140) circling at HOVER_ANG_SPEED (coil_angle column reused). Never shoots (excluded from chip fire and EliteKit), elite tier (is_elite=1), HP × run difficulty, biomass 20, gold 3. No rng anywhere.

The aura. Suppressor.refresh runs in Sim.tick right after _sweep_dead(): per living pilot, n = living Blights within AURA_RADIUS (240) → PlayerState.heal_suppress_n = n, heal_mult = (1 − SUPPRESS_FRAC)^n (0.75 → ×0.25 for one, ×0.0625 for two). The next tick’s heals read it, so a Blight killed this tick frees healing at once and the renderer shows the same state the heals use. The scan is skipped entirely (_live false) until a Blight has spawned or stepped, and heal_mult is exactly 1.0 otherwise, so x * heal_mult is bit-identical and both seed-1234 baselines are untouched.

Heal paths (audit 2026-10-07; every hp += / minf(hp + …) in /sim):

Heal Where Suppressed?
Passive regen (PlayerState.regen) Sim._apply_regen yes
Field Medic stand-still ramp Sim.stand_heal_rate (also the green heal aura) yes
Leech (mods.lifesteal via lifesteal_dealt) Sim._sweep_dead, after the per-tick cap yes
Scar Tissue lifesteal mod WeaponScarTissue discharge yes
Logistics drone repair pulse + overcharge vent DroneDirector.drone_logistics yes
Chronowell heal mod (bubble expiry) WeaponChronowell yes
Heal powerup pickup Sim._apply_powerup no (a world reward)
Story reward heal story reward kind heal no
Hull (max_hp) card also adds hp StatEffects no (capacity)
Tutorial MIN_HULL floor tutorial safety clamp no

Enemy-side heals (Leech enemy lifesteal, Tackler repair, Logistics drone-to-drone repair) are not pilot heals and are untouched.

Spawning: Suppressor.update (next to Tackler.update), survival only, only while sim.suppressor_spawns — main mirrors DevFlags.suppressor_spawns (default off; Debug Settings “Dev: Blight (anti-heal) spawns” + console dev_flag suppressor_spawns) at the top of _physics_process. Honors dev_suppress_spawns. First at SPAWN_START_S (90 s), then a check every SPAWN_INTERVAL_S (60 s) while below MAX_ALIVE (2); golden-angle ring placement. Dev spawn suppressor. 8 tunables (Suppressor.*, group Elites). Not in state_checksum() (heal_suppress_n / heal_mult are derived from enemy positions; needed before lockstep with the flag on).

Render: Nova unit suppressor (tools/nova_units suppressor(), broken-ring rot censer) + render/suppressor_renderer.gd (main.suppressor_renderer): the aura ring at the real AURA_RADIUS (dashed, inward thorns, faint fill; dim when empty, lit and pulsing while it holds a pilot), a siphon thread to each suppressed ship, and on the ship a sickly ring plus a heal cross struck through by a rot slash (fades with heal_mult, one pip per extra stacking ring). The stand-still green aura shrinks on its own because stand_heal_rate includes heal_mult. VFX library card suppressor_aura. Under VisualLook.blight_v2 (default ON) SuppressorRenderer steps aside and WebLinkLook draws the same data (SuppressorRenderer.auras / pilot_marks) as a hostile zone on a ground layer under the swarm, a siphon, and a struck heal cross keeping the real heal_mult with one collar arc per stacked Blight. Tests: tests/test_enemy_suppressor.gd (real Sim.tick_single heal paths, real main._physics_process).

Hard tuning — boss ramp + overall difficulty A/B (overnight T6, 2026-10-07)

Section titled “Hard tuning — boss ramp + overall difficulty A/B (overnight T6, 2026-10-07)”

Chris: “bosses need ramping up more; overall slightly too easy”. One Survival-only switch, sim.hard_tuning, mirrored every frame by main._physics_process from DevFlags hard_tuning (Debug Settings “Dev: hard tuning (bosses + difficulty)”, console dev_flag hard_tuning). Default OFF; every helper in sim/hard_tuning.gd (HardTuning) returns the identity while it is off (or in Story / the tutorial), so store/web/baselines are unchanged. The pause menu’s build line reads BUILD N · HARD TUNING while it is on, so a feel-test run is never mistaken for default tuning.

Lever Tunable (Enemy scaling group) Default Where it bites
Boss HP HardTuning.BOSS_HP_MULT 1.5 Sim._spawn_phase_boss hp_mult (Home rotation + Dimension bosses; not the mid-wave Warden/Boss2 elites)
Boss damage HardTuning.BOSS_DMG_MULT 1.3 Sim._hurt_player, boss sources only (HardTuning.is_boss_source: BOSS_DAMAGE_SOURCES + each boss type’s contact name and "<type> shot")
Boss rhythm HardTuning.BOSS_CADENCE 0.7 HardTuning.wait(): Warden approach + rest, FunZo drift/cooldown/zone/jester intervals, Graviton pull cadence/rest/blob interval, Eye cadence timers. Never a telegraph (fairness rule; pinned by test_telegraphs_never_shrink)
Earlier enrage HardTuning.BOSS_ENRAGE_FRAC 0.6 all four bosses’ enrage latch (default 0.4): Warden slam + extra arms, Graviton collapse, FunZo/Eye faster cadences arrive sooner
Boss-fight adds HardTuning.BOSS_ADD_RATE_MULT 1.5 Sim._spawn_boss_adds
Enemy HP HardTuning.ENEMY_HP_MULT 1.15 _spawn_one + _spawn_wyrm diff
Enemy contact HardTuning.ENEMY_CONTACT_MULT 1.1 _spawn_one + _spawn_wyrm contact (kept small: one-shot rule; bosses are not double-counted)
Wave size HardTuning.WAVE_BIOMASS_MULT 1.2 _spawn_wave biomass target

Not covered: Tackler/Suppressor spawns (own spawners), Halo Chain packs, and the HP of the mid-wave elites from Sim._spawn_due_elites (non-Home areas only, i.e. dev-reachable Aurora/Ember Reach): the Warden elite (gets cadence/enrage/damage, not HP) and Boss2 — dormant behind BOSS2_ENABLED = false, but if re-enabled it would get only the ×1.3 damage (is_boss_source covers TYPE_BOSS2), no HP, cadence or enrage change. Measure with tools/difficulty_probe/hard_tuning_probe.gd (off vs on, same seeds, mortal + sturdy SkilledAutoPlayer; --config= applies a remote patch) or scripts/soak.sh --hard=1.

First measurement (2026-10-07, bundled defaults, cobalt, seeds 2000-2005, 12 min, off -> on): fresh-save mortal bot survival 109 s -> 72 s (2 of 6 seeds died at ~60 s instead of ~117 s, so the global bump bites from wave 1); sturdy (4000 HP) bot: damage taken per run 2860 -> 4149 (+45%), damage taken during boss phases per boss 1179 -> 2235 (+90%), kills +27%, boss time-to-kill 260 s -> 282 s (+8%; bosses killed 6/8 -> 6/7). The bot is a weak boss killer (TTK of minutes), so TTK is a poor proxy for a human; damage-per-boss is the robust signal. Raw JSON: reports/hard-tuning-probe-*.json. Tests: tests/test_hard_tuning.gd (real Sim.tick, spawn and hit paths; real main._physics_process).