Skip to content

Graphics improvement loop 2026-10-07

Graphics improvement loop — 2026-10-07 (16:00–18:00)

Section titled “Graphics improvement loop — 2026-10-07 (16:00–18:00)”

Branch gfx-loop-1007, worktree .claude/worktrees/gfx-loop-1007. Not merged — Chris eyeballs first. Each improved look is a NEW switchable look (old kept), tunables on the console.

Queue (lowest tools/vfx_library scores, excluding items waiting on Chris’s picks)

Section titled “Queue (lowest tools/vfx_library scores, excluding items waiting on Chris’s picks)”

Skipped on purpose: beam, pulse, railgun_tell, mark_pop (Beam/Pulse/Railgun picks still owed by Chris, memory bullet-heaven-weapon-fx-v2).

  1. scatter (2) — legacy SwarmRenderer pellets
  2. momentum_core (2) — legacy SwarmRenderer bullets
  3. turret + turret_body (2)
  4. scar_tissue (2) + scar_tissue_meter (0, MISSING)
  5. elemental_aura (0, MISSING)
  6. wisp_phase (0, MISSING)
  7. disruptor_field (0, MISSING)

Score 2 -> 4 (pending Chris’s eyeball). Commit 5a8af411. Gate: == GATE PASS, 462/462 scripts, 4944/4944 GUT tests, node 220/220; determinism baselines unchanged.

What changed:

  • Muzzle blast (fx/weapons/scatter_burst_look.gd + .gdshader, an FxManager event look on scatter_fire): a fan sized from the real volley’s spread and pellet count. It has a barrel flash, a quick element-tinted cone fill, a blast-front arc and embers, plus one tracer streak per pellet at the real fan angles. The streaks ride the real pellets: the sim event now carries dir (a Vector2), spread, pellets and speed (render channel only), and the look turns those into the shader’s head position. A dense fan (the Fusillade) gets a lower per-streak weight, so it does not sum to a white wedge.
  • Pellets (PlayerBoltRenderer KIND_SCATTER branch): stubby round white-hot slugs with a short comet tracer that cool to the element tint over the last SCATTER_COOL_S. Evolved pellets are short flak arrowheads. The Blaster stays a long thin needle.
  • Power grows both quads (at most 1.5x) and makes them hotter. Rungs and evolve drive the element_accents code, as on the other v2 looks.
  • Six live console tunables: ScatterBurstLook.HALF/LIFE/GAIN and PlayerBoltRenderer.SCATTER_LEN/SCATTER_WID/SCATTER_COOL_S.

How Chris toggles it: Debug Settings > Scatter Look (A/B), or the same row in the Remote Console (graphics dev command scatter_v2). Off = the generic v2 bolt + the old orange spark. The global Weapon FX v2 switch still falls back to v1.

Before / after:

  • Before: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/scatter_before/ingame_scatter_v1.png, ingame_scatter_v1_z2.png, ingame_scatter_v2_z2.png (generic v2 bolt), ingame_scatter_v2_evo_z2.png
  • After: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/scatter_after/compare_scatter.png (grid: v1 / v2-old / p0 / rungs / evolved over time), ingame_scatter_v2.png, ingame_scatter_v2_z2.png, ingame_scatter_v2_evo_z2.png, plus sequences seq_v2_z3_grid.png and seq_v2_evo_z3_grid.png

Findings / eyeball items:

  • FX age per rendered frame, not per sim second. main.gd calls fx_layer.advance(1/60) every frame. Below 60 fps (the uncapped capture harness) every event look lags the sim. This is why the in-game SEQ shots show the tracers behind the pellets, while compare_scatter.png (clock-matched) shows them on the pellets. On a 120 Hz display all event looks would age 2x fast. This is pre-existing and affects every look, so it was flagged, not fixed.
  • The evolved fan is still busy: 9+ arrowheads overlap into a zig-zag “flak wall”. That is distinctive, but worth a feel check.
  • ingame.gd gained HALFRES=0|1 and SCATTER_V2=0 env switches. The half-res host was ruled out as a cause of misplacement.

Score 2 -> 4 (pending Chris’s eyeball). Commit 9b51f683. Gate: == GATE PASS, 463/463 scripts, 4964/4964 GUT tests, node 220/220; determinism baselines unchanged.

What changed (CHARGED KINETIC, distinct from the Blaster needle and the Scatter fan):

  • Hull charge (MomentumChargeLook, persistent, main-owned): six capacitor cells round the ship fill with the weapon’s real charge. WeaponMomentumCore.charge_level(pilot) is a pure read of the sim’s 1 s speed window: the trailing run of fast samples over the window length. It is exactly 1 when the next burst will be charged and drops to 0 on the first slow sample. At full charge the cells fuse into a pulsing ring with three sparks.
  • Release (MomentumReleaseLook, event look, essential): a recoil flash, a narrow Mach shock cone with pressure fronts, and a shock diamond plus recoil vents on charged bursts; base/weak bursts get a lighter kick. The sim now emits momentum_fire for weak/base bursts beside momentum_charged_fire. Both carry dir/state/shots/speed/element on the render channel only.
  • Slugs (PlayerBoltRenderer KIND_MOMENTUM): a dense white slug with a bow shock and a chevron Mach-cone wake.
  • Seven live tunables: MomentumReleaseLook.HALF/LIFE/GAIN, MomentumChargeLook.HALF/GAIN and PlayerBoltRenderer.MOMENTUM_LEN/WID.

