Graphics improvement loop 2026-10-07 (part 2)
Graphics improvement loop — 2026-10-07, part 2 (from 18:30)
Section titled “Graphics improvement loop — 2026-10-07, part 2 (from 18:30)”Branch gfx-loop-1007b, worktree .claude/worktrees/gfx-loop-1007b, cut from main 951ddd04
(part 1, gfx-loop-1007, merged after Chris approved BUILD 273 on ATV). Not merged — Chris eyeballs first.
Same method as part 1: one item per pass, each new look behind its own A/B Debug Settings/console row
(old kept), tunables on the console, gate green per item, determinism untouched.
Queue (Chris, 18:30: “the rest of the vfx upgrades” — VFX only)
Section titled “Queue (Chris, 18:30: “the rest of the vfx upgrades” — VFX only)”Skipped on purpose: beam, pulse, railgun_tell, mark_pop (await Chris’s picks, memory bullet-heaven-weapon-fx-v2).
Not this loop: the sim render-read boundary item (stays top of NEXT_TASKS.md).
wisp_phase(0, MISSING) — Glass Wisp untargetable phase looks identical to solid.disruptor_field(0, MISSING) — slow/weaken/suppress field has no visible radius.turretbullets (3) — KIND_TURRET bolt so the shot itself carries the read.elemental_aura(3) — clutter cap (nearest-N / fade by density) from part 1’s eyeball note.- Remaining score-3 entries, gameplay-readability first:
enemy_projectile,thunder_wake,wildfire,chalice,chronowell,gem_glow,pickup,reaction,chain,ignite, then the rest.
wisp_phase
Section titled “wisp_phase”Score: wisp_phase MISSING 0 -> ENTITY CURRENT 3 (pending Chris’s eyeball). Commit 8ec6827f. Gate: == GATE PASS, 467/467 scripts, 4998/4998 GUT tests, node 221/221; determinism baselines unchanged.
What was invisible: enemies.phased / phase_timer. ElementalSystem.damage_enemy rolls 50% per hit on a solid Wisp; a phase-out negates that hit and every hit for GlassWisp.PHASE_DURATION (0.4 s), and GlassWisp.update counts it back to solid. Nothing drew any of it.
What changed:
WispPhaseLook(fx/wisp_phase_look.gd, main-owned,sync_from_sim(sim)every frame), pure read of the real columns:- Body ghost: main’s
_enemy_colorstints a phased Wisp toward pale glass (WispPhaseLook.tint), andArchetypeRenderer.synctakes a new optionalalpha_multsarray that multiplies the body and glow-layer instance alpha down toBODY_ALPHA. The ghost is a pure function of the timer: it ramps in overPHASE_IN_Sand back out over the lastFADE_OUT_S, so the body is fully solid exactly when the sim makes it hittable. - Time to solid: a spinning dashed glass outline (dashes read as “not solid”). Its lit dashes count down
phase_timer / PHASE_DURATION. - Extras: a faint cyan/magenta refraction rim hugging the body, an expanding shatter ping on phase-out, and a contracting snap ring the moment it is solid again. The snap is the only render-side state (entity_id -> re-solid time).
- Body ghost: main’s
- One additive
draw_multiline_colorscall.alpha_mults()is empty when nothing is ghosted, so the renderer keeps its fast path. Main re-syncs the swarm when that state flips while paused, so an A/B flip from the pause menu shows. - Nine live tunables:
WispPhaseLook.PHASE_IN_S/FADE_OUT_S/BODY_ALPHA/PAD/WIDTH/GAIN/SPIN/SNAP_S/PING_S.
Toggle: Debug Settings > Wisp Phase Look (A/B) (graphics dev command wisp_phase_v2). Off = phased looks identical to solid (as before).
Before / after: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/wisp_phase/mobile/wisp_bench.png and .../gl_compatibility/wisp_bench.png. Rows: old @3x / v2 @3x / v2 @1x in a swarm. Columns: solid / phase-out / phased 0.15 s / re-solidifying / solid again. They come from the new tools/weapon_vfx/wisp_bench.tscn (real sim phase roll + countdown, real ArchetypeRenderer body). No in-game capture.
Eyeball items:
- At game zoom (1x) the Wisp is a 10 px enemy and the whole phase lasts 0.4 s, so the dashed countdown is small and brief. The ghost body plus outline do read as “different”, but the dash countdown may be too fine to follow on a TV.
PAD/WIDTH/GAINare live. - The snap ring on re-solidify is the brightest part of the look. It is meant as the “shoot now” cue, but it may read as a hit effect.
SNAP_S/GAINare live. - The headless GUT RenderingServer stores no MultiMesh instance colours, so the body-alpha path is pinned only by the windowed bench, not by a test assert.
- Not checked: auto-aim. Whether weapons skip a phased Wisp is unchanged (the look only shows the existing state).
disruptor_field
Section titled “disruptor_field”Score: disruptor_field MISSING 0 -> WORLD CURRENT 3 (pending Chris’s eyeball). Commit be0b1d95. Gate: == GATE PASS, 468/468 scripts, 5010/5010 GUT tests, node 221/221; determinism baselines unchanged.
What was invisible: the Disruptor is a player DRONE (not an enemy hazard). DroneDirector.drone_disruptor drifts toward the nearest ranged enemy and, every tick, refreshes slow_timer (x0.5 move speed), plus weaken_timer/weaken_amt (Weaken upgrade, +20% damage taken per level) and suppress_timer (Suppress upgrade, ranged enemies hold fire) on every enemy within DISRUPTOR_RADIUS (160) x cfg Area; every 0.5 s it chips a little damage. Only its small purple hull was drawn.
What changed:
DroneDirector.disruptor_radius(d): new pure static helper.drone_disruptornow queries with it and the look draws with it, so the drawn edge is the gameplay edge. The inline0.5pulse literal becameconst DISRUPTOR_PULSE_S. Both are value-identical refactors.DisruptorFieldLook(fx/disruptor_field_look.gd, main-owned, z -1 under the swarm,sync_from_simevery frame), a pure read ofsim.dronesand the enemy slow/weaken/suppress columns:- Boundary: a bright ewar-purple ring at the real radius, a dim inner ring and 24 slowly turning inward “jammer” teeth. It is a solid edge with teeth, so it does not look like the Blight’s dashed yellow-green rot ring or the white-cyan Stasis net.
- Interior: a faint fill disc and three wobbling interference ripples that drift edge to centre.
- Timing: a sweep ring for
PULSE_Safter each real field pulse (DISRUPTOR_PULSE_S - pulse_timer). On deploy, a shock ring and an ease-out fade-in overSPAWN_S. Before expiry, a 5 Hz blink and a fade over the lastEXPIRE_Sofd.life; the field stays drawn because it is still live. When the drone leavessim.drones(expired or killed), a collapse ring. - “Inside it” cue (on enemies, because the field affects enemies): corner lock brackets on every enemy inside a drawn radius with
slow_timer > 0. Hot pink when weakened, plus a white mute bar above when suppressed.
- Render-side state is only drone instance id -> first-seen time and last pos/radius. Drawing is one additive fill polygon per field plus one
draw_multiline_colors. - 13 live tunables:
DisruptorFieldLook.GAIN/WIDTH/FILL/TEETH_LEN/SPIN/WOBBLE/RIPPLE_S/PULSE_S/SPAWN_S/EXPIRE_S/COLLAPSE_S/MARK_PAD/MARK_GAIN.
Toggle: Debug Settings > Disruptor Field Look (A/B) (graphics dev command disruptor_field_v2). Off = no field drawn, as before.
Before / after: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/disruptor_field/mobile/disruptor_bench.png and .../gl_compatibility/disruptor_bench.png. All rows are at 1x: old in a swarm / v2 sparse / v2 in a 46-enemy swarm. Columns: deploy 0.08 s / holding / pulse with weaken+suppress / expiring (0.5 s left) / collapse. They come from the new tools/weapon_vfx/disruptor_bench.tscn, which runs the real deploy_drones -> update_drones path with the real ArchetypeRenderer and DecoyRenderer. No in-game capture.
Eyeball items:
- In a dense swarm, ~40 brackets in one 160 px field are busy. Non-weakened brackets were dimmed to 0.6 alpha after the first bench.
MARK_GAIN/MARK_PADare live; a nearest-N cap may be wanted, like theelemental_auraclutter item. - Interior ripples are very subtle at 1x (
WOBBLE,FILLlive). The “slow” read relies mostly on the brackets and the field itself. - The suppress bar shows on every enemy carrying
suppress_timer, including melee enemies that never fire (the sim sets it on everything in the field). - The field is drawn at z -1 under the swarm, and the brackets sit outside each body, so they stay visible.
- Not checked on device or in a real main.tscn run; the look is pinned by
tests/test_disruptor_field_look.gd(12 tests through the real drone path).
turret bullets
Section titled “turret bullets”Score: turret 3 -> 4 (pending Chris’s eyeball). Commit 885eebd2. Gate: == GATE PASS, 469/469 scripts, 5023/5023 GUT tests, node 221/221; determinism baselines unchanged.
What was weak: part 1 gave the turret an emplacement body + per-shot tracer, but its rounds were still the generic v2 bolt (a white comet, the same as any elementless shot) and a landed round did nothing.
What changed (folded under the existing Turret Look (A/B) row, turret_v2; off = generic bolt, no ricochet):
PlayerBoltRendererKIND_TURRET branch: a compact pointed slug (white-hot core, steel-cyan jacket, nose glint) trailing a short thin tracer that cools to steel. Garrison (evolved) = amber with a spin glint. The turret is kinetic with no element, so the colours are the emplacement’s (TurretBodyLook.TINT/EVO_TINT), not the bullet tint. Uniformsturret_v2/turret_gainare pushed only on change.- Impact: every landed turret round stamps a render-channel
turret_impact(ElementalSystem.resolve_collisions, next tobolt_impact). FxManager skips it before any juice budget. Main forwards render events toTurretBodyLook.note_events, which draws a ricochet in the tracer’s colour: a hit flash, a tight ring and five sparks kicked back off the struck side, capped atIMPACT_CAP(24). - Five live tunables:
PlayerBoltRenderer.TURRET_LEN/WID/GAIN,TurretBodyLook.IMPACT_S/IMPACT_LEN.
Before / after: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/turret_bullets/mobile/turret_bullets_bench.png and .../gl_compatibility/turret_bullets_bench.png. Rows: old / v2 / Garrison at 2.2x, then the same at 1x. Columns: just left the muzzle / mid-flight / impact +0.03 s / impact +0.09 s. They come from the new tools/weapon_vfx/turret_bullets_bench.tscn (real sim, real PlayerBoltRenderer and TurretBodyLook). zoom_r1.png -> zoom_r3.png in the mobile folder are the iterations: r1 was a white blob (too close to the old comet); r3 has a slimmer cyan slug with a separate tracer. No in-game capture.
Eyeball items:
- At 1x the round is about 22 px and the ricochet about 18 px, so both are small on a TV.
TURRET_WID/TURRET_GAIN/IMPACT_LENare live. - The ricochet only fires through the real hit path, and the bench pins the enemy, so the sparks have not been seen in a dense horde.
- A piercing turret round stamps one ricochet per enemy it passes through (intended, but untested visually).
marker clutter cap
Section titled “marker clutter cap”Scores: elemental_aura 3 -> 4, disruptor_field 3 -> 4 (pending Chris’s eyeball). Commit 98f42d8f. Gate: == GATE PASS, 470/470 scripts, 5037/5037 GUT tests, node 221/221; determinism baselines unchanged.
What was weak: both looks drew one full marker per enemy, so a horde became noise (part 1 aura note; the disruptor eyeball item). The Disruptor mute bar also showed on melee enemies.
What changed. This is one shared treatment, not two caps: MarkerBudget (fx/marker_budget.gd, documented in docs/architecture/rendering-and-performance.md § Per-enemy marker density):
- Priority is distance to the nearest pilot (the
_nearest_pilotrule). The nearest N keep full detail. Past the Nth-nearest radius, markers fade acrossFADE_PX(smoothstep) down to a far floor that is thinned by local density (DENSITY_CELL/DENSITY_REF). The radius is slew-limited in sim time (SLEWpx/s), so when a wave lands on the pilot the old far markers fade over frames. Looks crossfade between the full form and the far form, so there is no pop at the switch. - Aura far form: a 2-segment cap tick above the enemy that still drains with the window (
EnemyAuraLook.NEAR_N24,FAR_ALPHA0.3). - Disruptor far form: two opposite corners. The other two corners and the mute bar fade with nearness (
MARK_NEAR_N12,MARK_FAR_ALPHA0.3). - Mute bar only on
DisruptorFieldLook.fire_suppressibleenemies: shooter/centurion/fleet kite/zapper/scatterer/bomber/lancer, CUSTOM beam, skirmish behaviour, elite kits. These mirror the real suppress gates in enemy_attacks/enemy_behaviors/elite_kit. Chip fire is deliberately excluded: it is one shared shot budget, so a suppressed chipper’s shot just goes to another enemy (EnemyChipFire.updateskips it and moves the cursor on). - 8 live tunables (registry + LiveScope + docs.d + tunable_docs.js; build-273 manifest regenerated). There is no new A/B row: with
NEAR_N/MARK_NEAR_Nraised to 400/300, the old look comes back exactly, live from the console. That is the comparison, and the bench uses it.
CPU (tools/weapon_vfx/clutter_measure.gd, 300 marked, Mac headless sync_from_sim only):
- Aura: 0.50 -> ~0.40 ms/frame, and 4800 -> ~1840 line segments.
- Disruptor: ~0.46 -> ~0.47 ms/frame (unchanged within noise; the per-held field test + budget dominate), and 3124 -> 1584 segments.
- The budget itself costs ~0.075 ms at 300 (one native float sort + one density pass).
- The draw-call submission saving (fewer segments) is not in these numbers.
Before / after: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/clutter/mobile/clutter_bench.png (+ gl_compatibility/). It shows a 300-enemy horde at 1x, budget off vs on, for aura and disruptor. aura_bench.png and disruptor_bench.png were re-rendered to the same folders.
Eyeball items:
- The far aura tick reads as a small chevron/“hat” above each enemy. It is not a ring fragment at 1x.
FAR_ALPHAis live; 0 hides far ticks. - Far Disruptor brackets are very faint in a dense hold, by design (the field itself carries “held”).
MARK_FAR_ALPHAis live. - The full-detail zone is around the pilot, not the drone. Brackets near the Disruptor but far from you are faint.
- Not seen in a real main.tscn run or on device.
- Sim note (not changed): CUSTOM bolt/fan/bomb attackers ignore
suppress_timer(only the beam path checks it), so they get no bar, which is correct for today’s sim.
enemy_projectile
Section titled “enemy_projectile”Score: enemy_projectile 3 -> 4 (pending Chris’s eyeball). Commit a7476527. Gate: == GATE PASS, 471/471 scripts, 5048/5048 GUT tests, node 221/221; determinism baselines unchanged.
What was weak:
- Every enemy shot was a 15x5 pink dash (SwarmRenderer, 3 MultiMeshes) in the same additive language as the player’s shots.
- It drew about 3 px half-width, but
Sim._check_pilot_hittests every enemy shot atSHOOTER_PROJ_RADIUS(6 px), so the drawing was dishonestly small. - No muzzle, no spawn cue, and missiles and burn/slow shots looked identical to plain bolts. At 1x in a real horde the dashes were nearly invisible.
What changed (new Enemy Bolts Look (A/B) row, VisualLook.enemy_bolts_v2, default ON; off = the old dash renderer). It is reachable from Debug Settings and the console graphics toggle, and the parity test is green.
EnemyBoltRenderer(fx/enemy_bolt_renderer.gd+fx/enemy_bolt.gdshader): one MultiMesh, one draw call,blend_premul_alpha. The body is a white-hot core inside a hostile magenta-red (1, 0.07, 0.26), cut out of the scene by a dark contour. Friendly shots are pureblend_addand can never draw dark, so the contour is the hostile signature.- Heading: a teardrop tail of speed x
TAIL_S, clamped to [TAIL_MIN,TAIL_MAX]. - Honest size: body radius = max(
SHOOTER_PROJ_RADIUS, the pool radius) xBODY_SCALE, withBODY_SCALEclamped to >= 1. A test proves this live, including whenSHOOTER_PROJ_RADIUSchanges. - Spawn ramp: the shot starts white-hot and cools over
FLASH_Sat full size, plus a muzzle bloom at the shooter (pos - vel x age) forMUZZLE_S. This needsEnemyProjPool.age, a new column that_move_enemy_projadvances. It is render-read only and not in the checksum (a test pins that). - Families only where the sim differs: homing shots get a torpedo with an amber exhaust; burn/slow shots (EliteKit effect, plus the Fleet Kite’s built-in slow) get a spinning dashed effect ring. Warden shots stay with WardenFx (tint a = 0, skipped).
- 11 live tunables:
GAIN,BODY_SCALE,HALO,CUT,TAIL_S,TAIL_MIN,TAIL_MAX,FLASH_S,MUZZLE_S,MUZZLE_R,SOURCE_MIX. Each has a registry row, a LiveScope entry and a docs.d label.tunable_docs.jsand the build-273 manifest are regenerated.
Perf (tools/weapon_vfx/enemy_bolt_perf.tscn, 500 shots = ENEMY_PROJ_CAP, Mac M5 mobile):
- CPU sync: old 0.5-0.97 ms vs v2 0.3-0.5 ms per frame (hoisted locals, unit-direction writes, inlined buffer writes).
- GPU: below the wall-clock noise floor for both, even at K=8 layers (Metal reports no viewport GPU time).
- The v2 quad covers about 1,050 px² in one pass, against about 470 px² over three passes for the old one. That fill difference has not been measured on ATV.
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/enemy_projectile/mobile/, plus gl_compatibility/):
enemy_bolt_bench.png: old vs v2 at 3x (green ring = the real hit radius) and 1x. Columns: just fired / flight 0.1 s / missile / slow / burn.enemy_bolt_ingame_{old,v2}.png+_crop.png: the dense mixed scene, real main.tscn at 1x with a ranged horde, Blaster + Scatter, gems and the background.- The gl_compatibility build compiles and renders the same.
Eyeball items:
- Bolts now dominate visually over the enemies themselves.
GAIN/HALOare live if that is too loud. - The per-source tint is mostly gone (only
SOURCE_MIXin the glow), so you can no longer tell which enemy fired from the colour. - The muzzle bloom reads as a small red disc at 1x.
- Not on the QualityManager halo ladder.
- Not seen on device.
thunder_wake
Section titled “thunder_wake”Score: thunder_wake 3 -> 4 (pending Chris’s eyeball). Commit 40264adf. Gate: == GATE PASS, 472/472 scripts, 5060/5060 GUT tests, node 221/221; determinism baselines unchanged.
Weakest moment (captured before changing anything, tools/weapon_vfx/out/thunder_wake/before/thunder_wake_bench.png): the strike frame itself. The legacy path was Pulse’s primitive: a 6-segment right-angle zigzag drawn SIDEWAYS from the ship to the target for 0.11 s (it read as a circuit trace, not thunder), plus the generic nova ring, which starts at ~25 px and fades out around 45 px, so it never reached the real 90 px strike radius and understated the AoE. The puddle (already scored 4) carried the whole weapon. Nothing changed with investment or evolution, and the Storm Front volley was just three unrelated zaps.
What changed (new Thunder Wake Look (A/B) row, VisualLook.thunder_wake_v2, default ON; off = the legacy zigzag + ring; console graphics toggle via the same row, parity test green):
ThunderStrikeLook(fx/weapons/thunder_strike_look.gd+.gdshader, FxManager event look, V2_LOOKS"thunder_wake", one draw call):- Strike: a two-octave jagged channel drops from the sky (it draws itself down over
DROP_S), leaning toward the pilot’s side (LEAN), with a white-hot core, branches (2 -> 4 with power, +1 evolved) and one re-strike flicker. - Impact: a white flash, then a shock ring that eases out and LANDS ON the event’s real strike radius (honest AoE). Six ground arcs crawl out to the radius and fade by mid-life, so the puddle’s own arcs take over: one lightning identity from sky to puddle.
- Attribution: a faint pilot -> strike filament (
LINK_GAIN, 0 hides it), replacing the old sideways bolt. - Evolved (Storm Front): a second braided violet-white channel plus the lightning evolve accent. The volley’s strikes are chained by bright arcs:
WeaponThunderWakenow stampsvolleyandchain_fromon the render channel only, with no state or rng change. - Rungs: the shared lightning accent crackle (
element_accents).
- Strike: a two-octave jagged channel drops from the sky (it draws itself down over
- 8 live tunables:
ThunderStrikeLook.HEIGHT/LIFE/DROP_S/WIDTH/LEAN/GAIN/LINK_LIFE/LINK_GAIN, each with a registry row, a LiveScope entry, a docs.d entry, regeneratedtunable_docs.jsand the build-273 manifest. Prewarm families 12/16 include the look. The puddle renderer is unchanged.
Before / after (all in /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/thunder_wake/):
mobile/thunder_wake_bench.pngandgl_compatibility/thunder_wake_bench.pngcome from the newtools/weapon_vfx/thunder_wake_bench.tscn, which uses the real sim, FxManager and ZoneRenderer. Rows: old / v2 fresh / v2 invested / v2 EVOLVED volley / v2 2x. Columns: +0.02 to +1.1 s after the strike.after/ingame_compare.pngcrops the real main.tscn at 1x. Top row: old / v2. Bottom row: old evolved / v2 evolved. The full frames sit beside it (_twold= old).ingame.gdgainedTHUNDER_V2=0andZONE_SETTLE=<s>to catch the strike frame.
Eyeball items:
- The impact flash plus the ring at t ~0.05 s is the brightest thing on screen for a moment.
GAINis live if it is too loud next to the puddle. - The pilot filament is very faint at 1x by design.
LINK_GAINis live. - In the evolved volley, three strikes plus chains are a lot of white for ~0.1 s.
- The channel is a “from the sky” read in a top-down game, which is a stylistic call:
LEAN0 = straight down screen. - Not seen on device. The per-pixel cost is one quad of ~(2R+68) x (230+R+34) px per strike, live for 0.36 s. That has not been measured on ATV.
wildfire
Section titled “wildfire”Score: wildfire 3 -> 4 and ignite 3 -> 4 (pending Chris’s eyeball; wildfire_pop was already 4, the look under it is new but the score is unchanged). Commit 1d7023d5. Gate: == GATE PASS, 473/473 scripts, 5075/5075 GUT tests, node 221/221; determinism baselines unchanged.
Weakest moment (captured before changing anything, tools/weapon_vfx/out/wildfire/before/wildfire_bench.png, +0.50 s): the whole 0.8 s fuse was invisible. A fused enemy looked identical to the rest, and the ignite spark was a 0.3 s orange smudge. Other problems:
- The pop was a generic white-edged ring that sat at ~45 of 70 px at its peak and reached the real hop radius only as it faded, so it understated the spread.
- Nothing linked one detonation to the next ignite, so the chain did not read as a chain.
- Investment and evolution changed nothing visually except the Firestorm zone.
- The shared 24-ring legacy pool dropped pops in a horde.
What changed (new Wildfire Look (A/B) row, VisualLook.wildfire_v2, default ON; off = the legacy spark + ring; console graphics toggle via the same row, parity test green). Both layers share fx/weapons/wildfire.gdshader. Its ramp is warm only: red -> orange -> amber -> pale gold, never white.
WildfireFuseLook (main-hosted, synced from enemies.ignite_fuse_timer):
- A fuse ring sits on each fused enemy.
- A spark burns clockwise at the REAL fuse progress, leaving an ember trail.
- The body glow builds toward the blow and flickers at the last moment.
- MarkerBudget nearest-N plus clump thinning keep a horde readable.
WildfireLook (FxManager event look, V2_LOOKS "wildfire", one draw call):
- Catch flare: shown on ignite.
- Flame hop: a comet from the detonation that lit the enemy (the ignite event’s new
from). This makes the chain visibly spread. - Fireball: fbm core and a flame rim that eases out and LANDS ON the event’s real hop radius, then gutters. Licks and embers follow.
- Heat: the fireball gets hotter with chain depth (
HEAT_STEP). - Firestorm: pops that birth a Firestorm zone (evolved, depth >=
FIRESTORM_CHAIN_LEN) get a vortex and last 1.3x. - Brightness control: a decaying crowd-load counter (
CROWD_N) scales colour, so a dense chain stays warm instead of summing to white. There is also a per-frame cap of 16 pops.
Sim: render channel only.
igniteandwildfire_popnow carrydepth;ignitealso carriesfrom, andwildfire_popcarriesfirestorm._chain_depthis recorded for every ignite and cleared when nothing is fused. It used to leak.
Tunables: 10 live tunables, WildfireLook.LIFE/IGNITE_LIFE/HOP_LIFE/GAIN/HEAT_STEP/CROWD_N and WildfireFuseLook.PAD/GAIN/NEAR_N/FAR_ALPHA. Each has a registry row, a LiveScope entry, a docs.d entry, regenerated tunable_docs.js and the build-273 manifest. Prewarm lists include the look. Pinned by tests/test_wildfire_fx_v2.gd (15 tests).
Before / after (all in /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/wildfire/):
mobile/wildfire_bench.pngandgl_compatibility/wildfire_bench.pngcome from the newtools/weapon_vfx/wildfire_bench.tscn(real sim, FxManager, WildfireFuseLook and ZoneRenderer on a pinned 7-enemy chain). Both renderers compiled with no shader errors.- Rows: old / old EVOLVED / v2 fresh / v2 invested / v2 EVOLVED / v2 2x.
- Columns: +0.05 to +3.3 s after the first ignite.
after/ingame_compare.pngandafter/ingame_compare_evo.pngcrop the real main.tscn at 1x (top row old, bottom row v2). The full frames sit beside them (_wfold= old).ingame.gdgainedWILDFIRE_V2=0.
Eyeball items:
- A dense piled pack still reads yellow-white for a moment. Part of that is the pale piled tank bodies (the old look is pink-white there too).
CROWD_NandGAINare live. - Evolved Firestorm zones re-ignite enemies standing in them, so a late evolved run is a constant stream of vortex pops. This is existing sim behaviour (up to 63 zones), not new.
- The flame hop is subtle at 1x (
HOP_LIFEis live). - The fuse ring is main-hosted, so the vfx_library card shows only flare/hop/pop.
- Not seen on device. Cost has not been measured on ATV: the pop quad is ~(2R+36)^2 px for 0.55 s, and there is one small fuse quad per fused enemy.
chalice
Section titled “chalice”Superseded (2026-10-08, BUILD 278 merge): main independently shipped its own Chalice v2 (
VisualLook.chalice_v2,ChalicePoolLook, BUILD 277, two perf review rounds). At the merge oforigin/maininto this branch, main’s version won outright and commit7fc48e5awas reverted in full (ChaliceSpillLook,chalice.gdshader,chalice_mat.tres, its tunables, rows and bench). The notes below describe the branch version. At Chris’s request (2026-10-08) it was then re-added as a THIRD, switchable look beside main’s:VisualLook.chalice_brew(default off, Debug Settings “Chalice look: Brew (alt)”), with the pool renamedChaliceBrewPoolLookand the shader/material renamedchalice_brew.gdshader/chalice_brew_mat.tres;ChaliceSpillLookkept its name.
Score: chalice 3 -> 4 (pending Chris’s eyeball). Commit 7fc48e5a. Gate: == GATE PASS, 474/474 scripts, 5088/5088 GUT tests, node 221/221; determinism baselines unchanged (no sim change at all).
Weakest moment (captured before changing anything, tools/weapon_vfx/out/chalice/before/chalice_bench.png + before/ingame_chalice_v2.png): the pool itself, the whole time it is down. In game it was a faint brown hexagon outline, nearly invisible against the background while the green aura rings on the enemies it poisoned dominated. All four recurring problems were present:
- Drawn size understated the sim. The hexagon’s vertices sat on the radius, so its flat sides were 13% inside the real
distance <= radiusdamage circle. The cast ring (the generic_spawn_ring) peaked at about a third of the radius and read as a separate green thing. - Invisible/misleading timer. The fill and outline faded linearly from the first frame while the dps stayed constant, so a pool that was still dealing full damage was nearly invisible in its last second.
- Upgrades changed nothing except size. Evolution (Overflowing) was a bigger hexagon.
- Read as something else. A rotating amber hexagon reads as a shield or force field, not a poisoned liquid, and its amber sits close to Wildfire’s fire palette.
What changed (new Chalice Look (A/B) row, VisualLook.chalice_v2, default ON; off = the legacy ring + hexagon; console graphics toggle via the same row, parity test green). Both layers share fx/weapons/chalice.gdshader. Its identity is liquid: a toxic lime brew with the chalice’s gold meniscus, smooth and viscous, hottest = pale gold, never white.
ChaliceSpillLook (FxManager event look, V2_LOOKS "chalice"):
- A splash crown races out from the cup and lands ON the real pool radius at
SPILL_S. - Droplets are flung out and land on the rim with a splat, and a gold glint marks where the cup tipped.
ChalicePoolLook (main-hosted, z -1, synced every frame from Sim.chalice_zones):
- The brew fills out on the same
SPILL_Sease, so the spill hands over seamlessly. - The body is translucent, with caustic veins, slow ripples and closed-form bubbles that swell and pop. Enemies and enemy bolts stay readable inside it.
- A gold-lime meniscus sits ON the real radius (the bench’s white reference circles coincide with it).
- Honest timer: the pool holds full strength for its whole damaging life and only evaporates over the last
EXPIRE_S(the rim breaks into dashes, the liquid thins, wisps rise). - Investment: power thickens the rim and adds bubbles.
- Evolved (Overflowing): a darker brew with the void evolve motif biting violet holes, a second inner tide line and a slow swirl.
- Overlapping pools dim per overlap (
OVERLAP_DIM). They still read brighter, because stacked pools do stack damage. - Zones carry no owner, so power/evolved/rungs come from the first pilot holding a Chalice (a co-op approximation).
Sim: none. Main sets ZoneRenderer.chalice_legacy = false while the pool look draws; every other host keeps the hexagon.
Tunables: 10 live tunables, ChaliceSpillLook.SPILL_S/GAIN/DROPS and ChalicePoolLook.GAIN/FILL/RIM_W/BUBBLES/CAUSTIC/EXPIRE_S/OVERLAP_DIM. Each has a registry row, a LiveScope entry, a docs.d entry, regenerated tunable_docs.js and the build-273 manifest. Prewarm lists include the spill look (the pool shares its saved material). Pinned by tests/test_chalice_fx_v2.gd (13 tests, real WeaponChalice -> Sim.tick -> FxManager.consume / sync_from_sim). A mutation check (pool radius x0.87) fails the suite as it should.
Before / after (all in /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/chalice/):
mobile/chalice_bench.pngandgl_compatibility/chalice_bench.png(v2 rows) come from the newtools/weapon_vfx/chalice_bench.tscn. It uses the real sim, FxManager, ZoneRenderer and ChalicePoolLook, with the pilot drifting so recasts land beside each other. Both renderers compiled with no shader errors.- Rows: old / old EVOLVED / v2 fresh / v2 invested / v2 EVOLVED / v2 2x.
- Columns: +0.03 to +3.8 s after the first cast (spill, settled, the 3 s recast overlap, the first pool evaporating).
- Thin white circles mark each pool’s real damage radius.
after/ingame_compare.pngandafter/ingame_compare_evo.pngcrop the real main.tscn at 1x (top row old, bottom row v2, three frames 0.4 s apart). The full frames sit beside them (_chold= old). The old-side captures do not wait for a cast (the legacy cast has no look to wait on), so the first old frames show no pool.ingame.gdgainedCHALICE_V2=0.
Eyeball items:
- The gold-lime rim is now the loudest part of the pool at 1x. That is deliberate (it is the damage edge), but
RIM_WandGAINare live if it competes with pickups or other rings. - The poison crystal rungs have no motif yet (
ElementAccents.BUILTlacks poison, a shared include, so it was left alone). Chalice rungs show only through power (rim, bubbles). - The evolved pool is busy with bubbles plus void bites at 1.6x radius.
BUBBLESis live. - Not seen on device. Cost has not been measured on ATV: one quad of ~(2R+32)^2 px per live pool (usually 1-2, evolved up to ~374 px across) with 3 fbm calls per pixel, plus a short-lived spill quad per cast.
chronowell
Section titled “chronowell”Score: chronowell 3 -> 4 (pending Chris’s eyeball). Commit 23459691. Gate: == GATE PASS, 475/475 scripts, 5104/5104 GUT tests, node 221/221; determinism baselines unchanged (no sim change at all).
Weakest moment (captured before changing anything, tools/weapon_vfx/out/chronowell/before/strip.png, a fresh bubble at +0.15 / +0.85 / +1.55 / +2.25 s): the second half of the bubble. The disc faded linearly from deploy, so at +2.25 s (0.25 s left, slow still at full strength) it was a barely visible outline. The five checks:
- Drawn size: the legacy disc was on the real radius. The first v2 draft inflated the rim over 0.3 s and so understated it, because the sim slows the whole disc from the deploy tick. Fixed: the rim is on R from the first frame, and the deploy front closes IN onto it.
- Invisible timer: the 2.5 s bubble had no timer. The fade looked like one but ran from deploy.
- Fade contradicting strength: yes, the same fault as Chalice.
- Upgrades / evolution: none visible.
slow,duration,rateandhealshowed nothing. The Stopwatch freeze was a second small copy of the same disc. - Reads as something else: a pale-cyan ring with 8 ticks read as a HUD/radar reticle in the player’s own cyan. The deploy was the generic ring.
What changed (new Chronowell Look (A/B) row, VisualLook.chronowell_v2, default ON; off = the legacy ring + disc; console graphics toggle via the same row, parity test green). Identity: a clock under glass. It uses aqua glass, a brass hand, hour/minute ticks and pale aqua at its hottest, never white. Nothing purple, no inward teeth or ripples, no square brackets, so it stays distinct from the Disruptor field. Everything is in fx/weapons/chronowell.gdshader.
ChronowellDeployLook (FxManager event look, V2_LOOKS "chronowell"):
- A glass front closes in from 1.25x and locks onto the real radius at
INFLATE_S. - Meanwhile a brass hand winds one turn behind a sweep.
ChronowellBubbleLook (main-hosted, z -1, synced every frame from Sim.chronowell_zones, one draw call):
- Bubble:
- The rim sits on the real radius from frame one, and the face fades in over
INFLATE_S. - Honest timer: the hand sweeps once over the zone’s real
duration. A bright arc and a brighter rim mark the time still to run, and the spent part dims to 40% but stays a clear edge. - Full strength holds until the last
EXPIRE_S, when the glass cracks and the rim blinks.
- The rim sits on the real radius from frame one, and the face fades in over
- Slow depth: the inner gear turns at the zone’s real
slow_mult, and frost density follows it. Heal: a mint ring whenheal > 0. Evolved: a counter-turning ring. - Freeze: the Stopwatch zone (
slow_mult0) is a crystal stopwatch with facets, a crown and a stopped hand. - Dial: every enemy inside a drawn bubble with
slow_timer > 0gets a dial whose tick steps atDroneDirector.DISRUPTOR_SLOW_MULT, the movement slow the sim actually applies. MarkerBudget nearest-N. - Smear: every enemy bolt inside a bubble gets afterimages along
-vel, longer for a deeperslow_mult, which is whatSim._move_enemy_projapplies. - The owner is each zone’s own
pilot, so co-op needs no approximation.
Sim: none. Main sets ZoneRenderer.chronowell_legacy = false while the look draws.
Tunables: 11 live tunables, ChronowellDeployLook.INFLATE_S/GAIN and ChronowellBubbleLook.GAIN/FILL/RIM_W/FROST/EXPIRE_S/MARK_GAIN/MARK_NEAR_N/MARK_FAR_ALPHA/SMEAR_LEN. Each has a registry row, a LiveScope entry, a docs.d entry, regenerated tunable_docs.js and the build-273 manifest. Prewarm lists include the deploy look. Pinned by tests/test_chronowell_fx_v2.gd (16 tests through the real WeaponChronowell -> Sim.tick -> FxManager.consume / sync_from_sim path). A mutation check (bubble radius x0.87) fails 2 of them, as it should.
Before / after (all in /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/chronowell/):
mobile/chronowell_bench.pngandgl_compatibility/chronowell_bench.pngcome from the newtools/weapon_vfx/chronowell_bench.tscn. It uses the real sim, FxManager, ZoneRenderer and ChronowellBubbleLook on a pinned cluster whose lead enemy is an elite. Three enemy bolts are placed in the capture frame. Both renderers compiled with no shader errors.- Rows: old / old EVOLVED / v2 fresh / v2 invested (rungs plus a deeper slow and heal) / v2 EVOLVED / v2 2x.
- Columns: +0.06 to +2.45 s.
- White circles mark the real radius.
after/ingame_compare.pngcrops the real main.tscn at 1x, with the old look on the top row and v2 on the bottom row.- The old-side run’s horde differs, because the sim follows wall time.
ingame.gdnow waits for a FRESH bubble (_chronowell_fresh); before that, a capture usually photographed an empty field. It also gainedCHRONOWELL_V2=0.
Eyeball items:
- The glowing rim is now the loudest part of the bubble at 1x. That is deliberate, since it is the slow edge and the timer, but
RIM_WandGAINare live if it competes in a swarm. - The spent-vs-remaining contrast on the rim and arc is subtle at 1x. The hand carries most of the timer read.
- The expiry cracks are pale aqua streaks that read near-white at 2x. They are not a flash, but
EXPIRE_Sis live. - Bolt afterimages read as a small “ooo” chain.
- Sim truth surfaced (not changed): the
slowupgrade deepens only the bolt slow, while enemy movement inside is always the flat Disruptor 0.5. The Stopwatch freeze sits where the elite WAS and does not follow it. The time crystal rungs have no motif (ElementAccents.BUILTlacks time). - Not seen on device. Cost has not been measured on ATV: one quad of ~(2R+36)^2 px per bubble (usually 1), plus a small quad per held enemy and per slowed bolt.
gem_glow + pickup
Section titled “gem_glow + pickup”Score: gem_glow 3 -> 3, pickup 3 -> 3 (no rework, on purpose). No look shipped, no toggle, no tunables. Only the manifest descriptions changed, and the sim is untouched.
Weakest moment: there isn’t one, because neither effect is reachable in shipped play.
Sim.AUTO_COLLECT_XPis atrueconst (sim/sim.gd)._award_xpbanks kill XP straight into every alive pilot, andgems.addsits only in the deadelsebranch. SoSim.gemsis always empty, andgem_renderer.syncin main draws zero instances.- The
pickupfx event has three emitters. Two are in_collect_gems, which has no gems to collect. The third is_apply_powerup, andSim.POWERUPS_ENABLEDisfalse. - Probe: a headless real-content run (seed 1234, AutoPlayer, the real
Sim.tickpath) gave kills>0,max_gems=0andpickup_events=0. This is consistent with earlier audits (docs/reference/improvement-punch-list.mdrun 22,docs/research/2026-08-19-tutorial-adversarial-audit.md,docs/research/2026-09-28-code-cleanup-audit.md). - So there was nothing to capture in-game. The “gems” mentioned in the enemy_projectile scene note do not come from
Sim.gems.
Why no rework: an A/B look, tunables and score 4 here would polish code no player sees, and the vfx library card would be the only place it ever draws. The real “XP dopamine beat” gap is that a kill’s XP has no visual at all: it is banked silently and the XP bar just moves. Options, all Chris’s call:
- Render-only XP motes: on
deathfx events, spawn batched motes that fly to the pilot and land on the XP bar, sized by_xp_worth(biomass). This needs the XP value on the death fx_event payload, which is a render-read payload addition. The sim is unchanged and the baseline is unaffected because fx_events are not checksummed (verify that before relying on it). - Bring physical gems back (
AUTO_COLLECT_XP-> a var/tunable). That is a gameplay change and moves balance and maybe determinism. Then do the gem + vacuum rework this item describes. - Leave it, and delete the dead gem path (see the cleanup audit).
reaction + chain
Section titled “reaction + chain”Scores: chain 3 -> 4 (pending Chris’s eyeball), reaction 3 -> 3 (no rework, on purpose; description corrected). Commit 51587d13. Gate: == GATE PASS, 476/476 scripts, 5117/5117 GUT tests, node 221/221; determinism baselines unchanged.
Reachability probe (headless, real content, seed 1234, SkilledAutoPlayer, 12 min of sim time, pilot HP raised so the run survives, upgrades rolled through the real roll_upgrade_choices path). Fresh save vs a save owning the Interceptor + Arc Chaining 2:
chain: reachable, but only via the shop. Its one emitter isDroneDirector.drone_chain_strike, which runs only for an Interceptor drone withcfg.chain > 0(unlock-drone-interceptor100 gold +drone-interceptor-chain, max 4). Fresh save: 0 events. Interceptor + chain 2: 351 events (133 two-hop and 85 one-hop strikes). Pulse’s own “chain” upgrade is unrelated (it draws through theboltkind, still the legacy primitive, Pulse is pending batch 3).reaction: the event fires a lot, but never as an element reaction. Nothing inelemental.gd/elemental_system.gdemits it. Per 12-min run: ~130-170 unlabelled Warden attack starts (lunge, barrage, spiral; the warden_burst ones are already redrawn by WardenFx), ~27-56 unlabelled nova-element bursts (the enemy Bomber’s bomb blast and the Tank’s missile-launch telegraph), ~50DRONEdeploy cues, plus bossBOSS/ENRAGED/SUMMON/BOSS DOWN. Not in this probe but in the code: the Bomber drone’sBOMB, elite brute slam, special FREEZE/NUKE, LogisticsOVERCHARGE, other bosses’ names.
Why no reaction look: it is a shared generic “burst moment” (a spark, one fixed 120 px ring, an optional label) used by about ten unrelated sources. One new look would be wrong for most of them, and its fixed ring matches none of their real radii (the enemy bomb’s real blast radius, for one). The honest fix is per-source looks, each a separate item, all Chris’s call:
- Enemy Bomber bomb blast: a ring on the real
brad, handing over fromBombRenderer’s telegraph. This is the only one that damages the player. - Tank missile launch: a muzzle cue at the tank (the missiles already have the enemy-bolts look).
- Drone deploy (
DRONE): a per-class deploy cue, so the generic ring + label are not shown about 50 times a run. - Warden non-burst attack starts: fold into WardenFx like the burst ones. The manifest description now says all this; the score stays 3.
Chain. Weakest moment (tools/weapon_vfx/out/chain/before/chain_bench.png, Arc Chaining 4 through a 7-enemy pack, 1x): the whole chain. Each hop was a 0.2 s, ~6 px white glint on top of the struck enemy. There was no line between hops, so nothing showed the chain walking, its order or where it started. All four recurring problems were present:
- Size: none to compare. The chain’s only spatial fact is the path, and it was not drawn.
- Timer: n/a; it is instant.
- Strength: each hop deals x0.7 less (
INTERCEPTOR_CHAIN_FALLOFF), but every glint looked the same. - Upgrades: Arc Chaining 1-4 changed only the number of glints.
- Read-as: it read as hit sparkle, not a chain.
- Found along the way, not fixed: the Interceptor’s own 70 px strike pulse (every 0.25 s) emits no fx event at all, so it is invisible. This breaks the CLAUDE.md “render consumer in the same chunk” rule. It is a missing-state item that needs a new event kind.
What changed (new Interceptor Chain Look (A/B) row, VisualLook.interceptor_chain_v2, default ON; off = the legacy spark; console graphics toggle via the same row, parity test green). InterceptorChainLook (fx/interceptor_chain_look.gd + fx/interceptor_chain.gdshader, one draw call) is FxManager-owned under the runtime key interceptor_chain. It is registered in a new FxManager.NON_WEAPON_LOOKS, because V2_LOOKS keys must be weapon ids.
- ARC per hop: a taut gold cord (the Interceptor class colour; the element only tints the fringe) from the jump-off point to the struck enemy. The jump-off point is the drone for hop 0, then the previous enemy. A bead of charge zips down it over
ZIP_S, then two strands hum round the core while it decays. The cord is smooth and bowed on purpose: Thunder Wake owns jagged cyan sky lightning, and nothing here branches or forks. - NODE per hop: a three-prong clamp (the Interceptor’s arrowhead) snaps shut on the enemy as the bead lands, with a small pinch flash. There are no square brackets, so it does not read as the Disruptor’s lock marks.
- Order: hops are staggered
HOP_DELAY(0.03 s) apart. The sim lands every hop in one tick; that stagger is the one liberty taken, andHOP_DELAY0 gives the truth. - Honest strength: each hop’s width and brightness come from the event’s
frac, which is that hop’s real damage divided by the first hop’s. A test pinsfracagainst the HP thatdrone_chain_strikeactually removes, and a mutation (no falloff) fails 3 tests. - Sim: render channel only.
chainnow carriesfrom,hopandfrac. A test pins equal checksums. - Still disposable juice (
enabled-gated, shared juice budget), as before.
Tunables: 7 live tunables, InterceptorChainLook.LIFE/HOP_DELAY/ZIP_S/WIDTH/GAIN/BOW/NODE_R. Each has a registry row, a LiveScope entry, a docs.d entry, regenerated tunable_docs.js and the build-273 manifest. Prewarm families 12/16 include the look. Pinned by tests/test_interceptor_chain_fx_v2.gd (13 tests, real deploy_drones -> update_drones -> drone_chain_strike -> FxManager.consume).
Before / after (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/chain/):
mobile/chain_bench.pngandgl_compatibility/chain_bench.pngcome from the newtools/weapon_vfx/chain_bench.tscn(real drone path, FxManager, ArchetypeRenderer, DecoyRenderer). Both renderers compiled with no shader errors and look the same.- Rows: old chain 4 / v2 chain 1 / v2 chain 4 / v2 chain 4 at 2x.
- Columns: +0.017 to +0.26 s after the strike. The last column shows the NEXT strike’s hop starting, because the real cadence is 0.25 s.
- No in-game main.tscn capture:
ingame.gdhas no drone-loadout hook, so the bench is the evidence.
Eyeball items:
- With a 0.25 s strike cadence and a 0.3 s
LIFE, consecutive chains overlap slightly, so an Interceptor in a pack is a near-constant gold web.LIFEandGAINare live. - The bow makes long hops arc visibly (they whip).
BOW0 = straight. - The cord core is near white-gold at hop 0. It is thin, not a flash, but
GAINis live. - At 1x the clamp prongs are small chevrons and read mainly as “something latched”.
NODE_Ris live. - Lightning-element enemies are also yellow, so in the bench the clamp sits on same-colour bodies.
- Not seen on device. Cost has not been measured on ATV: two small quads per hop, up to
CAP32 instances.
interceptor strike + drone_pulse
Section titled “interceptor strike + drone_pulse”Reachability (headless probe, real content, seed 1234, 12 min, SkilledAutoPlayer, pilot HP 5000).
- Fresh save: 476
drone_pulseevents, all from the default Decoy (Sentinel) drone. Sodrone_pulseis REACHABLE for every player; the old “Decoy or drone pulse impact” description undersold it. - Interceptor + Arc Chaining 2: 431 strikes, on average 0.66 enemies caught, radius 70. The strike emitted nothing at all before.
- Logistics + Disruptor save: 249 Logistics pulses. The Disruptor never deployed (1 slot).
Weakest moments (in-game capture before, tools/weapon_vfx/out/{drone_pulse,interceptor_strike}/before/strip.png).
- Decoy: its 150 px damage pulse drew a 0.18 s spark at the drone and nothing else. Its reach was invisible, so the damage numbers popping 150 px away had no visible source.
- Interceptor strike: nothing was drawn.
Changes.
- The sim emits on the render channel only (no rng, no state, not in the checksum):
drone_strike {pos, radius, hits, element}, before the chain hops.drone_pulsegainssrc/radius/hits; Logistics also getsto/healed.
- Strike: a new A/B row,
interceptor_strike_v2, NOT folded underinterceptor_chain_v2, so the two can be judged separately.- It is drawn as STRIKE mode of
InterceptorChainLook: a gold rim ON the real R, plus three clamp chevrons closing onto it. That is the same draw call and the same CAP 32 as the chain, so they share one density budget. - It lasts 0.12 s, less than the 0.25 s cadence, and is dim when it catches nothing. Score 4.
- It is drawn as STRIKE mode of
- Decoy and Logistics: a new
DronePulseLookunder the rowdrone_pulse_v2.- Decoy: a cyan sonar front lands on the real R, with a thin double rim on R from frame one.
- Logistics: a mint ring on the repair radius with riding “+” marks, and a thread with a bead to the pilot only when it really healed.
- The Disruptor pulse is left to
DisruptorFieldLook. - drone_pulse moves from 3 to 4.
- 11 live tunables:
InterceptorChainLook.STRIKE_LIFE/GAIN/W/EMPTYandDronePulseLook.LIFE/EXPAND_S/GAIN/RIM_W/EMPTY/LOGI_LIFE/LOGI_GAIN. - Benches:
tools/weapon_vfx/drone_fx_bench.tscn(real director + FxManager, with a dashed reference circle at the real radius) anddrone_ingame.tscn(real main.tscn). - Shaders were compile-checked windowed under mobile and gl_compatibility.
Images.
tools/weapon_vfx/out/drone_pulse/{mobile,gl_compatibility}/drone_fx_bench.png.../drone_pulse/after/strip_{sentinel,sentinel_pack,logi}.png.../interceptor_strike/after/strip.png(dense pack, 1x crops)
Eyeball items for Chris.
- The Decoy ripple now fires at about 2.2 Hz on screen. Check it is not too busy on the TV;
GAIN/LIFEare live. - The Logistics ring is faint at 220 px (it only matters when other drones are in range), and its thread is short because the drone hugs the pilot.
- Interceptor crits have no distinct visual.
chain_bench.gd(from the previous entry) labels its columns 2–3 frames early: itssincelogic advances before it checks. The new bench fixes this pattern; the old one does not.- Not seen on a device yet.
bomber blast + tank launch
Section titled “bomber blast + tank launch”Scores: new bomb_blast 4, new tank_launch 3 (pending Chris’s eyeball); reaction stays 3 with its description now listing only the sources still on it. Commit e22c168c. Gate: == GATE PASS, 479/479 scripts, 5161/5161 GUT tests, node 221/221; determinism baselines unchanged.
Weakest moments (in-game capture before, real main.tscn, a real Bomber / elite Tank spawned at a still, mortal pilot in a crowd; tools/weapon_vfx/out/{bomber_blast,tank_launch}/before/*_strip.png).
- Bomb: the detonation frame itself. BombRenderer’s telegraph ring vanishes, and for about 3 frames nothing marks where it hurt (only the player-hurt vignette). Then a white-ish spark in the PLAYER’s nova element colour pops at the centre and the generic ring grows from small to 120 px, past the real 90 px radius. Nothing agreed with the telegraph on size or colour.
- Tank: the launch drew a spark on top of the tank and the generic grey ring 0.18 s later, neither pointing anywhere. The missiles appear from under the hull with no cue.
- Correction to the brief: Tank missiles are enemy-pool darts (
TYPE_TANK_MISSILE, ArchetypeRenderer missile shape), NOT EnemyBoltRenderer shots. The bolts’ “homing torpedo” family is EliteKit’s ATK_HOMING. Left as is.
What changed (A/B row Enemy Blasts Look (A/B), VisualLook.enemy_blasts_v2, default ON; off = the generic burst; console graphics toggle via the same row, parity test green).
- Sim, render channel only: the two
reactionevents gainsrc+ real geometry.bomb_blast:radius(the bomb’s own),hit(a pilot was really hurt),tint(only a hazard bomb’scolor_ring).tank_launch:angle,count,spread, hullradius. The kind is unchanged, so the special_barrage sound, haptic and telemetry counts are unchanged. No rng, no state; a test pins equal checksums. HostileBlastLook(fx/hostile_blast_look.gd+hostile_blast.gdshader): one draw call, CAP 16,blend_premul_alphalike the enemy bolts, so it is never half-res hosted.- BLAST: the same centre and circle BombRenderer drew. It starts in the telegraph’s detonation red and turns hostile magenta-red within 0.1 s. A faint front lands on R. The whole disc flares (hostile, not white), then scorches DARK with turbulent embers. The rim on R lingers longest with the dark hostile contour. If it hurt nobody it is drawn at
MISS. Lightning hazard strikes keep their yellow and skip the red handover. - MUZZLE: an amber-to-magenta flare out of the hull along each missile’s real heading (1, or the elite 3-fan), a backblast puff and a dark-outlined recoil ring.
- Covers every
Sim.bombssource (Bomber, custom/elite firebomb, Dimension hazards), since they all share the telegraph.
- BLAST: the same centre and circle BombRenderer drew. It starts in the telegraph’s detonation red and turns hostile magenta-red within 0.1 s. A faint front lands on R. The whole disc flares (hostile, not white), then scorches DARK with turbulent embers. The rim on R lingers longest with the dark hostile contour. If it hurt nobody it is drawn at
- Routed in
FxManager.consumebefore the shared juice budget (a blast that hurt you is feedback).GpuBurstdrops its nova-coloured debris for routed events;HeroLightsflashes them hostile. - 8 live tunables
HostileBlastLook.LIFE/GAIN/RIM_W/CUT/MISS/MUZZLE_LIFE/MUZZLE_LEN/MUZZLE_GAIN(registry, LiveScope, docs.d, regenerated tunable_docs.js + build-273 manifest). The blast radius and flare headings are not knobs. Prewarm family 12 includes the look. - Pinned by
tests/test_hostile_blast_fx_v2.gd(16 tests, realSim.tick_singlefor both sources: blast radius == the pending bomb’s radius == biblebomb_radius, pos == the telegraphed pos,hittrue only when HP dropped; flare count == missiles really spawned, each flare on its missile’s heading; the look’s R and quad match the event).
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/):
bomber_blast/bomb_v2_strip.png(telegraph at fuse 0.90/0.45/0.12 s, then +0.00 to +0.40 s, 1x 360 px crops),bomb_v2_old_strip.png(old side),before/,gl_compatibility/bomb_v2_strip.png.tank_launch/tank_v2_elite_strip.png,tank_v2_strip.png,before/,gl_compatibility/.- Both shaders compiled and look the same under mobile and gl_compatibility (windowed).
Eyeball items.
- The blast frames sit under the existing full-screen player-hurt red vignette, which dominates the hit frames; the look reads clearly on the circle, but most of the colour in those frames is the vignette.
- The telegraph is orange-red and the blast turns magenta-red. The handover is deliberate, but the telegraph itself is still the old flat orange (not restyled).
- Tank flare (score 3): readable but small at 1x and partly under the missile dart leaving the hull. The elite fan’s three flares mostly merge because the missiles home in at once.
MUZZLE_LEN/MUZZLE_GAINare live. - A blast that hurt nobody is still fairly strong (
MISS0.55). - Not seen on device; ATV cost not measured (rare events, one quad each).
bomb telegraph + warden starts
Section titled “bomb telegraph + warden starts”Scores: bomb_telegraph 3 -> 4, new warden_launch 4 (pending Chris’s eyeball); reaction stays 3, and its description no longer lists the Warden starts (only the fallbacks and the remaining generic sources). Commit b60c421a. Gate: == GATE PASS, 481/481 scripts, 5178/5178 GUT tests, node 221/221; determinism baselines unchanged.
Weakest moments (in-game capture before, real main.tscn).
- Bomb (
bomb_telegraph/before/bomb_v2_strip.png): the last quarter second. From 0.25 s to 0.08 s of fuse it is an almost full, flat orange disc, so 0.25 s left looks the same as 0.08 s, with no urgency ramp. At 1.10 s it is only a faint thin ring. - Warden (
warden_starts/before/sheet_v2.png): the lunge latch and the dash. Each was a generic white spark plus a grey ring left behind at the start point, with no direction, so the lane the Warden was about to cross was unmarked. The barrage and spiral starts were a white spark over the body, with no headings.
What changed.
- Bomb telegraph (
BombRenderer, folded under Enemy Blasts Look (A/B),VisualLook.enemy_blasts_v2; off = the old flat orange):- Hostile magenta-red (
HOSTILE, the blast and enemy-bolt colour), with the rim ONbm.radius, the circle the blast lands on. - The REAL fuse left (
bm.delay) shows as one segment perTICK_S. The segments burn out clockwise from 12 o’clock, the burning one partly drawn with a spark head. - Over the last
URGENT_Sthe rim thickens and pulses atPULSE_HZ, and the colour lerps toHOT.HOTequals the blast shader’sTELEhandover colour (it was changed to match; a test parses the shader const). - A fill front reaches the rim exactly at detonation.
- A
_Contourchild (normal blend, behind) draws a ground shadow and a dark ring under the rim. - Sources stay one family: the sim treats them the same. The exception is a bomb with its own
color_ring(the lightning hazard), which keeps its yellow, as its blast already did.
- Hostile magenta-red (
- Warden starts (folded under the existing Warden Look (A/B),
VisualLook.warden_v2, not a new row: they are part of the Warden’s attack FX and the classic side keeps the generic burst):- Render-channel
srcplus geometry on 5reactionevents insim/boss_warden.gd:warden_barragefor both rings (ring 2 angle PI/count),warden_spiral(the pre-incrementspiral_phase; the increment moved after the append, same value),warden_lunge(lunge_dir, LUNGE_LENGTH) andwarden_dash(heading, min(dash travel, gap)). - New
WardenLaunchLook(fx/boss/warden_launch_look.gd+.gdshader, CAP 16, blend_add, a WardenFx child):- RADIAL: a muzzle ray on every real spoke out to the shots’ end-of-life radius, plus a streak riding with the shots.
- THRUST: chevrons that shoot ahead of the Warden down the real lane, a spine line, a flame backblast and a bow ring.
- Violet and pale pink, never white.
- FxManager, GpuBurst and HeroLights skip their generic burst for owned starts.
- Prewarmed in the WardenFx family of
render/prewarm_pass.gd.
- Render-channel
- 16 live tunables:
BombRenderer.GAIN/RIM_W/FUSE_W/FILL/SHADOW/CUT/TICK_S/URGENT_S/PULSE_HZandWardenLaunchLook.LIFE/THRUST_LIFE/GAIN/WIDTH/RAY/THRUST_LEN/DASH. They are in the registry, LiveScope and docs.d, with tunable_docs.js and the build-273 manifest regenerated. - Pinned by
tests/test_bomb_telegraph_v2.gd(9 tests) andtests/test_warden_starts_fx.gd(8 tests), both through the real sim paths:- bomb: a real Bomber via
tick_single; the rim == biblebomb_radius== the blast event radius; the fuse ==bm.delay; the last frame is at full urgency with the front on the rim; the handover colour == the shader TELE; - Warden: real
BossWarden.update; every bolt’s velocity angle lies on an event spoke; the lunge == the latchedlunge_dir; the dash heading == the real movement; - checksum unchanged in both.
- bomb: a real Bomber via
- Honest TDD note: the tests were written before the code but not run red first.
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/):
bomb_telegraph/mobile/bomb_v2_strip.png(best; fuse countdown into the blast handover), plusgl_compatibility/andbefore/.warden_starts/mobile/sheet_v2.png(rows: barrage, spiral, spiral_enraged, lunge, dash), the per-stage*_strip.png, plusgl_compatibility/andbefore/.- Both new or changed shaders compiled with no errors and look the same under mobile and gl_compatibility (windowed).
Eyeball items.
- Pre-existing, not from this pass:
- the barrage eruption (
charge_burst) still blows the Warden body out white at about +0.1 s; - the lunge end / pilot hit shows a white blowout at about +0.28 s.
- the barrage eruption (
- The dash backblast is faint (
DASH0.6 is live). - The fuse segments are small at 1x on a TV (
FUSE_Wis live). - The bomb’s ground shadow is invisible on red backgrounds.
- The rim marks the blast radius, but the pilot is hit when its EDGE crosses it (R + pilot radius). That is honest to the blast, a little generous to the hit.
- Not seen on device.
elite_fx + dimension_hazard
Section titled “elite_fx + dimension_hazard”Scores: elite_fx 3 -> 4, dimension_hazard 3 -> 4 (pending Chris’s eyeball). Commit ba551d21. Gate: == GATE PASS, 483/483 scripts, 5195/5195 GUT tests, node 221/221; determinism baselines unchanged.
Reachability (headless real-content probe, seed 1234, 16 min of SkilledAutoPlayer, pilot HP 50k):
- Elites: 52-66 per run, the first at 35.2 s. All 9 EliteKit attacks fire (100-180 each).
- Dimensions: the first boss dies at 553 s and the real offer was void_dim / fire / electricity. Fire, Void and Ice (as a later Dimension) are reached through real offers. Blood, Poison and Light were forced via the dev
dimensioncommand, which uses the portal’s own state change. The offer is 3 random fromDIMENSION_IDS, so all are reachable. Results: Blood had 35 zones, Poison at most 2 concurrent clouds growing to r 650, and Light 67 warns and 67 fires.
Weakest moments (in-game capture before, real main.tscn, */before/).
- Elite: an elite swarmer in the swarm. It was identical to the red swarmers around it apart from the small amber name label. The elite brute looked like the plain brute. The kit attacks had no wind-up, and an elite’s two attacks were invisible, so two elites of one type with different kits looked the same.
- Poison: age 24 s. By then the cloud was largest (520-650 px) and still at full dps, but had faded to near-invisible, because the old fade ran on
life_frac. - Light: the warning. It was a 1-20 px line for a 120 px lane, and the fired beam had a white core.
- Blood was a flat translucent disc. The Fire cluster was dim smudges with its orbit reach invisible, and Ice was hard to read.
What changed.
EliteThreatLook(fx/elite_threat_look.gd+.gdshader, main-hosted above the swarm and under the name bars, synced every frame from the enemy pool +Sim.elite_kit, one draw call):- a hostile crown on the REAL radius (6 turning spikes, dark contour);
- the two REAL attacks (
attacks_for/forced) as glyphs in the attack’s element colour; - a charge arc on the REAL
cooldown/cooldown_total(), which is pinned to the kit’s real refire. It flares over the lastWIND_S. While held (suppress_timer, tutorial) it is dim and dashed; - in the dive half of
EliteKit.diving()(a new pure helper; a test pins it againstprowl_dir’s 1.15 mult) the pilot-facing spikes stretch. - Kitless elites (Tackler, Blight) get the crown only.
DimensionHazardLook(fx/dimension_hazard_look.gd+.gdshader, z -1, one draw call, 7 modes).DimensionHazardRenderer.legacysteps the old look aside.- Blood: the rim sits on the real slow radius, with inward-dragging ripples. It brightens while a real pilot is inside.
- Poison: the cut rim sits on the real current radius at full strength until the last
EXPIRE_Sof the realremaining. A dashed ring marks the real final radius (base x growth). - Light: the lane edges are at the real
LASER_WIDTHfrom the first warn frame. A fill closes from both edges on the real warn timer. On fire the beam is gold, not white, and fades overLASER_ACTIVE_S. - Fire: a fbm fireball, plus the band the real orbiters sweep (offset +- child radius), plus each orbiter.
- Ice and Void: rimmed on the real hitbox. Electricity is BombRenderer’s telegraph and is not drawn here.
- A/B: Elite Look (A/B) (
VisualLook.elite_v2) and Dimension Hazards Look (A/B) (VisualLook.dimension_hazards_v2). Both default ON, each with a Debug Settings row and a console graphics toggle; the parity test is updated. - 18 live tunables:
EliteThreatLook.CROWN_GAIN/SPIKE/CUT/GLYPH_R/GLYPH_GAIN/WIND_S/ARC_W/DIVE_GAINandDimensionHazardLook.GAIN/FILL/RIM_W/CUT/EXPIRE_S/GROW_RING/LASER_EDGE_W/LASER_FILL/BODY_GAIN/FLOW. They are in the registry, LiveScope and docs.d, with tunable_docs.js and the build-273 manifest regenerated. Both looks are prewarmed. - Pinned by
tests/test_elite_threat_fx.gd(9 tests) andtests/test_dimension_hazard_fx.gd(8 tests). Both run through the realSim.tick,EliteKit.update,enter_areaand the periodic_update_dimension_hazarddispatch. The checksum is unchanged by sync. - Fixed mid-pass: the elite shader first flipped
lp.y(QuadMesh UV.y already runs bottom-up), which put the glyphs on top of the name bar and mirrored the dive heading. - Honest TDD note: the tests were written alongside the code. Only the tunables test was seen red.
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/):
elite_fx/elite_v2_strip.png(best: 1x crowd, plain and elite twins side by side) andelite_v2_old_strip.png.dimension_hazard/{blood,poison,light,fire,ice,void_dim}_v2_strip.png,poison_v2_old_strip.png, andbefore/.- Capture tool:
tools/weapon_vfx/hazard_ingame.tscn. - Both shaders compiled with no errors windowed under mobile and gl_compatibility (
lightandicewere captured under gl_compatibility).
Eyeball items.
- The attack glyphs are small at 1x on a TV (
GLYPH_R6.5 is live). - The Blood interior is still dark; the rim carries the read (
FILLis live). - Enemy haste inside Blood (1.5x) has no per-enemy marker. Skipped: it would need a MarkerBudget pass.
- The Ice chunk interior facets are faint.
- Orbiters outside the arena wall look dimmer.
- The Light capture places the beam at the crop edge (harness placement), so the closing fill is easiest to judge in
light_v2_f02.png. - Not seen on device.
phase_flicker + ghost_eye
Section titled “phase_flicker + ghost_eye”Scores: phase_flicker 3 -> 4 (now titled Void Flicker, WORLD), ghost_eye 3 -> 4 (pending Chris’s eyeball). Commit c20a80e7. Gate: == GATE PASS, 484/484 scripts, 5205/5205 GUT tests, node 221/221; determinism baselines unchanged.
Correction to the brief. phase_flicker is not a Ghost phase. It is the Void Dimension’s periodic environmental hazard (Sim._spawn_void_hazard, every DIMENSION_HAZARD_INTERVAL 20 s), which the Dimension card describes as “flickering dark”. The Ghost never phases or turns untargetable: it drifts, telegraphs and teleport-strikes with a solid, hittable body. So neither item shares the Glass Wisp’s state. Both new looks deliberately avoid the wisp’s language (pale glass, dashed countdown): the Ghost’s read is an eye, the flicker’s read is the world going dark.
Reachability (tools/weapon_vfx/ghost_reach_probe.gd, headless, real content, seed 1234, SkilledAutoPlayer, pilot HP 50k):
- In a 16-minute Home run, zero Ghosts spawned. Home’s roster is restricted (swarmer/shooter/elite/centurion), and the Ghost is on Void’s roster only (
AreaDefsvoid_dimenemy_types). - Starting in
void_dim, there were 9 Ghosts (1 elite) and 12phase_flickerin the 245 s before the next portal. The first flicker came at 20.0 s and the first Ghost at 59.4 s. 1654ghost_warnand 27 telegraphs fired. - Void is a real portal offer (seed 1234 offered void_dim at 821 s, and the autopilot picked poison). So both items are reachable in shipped play, Void only. The library card descriptions now say so.
Weakest moments (in-game capture before, real main.tscn in the Void Dimension, tools/weapon_vfx/out/{ghost_eye,phase_flicker}/*_before_strip.png).
- Flicker: the whole event. The frames from +0.02 s to +1.8 s show no visible change at 1x: a single small violet spark on the pilot. The hazard was invisible.
- Ghost: the drift. The Ghost is a violet wisp among Void’s violet swarmers. Its “eye” was a 0.12-scale warm dot, usually under the damage numbers from being shot. Nothing showed that a strike was coming until the endpoint ring appeared, and during the strike itself the dot was gone (no event).
What changed.
GhostEyeLook(fx/ghost_eye_look.gd+ghost_eye.gdshader; main-hosted at z 1, one draw call,blend_premul_alpha). Per real Ghost,state_for(sim, i)reads the realdash_phase/dash_timer:- DRIFT: the lid opens from
OPEN_MINto wide overGHOST_DRIFT_Swhile the iris starts to glare. The slit pupil watches the pilot. - TELEGRAPH: wide open, glaring hostile magenta-red with a quickening throb, the pupil turned on the real endpoint. The glare ramps on the same frac as
GhostTelegraphRenderer. - STRIKE: a streak along the real heading,
GHOST_STRIKE_SPEED * TRAIL_Slong. - A dark contour (the hostile signature) is drawn around the lid. No white.
- DRIFT: the lid opens from
VoidFlickerLook(fx/void_flicker_look.gd+void_flicker.gdshader; main-hosted at z 3, one view-covering quad on the live P1):note_eventsstarts it from the render event, and it runs on asim.run_timeclock (so a pause freezes it).- Outside a clear circle round every pilot (a
pilotsuniform, up to 4) the view darkens. The circle edge is ragged and writhing, with broken violet wisps. envelope()handles the timing: the dark closes in overIN_S(from 4xCLEAR_Rdown toCLEAR_R), stutters twice, then lifts overOUT_S, forDUR1.6 s in all.- Damage-free, so it uses void violet, not the hostile red. A first version drew a solid violet band at the edge, which read like a zone boundary you must stay inside, so it was cut to thin broken wisps.
- A/B: Ghost Eye Look (A/B) (
VisualLook.ghost_eye_v2) and Void Flicker Look (A/B) (VisualLook.void_flicker_v2). Both default ON, each with a Debug Settings row and a console graphics toggle; the parity test is updated. Under each flag, FxManager’s old dot or spark steps aside. Alegacyevent key keeps the vfx library cards drawing the old side, since those looks need a sim to host them. - 14 live tunables:
GhostEyeLook.SIZE/GAIN/OPEN_MIN/LOOK/CUT/TRAIL_SandVoidFlickerLook.DUR/IN_S/OUT_S/STRENGTH/CLEAR_R/FEATHER/EDGE_GAIN/STUTTER. They are in the registry, LiveScope and docs.d, with tunable_docs.js and the build-273 manifest regenerated. The eye’s timing and targets and the flicker cadence are not knobs: they are the real sim timers and positions. Both looks are prewarmed (family 12 count 21 -> 23). - Pinned by
tests/test_ghost_eye_void_flicker_fx.gd(10 tests). They run the realSim.tickinvoid_dimthroughstep_ghostand_update_dimension_hazard:- one eye per real Ghost, at its real radius;
- the eye opens over the real drift and watches the pilot;
- the telegraph look and frac equal
ghost_telegraphs()’ endpoint and frac; - the strike streak lies on the real velocity;
- the real hazard starts the flicker, which follows the live pilot and ends at
DUR; - the envelope ramps in from 0 to
STRENGTHatCLEAR_Rwith at least 2 stutter dips; - the off switches restore the old dot and spark;
- the checksum is unchanged by syncing;
- the tunables are registered, and a
CLEAR_Rpatch is read live.
- Honest TDD note: the tests were written before the code but not run red first (the classes did not exist yet).
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/):
phase_flicker/flicker_v2_f04.png(best: +0.6 s, the 1x crowd in the dark) andflicker_v2_strip.png(before, then +0.02 to +1.8 s). Alsoflicker_before_strip.png,flicker_old_strip.pngandgl_compatibility/.ghost_eye/ghost_v2_zoom_sheet.png(crops following the Ghost through drift 0.3/1.75 s, telegraph 0.15/0.8/1.12 s and strike 0.04/0.2 s) andghost_v2_strip.png(1x crowd crops). Alsoghost_before_strip.png,ghost_old_strip.pngandgl_compatibility/.- Capture tool:
tools/weapon_vfx/ghost_ingame.tscn(MODE=ghost|flicker,ELITE=1,GHOST_V2=0/FLICKER_V2=0). - Both shaders compiled with no errors windowed under mobile and gl_compatibility.
Eyeball items.
- The flicker is now a real visibility hazard, which is a difficulty change, not only a look. Enemies more than about 250 px away are dimmed to
STRENGTH0.85 for about 1 s every 20 s. Before, the hazard was cosmetic and invisible.STRENGTH/CLEAR_R/DURare live, so Chris may want it gentler. - Void’s background is already near-black, so the dark reads mostly through the enemies dimming and the wisps at the edge, not through the background.
- At the start of a drift the eye is deliberately calm and lidded (violet). At 1x it is often covered by the damage numbers of a Ghost being shot. It reads clearly from late drift onward.
- The pupil slit stays vertical. Only its offset tracks the target, and the eye shape does not rotate with the strike heading.
- An elite Ghost’s second, shorter telegraph uses the same frac, so the eye glares from about 60%. It was not captured separately.
- Not seen on device; ATV cost not measured (one quad per Ghost, and one full-view quad for about 1.6 s every 20 s, only in Void).
tackle + stasis web + suppressor aura
Section titled “tackle + stasis web + suppressor aura”Scores: tackler_tackle 3 -> 4, stasis_web 3 -> 4, suppressor_aura 3 -> 4 (pending Chris’s eyeball). Commit 827bc264. Gate: == GATE PASS, 485/485 scripts, 5213/5213 GUT tests, node 221/221. Determinism baselines unchanged.
The brief’s premise, checked in /sim. All three items are what the brief said: the Interceptor (sim/tackler.gd), the player’s stasis web (sim/stasis_web.gd: the Web drone plus the Stasis Web card), and the Blight’s anti-heal aura (sim/suppressor.gd). But all three are DEV content. DevFlags.tackler_spawns, web_drone and suppressor_spawns default to off and are forced off in non-dev builds, and the Stasis Web card is offered only while tackler_spawns is on. None of this is reachable in store play. I treated it as content awaiting Chris’s verdict, not dead content, so I did the pass rather than stopping. The library descriptions now open with “DEV CONTENT”.
Reachability (tools/weapon_vfx/tackle_reach_probe.gd, headless, real content, seed 1234, SkilledAutoPlayer, 600 s survival, all three flags on):
- Interceptor: 21 Interceptors spawned. The first lock started at 98.3 s and completed at 99.0 s. There were 590 locking ticks and 505 locked ticks; packs appeared from 300 s.
- Stasis web: an enemy was first webbed at 25.0 s, and the first card cast came at 40.1 s. There were 82 card casts, and the card reached level 3.
- Blight: 9 Blights spawned. The ship was first suppressed at 157.7 s, for 200 suppressed ticks in total.
- With all flags off, every count is zero.
- The autopilot is rarely tackled (about 8 s in 10 min), because its Web drone webs Interceptors first. It is suppressed for about 3 s.
Weakest moments (real main.tscn captures, *_before_strip.png):
- Tackle: the tackled frame. The tether and the net were white-cyan, the same colour as your hull and your own web, so being tackled read as friendly. Two things were invisible: the HOLD_S release and how close you were to snapping the leash at TACKLE_RANGE.
- Web: the card cast was a faint 0.14 s line, and its snap ring was not on the real radius. The wrap was a thin grey circle with no sense of the time left.
- Blight: holding looked like empty. The calm olive disc read as a friendly zone, and the struck cross on the ship was tiny.
What changed. WebLinkLook (fx/web_link_look.gd + web_link.gdshader) is main-hosted and synced every frame. It uses two draw calls: the layer at z 2, plus a ground child at z -1 for the aura disc under the swarm. The family has one grammar:
- HOSTILE (an enemy acting on you) is magenta-red, with barbs pointing at what it holds and a premul dark contour.
- FRIENDLY (your web on an enemy) is additive white-cyan glass, with knots instead of barbs and a countdown arc.
Everything is drawn from real state, through plan(sim):
Tackle.
- A reticle closes on the ship from
LOCK_OUT, and a dashed sight line firms up, both over the real linklock(LOCK_S). - Once locked, a braided barbed cable runs from the Interceptor, with chevrons crawling toward it. Its strain is
distance / TACKLE_RANGE, so it goes taut and hot near the snap point. - The ship wears a barbed net and a broken scram hexagon for exactly as long as
PlayerState.tackle_timer > 0. The net loosens withtackle_timer / HOLD_Sas the hold runs out.
Stasis web.
- Every webbed enemy wears a glass net at its real radius for exactly as long as
stasis_timer > 0. A clockwise arc counts downstasis_timer / peak, where the peak resets whenever the web is refreshed. - The Web drone has a knotted silk tether to its real target, dashed while it closes in to reach.
- The card fires a net shot from the
stasis_webevent (CAST_FLY_S), which then snaps shut onto the target’s real radius (CAST_SNAP_S).
Blight.
- A rim on the real
AURA_RADIUS, with inward thorns, a dark contour and a rot fill. - Dim while empty. While it holds a ship, the rim lights up and drain ripples contract inward.
- A siphon carries green heal motes from the ship to the Blight, turning red on the way.
- Over the ship sits a struck-through heal cross whose brightness is the real
heal_mult, with one collar arc per stacked Blight.
The old renderers step aside per flag. TacklerRenderer still draws retreat, repair, boost and pack.
Other changes.
- A/B toggles (all default ON, each with a Debug Settings row and a console graphics toggle; parity test updated):
- Interceptor Tackle Look (A/B) (
tackle_v2) - Stasis Web Look (A/B) (
stasis_web_v2) - Blight Aura Look (A/B) (
blight_v2)
- Interceptor Tackle Look (A/B) (
- 10 live tunables:
WebLinkLook.GAIN/WIDTH/CUT/WRAP_PAD/BARB/LOCK_OUT/WEB_GAIN/AURA_GAIN/AURA_FILL/MARK_SIZE. Every radius and timer drawn is a real sim value, not a knob. The look is prewarmed (family 12: 23 -> 24). - A bug found in capture, now fixed and pinned: the idle Blight ring never drew. The ground layer is a child of the top layer, and an empty top layer commits
visible = false, which hid it.sync_from_simnow keeps the parent visible whenevergroundhas items. tests/test_stasis_web.gd’s main-wiring test now checks that the card cast reachesweb_link_lookunder the flag.
Tests. tests/test_web_link_fx.gd has 8 tests, all through real Sim.tick_single:
- the reticle and dashed line equal the real
lock, and the cable endpoints and strain match the real tackler, pilot andTACKLE_RANGE; - the net lasts exactly
tackle_timerand only loosens after a real card web breaks the tackle; - the card shot, then the snap at the real radius, then the arc counting down to the end of the real
stasis_timer; - the drone tether’s endpoints are the real drone and target;
- the aura sits at
AURA_RADIUS, idle vs lit, with siphons and a mark carryingheal_multand n = 2. This includes the ground layer being visible in the tree when idle (red before the fix); - the buffer matches the plan, and each switch turns its own family off;
- the checksum is unchanged by syncing;
- the tunables are registered and read live.
Honest TDD note: the look code was written before the tests, and only the idle-ring visibility test was run red first.
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/):
tackler_tackle/tackle_v2_full.pngis the best shot: the 1x crowd frame of a tackled ship, with the taut red cable and the net plus scram.- Strips:
tackle_v2_strip.png: approach, lock 0.3/0.85, tackled, break off.web_v2_strip.png: drone tether, then the card shot, snap and countdown on an elite brute.blight_v2_strip.png: idle, holding, stacked x2.pack_v2_strip.png
- For comparison: the
*_before_*captures,blight_old_strip.png(V2=0), and the*_glc_*captures (gl_compatibility). - The shader compiled with no errors in windowed runs under both mobile and gl_compatibility.
- Capture tool:
tools/weapon_vfx/tackle_ingame.tscn(MODE=tackle|pack|web|blight,TAG,V2=0,CROP).
Eyeball items.
- When the ship is suppressed inside a crowd, the struck heal cross is small and partly covered by the ship’s own shield bubble.
MARK_SIZEwas raised to 1.25 and is live. - The Blight’s drain ripples are the main “it is holding you” signal. They are deliberately strong (concentric rings over the whole zone), and may be loud when two Blights overlap. They were softened once.
- The lock reticle at an early lock (about 0.3) is dim by design; it firms up as the lock fills.
- The card’s shot leaves from the ship’s position at cast time, so if the ship moves it can look detached for its 0.14 s.
- The last frame of the tackle strip (“repairing”) is mis-framed in both the before and v2 captures, because the fleeing Interceptor leaves the viewport. That is a harness framing issue, and the retreat look is unchanged.
- The vfx library cards still show generic samples for these three: there is no dedicated card sample.
- Not seen on device; ATV cost not measured (64 instances max plus 8 ground, and only with dev flags on).
damage_numbers + weapon_pickup
Section titled “damage_numbers + weapon_pickup”Scores: damage_numbers 3 -> 4, weapon_pickup 3 -> 4 (pending Chris’s eyeball). Commit 44890d1e. Gate: == GATE PASS, 486/486 scripts, 5227/5227 GUT tests, node 221/221. Determinism baselines unchanged.
What each item is. damage_numbers is the floating text (fx/damage_numbers.gd). dmgnum (already 4) is only the white impact spark FxManager draws behind it. weapon_pickup is the floor drop you fly over to collect (Sim.weapon_pickups, render/weapon_pickup_renderer.gd), plus the moment it is collected.
Reachability (tools/weapon_vfx/dmgnum_pickup_reach_probe.gd, headless, real content):
- Damage numbers are reachable everywhere at quality tiers 0-2, with no DevFlags gate. A seed-1234 SkilledAutoPlayer 600 s survival run gave 21.6 numbers/s, a peak of 28 per tick (the
DMGNUM_CAP, hit on 11 ticks), and only 0.9% “big” (14 dmg or more). Amounts were p50 6, p90 10.9, max 21.8; the max was a crit (crit_chance 0.15, x2). - Weapon pickup is reachable in store play only in the Tutorial, which every fresh install is forced into. The Blaster dropped at 0.2 s and was collected at 1.7 s; Nova dropped at 53 s. Story mode drops five weapons but is gated off by the
StartMenu.V01_CRYSTALS_ONLYconst. So this is the first reward a new player ever gets.
Weakest moments (real main.tscn captures, *_before_*):
- Numbers: they printed straight over the ship. In a mid-run fight the pilot sat under “24 29 12 41”. Every number was the same flat white 20 px. A merged rolling total never grew (its “big” flag was set by its first hit), and a crit looked like any other hit.
- Pickup: the collection. It was the generic elemental reaction burst, the same as any reaction, with the weapon id drawn in its own element colour (the Blaster’s dark blue), unreadable on the arena. On the floor it was a 12 px diamond. Its id label collided with the NPC name (“Echo”) it is dropped beside, and “PICK UP” was dim blue. The id is also the wrong name for Story’s
pulse, whose card is “Electricity”.
What changed.
DamageNumbersv2 (_consume_v2):- Size and tint come from
size_t(peak hit, rolling typical hit)on a log scale. A typical hit is small and cool white, about 2x typical (a crit) is a clear step bigger and gold, andSPAN(3x) typical is hot pale gold. The ramp avoids the hostile orange-red family; a first cut used hot orange, which read as enemy-red outlines with the hollow title font. - A merged total keeps counting, but it is sized by its strongest single hit. Otherwise a clump of chips read as one huge hit; this was seen in capture, and the test was rewritten to pin it.
- The dmgnum event carries no crit flag (projectile crits are baked in at fire time), so a crit reads through size only. There is no element tint.
keep_clear_zones(sim)+lift_clearlift a number to the upper edge of any of these, so it rises away and never back across them: live pilots (PILOT_CLEAR), Ghost bodies (the eye earlier passes found covered), Ghost teleport endpoints (at the GhostTelegraphRenderer’s collapsing radius), and livesim.bombstelegraphs.- Crowd budget: a clump keeps
CELL_MAXnumbers inside an ellipse ofCELL_R, with horizontal distance counted at 0.55x because numbers are wide. Further hits roll into the nearest one, so no damage is dropped from the totals. MarkerBudget is deliberately not used: it keeps detail nearest the pilot, which is exactly where numbers must yield. - Every v2 label uses one 40 px font with its size on the scale, so there is no theme-override churn. A merged total’s text is still written once per frame (
test_damage_numbers_merge’s perf rule; a first version broke it and the gate caught it).
- Size and tint come from
WeaponPickupRendererv2:- The drop is a beacon: a light column, the glyph in the true element hue, and a dashed ring on the real collect distance (
Sim.WEAPON_PICKUP_RADIUS+ pilot radius) that closes into a solid ring and brightens as a pilot nears. Below it is the weapon’s display name in white, with “FLY OVER TO COLLECT”, on a normal-blend child with a dark outline. - The arena colour grade re-saturates coloured text, so the lightened element colour still rendered deep blue. The text is now white, or lifted halfway to white.
- A drop the tutorial is still holding (
pickup_allowedfalse while its lesson intro reads) is dim, with no column and no cue. A first cut said “NOT YET” on the player’s very first weapon, which was cut. - Collection: a ring bursts off the drop, nine shards stream into the live ship, a ring closes on the hull, and a NEW WEAPON banner rises over the ship. The banner name is white inside an element-coloured rim inside a dark contour, and the banner runs on
sim.run_time. - Sim: the pickup’s
reactionevent now carries render-channelweapon_pickup(wid) +pilot. fx_events are not checksummed. FxManager and GpuBurst skip that event viaowns_event; sound and haptics are unchanged.
- The drop is a beacon: a light column, the glyph in the true element hue, and a dashed ring on the real collect distance (
- A/B: Damage Numbers Look (A/B) (
damage_numbers_v2) and Weapon Pickup Look (A/B) (weapon_pickup_v2). Both default ON, each with a Debug Settings row and a console graphics toggle; the parity test is updated. - 15 live tunables:
DamageNumbers.SMALL_PX/BIG_PX/SPAN/REF_EMA/CLEAR_PAD/PILOT_CLEAR/CELL_R/CELL_MAXandWeaponPickupRenderer.GAIN/BEACON_H/LABEL_PX/LIGHTEN/FLY_S/BANNER_S/BANNER_PX. They are in the registry, LiveScope and docs.d, with tunable_docs.js and the build-273 manifest regenerated. The keep-clear geometry and the collect ring are not knobs: they are real sim values. - Tests:
tests/test_dmgnum_pickup_fx.gdhas 14 tests, using realdamage_enemyevents and the real Tutorial drop and collect via StoryAutoPlayer:- size is monotonic, and the crit step is at least 0.4 of the ramp;
- real hits are sized by their real amount;
- a merged total is sized by its peak, not its sum;
- numbers are lifted off the pilot, a Ghost and a bomb;
- the clump budget keeps every point of damage;
- the off switch gives the old placement and 20 px;
- the beacon ring equals the sim’s collect distance and the display name is used (pulse shows ELECTRICITY);
- the burst flies to the live ship and ends at
BANNER_S; owns_eventfollows the flag;- the checksum is unchanged;
- the tunables are registered and read live.
- Honest TDD note: the tests were written before the code but not run red first. The bomb test appends a real-shaped
sim.bombsentry rather than waiting for a Bomber to fire.
Images (/Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007b/tools/weapon_vfx/out/):
damage_numbers/dmgnum_v2_f02.png(best: the ship clear, one gold rolling total over the clump, a small white chip) anddmgnum_v2_strip.png, againstdmgnum_before_f01.png/dmgnum_before_strip.png.dmgnum_*_full.pngis the 1x frame.weapon_pickup/pickup_v2_f02.png(the beacon on the floor, collectable) andpickup_v2_f04.png(the NEW WEAPON / BLASTER banner with shards), pluspickup_v2_strip.png(held, floor, then collection from +0.03 to +1.5 s), againstpickup_before_f00.png/pickup_before_f03.png.- Capture tool:
tools/weapon_vfx/dmgnum_ingame.tscn(MODE=dmgnum|pickup,V2=0,TAG,CROP). No shader edits, so no compile check was needed.
Eyeball items.
- With four brutes standing on a still pilot, the lifted numbers still bunch above the ship’s clear ring and can overlap a little.
CELL_R/CELL_MAX/PILOT_CLEARare live. - The clump budget makes rolling totals cover several enemies, not one. That is honest as a total, but a big area number (for example 1283) is a sum of many hits. It is sized by the strongest hit in it, not by the sum.
- “Typical” is a rolling mean, so a build that mixes chip weapons with big hitters will show the big hitter in gold most of the time.
REF_EMA/SPANare live. - Pickup: you collect it on touch, so the shards fly only about 50 px. The banner carries the reward read. The run-start area title (“EMBER REACH”) can overlap the banner in the harness, because the harness skips the tutorial’s read-freeze. Not checked on device.
- The vfx library cards still draw the old samples (neither look has a dedicated card). ATV cost was not measured. It is about one Label op per number as before, plus one zone list per consume.
Wrap-up (2026-10-08 ~02:40)
Section titled “Wrap-up (2026-10-08 ~02:40)”All passes gated green, determinism baselines unchanged throughout, every look behind its own A/B row (default ON). Nothing seen on a device yet; Apple TV cost unmeasured for every look.
Raised to 4: wisp_phase (0->3), disruptor_field (0->4), turret, elemental_aura (clutter cap, fx/marker_budget.gd),
enemy_projectile, thunder_wake, wildfire + ignite, chalice, chronowell, chain, interceptor strike (new), drone_pulse,
bomber blast (new; tank_launch new at 3), bomb_telegraph, warden_launch (new), elite_fx, dimension_hazard,
ghost_eye, phase_flicker, tackler_tackle / stasis_web / suppressor_aura (DEV-ONLY content), damage_numbers, weapon_pickup.
Unreachable in shipped play (descriptions corrected, no look built): gem_glow, pickup, powerup, reaction-from-elements.
Decisions for Chris:
- Void flicker now changes difficulty: enemies beyond ~250 px dim to 0.85 dark for ~1 s every 20 s (it was invisible before). Soften via
VoidFlickerLook.STRENGTH/CLEAR_Ror the A/B row. - Kill XP has no visual (gems are dead code under
Sim.AUTO_COLLECT_XP): draw-only XP motes / restore gems / delete gem code. - Remaining
reactionusers (drone deploy, boss labels) — per-source looks or leave. - Gameplay quirks found: Chronowell’s slow upgrade only affects enemy shots (movement fixed 0.5); Stopwatch freeze doesn’t follow the elite; damage events have no crit flag.
Held, not done: the Warden/boss family (boss_warden, warden_*, boss_eye, boss_attack_cues) awaits Chris’s Warden feel-check;
nebula_cluster (another session’s nebula work in progress); web_look_b; beam/pulse/railgun_tell/mark_pop await picks.