Overnight Improvement Loop — 2026-07-13
Overnight Improvement Loop — 2026-07-13 → 07-14
Section titled “Overnight Improvement Loop — 2026-07-13 → 07-14”Autonomous swarm run (haiku + sonnet agents, adversarial verify). Started 2026-07-13 22:39 BST, 10-hour budget (deadline ~08:39 BST 07-14). Each cycle fans out finder agents across many angles (sim, render/perf, UI/UX, content/balance, audio, code-quality, tests, tooling/docs, graphics ideas), adversarially verifies each candidate, then classifies.
☀️ Morning summary (loop wound down 2026-07-14 ~07:46 BST)
Section titled “☀️ Morning summary (loop wound down 2026-07-14 ~07:46 BST)”Ran 4 cycles (3 clean + 1 partial) before a sustained API degradation the second half of the night forced an early wind-down — agents began hitting “Connection closed mid-response” from ~00:22 onward, stalling cycles for ~2h each. Cycles 1–3 landed cleanly; 4 failed entirely; 4b got one finding through. Method comparison still produced a clear result (see below).
✅ Applied — 7 fixes, all LOCAL commits, NOTHING pushed (review git log, push what you like)
Section titled “✅ Applied — 7 fixes, all LOCAL commits, NOTHING pushed (review git log, push what you like)”8786700— cycle 1: pause-menu confirm honoursui_accept(was Enter/joypad-A only); fx-manager per-spawn micro-opt; 3 CLAUDE.md doc-drift fixes. (test-gated, determinism held)34556ef— cycle 2: CLAUDE.md co-op status corrected (P2 progression is done, not P1-only).9c1f676— cycle 3: addedtitle:frontmatter to 9 spec/plan files. ⚠️ PUSH THIS FIRST — the live docs sitedark-cosmos-docs.lumara.digitalbuild has been BROKEN by the missing frontmatter; this fixes it, but only takes effect on push (CF Pages rebuild). Build verified passing locally.
📋 Highest-value triage (real bugs — your call, not auto-applied)
Section titled “📋 Highest-value triage (real bugs — your call, not auto-applied)”- ✅ FIXED (
4319812) — Wyrm death pays XP/gold/kill-count ~20× per worm (once per segment) — real balance bug (c1). Gated the kill economy to once perwyrm_id; new test + full suite green, determinism held. - ✅ FIXED (
760b72d) — Disruptor drone slow field is inert vs dash/rush/ghost/homing enemies (c1). Routedenemy_speed_scale()through all 9 raw pos writes in step_dash/rush/ghost/homing + Accumulator (full fix per Chris: slows the locked lunge/burst too). New test, suite green, determinism held (scale is 1.0 with no active slow field). Wyrm-head/orbiter left raw — they already scale acceleration, so scaling integration would double-apply. - ✅ FIXED (
16f36a6) — Co-op: nova/beam/pulse/orbit/scatter apply P1’s element to P2’s hits, not P2’s own (c3). All 5 now readpilot.arsenal.<w>_element_idx; new co-op test, suite green, determinism held. ✅ Related follow-up now also FIXED (f869bd9):drone_director.gdreadsim.blade_element_idx(P1-only) for every drone’s element — now readsd.owner.arsenal.blade_element_idx(11 sites;pulse_at/drone_chain_striketake it as a threaded param). New co-op test, suite green, determinism held. - ✅ FIXED (
5ca07d4) — Halo Chain damage buff never applied to ranged/projectile attacks — was inert for ~half the roster; contradicted its own design comment (c4b). Now everyenemy_proj.add()multiplies damage by the firing enemy’sdmg_mult_for()at all 7 fire sites (Shooter, Fleet Kite, Zapper, Scatterer, Custom BOLT/FAN, Glass Wisp mote). New test, suite green. - ✅ FIXED (
b176faf,8b0314a) — No 2-pilot determinism test; checksums omit drone state (c1). Folded thedronespool intostate_checksum()/snapshot_string()(baseline-safe: existing single-pilot run deploys no drones, so the existing3312602286/4217109746baseline is unchanged, no re-pin; the accruing per-pilotdrone_chargescalar stays excluded — its effect surfaces via the pool). Addedtests/test_determinism_coop.gd: two same-seed Sims each with a P2 + a deployed Sentinel, 600 ticks driving both pilots, asserting byte-identical traces (the Phase-3 co-op netcode guarantee) and pinning a new separate 2-pilot baseline (370166760/2437492589). Full brainstorm→spec→plan→SDD cycle; clean Opus whole-branch review. Spec/plan:docs/superpowers/specs|plans/2026-07-14-coop-determinism-coverage*. - ✅ FIXED (
8a95e7b,1d8474a) — Input-leak family (DialogueBox / ShipConfigPanel / PauseMenu etc. let global hotkeys fire under open panels). Root cause:mainis the scene root, so its_input()saw hotkeys before the child CanvasLayer panels and dispatched them regardless. Fix (main.gd-only): a polled_any_content_modal_open()helper + three-stage_input()(pause keys → modal-suppress gate → decorative hotkeys) + a_toggle_pause()self-guard. Full brainstorm→spec→ plan→SDD cycle (both tasks reviewed clean + clean Opus whole-branch review). Decisions (Chris): a content modal owns input so pause does nothing over it; ALL decorative hotkeys suppressed under any modal. Determinism unaffected (no/simchange). Spec/plan:docs/superpowers/specs|plans/2026-07-14-modal-input-gating*.
Method comparison (the experiment you asked for)
Section titled “Method comparison (the experiment you asked for)”| Method | Cycles | Findings | Refuted (noise) | Verdict |
|---|---|---|---|---|
| flat-broad | 1, 2 | 27–28 | 6–7 | Good cheap opening; yield halves by cycle 2 (dedup). |
| hierarchical | 3 | 13 | 0 | Best precision — panel filtering killed the noise. Use for quality passes. |
| flat-deep | 4, 4b | 0 / 1 | — | Inconclusive — both hit the API stall. |
Recorded in the skill bh-investigation-loop (method-effectiveness.md) so the next run front-loads
hierarchical. Total: 7 applied, 48 triaged, 13 refuted across the run.
How to read this doc
Section titled “How to read this doc”Every finding is tagged with a disposition:
- ✅ AUTO-APPLIED — a fix judged clearly safe was implemented, test-gated, and committed
locally this run. Commit hash noted. Nothing was pushed; review the local
git login the morning and push if happy. - 📋 TRIAGE — logged for Chris. Real and probably worth doing, but subjective, gameplay/balance, graphics-visual, a new feature, risky, or determinism-affecting — needs a human call.
- ❌ REFUTED — a finder raised it, the adversarial verifier knocked it down. Kept for the record so later cycles don’t re-litigate it.
Auto-apply safety rails (what the loop is allowed to commit unattended)
Section titled “Auto-apply safety rails (what the loop is allowed to commit unattended)”ALLOWED (clearly safe, mechanical, verifiable):
- Typos / stale / wrong comments and docs; skill-doc drift.
- Confirmed-dead code (unreferenced, grep-verified across the whole tree incl.
_archive/andplatform/tvos/symlinks). - Small, obviously-correct bug fixes that have or gain a regression test, pass an adversarial review, keep the full suite green, and do not change the determinism baseline.
- Missing null/bounds guards with unambiguous correctness.
NEVER auto-applied (→ TRIAGE):
- Balance / tuning-constant changes (Chris’s EVE-style balance philosophy — his call).
- Any graphics / visual change (needs his eye + on-device look).
- New features of any size.
- Anything that changes
snapshot_string().hash()/state_checksum()— a determinism re-pin overnight is exactly the thing that needs human sign-off. - Anything touching deploy / App Store / n8n / the live site / secrets.
- Co-op / netcode semantics; large refactors.
- Anything an adversarial verifier flags as risky, subjective, or unclear.
Determinism baseline (must hold for any auto-apply touching /sim): read the literal pinned
assertion in tests/test_determinism_survival.gd — do not trust a prose number.
Findings
Section titled “Findings”Cycle 1 — broad sweep (started 2026-07-13 22:39 BST)
Section titled “Cycle 1 — broad sweep (started 2026-07-13 22:39 BST)”Counts: 5 auto-applied, 17 triage, 6 refuted.
✅ Auto-applied (commit 8786700, local only)
Section titled “✅ Auto-applied (commit 8786700, local only)”PauseMenu missing ui_accept action check for confirm
bug · sev med · effort trivial · angle ui · files: ui/pause_menu.gd
- Problem: PauseMenu._input() checks only InputEventJoypadButton (JOY_BUTTON_A) and KEY_ENTER/KEY_KP_ENTER for confirm, but does NOT check the ui_accept action. This breaks confirm on any device that maps ui_accept to something other than Enter (e.g. Space, Gamepad A remapped, or alternate control schemes). The comment on line 198 claims it checks ui_accept, but the actual code at lines 199-206 never calls event.is_action_pressed(). Other panels (LevelUpPanel line 515, DimensionChoicePanel line 130, MetaShopPanel reset-dialog line 761) correctly use event.is_action_pressed(“ui_accept”).
- Suggested fix: Add event.is_action_pressed(“ui_accept”) check alongside the joypad/key checks. Change lines 198-206 to:
var confirm := event.is_action_pressed("ui_accept"); if not confirm and event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_A: confirm = true; if confirm and _sel >= 0 and _sel < _buttons.size(): ... - ✅ Applied in commit
8786700(local, unpushed).
CLAUDE.md RNG function signature drift: roll_upgrade_choices signature changed
docs · sev med · effort trivial · angle tooling · files: CLAUDE.md
- Problem: CLAUDE.md line 75 states that the upgrade RNG rolling function is called via
sim.roll_upgrade_choices(n)and is now atsim.upgrade_system.roll_upgrade_choices(sim, n). However, the actual signature insim/upgrade_system.gdline 70 isfunc roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int)— it takes THREE parameters, not two. The docs are stale and would mislead developers trying to call this function correctly. - Suggested fix: Update CLAUDE.md line 75 to reflect the correct signature:
Roll via sim.upgrade_system.roll_upgrade_choices(sim, pilot, n).(added thepilotparameter). - ✅ Applied in commit
8786700(local, unpushed).
FxManager _LabelNode re-applies constant theme overrides on every spawn
perf · sev low · effort trivial · angle render · files: fx/fx_manager.gd
- Problem:
_LabelNode.reset()(called every time a reaction/level-up label spawns, e.g. every named elemental reaction and every level-up) callsadd_theme_color_override("font_outline_color", Color(0,0,0,0.8))andadd_theme_constant_override("outline_size", 4)with hardcoded, never-varying values. Onlyfont_color(the reaction’s tint) actually differs per spawn. Theme overrides mutate a Control’s internal override dictionary and can trigger theme-propagation/redraw work — doing this for two constants on every single spawn (rather than once in_init(), alongside the existingadd_theme_font_override/add_theme_font_size_overridecalls which already run once) is pure repeated waste with zero behavioural difference. - Suggested fix: Move
add_theme_color_override("font_outline_color", Color(0,0,0,0.8))andadd_theme_constant_override("outline_size", 4)fromreset()into_init()(next to the existing font overrides). Leaveadd_theme_color_override("font_color", col)inreset()since that one genuinely varies per call. - ✅ Applied in commit
8786700(local, unpushed).
CLAUDE.md test count stale: says 208 scripts, actually 224
docs · sev low · effort trivial · angle tooling · files: CLAUDE.md
- Problem: CLAUDE.md line 90 states the test suite ‘still runs all 208 scripts’, but a current count shows 224 test_*.gd files in the tests directory. This is a purely factual drift from the codebase state, likely due to the content batch (2026-07-09) and other tasks adding 16+ new tests since the line was written.
- Suggested fix: Update CLAUDE.md line 90 to read:
(verified: still runs all 224 scripts)— or remove the hardcoded number entirely and just say ‘(verified: runs all test_*.gd scripts)’ since the doc itself says the count is ‘self-maintaining.’ - ✅ Applied in commit
8786700(local, unpushed).
CLAUDE.md claims enemies-bosses-survival.md ‘not yet updated for v2’, but it is
docs · sev low · effort trivial · angle tooling · files: CLAUDE.md
- Problem: CLAUDE.md line 43 contains a note stating
docs/architecture/enemies-bosses-survival.mdis “⚠️ not yet updated for v2” (Elemental Dimensions v2). However, the actual doc has been updated with Fire/Void/Light references and Centurion content (which is designed around the v2 dimensions). This is a stale warning that no longer applies. - Suggested fix: Remove the warning clause from line 43. Change:
Details: docs/architecture/enemies-bosses-survival.md (⚠️ not yet updated for v2) + memoryto:Details: docs/architecture/enemies-bosses-survival.md + memory - ✅ Applied in commit
8786700(local, unpushed).
📋 Triage — needs Chris
Section titled “📋 Triage — needs Chris”CLAUDE.md references non-existent flag V01_LOCK_NETWORKED_MP
docs · sev med · effort trivial · angle tooling · files: CLAUDE.md
- Problem: CLAUDE.md line 45 states
main.V01_LOCK_NETWORKED_MP=trueas a launch gate, but this flag does not exist in main.gd — it only exists in the unmerged worktree.claude/worktrees/networked-multiplayer-mb-mc/main.gd. This is misleading documentation suggesting a gated feature exists on the current code when it does not. - Suggested fix: Remove or clarify line 45 of CLAUDE.md (the V01_LOCK_NETWORKED_MP reference) to avoid confusion. The networked-MP feature is still unmerged and not part of the current main branch. Alternatively, update the line to read: “LAN networked MP (M-B + M-C) is built + real-device tested but UNMERGED in worktree
.claude/worktrees/networked-multiplayer-mb-mc/and does NOT yet have a launch gate on main.”
PauseMenu missing set_input_as_handled for unhandled input in _input
bug · sev low · effort trivial · angle ui · files: ui/pause_menu.gd
- Problem: PauseMenu._input() exits early at line 195-196 and 206-207 via get_viewport().set_input_as_handled() + return for nav and confirm, but there is NO catch-all set_input_as_handled() at the end if NEITHER nav nor confirm matched. If an unrelated input event (e.g. a key press that’s not Enter, or an unrecognized joypad button) reaches the pause menu while visible, it falls through without being marked handled, potentially leaking to main._input() underneath and causing unintended hotkey triggers (e.g. pressing X to toggle debug overlay, or F4 to cycle quality, while the pause menu is open).
- Suggested fix: Add
get_viewport().set_input_as_handled()at the very end of _input() (after line 206), so every input event entering _input() while the pause menu is visible is marked handled, blocking leakage to underlying input handlers.
ShipConfigPanel._input returns early without marking unrelated events handled
bug · sev low · effort trivial · angle ui · files: ui/ship_config_panel.gd
- Problem: ShipConfigPanel._input() has multiple early-return paths at lines 241-250, 259-263, 265-267, but if NONE of those branches match (e.g. an unrecognized joypad button while in pre-run mode, or any key/button while not pre-run but back/cancel don’t match), the function exits without calling set_input_as_handled(). This allows unhandled events to leak to main._input() underneath, potentially triggering hotkeys like F4 quality-cycle or X debug-toggle while the panel is open.
- Suggested fix: Add get_viewport().set_input_as_handled() before returning at line 277 (after the back/cancel handling), so any input event that reached _input() is marked handled once the panel is open. Alternatively, gate the entire body in
if _open:and add set_input_as_handled() at the end before the closing brace.
Dead function _update_joy_debug_label() in main.gd — accompanies dead _joy_lbl field
cleanup · sev low · effort trivial · angle codeq · files: /Users/chris/Claude/bullet-heaven/main.gd
- Problem: The function _update_joy_debug_label() at lines 2052-2071 is only called at line 1098, and only if is_instance_valid(_joy_lbl) returns true. Since _joy_lbl is never initialized, this function never executes. It is confirmed dead code per CLAUDE.md. Removing it is part of the _joy_lbl cleanup.
- Suggested fix: Delete lines 2052-2071 (the entire _update_joy_debug_label() function). This is already flagged for removal; removing the field reference will make this function orphaned.
Wyrm death pays out XP/gold/kill-count once per segment instead of once for the whole worm
bug · sev high · effort small · angle sim · files: sim/sim.gd
- Problem: Wyrm enemies (
TYPE_WYRM) are ~20 pooled segments sharing ONE health pool viawyrm_id—ElementalSystem.damage_enemy(sim/elemental_system.gd:151-159) correctly mirrors the post-damage HP value into every segment carrying that wyrm_id. ButSim._sweep_dead()(sim/sim.gd:2184) has no wyrm_id dedup: it walks the enemy pool and, for every entity withdata[i] <= 0.0, unconditionally runs the full kill payout (kills += 1,_award_xp(_xp_worth(i), ...),run_gold += GOLD_PER_KILL, adeathfx event). When a Wyrm’s shared pool empties, ALL ~20 live segments satisfydata[i] <= 0.0in the SAME sweep and each independently pays out — so killing one Wyrm actually grants roughly 20x the intended XP, 20x the intended gold, and incrementskills/kills_by_typeby 20 for a single logical kill. Contrast withTYPE_TANK_MISSILE, which IS explicitly excluded from this payout path (if dead_type != EnemyPool.TYPE_TANK_MISSILE:), showing the codebase already has the pattern for excluding multi-entity/pooled kills from double-paying — Wyrm just never got it. No test covers this:tests/test_spawn_story_enemy.gd’s Wyrm tests check HP-pool mirroring and spawn wiring, never a kill/reward count after the pool is drained to zero. - Suggested fix: In
_sweep_dead(), when a dying enemy haswyrm_id >= 0, only run the kill/XP/gold/kills-counter payout for ONE segment per wyrm_id per sweep (e.g. track a_paid_wyrm_idsDictionary reset each sweep, or only pay out forwyrm_seg == 0), while still removing every dead segment from the pool and still firing per-segment death fx (visual feedback for each segment dying is fine, just not the reward). Needs a new test asserting killing a full Wyrm grants exactly one segment’s worth of gold/XP and incrementskillsby 1, not 20. This changes reward totals so it’s balance-adjacent — flag for Chris rather than auto-apply, and re-verify determinism isn’t affected (Wyrm kills are RNG-order-neutral here since payout amounts don’t feed back into rng draws, but worth a real test run since kills/xp/gold aren’t in the checksum but the change touches shared code paths).
No determinism trace test covers the 2-pilot (co-op) tick path
test · sev high · effort small · angle tests · files: tests/test_determinism.gd, tests/test_determinism_survival.gd, tests/test_coop_integration.gd
- Problem: Every determinism test in the repo (test_same_seed_identical_trace, test_different_seed_diverges, test_upgrade_rolls_do_not_perturb_sim_stream, test_survival_trace_is_stable) drives ONLY a single pilot via tick_single(inp). None constructs two independent same-seed Sims, adds a second pilot to both, and asserts a multi-hundred-tick Sim.tick([inputA, inputB]) trace (snapshot_string()/state_checksum()) is byte-identical between the two runs. Co-op is a documented, actively-developed system (per-pilot XP/upgrades/drones all landed 2026-07-09..07-11 per CLAUDE.md) explicitly flagged as needing ‘a real 2-controller playtest’ precisely because this path is undertested. A bug that only manifests when a second pilot’s RNG-adjacent state (drone deploy timing, weapon fire order, upgrade rolls under Sim.tick()’s two-pilot loop) diverges between two nominally-identical co-op runs would currently pass the entire suite silently, since no test would ever run that scenario twice and diff it.
- Suggested fix: Add a test in tests/test_determinism.gd (or a new tests/test_determinism_coop.gd) mirroring test_same_seed_identical_trace but with sim.add_pilot() on both sims and sim.tick([inputA, inputB]) each tick, asserting a) two independent same-seed runs produce identical traces (proves co-op is deterministic) and b) the trace differs from the equivalent single-pilot trace at the same seed (proves the second pilot’s state actually participates). No production code change needed — pure test addition, genuinely testable today.
Disruptor drone’s enemy-slow field silently no-ops against Dash/Rush/Ghost/Homing enemies — ✅ FIXED 760b72d (2026-07-14, full fix per Chris)
bug · sev med · effort small · angle sim · files: sim/enemy_behaviors.gd, sim/drone_director.gd
- Problem: The Disruptor drone sets
sim.enemies.slow_timer[ei]on enemies in its field (sim/drone_director.gd:198), andEnemyBehaviorsmovement functions are supposed to read that back viasim.drone_director.enemy_speed_scale(sim, i)to apply the slow multiplier. Butenemy_speed_scale(...)is only actually called fromstep_walk(line 462) and the tail ofstep_wyrm_head/step_wyrm_follower/step_orbiter’s walk fallback (lines 496-507). It is NOT called fromstep_dash(line 168, used by Spider/Charger/Accumulator’s dash-lunge and telegraph-approach),step_rush(line 267, Rusher’s aim/burst),step_ghost(line 299, Ghost’s drift/strike), orstep_homing(line 421, Tank Missile) — all of which write directly tosim.enemies.pos[i]with rawspeed/veland noenemy_speed_scalemultiplier (e.g. sim/enemy_behaviors.gd:177, 197, 275, 287, 308, 335, 402, 411, 433). Since a Disruptor field is documented generically as “slows enemies in a field” with no scoping note, this reads as an unintentional gap: the drone is advertised as general crowd control but is actually inert against every dash/rush/ghost/homing enemy — precisely the fast, dangerous types where a slow field would matter most. - Suggested fix: Route the Disruptor slow multiplier through the raw speed/velocity reads in
step_dash,step_rush,step_ghost, andstep_homingthe same waystep_walkdoes (multiply the movement delta bysim.drone_director.enemy_speed_scale(sim, i), being careful with the DASH_LUNGE phase wherevelis a locked burst — decide with Chris whether slow should reduce lunge speed too, since that’s a feel/balance call, or only apply during the charge/approach phases). This is gameplay-affecting (changes how strong the Disruptor drone actually is), so it needs Chris’s input on scope rather than a silent mechanical patch, and should get a regression test asserting a slowed Dasher/Rusher/Ghost/Homing-missile actually covers less distance per tick than an unslowed one.
Warden boss missing from bible.json
bug · sev med · effort small · angle content · files: data/bible.json
- Problem: The Warden boss (TYPE_BOSS, spawned via boss_warden.gd and _spawn_dimension_boss()) is fully implemented and live in the sim, but its definition is missing from bible.json. All other bosses (FunZo, Graviton, Eye) are in bible.json; Warden should be too. This creates an incomplete data record.
- Suggested fix: Add Warden to bible.json enemies array. Use the stats from bestiary.json as reference: hp:1500, speed:40, contact_damage:40, armor:0, element:void. Fill in archetype/tags/etc. to match the pattern of other bosses.
6 new weapons (2026-07-09 batch) have no audio mapping
bug · sev med · effort small · angle audio · files: audio/audio_manager.gd, audio/SOUND_MAP.md
- Problem: Six weapons added in the content batch (2026-07-09, commit 1e214d5) emit fx_events with kinds that are NOT mapped in SOUND_FOR_FX or documented in SOUND_MAP: Chronowell (chronowell_deploy), Chalice (chalice_cast), Wildfire (ignite, wildfire_pop), Momentum Core (momentum_charged_fire), Scar Tissue (scar_tissue_stack), Thunder Wake (thunder_wake_strike), and Railgun (lance). The Railgun’s ‘lance’ kind conflicts with a pre-existing visual-only event of the same name (the lance shape overlay in weapon_beam.gd and weapon_railgun.gd), creating ambiguity for future sound design.
- Suggested fix: Add each missing fx_event kind to SOUND_FOR_FX in audio_manager.gd with a mechanical sound mapping (e.g., chalice_cast -> a cast/summon sound similar to drone_summon.wav or funzo_zone.wav; wildfire_pop -> an explosion or ignition sound; thunder_wake_strike -> a zap similar to pulse_zap.wav; momentum_charged_fire -> a charged-shot wind-up similar to beam_hum.wav). Update SOUND_MAP.md’s Weapons table to include rows for the 6 new weapons with their fx_event kinds, sound files (use existing GENERIC cues as placeholders if genuinely-matching sounds don’t exist yet), and status (GENERIC or DONE). Rename weapon_railgun.gd’s ‘lance’ event to ‘railgun_fire’ or similar to disambiguate from the pre-existing ‘lance’ visual kind. Re-pin determinism if any sim-side RNG draw order changes.
SOUND_MAP.md is stale — weapons table missing 6 new weapons, snapshot line desynced from SOUND_FOR_FX inventory
docs · sev med · effort small · angle audio · files: audio/SOUND_MAP.md
- Problem: SOUND_MAP.md was last updated 2026-07-02 (before the 6-weapon content batch landed 2026-07-09). The living Weapons table documents only the original 7 weapons; the new 6 are entirely absent. The Snapshot line (line 9) claims ‘59 DONE · 11 GENERIC · 6 SILENT · 0 MISWIRED · 0 DOUBLE’ but is not re-computed when new events land. Worst case: the status snapshot is wrong and misleads the next person reviewing audio coverage.
- Suggested fix: Add table rows for Chronowell, Chalice, Wildfire (2 fx_event kinds), Momentum Core, Scar Tissue, Thunder Wake, and Railgun (using the renaming suggested in the previous finding). Decide on sound file mappings (GENERIC vs. DONE status) and update accordingly. Recount the Snapshot line: add 7 new rows (or 8 if wildfire_pop + ignite are counted separately), assign each a status, then recalculate the totals (DONE/GENERIC/SILENT/MISWIRED/DOUBLE) and update line 9. Document the count-recomputation step in SOUND_MAP’s own preamble so this stays automated in future (e.g., ‘Recompute this line by hand whenever a row’s status changes’ should be ‘Recompute via a periodic audit or whenever major content lands’).
state_checksum() and snapshot_string() omit drone state entirely
test · sev med · effort small · angle tests · files: sim/sim.gd
- Problem: state_checksum() (sim/sim.gd:2555) hashes pilots/enemies/projectiles/enemy_proj/gems but never touches sim.drones (the shared DroneState pool with per-pilot owner, added 2026-07-11) or per-pilot drone_charge. snapshot_string() (sim/sim.gd:2581) likewise has no drone segment. This means even if a 2-pilot determinism test existed (see companion finding), it would be structurally blind to any RNG or ordering desync that only affects the drone director’s per-pilot update loop – e.g. if P2’s drone deploy/recharge ever drew from the wrong RNG stream or ticked in a different order between two same-seed runs, the checksum/snapshot would still match because neither drone position, drone owner, nor drone_charge is part of the hashed state.
- Suggested fix: Add drones (pos + owner index + charge/hp fields as relevant) and each pilot’s drone_charge into both state_checksum()’s parts array and snapshot_string()’s formatted string, in a stable loop order (mirroring the existing ‘loop over all pilots’ pattern). This WILL move the pinned baseline (snapshot_string().hash()/state_checksum() in tests/test_determinism_survival.gd), so it must be paired with a deliberate re-pin, not silently merged.
ArchetypeRenderer.sync() calls shape_for() twice per enemy every frame
perf · sev low · effort small · angle render · files: render/archetype_renderer.gd
- Problem:
sync()runs two full passes over the live enemy pool every render frame: a counting pass that callsshape_for(tid, shape_id[i])to tally_bucket_count, then a fill pass that callsshape_for()again for the sameito place the instance.shape_for()is only O(1) (array index / branch), so the per-call cost is small, but it’s needless duplicate work at swarm scale (100-300+ enemies × 2 calls = redundant branch evaluations every single frame with no adaptive-quality ladder currently active to offset it). - Suggested fix: Compute
shape_for()once per enemy into a scratchPackedInt32Arraysized tonduring the counting pass, then reuse that array’s values in the fill pass instead of recallingshape_for().
sim.elite_render_info() allocates a fresh Array+Dictionary per elite every render frame regardless of change
perf · sev low · effort small · angle render · files: sim/sim.gd, main.gd, render/elite_bar_renderer.gd
- Problem: main.gd’s per-frame render step calls
sim.elite_render_info()every frame (not gated by whether any elite exists or changed), which allocates a newArrayplus oneDictionary(with a freshly built uppercase name String viaAreaDefs.enemy_name_for) per live elite.EliteBarRenderer.update_elites()then unconditionally callsqueue_redraw()even when_infosis empty or unchanged from the previous frame. On a run with several elites alive this is steady per-frame GC churn (String building + Dictionary allocation) for data that mostly doesn’t change frame-to-frame (HP fraction moves slowly; name/radius never change while alive). - Suggested fix: Either (a) skip the whole call/redraw when
sim.elite_render_info()would return empty (fast early-out using an elite count check before building the array), or (b) have EliteBarRenderer cache the last info and only queue_redraw when the data actually differs meaningfully (e.g. positions/fracs changed beyond an epsilon). Low risk since it’s render-side only, but touches a function with an existing signature other code may depend on — worth a deliberate look rather than blind edit.
StartMenu carousel confirmation uses wrong default fallback ship mode
bug · sev low · effort trivial · angle ui · files: ui/start_menu.gd
- Problem: StartMenu._input() line 558 calls
_choose(String(_cards[0].get_meta("mode", "survival")))with a fallback default of “survival”. However, when V01_CRYSTALS_ONLY is true (currently true for v0.1 launch), ONLY the Survival mode is shown, so the center card is always Survival and the fallback never activates — this is safe by accident. But if V01_CRYSTALS_ONLY is flipped to false to restore Tutorial/Story modes, the fallback remains hardcoded to “survival” instead of being situational. If somehow _cards[0] is rendered without a “mode” meta (a defensive-coding edge case), pressing confirm would silently select Survival regardless of which card is actually centered. - Suggested fix: Either: (1) Remove the fallback default entirely (
get_meta("mode")will error if missing, forcing developers to fix any meta-wiring bugs), OR (2) Change the fallback to_items[0][0](the id from the first item in the current _items list), which is situational and matches the actual centered card’s mode. Option (2) is safer:_choose(String(_cards[0].get_meta("mode", String(_items[_carousel_index][0])))).
Evolutions array empty but weapons reference evolutions
cleanup · sev low · effort small · angle content · files: data/bible.json
- Problem: The bible.json ‘evolutions’ array is empty ([]), yet 5 weapons define evolution names: Blaster→Auto-Cannon, Wildfire→Firestorm, Scar Tissue→Hemorrhage, Momentum Core→Overdrive, Chronowell→Stopwatch. No code seems to enforce that evolutions exist in the array, but this is an incomplete data record that could break if validation is added.
- Suggested fix: Either: (1) populate the evolutions array with proper entries (id, name, etc.), or (2) confirm evolutions are computed/generated at runtime and intentionally not stored here. If (2), add a comment explaining why the array is empty despite evolution names being referenced by weapons.
Railgun ‘lance’ fx_event kind collides with pre-existing ‘lance’ visual shape
cleanup · sev low · effort trivial · angle audio · files: sim/weapon_railgun.gd, sim/weapon_beam.gd, render/archetype_renderer.gd
- Problem: weapon_railgun.gd emits
{"kind": "lance", ...}as its sound event, but “lance” is ALSO used as a visual shape identifier in weapon_beam.gd (the Lance evolution) and likely as a render shape constant in archetype_renderer.gd. When the fx_events tuple goes to audio_manager for sound routing, there’s no collision (audio ignores ‘lance’ today, treating it as unmapped), but future audio wiring for railgun will conflict with potential visual shape routing or other render-side use. A near-miss: if someone later adds a shape kind or a visual fx kind both named ‘lance’, the collision becomes a real bug. - Suggested fix: Rename weapon_railgun.gd’s fx_event kind from “lance” to “railgun_fire” (or “prism_lance_fire” if the weapon’s in-game name is ‘Prism Lance’). Update any audio mappings that reference it. Verify that the Lance weapon’s shape/visual effects are keyed on a separate constant (e.g., a render shape constant like SHAPE_LANCE or a distinct visual fx kind), not the same “lance” string. Grep for all uses of ‘lance’ (both the word and the string literal) to confirm no other collision points exist.
Uninitialized Label field _joy_lbl in main.gd — dead dev-only debug readout
cleanup · sev low · effort trivial · angle codeq · files: /Users/chris/Claude/bullet-heaven/main.gd
- Problem: The _joy_lbl field is declared at line 87 with comment ‘dev-only live joypad readout’, but is never initialized. References at lines 1097-1098 guard with is_instance_valid(_joy_lbl), and line 2071 attempts to set .text, but since the label was never created, this dead code silently does nothing. CLAUDE.md explicitly states: ‘The dev-only joypad debug readout used to confirm this is now REMOVED entirely (BUILD 161/162, Chris asked for it gone even from tvOS — no remaining use case).’ The field and its associated dead function _update_joy_debug_label() (lines 2052-2071) should be removed completely.
- Suggested fix: Delete line 87 (var _joy_lbl declaration), lines 1097-1098 (the is_instance_valid guard + call to _update_joy_debug_label), and the entire _update_joy_debug_label() function (lines 2052-2071). Confirmed dead by CLAUDE.md status notes.
- Downgraded to TRIAGE by controller: a companion verifier found via git history that this is a half-lost merge artifact from Toby’s active MFi-controller debugging (re-added Jul 8, init accidentally dropped in the Jul 9 merge), not settled cruft. Per the ‘recently touched, verify before deleting’ rule, Chris/Toby should decide delete-vs-restore rather than auto-delete.
❌ Refuted (kept so later cycles don’t re-litigate)
Section titled “❌ Refuted (kept so later cycles don’t re-litigate)”-
Trailing colon syntax error in weapon_panel.gd line 31 (angle codeq) — Line 31
var kind: String = "":is valid GDScript 2.0 inline-setter syntax — the trailing colon opens the indentedset(v):block on lines 32-36 that follows, exactly like a Python-style def block header. This is NOT a syntax error; it’s the standard idiom for declaring a property with a custom setter,var name: Type = default:then an indentedset(v): .... Confirmed three ways: (1) the iden -
Trailing colon syntax error in drone_dock.gd line 36 (angle codeq) — The finding is factually wrong about GDScript syntax.
var klass: String = "":followed by an indentedset(v):block is Godot 4.x’s standard "property with inline setter" syntax — the trailing colon opens the setter block on lines 37-42, exactly as with cooldown_frac two lines below it. Verified: a headless Godot boot of the full project (–quit-after 30) produced zero SCRIPT ERROR / Parse Er -
Blaster weapon missing from WEAPON_ORDER (angle content) — Refuted by an explicit, currently-passing test. tests/test_weapon_blaster.gd::test_blade_is_still_offerable asserts
assert_false(UpgradeSystem.WEAPON_ORDER.has("blaster"), "the starter is not offered as a grant")– the exclusion is deliberate design, not an oversight. Blaster is the always-active starter weapon (pilot_arsenal.gd: active_weapon_ids = [“blaster”]), so it never needs to be ’offere -
Sentinel in bestiary.json is not an enemy (angle content) — The finder confused two unrelated same-named concepts. “Sentinel” the drone class (meta-progression, drone_director.gd) is real and separate from “Sentinel” the boss enemy in data/bestiary.json (threat:5, 1800 HP, telegraphed multi-attack boss). The bestiary entry maps directly to a real EnemyPool type: content/bestiary_db.gd’s TYPE_KEYS array indexes “sentinel” at position 15, which is EnemyPool.
-
MetaShopPanel carousel main input loop doesn’t return after drag input (angle ui) — The finder explicitly states “This is actually correct as-is” and “No fix needed.” Code inspection confirms: line 699 does return after handling InputEventScreenTouch, lines 700-703 return after InputEventScreenDrag, and 704-706 return after InputEventMouseMotion (when drag is active). The drag-checked-first pattern is correct and working as intended—drag handlers all return before falling through
-
Level-up panel detail box rebuild clears children but doesn’t null-check before reading (angle ui) — The finding claims _render_detail() lacks a null-check guard before populating _detail_box and suggests adding one. However, lines 433-434 already implement exactly the guard suggested:
if _detail_box == null or not is_instance_valid(_detail_box): return. The finder appears to have misread or overlooked this existing defensive check. The code is already safe and doesn’t need the suggested fix.
Cycle 2 — broad sweep (started 2026-07-13 23:15 BST)
Section titled “Cycle 2 — broad sweep (started 2026-07-13 23:15 BST)”Counts: 1 auto-applied, 18 triage, 7 refuted.
✅ Auto-applied (commit 34556ef, local only)
Section titled “✅ Auto-applied (commit 34556ef, local only)”CLAUDE.md line 44 contradicts lines 78-80 on co-op P2 progression status
docs · sev med · effort trivial · angle tooling · files: CLAUDE.md
- Problem: Line 44’s launch-gate bullet claims ‘P2 progression (XP / upgrades / drone) is still P1-only routed — co-op is playable, not finished’, contradicting lines 78-80 which correctly document that both P2 upgrade pipeline (2026-07-10, commits b057167..6746eca) and P2 drone/decoy (2026-07-11, 83f1e98..0a6ddf4) are DONE and merged. The architecture section is accurate; the launch-gates section is stale and would mislead a reader about the actual current state of co-op implementation.
- Suggested fix: Update line 44 from ‘⚠️ P2 progression (XP / upgrades / drone) is still P1-only routed — co-op is playable, not finished; a real 2-controller playtest is owed.’ to ‘⚠️ P2 progression is now DONE (kill-XP 2026-07-09, upgrade pipeline 2026-07-10, drone/decoy 2026-07-11); a real 2-controller playtest is still owed to confirm on-device behavior.’
- ✅ Applied in commit
34556ef(local, unpushed).
📋 Triage — needs Chris
Section titled “📋 Triage — needs Chris”test_drone_rng_is_isolated is tautological — it never proves isolation
test · sev med · effort small · angle tests · files: tests/test_drone_interceptor_novel.gd
- Problem: test_drone_rng_is_isolated() (line 16-18) only asserts
sim.drone_rngis not null. That’s not a test of isolation at all — it would pass even if drone crit rolls drew fromsim.rng(the spawn stream) instead ofdrone_rng, which is exactly the kind of RNG-stream-contamination bug that would silently desync co-op/replay determinism. Contrast with the two OTHER isolated streams on Sim, which both have a real isolation test:tests/test_determinism.gd::test_upgrade_rolls_do_not_perturb_sim_stream(rolls upgrade choices, then diffs the full sim trace) andtests/test_player_crit.gd::test_zero_chance_draws_nothing_from_player_rng+test_crit_rolls_draw_from_player_rng_not_the_spawn_stream(draws player_rng directly and confirmssim.rng’s stream position is unaffected).drone_rng(sim/sim.gd:239, seeded independently at sim/sim.gd:596) has no equivalent — its crit draw at sim/drone_director.gd:261 is the ONLY place it’s touched anywhere in the codebase, and nothing proves that draw doesn’t also silently consume fromsim.rng. - Suggested fix: Replace or add a real isolation test mirroring test_zero_chance_draws_nothing_from_player_rng’s pattern: create two same-seed Sims, deploy an Interceptor drone with guaranteed crit on one and force several drone_interceptor() strikes (consuming several drone_rng draws), leave the other untouched, then assert sim.rng.randf() (or a full snapshot_string()/tick trace) is identical between the two — proving drone crit rolls never perturb the spawn stream. Pure test addition, no production code change, directly testable today, would fail loud if a future edit ever routed drone crit through the wrong RNG.
CLAUDE.md’s ‘2 separate RNG streams’ claim is stale — there are 4, and the doc undersells the isolation surface tests need to cover
docs · sev low · effort trivial · angle tests · files: CLAUDE.md, sim/sim.gd
- Problem: CLAUDE.md’s Architecture section states: ‘Two separate RNG streams on Sim: rng (spawns/sim) and upgrade_rng (upgrade rolls). Never draw upgrade choices from rng — that desyncs the spawn stream.’ But sim/sim.gd actually seeds FOUR independent streams: rng, upgrade_rng (sim.gd:238), drone_rng (sim.gd:239, added for drone crit), and player_rng (sim.gd:240, added for player weapon crit) — both drone_rng and player_rng post-date this doc line and were never folded into it. This isn’t just a doc nit: it’s a coverage map — a reader following CLAUDE.md’s guidance when adding a new RNG-driven feature would reasonably assume only 2 streams exist and only 1 isolation invariant (upgrade vs spawn) needs protecting, missing that drone_rng and player_rng need the same protection (see the companion finding: drone_rng’s isolation is currently untested).
- Suggested fix: Update the CLAUDE.md line to read: ‘Four separate RNG streams on Sim: rng (spawns/sim), upgrade_rng (upgrade rolls), drone_rng (drone crit), player_rng (player weapon crit). Never draw from the wrong stream — each has (or should have) a dedicated isolation test proving it doesn’t perturb sim.rng’s spawn stream (see tests/test_determinism.gd, test_player_crit.gd, test_drone_interceptor_novel.gd).’ Mechanical doc correction, no code change.
Wyrm shared-HP pool takes multiplied damage from any AoE that hits multiple segments in one tick
bug · sev high · effort small · angle sim · files: sim/elemental_system.gd
- Problem: Wyrm enemies are ~20 pooled segments chained together (EnemyBehaviors.WYRM_FOLLOW_DIST = 22px apart) sharing ONE HP pool via wyrm_id, with damage_enemy() mirroring the post-hit HP value into every segment (sim/elemental_system.gd:151-159). This mirroring is correct for a SINGLE damage_enemy() call, but nothing dedupes MULTIPLE damage_enemy() calls against different segments of the SAME worm within one tick. Both AoE damage paths in this file hit exactly that case: (1) resolve_collisions()’s impact-burst loop (lines 76-81) calls damage_enemy(sim, bei, bdmg) once per enemy index found by sim.hash.query_circle(ppos, br, sim.enemies) – with a typical burst radius and 22px segment spacing, several consecutive Wyrm segments fall inside one burst and each independently subtracts bdmg from the shared pool; (2) update_zones() (lines 215-222) does the same every tick for every enemy standing in a lingering elemental zone (ZONE_RADIUS=78px, comfortably spanning 3-4 chained segments) – a Wyrm sitting in a zone takes N x ZONE_DPS per tick instead of 1x, where N is however many of its own segments are inside the zone radius. Net effect: any AoE weapon or zone effect deals several times its intended damage against Wyrms specifically, making them die far faster than their bible.json HP suggests and trivializing them compared to single-target weapons – the opposite of an intentional design (Wyrms are meant to be a tanky segmented-body enemy, not a burst-AoE speed bump). This is upstream of and distinct from the already-reported Sim._sweep_dead() N-segments-pay-N-kills-of-XP/gold bug (both stem from the same lack of a wyrm_id per-tick dedup, but one multiplies damage taken and the other multiplies rewards paid – fixing one does not fix the other).
- Suggested fix: Add a per-tick ‘already hit this wyrm_id’ guard shared across both AoE call sites (and any future ones): e.g. a Dictionary reset at the top of resolve_collisions()/update_zones() (or centralized inside damage_enemy() itself, gated on whether the SAME source-effect already applied to this wyrm_id this tick) so a burst or zone that overlaps multiple segments of one worm only subtracts its damage from the shared pool once, while still letting the direct single-target hit (the projectile’s primary hit_ei) apply normally. Needs a new test spawning a Wyrm and firing an AoE burst/zone that geometrically overlaps 3+ of its segments, asserting the shared pool only drops by one hit’s worth of damage, not the segment count’s worth. Determinism-safe if implemented without new RNG draws (pure bookkeeping), but re-run the full suite + baseline check since it changes enemy-death timing in existing playtraces.
Sequential co-op level-up chaining (_next_leveling_pilot / _on_upgrade_chosen chain-advance) has zero test coverage
test · sev high · effort small · angle tests · files: main.gd, tests/test_main.gd, tests/test_coop.gd
- Problem: CLAUDE.md explicitly documents
main.gdas chaining level-up panels sequentially in co-op (‘main.gd scans all pilots for pending_levelups > 0 and chains level-up panels sequentially. P1 first on a simultaneous level-up, sim stays paused for both’) and separately flags this as ‘⚠️ Owed: a real 2-controller … playtest — confirm P1 can’t hijack P2’s panel and panels chain correctly on-device.’ The actual chaining logic —_next_leveling_pilot()(returns the first pilot insim.pilotswithpending_levelups > 0) and_on_upgrade_chosen()’s re-entrantif _next_leveling_pilot() != null: _open_levelup()chain-advance (main.gd:1830-1854) — is pure enough to unit test (only readssim.pilots/pending_levelups, no engine-only APIs beyond calling into already-testedmain.gdnode methods thattests/test_main.gdalready instantiates directly). Grepped every test file: nothing calls_next_leveling_pilot()or exercises a scenario with BOTH pilots holdingpending_levelups > 0simultaneously and confirms P1 is offered first, P2 second, and the sim stays paused throughout. A regression here (e.g. P2’s panel silently applying to P1’s pilot, or the chain never advancing to P2 and leaving them stuck at a pending level forever) would pass the entire existing suite. - Suggested fix: Add a test in tests/test_main.gd (or a new tests/test_coop_levelup_chain.gd) that: instantiates main.gd’s Main node with a 2-pilot sim (as test_main.gd already does for other main.gd behaviors), grants both sim.player and sim.player2 pending_levelups > 0 simultaneously (e.g. via the existing dev-command force-levelup seam used by test_dev_command_force_levelup_opens_the_panel, or directly setting pilot.pending_levelups), calls the equivalent of _open_levelup(), and asserts: (1) _leveling_pilot is player (P1) first, not player2; (2) after simulating P1’s choice via _on_upgrade_chosen, _leveling_pilot switches to player2 (P2), not null, and the game remains paused (_paused_for_levelup still true); (3) only after P2 also chooses does _paused_for_levelup clear and both pilots get the RESUME_IFRAMES grace. This directly targets the exact scenario CLAUDE.md flags as unverified.
SOUND_MAP.md missing 7 new weapons from 2026-07-09 content batch
docs · sev med · effort small · angle codeq · files: audio/SOUND_MAP.md, sim/weapon_chronowell.gd, sim/weapon_chalice.gd, sim/weapon_wildfire.gd, sim/weapon_momentum_core.gd, sim/weapon_scar_tissue.gd, sim/weapon_thunder_wake.gd, sim/weapon_railgun.gd
- Problem: The weapons table in SOUND_MAP.md (last updated 2026-07-02, before the content batch landed 2026-07-09) is missing rows for Chronowell, Chalice, Wildfire (2 events: ignite + pop), Momentum Core, Scar Tissue, Thunder Wake, and Railgun. The Snapshot line (line 9) still claims ‘59 DONE · 11 GENERIC · 6 SILENT’ but hasn’t been recomputed to include the 7 new weapon rows. This leaves the living sound audit incomplete and misleading — future sound designers consulting this doc won’t know the coverage status of the 6 new weapons.
- Suggested fix: Add 7 new rows to the Weapons table in SOUND_MAP.md (one per weapon, or two for Wildfire if its ignite/pop events warrant separate rows). Assign each a status (DONE if mapped in audio_manager.gd, GENERIC/SILENT otherwise). Recount the totals on line 9 to reflect the new weapon count. Example: if all 7 are currently unmapped, the new snapshot should read ‘59 DONE · 18 GENERIC · 6 SILENT’ (11+7 new), or adjust DONE/SILENT as appropriate.
state_checksum() and snapshot_string() do not include drone state (missing co-op determinism validation)
test · sev med · effort small · angle codeq · files: sim/sim.gd, tests/test_determinism_survival.gd
- Problem: state_checksum() (line 2555) and snapshot_string() (line 2581) hash/snapshot pilots, enemies, projectiles, gems, etc., but omit sim.drones (the per-pilot DroneState pool added 2026-07-11) and per-pilot drone_charge. This means a 2-pilot determinism test (if one existed — see companion overnight finding) would be structurally blind to any RNG or ordering desync affecting only the drone director’s per-pilot update loop. A bug that only changes drone deploy/recharge order between two same-seed co-op runs would pass the checksum silently because drone state never participates in the hash.
- Suggested fix: Add drones (pos, owner index, charge/hp) and each pilot’s drone_charge into both state_checksum()’s parts array (line 2555+) and snapshot_string()’s formatted string (line 2581+), in a stable loop order (e.g. mirroring the existing ‘for pilot in pilots’ pattern). This WILL re-pin the baseline (snapshot_string().hash() / state_checksum() values in tests/test_determinism_survival.gd line 34-35), so the change must be paired with a deliberate re-pin, not silently merged. The re-pin is expected and safe — it just ensures the checksum now validates drone state too.
Boss-intro / elite-spawn moments have no screen-space announcement effect, unlike the Graviton’s bespoke lens
graphics · sev med · effort medium · angle graphics · files: render/gravity_lens.gd, render/elite_bar_renderer.gd, sim/boss_rotation.gd
- Problem: GravityLens (a full-screen BackBufferCopy shader, active only while a Graviton lives) is the one boss that gets a genuinely dramatic screen-space treatment. Other bosses (Warden, Boss2, FunZo, Eye) and elite spawns get a health-bar (elite_bar_renderer.gd) but no equivalent arrival moment — no screen flash, radial vignette pulse, or brief distortion when they spawn. ScreenFeedback already owns a damage-vignette shader + camera shake, currently scoped only to player damage, not enemy-side spectacle.
- Suggested fix: Add a short (0.3-0.5s) reusable threat-announce pulse to ScreenFeedback — a radial vignette flash tinted to the boss/elite’s element color, triggered off boss-spawn / elite-promotion fx_events. Reuses existing vignette shader infra, low implementation cost, but pacing/intrusiveness needs Chris’s judgment given repeated elite spawns could get annoying if overused.
upgrade_system.active_weapon_views() rebuilds a fresh Array+Dictionary-per-weapon (with string formatting) every render frame for the weapon dock HUD
perf · sev low · effort small · angle render · files: sim/upgrade_system.gd, ui/weapon_panel.gd
- Problem: WeaponPanel.update_panel() (main.gd’s _process calls this every frame via weapon_panel.update_panel(sim)) calls
sim.upgrade_system.active_weapon_views(sim, sim.player)unconditionally each frame. That function (sim/upgrade_system.gd:629) builds a brand-newArrayplus oneDictionaryper active weapon (up to sim.max_weapon_slots, typically 3-5), each populated with 2-4 String-formatted stat fields (e.g."dmg %.1f" % (...)) via%formatting — real per-frame GC churn (Dictionary alloc + String formatting) for data (weapon name/glyph/element identity) that is almost entirely static while a weapon is equipped; only the numeric stat readout changes, and even that changes at most once per tick (60Hz sim) not per render frame. This is the same category of waste as the already-flagged sim.elite_render_info() finding but in a different, separately-invoked code path (weapon dock HUD vs elite name bars) — every frame, at swarm/multi-weapon scale, on hardware with the adaptive quality ladder OFF. - Suggested fix: Either (a) cache the built views and only rebuild when active_weapon_ids or the numeric stats actually change (e.g. compare a cheap fingerprint like weapon count + summed damage before rebuilding), or (b) have WeaponPanel own persistent per-tile state and only call into active_weapon_views() at a reduced cadence (e.g. every N frames or on sim tick boundary) since the underlying numbers only change on tick, not on render frame. Low risk (render/UI-only), but worth a deliberate look at call frequency rather than a blind edit given active_weapon_views() is presumably also used elsewhere (e.g. a stats/debug screen) and any caching needs to stay correct for those callers too.
Ship Tilt debug mode can leave cobalt hull in incompatible BAKED_FLIPBOOK state on mode switch
bug · sev low · effort small · angle ui · files: ui/debug_settings_panel.gd
- Problem: DebugSettingsPanel’s _tilt_mode_row (lines 405-414) cycles through TILT_MODE_NAMES [“Off”, “Shader Warp”, “Baked Flipbook”]. The BAKED_FLIPBOOK mode (index 2) only works for cobalt hull (per CLAUDE.md: ‘
cobalthull only — silently no-ops for other selected hulls’). If a dev switches to BAKED_FLIPBOOK, then selects a different hull (e.g. in co-op ship config), then cycles back to BAKED_FLIPBOOK, the mode will be applied to a non-cobalt hull and silently no-op, leaving the UI state (showing BAKED_FLIPBOOK) desynchronized from the actual render behavior (Off/no-op). No visual feedback to the dev that the mode is inactive. - Suggested fix: In apply_tilt_mode(), check if the current selected hull is cobalt before applying BAKED_FLIPBOOK; if not, silently fall back to SHADER_WARP (the mode that works on any hull). Or, update the cycle row to exclude BAKED_FLIPBOOK when the current hull isn’t cobalt (requires threading ship_config state into the panel). Optionally, add a comment in CLAUDE.md noting this limitation for future devs.
Evolutions array in bible.json is empty despite 5+ weapons declaring evolution names
cleanup · sev low · effort small · angle content · files: data/bible.json
- Problem: The bible.json evolutions array is an empty list, yet 16 weapons (all of them) have an ‘evolution’ field with names like ‘Auto-Cannon’, ‘Firestorm’, ‘Hemorrhage’, ‘Overdrive’, ‘Stopwatch’. Either the evolution metadata should be stored in the array for completeness (parallel to weapons/enemies/elements), or the comment/doc should explicitly explain why it’s empty despite evolution names being referenced. Currently, the file appears incomplete.
- Suggested fix: Either: (1) populate the evolutions array with entries like {id: ‘auto-cannon’, weapon: ‘blaster’, name: ‘Auto-Cannon’, …} matching the pattern of other content types, creating a canonical evolution registry; OR (2) add a comment in bible.json explaining evolutions are computed at runtime and intentionally not stored, and update any code that loads/iterates this file to document that assumption. Option (1) is cleaner for future content pipelines.
Railgun fx_event kind ‘lance’ collides with pre-existing ‘lance’ visual shape constant
cleanup · sev low · effort trivial · angle codeq · files: sim/weapon_railgun.gd, render/archetype_renderer.gd, sim/weapon_beam.gd
- Problem: The Railgun weapon (sim/weapon_railgun.gd) emits fx_events with kind=‘lance’, but ‘lance’ is ALSO used as a render shape constant in archetype_renderer.gd (SHAPE_LANCE = 13, line 40) and as a visual shape in weapon_beam.gd (the Lance evolution). Today this creates no active bug (audio_manager.gd treats ‘lance’ as unmapped/SILENT), but it creates a naming collision that risks future confusion: if someone later wires audio for ‘lance’ events or adds a separate visual fx kind also named ‘lance’, the collision becomes a real problem. This is a near-miss that should be resolved preemptively.
- Suggested fix: Rename weapon_railgun.gd’s fx_event kind from ‘lance’ to a railgun-specific identifier like ‘railgun_fire’ or ‘prism_lance_fire’ (if the weapon’s in-game name is ‘Prism Lance’). Verify that archetype_renderer.gd’s SHAPE_LANCE (visual) and any other uses of ‘lance’ are isolated to their own namespaces (render shapes vs. fx_event kinds) and won’t collide. Grep for all uses of the string ‘lance’ to confirm no other collision points exist.
sim.elite_render_info() allocates fresh Array + Dictionary per elite every render frame regardless of change
perf · sev low · effort small · angle codeq · files: sim/sim.gd, main.gd, render/elite_bar_renderer.gd
- Problem: main.gd’s per-frame render step (around line 1166+) calls
sim.elite_render_info()unconditionally every frame, which allocates a new Array plus one Dictionary-per-live-elite (with a freshly-built uppercase name String via AreaDefs.enemy_name_for) regardless of whether elite state changed. EliteBarRenderer then unconditionally calls queue_redraw() even when the data is empty or unchanged. On a run with multiple elites alive, this causes steady per-frame GC churn (String allocation + Dictionary creation) for data that mostly doesn’t change frame-to-frame (elite HP fraction moves slowly; name/position/radius are static while alive). At 60 FPS with 2-3 elites, this is unnecessary allocation overhead. - Suggested fix: Either (a) skip the entire call/redraw when sim.elite_render_info() would return empty (fast early-out using an elite count check before building allocations), or (b) have EliteBarRenderer cache the last info and only queue_redraw when data meaningfully differs (e.g. positions/HP fracs changed beyond epsilon). Option (a) is simpler and safer: check
sim._elite_count() > 0before calling elite_render_info(), avoiding allocation entirely when no elites exist.
Recolor XP gem trail into an additive comet streak instead of a static dot
graphics · sev low · effort small · angle graphics · files: render/swarm_renderer.gd, sim/sim.gd
- Problem: Gems currently render as plain halo’d dots via SwarmRenderer (same MultiMeshInstance2D pipeline as enemies/projectiles) with no directional motion cue. Since the recent “per-instance size” and “bullet mode” (velocity-oriented elongated quad) techniques already exist in SwarmRenderer (see
_orient/set_bullet_shape), gems that are actively drifting toward the player during a magnet-pull could reuse the exact same elongated-quad path to read as a bright comet/spark streaking in, rather than a static circle sliding across the screen. This is a genuinely new visual read (motion legibility + neon flourish) that fits the shipped “HALO_SCALE cost scales with area” cost discipline already established, at effectively zero extra draw calls (same MultiMesh, just orient by velocity like bullets already do). - Suggested fix: When a gem’s magnet-pull velocity exceeds a small threshold, flip that instance to bullet/oriented mode (reusing
set_bullet_shape’s existing per-instance orientation) so pulled gems render as short additive streaks pointed at the player, reverting to round when idle/slow. Needs a per-instance velocity signal already available render-side (gem pool tracks pos deltas) — no /sim changes, so determinism-safe by construction. Needs Chris’s eye on the streak length/opacity before shipping.
Level-up tier transitions have an ascension flash but no persistent motion signature per tier
graphics · sev low · effort small · angle graphics · files: render/player_renderer.gd
- Problem: PlayerRenderer’s 5 level-tier bands already grow glow + orbiting accent orbs + a hotter core on tier-up, with a one-shot ascension flash. But between tier-up moments, all 5 tiers otherwise share the same idle motion language (thruster trail rate, orb orbit speed) — the escalation reads only in silhouette/size, not kinetic energy. Nova Drift-style tier signalling usually pairs size with motion-rate as a secondary signal.
- Suggested fix: Scale the existing accent-orb orbital angular velocity and the ThrusterTrail’s PER_FRAME_MAX/rate curve slightly upward per tier (e.g. +10-15% per band), so a tier-4 flagship visibly hums with more energy at idle, not just at the ascension pop. Pure render tuning on values that already exist. Needs Chris’s eye for pacing/subtlety.
NebulaBg SubViewport-downres technique not yet applied to GravityLens’s full-screen BackBufferCopy pass
perf · sev low · effort small · angle graphics · files: render/gravity_lens.gd, render/nebula_bg.gd
- Problem: docs/architecture/rendering-and-performance.md establishes SubViewport downres (render at half resolution, upscale via bilinear TextureRect) as the go-to fix for full-screen shader fill-rate cost on the ATV’s A12 GPU, citing NebulaBg as the reference implementation. GravityLens is a full-screen BackBufferCopy + shader pass (same fill-rate-bound cost profile) that predates that pattern and doesn’t use it — it always samples/writes at full display resolution while a Graviton is alive. The lens shader is a soft screen-space swirl/distortion, exactly the case the doc says is invisible for anything already soft/blurred when downres’d.
- Suggested fix: Route GravityLens’s BackBufferCopy+shader through a half-res SubViewport (mirroring NebulaBg’s _viewport/_display/RESOLUTION_SCALE fields) before upscaling to the ColorRect. Should cut fill cost ~4x while a Graviton is alive with minimal visible softening. Perf-motivated but touches a shader users directly perceive (swirl sharpness near the boss center), so still needs an eye-check on real hardware before shipping.
Thruster trail color is a hardcoded single cyan constant with no per-hull or per-tier tint variation
graphics · sev low · effort small · angle graphics · files: fx/thruster_trail.gd, render/ship_classes.gd
- Problem: ThrusterTrail.COLOR is a single hardcoded Color(0.4, 0.8, 1.0) cyan regardless of which of the 6 hull classes is active or which player-tier band is active. Every other per-entity visual in this codebase is data-driven off element/hull identity (ElementPalette, per-enemy-type colors in bible.json) — the thruster trail is the one player-facing effect that doesn’t follow that convention, so switching hulls in the shop doesn’t change exhaust color even though the hull itself has a distinct palette.
- Suggested fix: Add an optional tint parameter to ThrusterTrail (set once at ship selection, not per-frame) sourced from the hull’s existing color identity in ship_classes.gd/ship_sprites, defaulting to the current cyan if no mapping exists yet. Small, isolated change, but a visible cosmetic choice per hull that Chris should eyeball across all 6+ hulls before it ships, especially against the Obsidian cruiser’s placeholder sprite.
ArchetypeRenderer.sync() allocates a fresh bucket_idx PackedInt32Array every render frame instead of reusing a cached member
perf · sev low · effort trivial · angle render · files: render/archetype_renderer.gd
- Problem: In ArchetypeRenderer.sync() (called every render frame from main.gd’s _process via enemy_renderer.sync()), the per-shape write-cursor array is declared as a fresh local:
var bucket_idx: PackedInt32Array; bucket_idx.resize(SHAPE_COUNT); bucket_idx.fill(0)(lines 486-488). This happens every single frame regardless of enemy count. The file already has the correct pattern one field up:_bucket_countis a member var, resized ONCE in_init()and only.fill(0)’d per frame (with an explicit comment ‘Per-bucket counts used during sync (reused each frame)’).bucket_idxserves the exact same role (a small fixed-size SHAPE_COUNT=20 array indexed during the fill pass) but wasn’t given the same treatment, so it’s a needless small heap allocation happening on every frame at swarm scale with no adaptive-quality ladder currently active to offset it. - Suggested fix: Add
var _bucket_idx: PackedInt32Arrayas a member field (resized to SHAPE_COUNT in _init(), same as _bucket_count), then in sync() replace the local declaration+resize with_bucket_idx.fill(0)and use_bucket_idxthroughout the fill loop instead of the localbucket_idx. Mechanical, no behavior change, matches the established sibling pattern in the same function. - Downgraded to TRIAGE by controller: render-side change whose visual output can’t be verified headless (MultiMesh per-instance data is unreadable in headless tests), and the perf upside is negligible (a 20-element PackedInt32Array/frame). Doesn’t clear the ‘clearly worth an unattended commit’ bar — apply + eyeball on next play.
ArchetypeRenderer.sync() calls shape_for() twice per enemy every frame (redundant)
perf · sev low · effort small · angle codeq · files: render/archetype_renderer.gd
- Problem: The sync() method (lines 469-512) runs two separate passes over the enemy pool: a counting pass (lines 474-477) that calls
shape_for(tid, enemies.shape_id[i])once per enemy, then a fill pass (lines 490-511) that callsshape_for()again for the sameito determine the bucket slot.shape_for()is O(1) (array index + branch), so per-call cost is tiny, but this is redundant work: every enemy’s shape is computed twice per frame even though the result is deterministic. At swarm scale (100-300+ enemies × 60 FPS), this is unneeded branch evaluation overhead. - Suggested fix: Cache the shape results during the counting pass into a reusable PackedInt32Array (sized to
n). Reuse that array’s values in the fill pass instead of recallingshape_for(). Example:var shapes := PackedInt32Array(); shapes.resize(n); for i in range(n): shapes[i] = shape_for(...); ... // then fill pass uses shapes[i] directly. This is a pure mechanical optimization with zero behavioral change, and reduces per-frame work by ~100 redundant branch calls at full swarm scale. - Downgraded to TRIAGE by controller: same reason as the bucket_idx render opt — unverifiable headless, negligible perf value.
❌ Refuted (kept so later cycles don’t re-litigate)
Section titled “❌ Refuted (kept so later cycles don’t re-litigate)”-
DebugSettingsPanel missing ui_accept action check for confirm (angle ui) — The pattern deviation is real (debug_settings_panel.gd hardcodes InputEventJoypadButton/KEY_ENTER checks instead of event.is_action_pressed(“ui_accept”) like level_up_panel.gd/dimension_choice_panel.gd/meta_shop_panel.gd), but the claimed functional bug does not hold up. Verified via a live headless Godot engine query that Godot’s built-in ‘ui_accept’ default binds ONLY to Enter, KP Enter, and Spa
-
TYPE_NAMES array has stale telemetry-only entries mismatching actual TYPE constants (angle content) — The three “stale” TYPE_NAMES entries are intentional legacy aliases, not mismatches. sim/sim.gd’s _ENEMY_NAME_TO_TID dict explicitly maps both “tank” and “pyromancer” to TYPE_TANK, and “fireball” to TYPE_TANK_MISSILE. data/bible.json uses archetype “charger” for the Elite enemy. Most decisively, tests/test_tank_missiles.gd::test_type_name_is_fireball asserts TYPE_NAMES[18] == “fireball” with the c
-
V01_LOCK_NETWORKED_MP documented in CLAUDE.md but doesn’t exist in main.gd (angle codeq) — CLAUDE.md line 45 already states, in full: “main.V01_LOCK_NETWORKED_MP=true — LAN networked MP (M-B + M-C) is built + real-device tested but UNMERGED, in worktree .claude/worktrees/networked-multiplayer-mb-mc. Needs re-reconciling against main before merge…” This is not misleading — it explicitly flags the constant as unmerged and worktree-only, which matches reality (grep confirms V01_LOCK_NETW
-
DimensionChoicePanel missing fallback set_input_as_handled() on unmatched input (angle ui) — The finding misreads the actual code. dimension_choice_panel.gd is only 162 lines; there is no “final return at line 149” — that line is blank, past the end of _input(). The down/up nav path (lines 138-148) already calls get_viewport().set_input_as_handled() at line 148 before falling off the end of the function, exactly where the finder’s suggested fix would add it — so the proposed fix is a no-o
-
_make_btn in DebugSettingsPanel line 380 has syntax error — missing parenthesis in callback (angle ui) — The reported “syntax error” on line 380 is not real. The code is valid GDScript. The line
lbl.text = _row_text(row), Color(0.4, 1.0, 0.55))properly closes a multi-line lambda and passes a third parameter to_make_btn. The comma on the same line as the last lambda body statement is valid GDScript syntax for separating the lambda parameter from the next parameter. The_make_btnfunction signa -
LevelUpPanel.show_for() caches upgrade effects but never re-evaluates on sim state change during focus changes (angle ui) — The finding claims that upgrade effects cache in LevelUpPanel can become stale if pilot state changes while a panel is open (e.g., another pilot’s upgrade applying). However, this scenario is architecturally impossible. Analysis: (1) While any level-up panel is open, main.gd sets _paused_for_levelup=true, which prevents sim.tick() from running (checked at line 327). (2) The sim state only changes
-
Boss entities (Warden, Boss2, Custom) missing from bible.json enemies array (angle content) — The missing entries are intentional by design. TYPE_BOSS, TYPE_BOSS2, and TYPE_CUSTOM are explicitly excluded from bible.json with inline comments in sim.gd stating they are “spawned manually, not from the bible table” or “built at runtime from shape_id/attack_id, not the bible table.” All three have empty-dict entries in the _enemy_types array, which is the correct representation. No code path at
Cycle 3 — hierarchical sweep (started 2026-07-13 23:37 BST (hierarchical method))
Section titled “Cycle 3 — hierarchical sweep (started 2026-07-13 23:37 BST (hierarchical method))”Counts: 1 auto-applied, 12 triage, 0 refuted.
✅ Auto-applied (commit 9c1f676, local only)
Section titled “✅ Auto-applied (commit 9c1f676, local only)”9 spec/plan files missing required title: frontmatter — docs site build fails
docs · sev high · effort trivial · angle ? · files: docs/superpowers/plans/2026-07-05-reset-shop.md, docs/superpowers/plans/2026-07-05-start-menu-carousel.md, docs/superpowers/plans/2026-07-06-centurion-enemy.md, docs/superpowers/plans/2026-07-06-player-ship-tilt.md, docs/superpowers/plans/2026-07-09-reactions-mode-archive-and-rename.md, docs/superpowers/plans/2026-07-10-p2-upgrade-pipeline.md, docs/superpowers/plans/2026-07-13-beta-request-flow.md, docs/superpowers/plans/2026-07-11-p2-drone-decoy.md, docs/superpowers/specs/2026-07-09-dimension-choice-popup-design.md
- Problem: Starlight requires all markdown files in the content collection to have a YAML frontmatter block with a
title:field. Nine spec and plan files lack this frontmatter. When these files are symlinked intodocs-site/src/content/docs/specs-and-plans/, the Astro build fails with[InvalidContentEntryDataError] ... data does not match collection schema. title**: **title: Required. Runningnpm run buildin docs-site/ fails immediately on the first missing file (currently2026-07-05-reset-shop.md). The directive’s claim that “ALL 12 symlinked docs” are missing frontmatter is partially true — only 9 of them are missing it; the other 12 top-level symlinked files (NEXT_TASKS.md, DESIGN.md, PRODUCT.md, roadmap.md, etc.) all have correct frontmatter. However, these 9 files ARE actually blocking the current build. - Suggested fix: Add a YAML frontmatter block with
title:to each of the 9 files. The pattern used in all other docs is---\ntitle: "<descriptive title>"\n---on separate lines, followed by a blank line before the first#heading. For example:---\ntitle: "Reset Shop (respec) — implementation plan"\n---(extracting a title from the existing# Headingline and adapting it). Starlight’sautogenerateconfig (inastro.config.mjs) then discovers them and renders them live. - ✅ Applied in commit
9c1f676(local, unpushed).
📋 Triage — needs Chris
Section titled “📋 Triage — needs Chris”DialogueBox._input() missing catch-all set_input_as_handled() for unrelated input
bug · sev med · effort trivial · angle ? · files: ui/dialogue_box.gd
- Problem: DialogueBox._input() (lines 143-151) only calls set_input_as_handled() if confirm input is detected. If ANY other input event (F4, X, arrow keys, unrecognized joypad buttons) is received while dialogue is showing, it falls through without being marked handled, allowing it to leak to main._input() and potentially trigger hotkeys or debug overlays while the dialogue box is active. This matches the exact pattern that was confirmed as a bug in PauseMenu (overnight-review line 95-98) and ShipConfigPanel (line 100-103) on the same run.
- Suggested fix: Add get_viewport().set_input_as_handled() at the end of _input() (line 151), after the confirm-true branch closes, to consume all input events entering _input() while the dialogue is showing. This prevents unrelated input from leaking to underlying handlers. The fix is identical to the pattern applied in PauseMenu and ShipConfigPanel: a catch-all set_input_as_handled() that blocks every input event to a modal overlay from falling through.
Co-op: nova/beam/pulse/orbit/scatter weapons apply P1’s element to P2’s hits, not P2’s own
bug · sev high · effort small · angle ? · files: sim/weapon_nova.gd, sim/weapon_beam.gd, sim/weapon_pulse.gd, sim/weapon_orbit.gd, sim/weapon_scatter.gd, sim/sim.gd, sim/pilot_arsenal.gd
- Problem: Sim.tick()’s weapon-fire loop (sim.gd:787-791) is genuinely per-pilot:
for pilot in pilots: for wid in pilot.arsenal.active_weapon_ids: pilot.arsenal.weapon_by_id[wid].update(self, pilot, dt)— each pilot’s own WeaponNova/WeaponBeam/WeaponPulse/WeaponOrbit/WeaponScatter instance fires with that pilot’s own state. But inside each weapon’s update(sim, pilot, dt), the element applied to damage/reactions and the FX event’s ‘element’ field are read fromsim.nova_element_idx/sim.beam_element_idx/sim.pulse_element_idx/sim.orbit_element_idx/sim.scatter_element_idx(weapon_nova.gd:27,31; weapon_beam.gd:54-55; weapon_pulse.gd:59-60; weapon_orbit.gd:45; weapon_scatter.gd:62) instead ofpilot.arsenal.X_element_idx, which is in scope via thepilotparam already passed in. sim.gd:363-389 confirms thesesim.X_element_idxproperties are P1-only forwarders (get: return player.arsenal.nova_element_idx, etc. —playeris always pilots[0]), and there is no per-tick sync that reassigns them to the currently-firing pilot. PilotArsenal itself (pilot_arsenal.gd:29-38) genuinely stores independentnova_element_idx/beam_element_idx/pulse_element_idx/orbit_element_idx/scatter_element_idxfields per pilot instance, each set at construction from content and independently mutable (sim.gd’s setters write through:set(value): player.arsenal.nova_element_idx = value). Confirmed the fix pattern already exists correctly elsewhere in the codebase for these exact same fields: sim/upgrade_system.gd (HUD display + active_element_count, lines 188-194 and 636-655) already readspilot.arsenal.nova_element_idxetc., notsim.nova_element_idx— only the weapon update() bodies (where the actual gameplay hit/reaction/FX happens) got it wrong. Net effect in co-op: when P2’s nova/beam/pulse/orbit/scatter weapon fires, it correctly uses P2’s own cooldown/damage/position state but tags every hit and FX event with P1’s selected element instead of P2’s — silently breaking P2’s elemental build (wrong reactions triggered, wrong FX colour/label) for 5 of the game’s weapons. No existing test catches this: tests/test_pilot_arsenal.gd only asserts the Sim forwarder correctly mirrors player.arsenal (testing the forwarder mechanism itself, not weapon-hit element application), tests/test_weapon_scatter.gd’s element test (test_scatter_pellets_carry_its_element) is single-player only where pilot==player so sim.scatter_element_idx already equals pilot.arsenal.scatter_element_idx and the bug is invisible. Single-player is provably unaffected byte-for-byte (pilot is always the soleplayer, sosim.X_element_idxalready equalspilot.arsenal.X_element_idx) — the fix cannot move the determinism baseline. Related same-pattern bug found just outside the directive’s target-file list, for context: sim/weapon_melee.gd (blade, lines 45-46), sim/weapon_scythe.gd (lines 48-49), and sim/weapon_chalice.gd (line 31) have the identical bug (read sim.blade_element_idx / sim.scythe_element_idx / sim.chalice_element_idx instead of pilot.arsenal.*), unlike WeaponBlaster/WeaponAim/WeaponRailgun/WeaponWildfire which correctly carry their own element_idx field on the weapon instance itself. - Suggested fix: In each of the 5 target weapon files’ update(sim, pilot, dt), replace sim.nova_element_idx -> pilot.arsenal.nova_element_idx (weapon_nova.gd:27,31), sim.beam_element_idx -> pilot.arsenal.beam_element_idx (weapon_beam.gd:54-55), sim.pulse_element_idx -> pilot.arsenal.pulse_element_idx (weapon_pulse.gd:59-60), sim.orbit_element_idx -> pilot.arsenal.orbit_element_idx (weapon_orbit.gd:45), sim.scatter_element_idx -> pilot.arsenal.scatter_element_idx (weapon_scatter.gd:62). This mirrors the already-correct pattern in sim/upgrade_system.gd. Add a test (e.g. extend tests/test_pilot_arsenal.gd or tests/test_coop.gd) that: builds a 2-pilot sim, sets pilot2.arsenal.nova_element_idx (and beam/pulse/orbit/scatter) to a different element index than pilot1’s, ticks until each weapon fires for pilot2, and asserts the resulting elemental_system reaction/fx_event/enemy element-tag reflects pilot2’s element, not pilot1’s. No determinism re-pin needed (single-player output is unchanged since pilot==player there). Consider also fixing the same pattern in weapon_melee.gd/weapon_scythe.gd/weapon_chalice.gd while in this code, since it’s the identical root cause, though those files were outside this directive’s explicit scope.
P2’s element-tagged weapons (Nova/Beam/Pulse/Orbit/Scatter) fire using P1’s selected element in co-op
bug · sev high · effort small · angle ? · files: sim/weapon_nova.gd, sim/weapon_beam.gd, sim/weapon_pulse.gd, sim/weapon_orbit.gd, sim/weapon_scatter.gd, sim/sim.gd
- Problem: Every element-tagged weapon’s update(sim, pilot, dt) reads its element via the Sim-level forwarding accessor (sim.nova_element_idx / sim.beam_element_idx / sim.pulse_element_idx / sim.orbit_element_idx / sim.scatter_element_idx) instead of pilot.arsenal.X_element_idx. Those Sim-level accessors (sim.gd:363-389) are hardwired to player.arsenal.X_element_idx, where player is always pilots[0] (P1) - confirmed via sim.gd:241 and the add_pilot()/add_player2() flow (sim.gd:2058-2070), which creates P2 with its own independently-constructed PilotArsenal.new(content) (sim.gd:2062), fully populated with its own correct X_element_idx fields (pilot_arsenal.gd:29-38, 49-62) that are simply never read. The per-pilot firing loop (sim.gd ~786-791, ‘for pilot in pilots: … pilot.arsenal.weapon_by_id[wid].update(self, pilot, dt)’) correctly ticks P2’s own weapon instance with pilot=player2, and every weapon correctly threads pilot for position (pilot.pos), damage (PlayerCrit.roll(sim, pilot, dmg)), and fire-rate (sim.effective_fire_rate(pilot)) - only the element lookup breaks that per-pilot pattern. Confirmed exact call sites: weapon_nova.gd:27,31; weapon_beam.gd:54,55; weapon_pulse.gd:59,60; weapon_orbit.gd:45; weapon_scatter.gd:62 (9 sites total). apply_element(sim, ei, element_idx) (elemental_system.gd:203) takes a plain int, sets sim.enemies.aura_element[ei] = element_idx, and touches no RNG stream, so this is a pure read-site bug - no downstream signature changes needed, and it cannot move the determinism baseline (element choice never consumes rng/upgrade_rng).
- Suggested fix: At each of the 9 call sites in the 5 weapon files, replace sim.X_element_idx with pilot.arsenal.X_element_idx (the pilot param is already in scope in every update() call). No signature changes, no changes to apply_element() or fx_events schema (the fx_events ‘element’ key already just carries an int). Verify determinism baseline is unaffected post-fix (expected: unchanged, since element choice doesn’t consume RNG) and re-run the full suite.
Player 2 can trigger Drone Bay opening via long LB hold, wiping own active drones
bug · sev high · effort small · angle ? · files: sim/drone_director.gd, input/input_router.gd, main.gd
- Problem: The Drone Bay is documented as ‘P1-only by design’ (CLAUDE.md), but
poll_device(device: int)(InputRouter) allows P2’s held LB button to set the shared_decoy_bay_openflag. When P2 holds LB past DECOY_HOLD_MS (400ms),device_edges()returnsbay_open: true, which sets_decoy_bay_open = trueon line 218-219 of input_router.gd. On the NEXT frame, main.gd line 973 consumes this flag (before calling poll_device again) and opens the Drone Bay. When the bay’s deploy button is pressed,_on_bay_deploy()callsdrone_director.deploy_now(), which unconditionally callssim.drones.clear()at line 418. This wipes ALL drones in the shared pool, including any active drones owned by P2. The wipe is an unintended side effect of a P2 input action, not a designed part of the Drone Bay’s P1-only scope. - Suggested fix: Gate the bay-open flag to P1 only: either (1) have
poll_device()NOT set_decoy_bay_openwhen called for device > 0, or (2) havemain.gdonly consume bay_open after calling poll() for P1, not from the shared InputRouter state. Option (1) is cleaner: add a parameter or check device id insidepoll_device()before setting_decoy_bay_open = trueon line 219. This matches the documented design that the Drone Bay is P1-only.
Boss2 charge attack only tests nearest pilot, missing other pilots in the ray
bug · sev high · effort small · angle ? · files: sim/boss2.gd
- Problem: In Boss2’s charge-attack active_update() (lines 159-173), the code retrieves the nearest pilot to the boss’s current position via
var charge_pilot := sim._nearest_pilot(bpos)and then only tests that single pilot against the charge ray. In co-op, if a different (non-nearest) pilot is positioned in the locked charge direction, that player takes no damage even though they are directly in the attack’s path. This asymmetry with Warden (which tests whichever pilot is actually in range via swing_hit) and FunZo (which tests all pilots via update_zones’ per-pilot loop at line 136 of test_funzo.gd) creates a correctness gap: the charge can miss pilots who are geometrically in the path but happen to be farther from the boss at the moment of impact. - Suggested fix: In active_update(), iterate over all pilots (like FunZo.update_zones does) and test each one against the charge ray. The direction locking during telegraph is fine (nearest-at-telegraph is correct); only the active-phase hit detection needs to change. Replace the single
charge_pilottest with a loop:for pilot in sim.pilots:that checks each pilot’s distance to the ray independently. This mirrors the pattern in BossWarden.swing_hit (lines 121-131), which gets the nearest pilot for approach movement but then tests that specific pilot against range, except here we test all pilots against the ray rather than finding the nearest.
_danger_mult() reads only P1’s level, undertuning enemy/boss difficulty in co-op when P2 outlevels P1
bug · sev med · effort small · angle ? · files: sim/sim.gd
- Problem:
_danger_mult()(sim.gd:2179-2182) computes the level-scaled enemy/boss danger multiplier fromplayer.levelalone, whereplayeris the P1-only compatibility accessor (pilots[0]). This value feeds two live balance-scaling call sites: regular enemy spawn HP/contact/speed scaling (_spawn_one, around line 1279:var danger := _danger_mult()) and boss HP scaling (_spawn_phase_boss, line 1754:var hp_mult := _danger_mult()). In local co-op, if P2 (pilots[1]) levels ahead of P1 (e.g. P1 dies/is passive while P2 farms kills, or P2 simply plays more aggressively), the whole party’s threat stays pinned to P1’s level — the fight is undertuned relative to the party’s actual combined power. This is the same category of gap CLAUDE.md documents as deliberately fixed for XP (commit 104b24c), the upgrade pipeline (b057167..6746eca), and drones/decoys (83f1e98..0a6ddf4) during the 2026-07-09 to 07-11 ‘co-op sweep’ — but danger scaling was missed. Confirmed via git blame:_danger_mult()was authored 2026-06-30 (commit 32d746b), before the co-op M-A sim foundation (Sim.pilotsarray) and before co-op was un-gated (commit 39a5085) — it is single-player-era code that was never revisited when co-op was built out. No design comment anywhere justifies ‘balance around P1 only’ as an intentional choice; the function’s own comment describes intent purely as ‘the player powers up’ with no co-op consideration mentioned.sim.gdalready has an establishedfor pilot in pilots:idiom used at ~8 other sites (e.g. lines 743, 788, 799, 840, 2165, 2550, 2560) for exactly this kind of per-pilot aggregation, making this a clear oversight matching the same pattern already fixed elsewhere, not a considered design choice. - Suggested fix: Change
_danger_mult()to scale off the party’s peak level rather than P1 alone, e.g.:var max_level := 1; for p in pilots: max_level = maxi(max_level, p.level)then usemax_levelin place ofplayer.level. This mirrors thefor pilot in pilots:idiom already used throughout sim.gd and keeps single-player behavior byte-identical (pilots.size()==1 there, so max_level == player.level always). Updatetests/test_level_danger.gdto add a co-op case (2 pilots, P2 higher level than P1, assert danger scales off P2’s level not P1’s) alongside the existing single-pilot tests. Also worth updating the comment above DANGER_START (sim.gd:184-186) to mention it scales off the party’s max pilot level, not just ‘the player’, for future maintainers.
_pick_extra_variety() can return a population-capped Beacon (or Brute), silently breaking the ‘at least 2 distinct types per wave’ guarantee
bug · sev med · effort small · angle ? · files: sim/sim.gd
- Problem: sim.gd:1650-1664 (the main _spawn_wave fill loop) explicitly substitutes TYPE_SWARMER for TYPE_BRUTE at MAX_ALIVE_BRUTES and for TYPE_BEACON at BeaconDirector.MAX_ALIVE_BEACONS before calling _spawn_one, specifically so the population cap doesn’t quietly eat a spawn. The fallback picker _pick_extra_variety() (sim.gd:1683-1696), called from sim.gd:1673 whenever the probabilistic fill produced only 1 distinct type, has no equivalent check: it builds
candidatespurely from SpawnTable.types_unlocked(wave) intersected with the current dimension’s allowed roster minusexclude, with no population-cap filtering. If Beacon is the only remaining unlocked/on-theme/non-excluded candidate AND the Beacon cap (2) is already hit, _pick_extra_variety returns TYPE_BEACON, and the immediate sim._spawn_one(alt, …) call at sim.gd:1674-1675 hits its own hard-cap guard (sim.gd:1261-1263,if tid == EnemyPool.TYPE_BEACON and beacon_director.alive_count(self) >= BeaconDirector.MAX_ALIVE_BEACONS: return) and silently no-ops. The wave then ends with only 1 distinct type spawned, even though the code path believed it had satisfied the ‘every wave has >=2 distinct types’ invariant stated in the comment at sim.gd:1665-1670. Reproduced live: entering AreaDefs.VOID_DIM (whose roster includes TYPE_BEACON), pre-spawning 2 Beacons via the real _spawn_one path to hit MAX_ALIVE_BEACONS, then calling _pick_extra_variety(30, exclude) with every other unlocked/allowed type excluded returns 25 (TYPE_BEACON) as the sole candidate; the subsequent _spawn_one(25, …) call is confirmed to add zero enemies (enemies.count unchanged). Brute has a milder version of the same gap: _pick_extra_variety has no MAX_ALIVE_BRUTES check either, but _spawn_one has no corresponding hard-cap guard for Brute (only the main fill loop substitutes it), so a capped-Brute pick from _pick_extra_variety would actually spawn anyway, just overshooting the soft cap by one — not a silent drop, but still inconsistent with the main loop’s stated intent. - Suggested fix: Give _pick_extra_variety the same population-cap awareness as the main fill loop: either (a) filter TYPE_BEACON out of
candidateswhen beacon_director.alive_count(sim) >= BeaconDirector.MAX_ALIVE_BEACONS (and TYPE_BRUTE when _count_of_type(TYPE_BRUTE) >= MAX_ALIVE_BRUTES) before the pick, mirroring the substitution comment style already used above it, or (b) after pickingalt, apply the same TYPE_BEACON/TYPE_BRUTE -> TYPE_SWARMER substitution used in the main loop before calling _spawn_one. Needs sim.beacon_director / sim._count_of_type in scope — the call site at sim.gd:1673 already hasself, so pass it in or make the function take an explicit sim param. Add a regression test that pre-fills the Beacon (or Brute) population cap, forces a wave where Beacon/Brute would otherwise be the only extra-variety candidate, and asserts the wave still ends with >=2 distinct spawned types (not just that _pick_extra_variety returned a non -1 value).
_danger_mult() scales enemy HP/speed/contact damage off P1’s level only, ignoring P2 in co-op
balance · sev med · effort small · angle ? · files: sim/sim.gd
- Problem:
_danger_mult()(sim.gd:2179-2182) computesclampf(1.0 + float(player.level - 1) * DANGER_PER_LEVEL, 1.0, DANGER_LEVEL_MAX)reading onlyplayer.level—playeris a compatibility accessor forpilots[0](P1), confirmed at sim.gd:241/598. It is called at line 1279 for every regular enemy spawn’s HP/speed scaling (diff := spawner.difficulty_mult(run_time) * area_difficulty_mult * danger) and at line 1754 for boss HP scaling (hp_mult := _danger_mult(), wired in by commit369c0cespecifically to fix bosses feeling too weak against a leveled-up build).levelis confirmed a per-PlayerState field (player_state.gd:10), and the rest of sim.gd is thoroughly per-pilot-aware —for pilot in pilotsappears 11+ times for kill-XP, weapon leveling, drone charge, etc. (the 2026-07-10/11 co-op sweep documented in CLAUDE.md)._danger_mult()was introduced 2026-06-30 (commit32d746b), before that sweep, and was never revisited to account forpilots[1]. No other read ofpilots[1].levelor any max/avg-across-pilots exists near either call site — grep-confirmed. In co-op: if P2 out-levels P1 (e.g. P1 stays defensive while P2 aggressively farms XP), overall difficulty stays pinned to P1’s level while P2 is effectively much stronger — the run gets trivially easy for P2. Conversely if P1 out-levels P2 (e.g. P1 plays more aggressively or has a stronger build), difficulty scales to P1’s level while P2 faces enemies calibrated well above what they’ve earned, potentially overwhelming P2 disproportionately. Both directions defeat the stated intent of_danger_mult()(‘a powered-up build still faces a real fight’) in exactly the two-player scenario the codebase has otherwise gone out of its way to support. - Suggested fix: This is a scoping question for Chris, not a silent fix — several defensible choices exist: (a) max-across-pilots (
pilots.map(p => p.level).max()) so danger tracks whichever pilot is strongest, matching the ‘don’t trivialize a powered-up build’ intent but risking overwhelming a weaker P2; (b) average-across-pilots, a middle ground; (c) min-across-pilots, protecting the weaker player at the cost of undertuning against the stronger one; (d) leave it P1-only by explicit design (P1 as ‘party leader’ driving difficulty) — currently true only by omission, not stated intent. Whichever is chosen, note it changes DANGER_PER_LEVEL’s effective in-run curve for co-op sessions specifically (a balance constant, per the auto-apply rails) and should get its own co-op playtest alongside the already-owed 2-controller playtest noted in CLAUDE.md.
_danger_mult() reads only player.level in co-op, not max/average pilot level
bug · sev med · effort small · angle ? · files: sim/sim.gd
- Problem: The _danger_mult() function at line 2182 reads only player.level (P1), used for spawned enemy HP scaling (line 1279) and boss HP scaling (line 1754). In co-op, if P2 levels up to 15 while P1 stays at 5, the danger multiplier stays at 1.24 (based on P1 only) rather than scaling up toward P2’s actual power level. This creates an unflagged balancing gap: if P2 races ahead in levels via independent progression (2026-07-10 per-pilot upgrade sweep, merged), the shared-world enemy difficulty silently lags behind party power, undermining the threat-scaling intent of the co-op feature.
- Suggested fix: Determine Chris’s design intent for multi-pilot danger scaling, then implement accordingly. Three options: (1) Use max pilot level across all pilots (fairness to stronger pilot, avoids sandbagging the team), (2) use average level, or (3) document the current P1-anchor behavior as intentional (e.g., ‘danger anchors to P1 as the leader’ — would need a comment explaining this choice). Once intent is confirmed, add a test case (test_level_danger.gd or a new co-op-specific danger test) that pins the expected behavior for P1≠P2 levels. This is balance-adjacent and requires explicit human sign-off before implementation.
TouchControls._Pad._input() does not differentiate between handled and unhandled touch input
bug · sev low · effort small · angle ? · files: ui/touch_controls.gd
- Problem: TouchControls._Pad._input() (lines 153-170) never calls set_input_as_handled() at all. While this is intentional for carousel drags (which must pass through un-consumed per commit c810405), the current implementation also fails to mark button/joystick taps as handled when they ARE handled. Taps on WARP/DRONE buttons, moves on the left joystick, and touches on the aim stick all execute their handlers but don’t call set_input_as_handled(), allowing those touches to leak through to other input handlers (though in practice this is likely benign since most underlying input systems ignore redundant touch events). The design intent (let carousel drags pass, consume UI interactions) is correct, but the implementation achieves it through blanket omission rather than selective handling.
- Suggested fix: Add selective set_input_as_handled() calls in _on_down() before each early return (lines 199, 206, and after line 213 for aim-zone match). Add similar calls in _on_up() to mark tracked finger releases as handled. This ensures button taps and recognized joystick/aim interactions are consumed while still allowing unrecognized touches (carousel drags in center screen) to fall through un-consumed. This requires modifying the handler functions to distinguish between ‘touch was handled’ and ‘touch passed through’, rather than the current blanket approach of never marking anything handled.
Graviton collapse can target different pilots in pull vs push phases if a pilot dies mid-pull
bug · sev low · effort small · angle ? · files: sim/graviton.gd, sim/sim.gd
- Problem: The COLLAPSE_PULL and COLLAPSE_PUSH phases each independently call sim._nearest_pilot(gpos) to select which pilot to affect. This is mechanically correct but can produce asymmetric behavior if the pulled pilot dies during the pull phase: the dead pilot gets skipped by _nearest_pilot, so the push phase targets a different (living) pilot. Result: P1 pulled + P2 pushed, an unexpected flip.
- Suggested fix: This is not a code bug but a feel/balance question that requires Chris’s judgment after a real co-op playtest. The current code is correct (dead pilots shouldn’t be targeted). Two options: (1) Lock the target pilot at collapse-trigger time by caching _nearest_pilot result in GravitonState when enrage fires, reuse the same pilot for both phases; or (2) Add a comment documenting the current independent-per-phase targeting behavior and defer to playtest feedback. The edge case is rare (would require a pilot to die during the 1.5-second pull from satellite damage) but plausible.
MetaShopPanel: InputEventScreenDrag not marked as handled for carousel drags
bug · sev low · effort trivial · angle ? · files: ui/meta_shop_panel.gd
- Problem: Lines 700-703 handle InputEventScreenDrag with index==0 but do not call get_viewport().set_input_as_handled() when processing. This allows the event to propagate to lower-layer touchscreen handlers (TouchControls) even though the shop should be consuming carousel-drag input. On iOS/iPad, if a player simultaneously drags the carousel (finger 0) and has a finger on the on-screen joystick (finger 1), the joystick may receive drag input while the shop is open.
- Suggested fix: Add get_viewport().set_input_as_handled() inside the
if event.index == 0:block on line 702, after _update_drag(). This follows the same pattern used in ship_config_panel.gd lines 252-253. Code:if event.index == 0:\n _update_drag(event.position.x)\n get_viewport().set_input_as_handled()
❌ Refuted (kept so later cycles don’t re-litigate)
Section titled “❌ Refuted (kept so later cycles don’t re-litigate)”(none)
Cycle 4 — flat-deep sweep (started 2026-07-14 00:22 BST) — ❌ FAILED (API stall)
Section titled “Cycle 4 — flat-deep sweep (started 2026-07-14 00:22 BST) — ❌ FAILED (API stall)”No findings produced. 8 of 9 finder agents stalled with “Connection closed mid-response” / “no
progress for 180000ms”, a transient server-side API degradation between ~00:22–03:02 BST. The
workflow burned ~2.67h and ~3.2M tokens on retries before returning empty. Also exposed a latent
bug: workflow args were arriving as a JSON string (not a parsed object), so depth:"deep" and
the cycle label silently fell back to defaults — this cycle actually ran the broad finder set, not
deep. Both fixed (scripts now parse args defensively). Re-running as cycle 4b.
Cycle 4b — deep sweep (started 2026-07-14 ~06:58 BST (flat-deep, partial — 2/4 finders stalled))
Section titled “Cycle 4b — deep sweep (started 2026-07-14 ~06:58 BST (flat-deep, partial — 2/4 finders stalled))”Counts: 0 auto-applied, 1 triage, 0 refuted.
✅ Auto-applied (commit n/a, local only)
Section titled “✅ Auto-applied (commit n/a, local only)”(none)
📋 Triage — needs Chris
Section titled “📋 Triage — needs Chris”Halo Chain damage-dealt buff never applies to ranged/projectile attacks, only melee contact damage
bug · sev med · effort small · angle sim · files: sim/sim.gd, sim/enemy_attacks.gd, sim/glass_wisp.gd, sim/halo_chain_director.gd
- Problem: HaloChainDirector’s own doc comment (sim/halo_chain_director.gd:199-201) states the damage-dealt buff (
dmg_mult_for) is ‘Read by sim.gd’s contact-damage line and by any ranged-attack damage computation’. In reality it is only read once, at sim.gd:2540 inside_check_pilot_hit’s CONTACT damage branch (_hurt_player(enemies.contact_dmg[i] * CONTACT_HIT_MULT * hc_mult, ...), line 2541). The RANGED/projectile branch of the exact same function, 16 lines above (sim.gd:2525,_hurt_player(enemy_proj.damage[ep], "ranged", false, false, pilot)), applies zero Halo Chain multiplier. Every enemy that deals damage exclusively via projectiles while standing in a Halo Chain field is completely unaffected by the buff: Shooter’s projectiles (enemy_attacks.gd, SHOOTER_PROJ_SPEED), Fleet Kite’s bolts, Scatterer’s pellets, Custom’s bombs/shots, Beam’s tick damage, and Glass Wisp’s mote bursts (sim/glass_wisp.gd:54,sim.enemy_proj.add(pos, dir * MOTE_SPEED, ..., MOTE_DAMAGE, ...)) are all built with flat damage values that never consulthalo_chain_director.dmg_mult_for. This silently makes the mechanic’s headline effect (‘any enemy… gets a stacking damage-dealt buff’) dead for roughly half the enemy roster (every ranged attacker), the opposite of what the design comment claims and what a player would reasonably expect from standing inside the tether field with a ranged pack nearby. Verified via grep:dmg_mult_forhas exactly one call site in the whole non-test codebase (sim.gd:2540), andenemy_attacks.gdhas zero references tohalo_chainanywhere. tests/test_enemy_halo_chain.gd only unit-testsdmg_mult_foragainst the director directly (never through a ranged-damage path), so this gap is fully untested and would pass the entire suite silently. - Suggested fix: Multiply enemy_proj.damage[ep] by the source enemy’s Halo Chain buff at the point the projectile is fired (not at hit time, since by hit time the firing enemy’s index/buff state may have changed or the enemy may be dead) – e.g. in enemy_attacks.gd and glass_wisp.gd, wherever
sim.enemy_proj.add(...)is called, multiply the damage argument bysim.halo_chain_director.dmg_mult_for(sim, i)(the firing enemy’s own pool index is already in scope at every call site). This is deterministic (no new RNG draws, pure multiplication of an already-computed float) and should not move the checksum baseline in any existing test since halo_chain packs aren’t part of the pinned single-player determinism trace’s early ticks, but re-run the full suite + baseline check regardless since it changes enemy_proj.damage assignment code paths. Needs a new regression test: spawn a Halo Chain pack + a Shooter-type enemy standing inside the tether polygon, force it to fire, and assert the resulting enemy_proj.damage carries the buff multiplier – mirroring the existing test_enemy_inside_tether_polygon_gets_buff pattern but through the real fire path instead of calling dmg_mult_for directly. This is balance-adjacent (changes effective enemy DPS) so it should go to Chris/Toby for triage rather than auto-apply, even though the code fix itself is mechanical.
❌ Refuted (kept so later cycles don’t re-litigate)
Section titled “❌ Refuted (kept so later cycles don’t re-litigate)”(none)