Toggle: Debug Settings > Momentum Look (A/B) (graphics dev command momentum_v2). Off = the generic v2 bolt + the legacy charged spark.

Before / after:

  • Before: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/momentum_before/ingame_momentum_core_v2_z2_mcold.png, ingame_momentum_core_v2_mcold.png, ingame_momentum_core_v1_z2.png
  • After: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/momentum_after/compare_momentum_core.png (rows: v1 / v2 old / p0 charged / p0 base / rungs / evolved; columns: charge 1/3, 2/3, full, full + release age), ingame_momentum_core_v2.png, ingame_momentum_core_v2_z2_s00..s03.png, ingame_momentum_core_v2_evo_z2.png

Eyeball items:

  • A 6-8 slug charged burst at close range overlaps into one white mass for a frame or two (SPREAD is only 0.09 rad). It reads as “heavy”, but check the feel.
  • The hull cells are subtle in-game at zoom 1 while partly charged. MomentumChargeLook.GAIN/HALF are live if they need more presence.
  • The pre-existing per-frame FX clock (see scatter) makes the cone trail the slugs in uncapped captures.
  • ingame.gd: MOMENTUM_V2=0 env (_mcold suffix). A weapon whose look is switched off, or V2=0, no longer waits forever for a look that cannot spawn.

Scores: turret 2 -> 3, turret_body 2 -> 4 (pending Chris’s eyeball). Weapon stays at 3 because the bullets themselves are still the generic neutral kinetic dot (proj_renderer); the tracer carries the shot read. Commit bd63d0ed.

What changed (a neon EMPLACEMENT in place of the flat grey hex with a fixed barrel):

  • TurretBodyLook (fx/weapons/turret_body_look.gd, persistent, main-owned, FxManager.V2_LOOKS "main:TurretBodyLook"): Node2D _draw, no shader. Dark hex plate with neon rim, three braced feet, rung notches, a domed head with a hot white core and halo, and a barrel that tracks the turret’s REAL target (eased swing, snapped onto the shot line on each real shot).
  • Moments: land (drops in from 1.5x with a shock ring + core flare), every real shot (barrel recoil, muzzle star, tracer streak), expiry (a life ring drains with the real remaining life; in the last EXPIRE_S the rim blinks faster and shifts to amber, then the body shrinks out). Evolved (Garrison): twin barrels, amber accent.
  • Sim, render-channel only: WeaponTurret.render_states(sim) (pure read; aim = the same nearest-in-range rule _fire uses) and turret_fire now carries dir. No state written, no rng drawn; determinism baselines unchanged.
  • Five live tunables: TurretBodyLook.SIZE/CORE_GAIN/TRACER_LEN/FLASH_S/EXPIRE_S.
  • Element accents: none. The turret has no element in the sim (kinetic, PROJ_NEUTRAL), so the accent is steel-cyan / Garrison amber.

Toggle: Debug Settings > Turret Look (A/B) (graphics dev command turret_v2). Off = main’s old flat hex bodies + the legacy turret_fire spark.

Before / after (all in /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/turret/):

  • Bench (best read): turret_bench.png (top row old, bottom v2; deploy / firing / tracking / expiring / garrison), from the new tools/weapon_vfx/turret_bench.tscn; turret_bench_r1.png is the first iteration.
  • In-game: ingame_turret_v2_z2.5_tuold.png (before) / ingame_turret_v2_z2.5.png (after). Busy and hard to read at this seed; the bench is the honest comparison. ingame.gd gained TURRET_V2=0 (_tuold) and waits for a real turret shot.

Eyeball items:

  • In-game readability in a dense horde at zoom 1 (not checked on device).
  • The expiry blink (3 -> 12 Hz over the last 1.5 s) may read as too nervous; EXPIRE_S is live.
  • The turret bullets themselves are unchanged (generic dot); a KIND_TURRET bolt branch would be the next step to 4.

Scores: scar_tissue 2 -> 3, scar_tissue_meter MISSING 0 -> CURRENT 3 (pending Chris’s eyeball). Commit 5a83e11a.

What was invisible: the weapon’s wound stacks (and its decay clock) lived only on WeaponScarTissue; the player saw a faint spark per stack and the nova ring on discharge, nothing in between.

