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

Dash enemies, spider/webs, QoL + UX (M2 cycle 14, DONE)
Section titled “Dash enemies, spider/webs, QoL + UX (M2 cycle 14, DONE)”- Build number convention:
Sim_Const.BUILD(currently 14), shown bottom-left in-game (hud.gd) and in the F2 overlay. Bump it on every livedeploy-demo.shand mirror the change in the site changelog (~/Claude/bullet-heaven-site/index.html.changelogsection + the play-bar “Build N” text). The changelog lists what’s new AND how it interacts with existing systems — maintain it each deploy (Chris’s standing request). - Dash movement primitive (
EnemyPool.BEHAVIOR_DASH): charge (telegraph) → fast locked lunge → recharge. New columnsbehavior/dash_phase/dash_timer; the lunge velocity reuses the basevelcolumn (enemies otherwise move toward the player and ignorevel). Per-type dash params (charge_s/dash_s/dash_speed) come from the bible;enemy.speedis the slow charge drift.Sim._step_dashruns the state machine;Sim.enemy_charge_frac(i)feedsrender/dash_telegraph_renderer.gdwhich draws the brightening lunge line + endpoint marker (Toby’s fairness rule: telegraph everything). Elite is now the Dasher. - Spider (
EnemyPool.TYPE_SPIDER) + webs: a fast small dasher that lays a slowing web trail.Sim.webs(separate from reactionzones) — every web is a CAPSULE since 2026-09-30 (Toby):{pos, dir, half_len, radius, remaining}, the segmentpos ± dir*half_leninflated byradius;half_len == 0is a plain circle web, byte-identical to the pre-capsule model (that’s what protects the determinism baselines).ElementalSystem.drop_websacts on fresh ground (dedup: no web withinWEB_SPACING, point-to-segment, via a per-call coarse grid pinned equal to a full scan bytests/test_web_drop_grid.gd). Capsule trails (Web v2, 2026-10-01, specdocs/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 withinTRAIL_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,remainingresets toWEB_DURATION, the web is re-filed in the grid under its existing sequence number (so theevictedprefix still maps tosim.websorder). 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). UnderWEB_CAPpressurepop_frontevicts the lowest INDEX (insertion order), which for a stretched trail capsule is not its age. Elite Spider webs have notrailflag and are never stretched.update_websexpires webs;web_slow_mult(point-to-capsule-segment) →pilot.move_mult(set BEFOREintegrateeach 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 singleTRAIL_SLOW_WEIGHT= 1.7 →pow(WEB_SLOW_PER, total)floored atWEB_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 bytest_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_capsuleis 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 atELITE_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 inZoneRenderer’s batched MultiMesh web layer (web_instancesizes+rotates the quad to the grown capsule; growth overWEB_GROW_S— except trail capsules, which render at full length via_web_grownbecause every stretch resets theirremaining;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 inrender/web_field.gdshader(under the reaction zones).SpawnDirector.pick_typenow blends types (Toby: never send one type alone). - Stand-still heal: holding no move input for
STILL_HEAL_DELAY=0.8sregeneratesSTILL_HEAL_DPS=7/s (Sim._stand_still_heal,player.still_timer). Determinism-neutral for the baseline (the moving-input run never heals). - Perf: F2 overlay now shows fps / sim-ms / render-ms / draw-calls / primitives / zone+web counts (CPU-vs-GPU diagnosis). F4 toggles low-fx (
main._toggle_low_fx): disables the WorldEnvironment bloom + the additive halo layer (SwarmRenderer.set_halo_visible) — the main overdraw sources; the mitigation for weaker Macs (Toby reported lag). - Level-up overhaul (
ui/level_up_panel.gd): dimmed field + opaque colour-coded cards (cyan=weapon, gold=transformative, steel=stat) with hover glow, and each shows the RESULTING powernow → afterfromSim.upgrade_preview(id)(readsStatEffects.TABLE/SimMods.TABLEfield+op to project the value). Replaced the translucent default buttons that were lost against the busy field. - ⚠️ 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/), andapply_element()now only sets the aura plus a single status stack (burn DoT / shock vulnerability, since 2026-09-23) — no reactions.ZONE_DPSdid 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 ramp1.2+0.30tplus 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) — seetests/test_determinism_survival.gdfor 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.gdBossState,render/boss_renderer.gd): a pooled enemy (EnemyPool.TYPE_BOSS/BEHAVIOR_BOSS) so all player weapons + projectile collisions damage it for FREE (no per-weapon code). Its behaviour is a separate attack-pattern state machine onSim(approach→telegraph→swing/barrage/missiles→rest), found each tick by_boss_index()(scan — robust to swap-remove). Homing missiles areSim.boss_missiles(a dict array, not the pool). Spawns at 40s then every 80s;_move_enemiesSKIPSBEHAVIOR_BOSS(moved by_update_boss). HUD boss HP bar viaboss_render_info(). - Skirmisher mini-boss (
TYPE_SKIRMISHER/BEHAVIOR_SKIRMISH,bible.json): mid-range strafing AI that fires aimed shots; reuses the dash columns (dash_timer=fire clock,dash_phase=strafe dir) — no new columns. Drops a random powerup on death. - Powerups (
Sim.powerups,render/powerup_renderer.gd): slow / freeze / nuke / heal, collected on contact, immediate. Global slow/freeze is aedtenemy time-scale computed intick()and passed to_move_enemies/_update_boss/_move_enemy_proj/_update_shooters(realdtstill drives player/weapons/gems). - Decoy (
sim/decoy_state.gdDecoyState,render/decoy_renderer.gd): LB/Q-triggered replica that pulls walk-enemy aggro (the WALK branch of_move_enemiestargetsdecoy.poswhen active), pulses AoE at ~70%, recharges over time + faster on damage (via an hp-delta check at end oftick). Input edge viaInputState.decoy. - Build-craft: damage is now PER-WEAPON. Each weapon has its own
damage_mult(was globalplayer.damage_mult); the global “damage” upgrade is filtered out of the offer and replaced byWEAPON_MODS(per-owned-weapon upgrades, idswm:<weapon>:<kind>: power / orbit shard / nova radius), routed inapply_upgrade/upgrade_preview/upgrade_choice_display. Orbit shard count is nowvar shards(capMAX_SHARDS=7; pre-evolve capMAX_SHARDS - EVOLVE_SHARDS= 4, evolve adds +3), rendered dynamically inmain.gd. - Armor (
PlayerState.armor, StatEffects"armor", bible “Armor Plating”): all player damage routes throughSim._hurt_player(6%/point reduction, cap 75%). - Determinism baseline was UNMOVED through all four (was
4152236597/1267954985at the time; now re-pinned to4152236597/2325839371by task 10) — boss/skirmisher/powerups are time-gated past the 10s baseline window, and decoy/armor/per-weapon-mult default to no-ops.
Combat depth, new content & balance (M2 cycle 20, DONE) — builds 47-48
Section titled “Combat depth, new content & balance (M2 cycle 20, DONE) — builds 47-48”A 13-task cycle (spec docs/superpowers/specs/2026-06-25-combat-depth-content-balance-design.md, plan docs/superpowers/plans/2026-06-25-combat-depth-content-balance.md), each TDD’d + reviewed. Shipped to the Apple TV @ BUILD 48. Re-pinned the determinism baseline to 4152236597/2325839371 (Task 10 per-enemy variation; see the keystone note). All other tasks were determinism-neutral (time-gated / input-gated / non-blade weapons / armor-0-safe).
- Player thruster/dodge:
InputState.dashedge →Sim._update_dashgives a short committed burst + i-frame window (is_invulnerable()whileplayer.iframe_timer > 0). EVERY player-damage site is i-frame-guarded (contact, enemy_proj, shooter, boss swing/missiles, boss2 cutter/charge, bomb/artillery blast, orbiter shards, lancer beam). In-run upgradethrusters(StatEffects →player.dash_cooldown_mult) + a metathrustersupgrade. Determinism-safe (no dash input in the baseline → all guards always-true). - New enemy behavior
BEHAVIOR_RUSH=4(Rusher,TYPE_RUSHER=9): re-aim → fast straight burst → immediately re-aim, NO recharge pause; dodgeable by moving perpendicular (it overshoots). The counter-play to thrusters. EnemyProjPool extends EntityPool(sim/enemy_proj_pool.gd):enemy_projis now this pool with a per-shotdamagecolumn (add(p,v,r,life,dmg), swap-removed in lockstep)._check_player_hitdealsenemy_proj.damage[ep], not a constant.- 5 weapon-mirroring enemies (spawn-gated ≥20-50s, determinism-safe):
TYPE_ZAPPER=10(fast lightning bolts),TYPE_SCATTERER=11(blood pellet fan),TYPE_BOMBER=12(slow; lobs a delayed AoE —Sim.bombs+_update_bombs+ telegraphrender/bomb_renderer.gd; explosion uses the existingreactionfx),TYPE_ORBITER=13(cold spinning shards = moving contact hazard; spin phase in the freedash_timercolumn;render/orbiter_renderer.gd),TYPE_LANCER=14(telegraphed light beam-line; state in_lancer_statekeyed by entity_id;render/lancer_telegraph_renderer.gd; fire uses thebeamfx — keys MUST bepos/dir/length/elementto match FxManager). Ranged firing is a type-driven_update_armed_enemiespass keyed byentity_id. - Warden attack selection (rebuild 2026-10-01, Task 1): the tutorial/story Warden (
sim.story != null) keeps the fixed(idx+1) % TUTORIAL_ATTACK_COUNTcycle. 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 inWardenDeck.WEIGHTS_CLOSE/MID/FAR(attack_id → weight; bundled-only, the tunables registry can’t carry Dictionaries). Never repeatsBossState.last_attack;ATTACK_SLAMonly when enraged;ATTACK_SUMMONonly whensince_summon >= DECK_SUMMON_GAP(3) and fewer thanSUMMON_MAX_ADDSsummoned elites live. Onesim.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
BossWardenstatics with registry rows. Aimed fan:FAN_VOLLEYS(enragedFAN_VOLLEYS_ENRAGED) volleys ofFAN_SHOTSbolts across±FAN_HALF_ANGLE,FAN_VOLLEY_GAPapart, each volley re-aimed byBossWarden.predict_aim(sim, from, speed)(pilotpos + velocity * min(dist/speed, FAN_LEAD_MAX)). Barrage: Survival adds a second ring offset byPI / count,BARRAGE_RING2_DELAYafter the first (story stays one ring). Lunge: aim re-predicted every TELEGRAPH tick, latched at fire; ACTIVE chargesLUNGE_LENGTHatLUNGE_SPEED(clamped to±ARENA_HALF), swept-segment hit once (boss.fired) forLUNGE_DMG, thenLUNGE_BURSTradial shots. Slam (enraged-only pick): the swing hit, then a second swingSLAM_SECOND_DELAYlater with its own guard (boss.second_fired) and its ownBOSS_ACTIVE_TIMEhit window. ACTIVE is held open pastBOSS_ACTIVE_TIMEwhileBossWarden.active_pending()(volleys left, ring 2 pending, lunge charging, slam’s second window open). Fx:boss_swing_startat each Survival swing/slam swing start,boss_swing_hitper landed swing.render_infoaddslane(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) andtelegraph_kind(-1 outside TELEGRAPH). - Survival Warden movement + phase 2 (rebuild Task 3): walk speed is the bible warden
speed(120;spawn()writes it intoenemies.speed, which movement reads, so a bible overlay tunes it;SV_SPEEDis only the fallback/test reference). APPROACH walks (first tick:SV_DASH_SPEEDdash when the pilot is pastSV_DASH_MIN_DISTAND beyond the target range) along the pilot line toBossWarden.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 atspeed * STRAFE_MULT * boss.strafe_sign(strafe_signflips every deck pick); SWING/SLAM/LUNGE wind-ups stay planted. All movement clamps to±ARENA_HALF(clamp_arena). Enraged: the barrage wind-up isBOSS_FIRE_TELEGRAPH_TIME * BOSS_ENRAGE_TIME_MULT, and every Survival telegraph (calm or enraged, all 9 attacks) is floored atTELE_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 andrender_info’sfire_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_playerfrom the Warden carries a source tag (swing,slam,lunge,missile,shot,swarm, orcontactfor body contact), recorded per hit in the run timeline so telemetry shows which attack lands (sim.gdenemy-projectile hit site;boss_warden.gdfor the melee/lunge/missile sites). Look: the Warden is anArchetypeRendererNova bucket (eye + hub core) plusrender/warden_body_renderer.gd’s two counter-rotating ring layers. FX live infx/boss/:warden_fx.gd(WardenFx, fed bymain.gdevery frame) owns oneWeaponFxLayerlook each for bolt / drone / missile (persistent,sync_projectiles), swing / lane / charge (persistent telegraphs fromrender_info,update_boss) and burst / swing crescent (events fromsim.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 theVisualLook.warden_v2A/B toggle (Debug Settings “Warden Look (A/B)”, default on); off = the old sprite and primitive FX. Roster entrieswarden_*intools/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_rocketsarc up via a render-onlyaltitudetrick → land → radial shrapnel into enemy_proj), Shockwave Rings (concentric, with a dodge gap), Charge slam, Summon. Pooled like the Warden (weapons damage it free). Survival ALTERNATES Warden/boss2 (_boss_spawn_count, one boss at a time).render/boss2_renderer.gddraws body + all telegraphs + rockets. Coexists with the original 4-attack Warden. - Per-enemy variation + escorts (THE re-pin, Task 10):
Sim._vary_stats(radius,hp,speed,contact)jitters every spawn (one size roll: bigger=tankier+slower, smaller=faster+weaker, +noise, ~8% stronger “variant”); tank/brute spawns bring a 3-5 escort cluster. Applied in_spawn_enemies+ story randomized-replay spawns (NOT tutorial-authored). Draws fromrngin the baseline window → movedstate_checksumto2325839371. - Visual archetype silhouettes (
render/archetype_renderer.gd): replaced the single-mesh swarm with per-shapeMultiMeshpartitions (distinct silhouette perTYPE_*);shape_for/_TYPE_SHAPEsized toTYPE_BOSS2+1._polygon_meshfans from the CENTRE (0,0) so star-shaped/non-convex silhouettes render correctly. ReplacedSwarmRendererfor enemies only (gems/projectiles still SwarmRenderer). - Audio (
audio/audio_manager.gd+audio/*.wav): first audio in the game — reuses chess-defense SFX (placeholder).consume(fx_events)maps fx kinds → sounds with a per-frame cap; discretelevel_up/game_over/victory/dash/hurthooks in main.gd.ui_nav/ui_buyexist but unwired. Render-side (NOT /sim). Boss spawn/enrage cues are per-boss slots (AudioManager.BOSS_CUE_FALLBACK); every boss enrage reaction (ENRAGED, FunZo’sENRAGE) carries"boss": "<warden|boss2|funzo|graviton|eye>"so audio can pickenrage_<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 responsiveGridContainer(_columns_forclamp 3-5) spread across the screen with procedural per-upgrade icons + entrance/focus/buy animations + 2D grid nav (tvOS-safe).show_shop/restart_requestedunchanged. - Balance: armor floor
amount*0.1→*0.25(chip/ranged weapons stop doing ~nothing to armored foes; armor-0 enemies unaffected → baseline-safe); non-blade weapon damage buffs (blade stays 3.0 to hold the baseline);BOSS_GOLD25→40 + heavy-kill +2 gold; metacost_growthcapped 1.6;DMGNUM_MIN7→2 (damage numbers now show for ordinary hits). - Telemetry DPS fix (parallel-session commit a6194c0, accepted):
dmg_dealt_totalnow caps each credit at the enemy’s remaining HP (the dashboardavg_dpswas reading millions from AoE/overkill); HP subtraction unchanged → determinism-neutral. - DEFERRED: web demo R2 redeploy (the start menu changed the public demo’s first screen auto-attract→menu — needs Chris’s OK); site landing is otherwise behind on builds 32-47 content (storyline/telemetry/decoy not yet in the changelog/legend); polish nits (boss rockets use
dtnotedtduring freeze;BOSS2_RINGS_SPEEDSshould derive its loop bound from the array size; lancer/cutter beams ignore story walls). Playtest-feel items: enemy silhouettes, new-boss telegraphs, thruster-vs-Rusher dodging, audio.
“The Chasm” content + survival rework (M2 cycle 21, DONE) — build 51
Section titled ““The Chasm” content + survival rework (M2 cycle 21, DONE) — build 51”Ported the best unbuilt enemies/bosses from Chris’s earlier game The Chasm + reworked survival pacing. Spec docs/superpowers/specs/2026-06-25-chasm-content-survival-rework-design.md, plan …/plans/2026-06-25-chasm-content-survival-rework.md. 8 TDD’d tasks, each per-task reviewed + a final whole-branch review (READY TO MERGE, no Critical/Important). Survival-mode only (story integration deferred). Re-pinned the determinism baseline ONCE in Part A → 1432233777/2300319179 (see keystone note); Tasks 2–8 hold it byte-identical (new enemies spawn-gated, tank-missile fire gated ≥20s, bosses story==null+≥40s, every boss _update_* early-returns no-op when absent).
-
Part A — survival spawn & balance rework:
clean arena around EVERY boss(superseded by cycle 22b below;_spawn_suppressed()/BOSS_QUIET_LEADno longer exist). Fewer/deadlier/quicker:SOFT_ENEMY_CAP340→140,SpawnDirector.rate_atcut ~45%, tanky HP CUT for faster TTK (tank 85→55, brute 160→110, elite 60→45) +LETHALITY_MULT1.6× contact at spawn (deadly via damage/behaviour, NOT HP). Arena-wide movement: newEnemyPool.flankcolumn (signed angular offset fromrngat spawn) rotates theBEHAVIOR_WALKheading byflank × falloff(distance)— walkers fan out / circle far away, converge close in. -
New enemies (survival pool, spawn-gated): Ghost (
TYPE_GHOST=16,BEHAVIOR_GHOST) — DRIFT→TELEGRAPH→STRIKE teleport-strike; the silhouette endpoint tracks the player-relative offset each tick (running doesn’t escape); the REAL ghost emits a distinctghost_eyefx on its body so it’s never confused with theghost_warnendpoint silhouette; offset keyed byentity_id, gated ≥30s. Accumulator (TYPE_ACCUMULATOR=17, reusesBEHAVIOR_DASH+ agrow_tcolumn) — grows radius/speed/contact over its lifetime, forcing a priority kill; gated ≥50s, rare. 2026-07-08: the old flatACCUMULATOR_MAX_SCALEcap is gone (for every Accumulator, not just elites) — growth is capped by RADIUS instead, atACCUMULATOR_MAX_RADIUS(= the Ice-dimension ice-chunk hazard’s radius, “ridiculously big” tops out there);MAX_ENEMY_RADIUSwas 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_deadkill them free), limited turn rate = dodgeable, reusedash_timeras an age counter, no gem/gold on death, tank fire keyed byentity_id+ gatedrun_time>=20. -
Three new bosses (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_timebookkeeping 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_deaddeath branch (gold + dimension crystals + “BOSS DOWN”) and a nuke-exemption entry:

- FunZo (
FunZoState,TYPE_FUNZO=19) — zone-flooding clown: summons growing fuchsia DoT zones beneath itself that damage the PLAYER (Sim.funzones, stack with overlap, decay fast on death), slow-drift/fast-dash alternation, body grows as HP drops; enrichments: jester one-hit adds, confetti ring on dash landing, enrage spikes zone/dash cadence. - Graviton (
GravitonState,TYPE_GRAVITON=20) — radial dark-blob fire with safe gap-lanes + a telegraphed gravity pull (ADDITIVE displacement applied after the player integrate, so input still steers;GRAVITON_PULL_STRENGTH200→480 as HP drops); enrichments: Singularity Collapse enrage ultimate (charge→strong pull→REVERSE push + shockwave ring) + orbiting satellite blobs. - The Eye (
EyeState,TYPE_EYE=21) — predictive-aim lasers that LEAD the player’s velocity (derived fromlast_player_posdelta) with a telegraph window that shrinks as HP drops but never belowEYE_LASER_WINDOW_FLOOR=0.30s(always dodgeable); lazy edge dash (huge damage + time-bounded afterimage line); enrichments: blink reposition, multi-beam fan below 50% HP, pupil tracks its next endpoint. Beam fx uses the required keyspos/dir/length/element.
- FunZo (
-
Lessons baked in: (1) a new zone/projectile mechanic that should hurt the PLAYER can silently be copy-pasted from the enemy-damaging reaction
zonesand hurt ENEMIES instead — FunZo shipped this Critical (zone DoT queried the enemy hash); always verify a “damages the player” mechanic routes through_hurt_player(respectingis_invulnerable()), not_damage_enemy. (2) A new pooled boss needs FIVE things or the economy/render breaks: a_sweep_deaddeath branch (gold + crystals + “BOSS DOWN”; the old_next_boss_timeadvance 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_enemiesmust skip itsBEHAVIOR_BOSS. (3) the gravity pull must be applied AFTERplayer.integrateso it ADDS to input (escapable); tune pull < player move speed at full HP. -
DEFERRED (Chris’s call): the web demo R2 redeploy + the site landing changelog/legend for this content (outward-facing public page). Deferred Minors:
_step_accumulatordedup vs_step_dash; a stronger additive-pull composition test; Eye telegraph beam-fx per-tick churn;test_suppressed_while_boss2_alive. Story-mode integration of the new content is a later data-authoring pass.
Survival pacing rework — 3-min buildup → swarm → boss-with-adds (M2 cycle 22b, DONE) — build 55
Section titled “Survival pacing rework — 3-min buildup → swarm → boss-with-adds (M2 cycle 22b, DONE) — build 55”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 withSWARM_BURST_TIME/SWARM_BURST_COUNT/_swarm_burst_fired;BOSS_FIRST_TIME/BOSS_INTERVAL/_next_boss_timewere deleted too. Boss timing is the phase-driven wave macro-loop (WAVE_PHASE_S→ wind-down until the arena is clear →BOSS_PREP_Scountdown →_spawn_phase_boss()→ rest → waves), matchingdocs/architecture/remote-config.md. The boss-adds trickle (_spawn_boss_adds,BOSS_ADD_RATE) and_spawn_oneare still live; “any boss alive” isboss_rotation.any_boss_alive(sim).

Reshaped survival into a deliberate load curve (Chris’s design; also the controlled scenario for the fps measurement). REPLACES the cycle-21 “clean arena around every boss” — bosses now fight amid adds. Sim-touching; re-pinned the determinism baseline ONCE → 3428105085/1447851215 (the early ramp + removed quiet-lead change the 0–10s window; sim still deterministic — run twice = identical).
- 3-min ramp:
SpawnDirector.rate_atnow rampsbase = 1.0 + clampf(t/180,0,1)*8(1/s → 9/s by 3:00) with a wave-texture floor of 0.4 (never fully dry) — normal enemies BUILD UP over the first 3 minutes instead of the cycle-21 cut curve.SOFT_ENEMY_CAP140→200 (more on-screen). Swarm crescendo at 3:00:(deleted, see note above)Sim._spawn_swarm_burst()dumpedSWARM_BURST_COUNT=70of the current blend at once, ONE-time (_swarm_burst_firedguard), atSWARM_BURST_TIME=180.First boss at 3:30:(deleted, see note above)BOSS_FIRST_TIME40→210, then aBOSS_INTERVAL=80cadence.- Adds during boss (not quiet):
_spawn_suppressed/BOSS_QUIET_LEADare GONE._spawn_enemiesnow: ifboss_rotation.any_boss_alive(sim)→_spawn_boss_adds(dt)(trickle small/fast types — swarmer/spider/zapper/rusher viaSpawnDirector.pick_boss_add_type, atBOSS_ADD_RATE=1.8/s); else swarm-crescendo check + the normal ramp. So a boss fight stays busy with light adds. - Refactor: extracted
Sim._spawn_one(tid, pos)(vary+flank+add) used by the ramp, boss-adds, and burst — its rng draw order (_vary_stats→ flank sign → flank magnitude) MATCHES the old inline spawn, so the extraction itself is determinism-neutral (only the rate/quiet-lead changes moved the baseline).boss_rotation.any_boss_alive(sim)replaces the repeated 5-index boss check. - Tests:
test_spawn_reworkA1 rewritten (boss-adds + swarm crescendo +_any_boss_aliveinstead of suppression), cap/boss-time asserts updated;test_boss_rotationsuppression tests →_any_boss_alive; the eye/funzo/graviton death-rearm tests setrun_time = _next_boss_timefirst (a boss only dies after it spawned, sorun_time + BOSS_INTERVALadvances past the now-210 first-boss time). All tuning is constants at the top ofspawn_director.gd/sim.gd— retune there.
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 oneSHAPE_STARuses), 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_SHAPEentry) — 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_typealready 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.gdcurates a per-areaenemy_typesallow-list that every spawn path (SpawnTable.pick’sallowedparam,Sim._remap_to_dimension) filters against BEFORE color is ever considered — andhomecounts as a curated area too (it has abossfield, 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 toarea_defs.gd’senemy_typesexplicitly — 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 againstconcept-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): hoversDRONE_STANDOFFoff the target nearest its PILOT and holds the web while withinDRONE_RANGE× cfg radius (DroneState.target_eid/webbingare render reads). Fielded as a bonus drone (beyond the slot count) on every launch whilesim.web_drone_grant, which main mirrors fromDevFlags.web_drone(default off; Debug Settings “Dev: Web drone joins every launch” + consoledev_flag web_drone). Not inMetaState.KNOWN_CLASSESand 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 byUpgradeSystem.roll_upgrade_choicesonly whileStasisWeb.card_offerable(sim.tackler_spawns, no story/tutorial, belowCARD_MAX_LEVEL3), so the flag-off pool and everyupgrade_rngdraw are unchanged.apply_upgradebumpsPlayerState.stasis_web_level;StasisWeb.update_pilot(in the weapon loop ofSim.tick, per living pilot) casts on the nearest Tackler/elite withinCARD_RANGEforcard_web_seconds(lvl)and then waitscard_cooldown(lvl)(the cooldown starts on a cast, so it fires as soon as a target comes in), emitting fxstasis_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).