What changed:

  • ScarTissueMeterLook (fx/weapons/scar_tissue_meter_look.gd, persistent, main-owned, FxManager.V2_LOOKS "main:ScarTissueMeterLook"): Node2D _draw, no shader. A segmented crimson ring round the hull, one segment per stack up to the REAL discharge_threshold (so the “threshold” mod visibly shortens the meter). The newest lit segment drains with the decay clock; one stack short of discharge the meter throbs with a lub-dub heartbeat bloom; each stack gain cuts a jagged scar slash across the hull and flares the new segment white. Fades in/out with stacks > 0 (no clutter at rest). Evolved (Hemorrhage): a thin outer bleed ring.
  • Discharge payoff: the meter’s segments blow outward (BURST_SCALE x radius) with radiating shards and a core flash, keyed on the sim’s own scar_tissue-stamped nova (main forwards render_fx_events to note_events), never on a stacks drop, so a decay can’t fake a detonation. The damage ring itself is still NovaBurstLook in the element tint (not reworked: no time; it reads, but is not Scar-crimson).
  • Sim, render-channel only: WeaponScarTissue.meter_state() (pure read). No state written, no rng drawn; determinism baselines unchanged (in-suite).
  • Six live tunables: ScarTissueMeterLook.RADIUS/WIDTH/GAIN/GAIN_S/BURST_S/BURST_SCALE.

Toggle: Debug Settings > Scar Tissue Look (A/B) (graphics dev command scar_tissue_v2). Off = the legacy per-stack spark.

Before / after: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/scar_tissue/mobile/scar_bench.png and .../gl_compatibility/scar_bench.png (top row old, bottom v2; 1 stack / decaying / primed / stack gain / discharge) from the new tools/weapon_vfx/scar_bench.tscn; .../scar_tissue/scar_bench.png is iteration 1 (over-bright discharge core). No in-game capture (needs a run where the pilot takes hits; not done in budget).

Eyeball items:

  • Size vs the hull on device (RADIUS 25 px; Momentum Core’s cells sit at ~19 px, so both equipped nest rather than collide; unchecked).
  • The heartbeat only shows in motion; the still can’t prove it.
  • The discharge ring is still element-tinted NovaBurstLook; a crimson recolour / stack-scaled ring is the step to 4.
  • The vfx library scar_tissue card now shows only the nova (its stack event is drawn by the main-owned meter, which the library page doesn’t host).

Score: elemental_aura MISSING 0 -> ENTITY CURRENT 3 (pending Chris’s eyeball). Commit 6ef14858.

What was invisible: the per-enemy aura_element / stacks / aura_remaining columns on EnemyPool. In live 0.1 play ElementalSystem.apply_element sets them on every weapon hit with an element (burn DoT, shock vulnerability), but nothing drew them. Note Elemental.apply (stack accumulation, reactions) is unreachable in live play, so stacks are always 1; every elemental enemy also carries a persistent innate base aura (BASE_AURA_PERSIST).

What changed:

  • EnemyAuraLook (fx/enemy_aura_look.gd, main-owned, sync_from_sim(sim) every frame): an ElementPalette.color_for ring just outside each enemy with an APPLIED aura; a bright arc drains clockwise from 12 o’clock with the real aura_remaining / aura_decay_s over a faint socket; one extra concentric ring per stack (up to 3, for when accumulation turns on). Innate auras are not ringed (the body colour already shows the element; ringing every elemental enemy would be noise).
  • Batched: the whole swarm is ONE additive draw_multiline_colors call (16 segments per ring, CAP 400). Perf: headless sync ~0.64 ms/frame at 300 afflicted enemies on the M5 (tests/test_enemy_aura_look.gd prints it); GPU cost not measured, Apple TV unmeasured.
  • Sim untouched: pure read of sim.enemies (a test asserts state_checksum unchanged); determinism baselines unchanged.
  • Live tunables EnemyAuraLook.PAD/WIDTH/GAIN.

Toggle: Debug Settings > Enemy Aura Rings (A/B) (graphics dev command aura_rings_v2). Off = no indicator (as before).

Before / after: /Users/chris/Claude/bullet-heaven/.claude/worktrees/gfx-loop-1007/tools/weapon_vfx/out/aura/mobile/aura_bench.png and .../gl_compatibility/aura_bench.png (top row old, bottom v2; fresh fire / lightning 50% / cold 10% / innate only / swarm of 120) from the new tools/weapon_vfx/aura_bench.tscn. Glow dots stand in for enemy bodies; no in-game capture.

Eyeball items:

  • Fire’s red ring vs the neutral magenta enemy body: similar hues, may read weakly on auraless enemies.
  • Dense hordes: rings overlap in the bench’s tight swarm grid; at zoom 1 on device they may read as clutter. WIDTH/GAIN are live; a cap-to-nearest-N could be the next step.
  • No reaction/stack-change flash (stacks never change in live play); a pulse on aura apply would be the step to 4.