Skip to content

Weapon FX v2 batch 2 — implementation plan (prep, zones, Beam + Railgun shootout)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the shootout harness serve Beam + Railgun (including a mark board), make Thunder Wake puddles and Firestorm zones visible, then run the four-model blind shootout for both weapons and hand Chris the boards.

Architecture: Event synthesis moves out of shootout.gd into a small testable ShootoutEvents helper that builds the REAL weapon objects to get post-mod/evolve width + length. The mark is a separate immediate-mode WeaponFxLayer per Railgun candidate, driven once per frame through a dormant FxManager.draw_marks hook. Zones are a pure ZoneRenderer.weapon_zone_discs(sim) helper (testable) consumed by _draw(). Candidates are written by eight agents in isolated worktrees and merged as folders only.

Tech Stack: Godot 4.7 typed GDScript, GUT, blend_add canvas_item shaders, MultiMeshInstance2D.

Spec: docs/superpowers/specs/2026-10-01-weapon-fx-v2-batch2-design.md

Promotion of the winners (spec Part D) is a SEPARATE plan, written after Chris picks — its code depends on which candidate wins.

  • Work in worktree .claude/worktrees/fx-v2-batch2, branch fx-v2-batch2-beam-railgun. Never touch pulse-shootout or its uncommitted files.
  • /sim is pure logic: the ONLY sim edit in this plan is one read-only getter (WeaponWildfire.burn_zones()). No RNG draw, no state change. Determinism baselines (tests/test_determinism*.gd) must stay green unchanged.
  • No ShaderMaterial.new() / Shader.new() in render/, ui/, fx/, main.gd — every shader has a saved _mat.tres (tests/test_baked_shader_materials.gd).
  • Half floats on gl_compatibility: never pack large ints into COLOR / INSTANCE_CUSTOM; accent codes go through accent_slot().
  • After any windowed godot --path . run: git diff project.godot and git checkout -- project.godot before committing.
  • Never pull a full GUT run into context: scripts/check-test-count.sh or | tail -20.
  • Run a single GUT file: godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit 2>&1 | tail -20
  • Commit messages via git commit -F <file> (scratchpad), ending with the session’s Co-Authored-By / Claude-Session lines.
  • Anchor edits on quoted TEXT, not line numbers — line numbers in this plan are approximate.
File Responsibility
Create tools/weapon_vfx/shootout_events.gd class_name ShootoutEvents — per-weapon cell size, fire geometry, event dictionaries (pulse/beam/railgun), mark-board states. Pure, testable.
Modify tools/weapon_vfx/shootout.gd Uses ShootoutEvents; per-weapon cell size; STATE=mark board for Railgun.
Create tests/test_shootout_events.gd Pins event shapes: beam/railgun width + length differ p0 vs evolved, railgun evolved = 2 lances, pulse unchanged.
Modify fx/fx_manager.gd mark_layer + draw_marks(enemies, now) dormant hook.
Modify main.gd Calls fx_layer.draw_marks(sim.enemies, sim.run_time) each frame.
Create tests/test_mark_hook.gd Hook forwards when a layer is set + v2 active; no-op otherwise.
Modify sim/weapon_wildfire.gd burn_zones() getter.
Modify render/zone_renderer.gd weapon_zone_discs(sim) static helper + draw in _draw().
Create tests/test_zone_renderer_weapon_zones.gd Puddle / Firestorm zone → disc; none when empty.
Modify tools/vfx_library/library_manifest.gd thunder_wake_zone / wildfire_zone → CURRENT + scores.
Create tools/weapon_vfx/BATCH2_ADDENDUM.md Beam/Railgun notes, mark contract, mechanic-proposal invitation.
Modify docs/RELEASE-1.0.1.md Correct the “Beam Lance fixed by batch 1” claim; tick the zone items.
Create (by agents) fx/weapons/candidates/{beam,railgun}/<slug>/… Candidate looks (+ mark.* for railgun).
Create fx/weapons/candidates/{beam,railgun}/_labels.json Blind letter mapping.

Task 1: ShootoutEvents + generalised shootout board

Section titled “Task 1: ShootoutEvents + generalised shootout board”

Files:

  • Create: tools/weapon_vfx/shootout_events.gd
  • Modify: tools/weapon_vfx/shootout.gd
  • Test: tests/test_shootout_events.gd

Interfaces:

  • Produces: ShootoutEvents.cell_size(wid: String) -> Vector2i; ShootoutEvents.events(content: ContentDB, wid: String, evolved: bool, power: float, rungs: int, origin: Vector2) -> Array (array of event Dictionaries, all with wid/power/evolved/rungs/element); ShootoutEvents.targets(content, wid, evolved, origin) -> PackedVector2Array (enemy dot positions); ShootoutEvents.MARK_STATES: Array (used by Task 2).

  • Step 1: Write the failing test — tests/test_shootout_events.gd

extends GutTest
# Pins the shootout's synthetic events to the REAL weapons: a board that fed every candidate the same
# width at p0 and evolved would hide exactly the bug batch 2 exists to fix (Beam Lance ignored width).
var _content: ContentDB
func before_all() -> void:
_content = ContentLoader.load_from_path("res://data/bible.json")
func _one(wid: String, evolved: bool) -> Array:
return ShootoutEvents.events(_content, wid, evolved, 0.5 if evolved else 0.0, 7 if evolved else 0, Vector2.ZERO)
func test_beam_event_carries_real_width_and_length() -> void:
var p0: Dictionary = _one("beam", false)[0]
var ev: Dictionary = _one("beam", true)[0]
assert_eq(p0["kind"], "beam")
assert_almost_eq(float(p0["width"]), WeaponBeam.BEAM_WIDTH, 0.01)
assert_almost_eq(float(p0["length"]), WeaponBeam.BEAM_RANGE, 0.01)
assert_almost_eq(float(ev["width"]), WeaponBeam.BEAM_WIDTH * 2.5, 0.01, "Lance evolve widens ×2.5")
assert_almost_eq(float(ev["length"]), WeaponBeam.BEAM_RANGE * 1.4, 0.01, "Lance evolve reaches ×1.4")
assert_true(bool(ev["evolved"]))
assert_false(bool(p0["evolved"]))
func test_railgun_evolved_fires_two_thinner_lances() -> void:
var p0 := _one("railgun", false)
var ev := _one("railgun", true)
assert_eq(p0.size(), 1)
assert_eq(ev.size(), 2, "Prism Lance forces lance_count >= 2")
assert_eq(String(ev[0]["kind"]), "railgun_lance")
assert_almost_eq(float(ev[0]["width"]), WeaponRailgun.LANCE_WIDTH * 0.6, 0.01)
var a: Vector2 = ev[0]["dir"]
var b: Vector2 = ev[1]["dir"]
assert_almost_eq(a.angle_to(b), WeaponRailgun.SECOND_LANCE_ANGLE, 0.001)
func test_every_event_carries_the_stamp_fields() -> void:
for wid in ["pulse", "beam", "railgun"]:
for e in _one(wid, true):
for key in ["wid", "power", "evolved", "rungs", "element"]:
assert_true((e as Dictionary).has(key), "%s event missing %s" % [wid, key])
func test_pulse_shape_unchanged() -> void:
assert_eq(_one("pulse", false).size(), 1, "p0 pulse = one link")
assert_eq(_one("pulse", true).size(), 3, "evolved pulse = full chain")
assert_eq(String(_one("pulse", false)[0]["kind"]), "bolt")
assert_eq(ShootoutEvents.cell_size("pulse"), Vector2i(360, 360))
func test_lance_cells_fit_the_real_length() -> void:
for wid in ["beam", "railgun"]:
var ev: Dictionary = _one(wid, true)[0]
assert_true(ShootoutEvents.cell_size(wid).x >= int(float(ev["length"])) + 80,
"%s cell must fit the evolved lance plus margin" % wid)
  • Step 2: Run it — expect FAIL (Identifier "ShootoutEvents" not declared).

Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_shootout_events.gd -gexit 2>&1 | tail -20

  • Step 3: Implement — tools/weapon_vfx/shootout_events.gd
class_name ShootoutEvents
extends RefCounted
# Synthetic fire events for tools/weapon_vfx/shootout.gd, one shape per weapon. Width and length come
# from the REAL weapon object (constructed from bible.json, evolve() called for the evolved board), so
# a candidate is judged at exactly what the game fires — never a hand-copied constant that drifts.
# Pulse chain geometry, px from the cell centre: [0] is the pilot, the rest are struck enemies.
const PULSE_NODES := [Vector2(-148.0, 44.0), Vector2(-28.0, -62.0), Vector2(84.0, 30.0), Vector2(166.0, -44.0)]
# Lances fire left -> right across a wide, short cell. The pilot sits LANCE_PAD in from the left edge.
const LANCE_PAD := 40.0
# Mark board columns: [mark_timer / mark_duration, mark_amt]. Fresh single, half-faded single,
# almost-expired single, fresh double-stack ((1.2 * 1.2) - 1 = 0.44, the "mark" mod's compounding).
const MARK_STATES := [[1.0, 0.20], [0.5, 0.20], [0.12, 0.20], [1.0, 0.44]]
static func cell_size(wid: String) -> Vector2i:
match wid:
"beam": return Vector2i(720, 280)
"railgun": return Vector2i(1000, 320)
return Vector2i(360, 360)
static func _element(content: ContentDB, wid: String) -> int:
return content.element_index(String(content.weapon(wid).get("element", "")))
static func _stamp(ev: Dictionary, content: ContentDB, wid: String, evolved: bool, power: float, rungs: int) -> Dictionary:
ev["element"] = _element(content, wid)
ev["wid"] = wid
ev["power"] = power
ev["evolved"] = evolved
ev["rungs"] = rungs
return ev
static func _pilot(wid: String, origin: Vector2) -> Vector2:
var cs := cell_size(wid)
return origin + Vector2(-cs.x * 0.5 + LANCE_PAD, 0.0)
static func events(content: ContentDB, wid: String, evolved: bool, power: float, rungs: int, origin: Vector2) -> Array:
var out: Array = []
match wid:
"beam":
var w := WeaponBeam.new(content.weapon("beam"))
if evolved:
w.evolve()
out.append(_stamp({"kind": "beam", "pos": _pilot(wid, origin), "dir": Vector2.RIGHT,
"length": w.beam_range, "width": w.width}, content, wid, evolved, power, rungs))
"railgun":
var r := WeaponRailgun.new(content.weapon("railgun"))
if evolved:
r.evolve()
# Aim a touch upward so the evolved second lance (+SECOND_LANCE_ANGLE) stays inside the cell.
var base := Vector2.RIGHT.rotated(-0.17)
for i in range(r.lance_count):
var d := base if i == 0 else base.rotated(WeaponRailgun.SECOND_LANCE_ANGLE)
out.append(_stamp({"kind": "railgun_lance", "pos": _pilot(wid, origin), "dir": d,
"length": r.lance_range, "width": r.width}, content, wid, evolved, power, rungs))
_:
var n := PULSE_NODES.size() - 1 if evolved else 1
for i in range(n):
out.append(_stamp({"kind": "bolt", "from": origin + PULSE_NODES[i], "to": origin + PULSE_NODES[i + 1]},
content, wid, evolved, power, rungs))
return out
# Pilot + enemy dot positions for the cell backdrop. Lance weapons get enemies strung along the line(s)
# so a look is judged against the things it pierces.
static func targets(content: ContentDB, wid: String, evolved: bool, origin: Vector2) -> PackedVector2Array:
var pts := PackedVector2Array()
if wid == "beam" or wid == "railgun":
var evs := events(content, wid, evolved, 0.0, 0, origin)
pts.append(evs[0]["pos"])
for e in evs:
for f in [0.35, 0.6, 0.85]:
pts.append((e["pos"] as Vector2) + (e["dir"] as Vector2) * float(e["length"]) * f)
return pts
var n := PULSE_NODES.size() if evolved else 2
for i in range(n):
pts.append(origin + PULSE_NODES[i])
return pts

Check before Step 3 lands: WeaponBeam.new(def) / WeaponRailgun.new(def) take the bible weapon dict (_init(def: Dictionary) reads base_damage, cooldown_s) — confirm WeaponRailgun._init has the same signature (/usr/bin/grep -n "func _init" sim/weapon_railgun.gd); if it also needs area/others, they come from the same dict.

  • Step 4: Rewire shootout.gd to use the helper. Replace the NODES const, _element, _links, _event and _targets functions and the per-cell loops so that:
# _ready(): size comes from the weapon's cell, not the square CELL const
var cs := ShootoutEvents.cell_size(_wid)
var size := Vector2i(GUTTER + cs.x * TIMES.size(), cs.y * rows.size())
...
var centre := Vector2(GUTTER + cs.x * c + cs.x * 0.5, cs.y * r + cs.y * 0.5)
# and the label y: cs.y * r + cs.y * 0.5 - 14
func _events(centre: Vector2) -> Array:
return ShootoutEvents.events(_content, _wid, _evolved(), _power(), _rungs(), centre)
func _element() -> int:
return _content.element_index(String(_content.weapon(_wid).get("element", "")))

In _cell: for ev in _events(centre): look.spawn_event(ev, tint, rng.randf()). In _v1_cell: fx.consume(_events(centre)) (FxManager’s "beam", "railgun_lance" handler already draws the legacy _BeamNode; the "bolt" handler the legacy bolt). In _targets: loop ShootoutEvents.targets(_content, _wid, _evolved(), centre), index 0 = pilot (cyan, 0.55), rest = enemies (pink, 0.42). Update the header comment’s example lines to mention WEAPON=beam / WEAPON=railgun.

  • Step 5: Run the test — expect PASS. Same command as Step 2.

  • Step 6: Eyeball the v1 baselines (proves the harness draws lances and that Pulse still renders):

Terminal window
for w in beam railgun pulse; do for s in p0 evolved; do
WEAPON=$w STATE=$s scripts/dev/godot-window-lock.sh godot --path . --rendering-method mobile res://tools/weapon_vfx/shootout.tscn 2>&1 | /usr/bin/grep -E "ERROR|shootout" ; done; done
git diff --quiet project.godot || git checkout -- project.godot

Open tools/weapon_vfx/out/shootout_{beam,railgun,pulse}_{p0,evolved}.png with Read. Expected: v1 row shows the beam line hitting the enemy dots; railgun evolved shows two lines; v1 beam looks the SAME width at p0 and evolved (that is the bug, and proves the board is honest).

  • Step 7: Commit — tools/weapon_vfx/shootout_events.gd tools/weapon_vfx/shootout.gd tests/test_shootout_events.gd, message tools(weapon_vfx): shootout serves Beam + Railgun with real weapon geometry.

Task 2: Railgun mark contract — shootout mark board + dormant live hook

Section titled “Task 2: Railgun mark contract — shootout mark board + dormant live hook”

Files:

  • Modify: tools/weapon_vfx/shootout.gd
  • Modify: fx/fx_manager.gd (near func v2_look( — add after it)
  • Modify: main.gd (in _process, right after fx_layer.advance(1.0 / 60.0))
  • Test: tests/test_mark_hook.gd

Interfaces:

  • Consumes: ShootoutEvents.MARK_STATES, ShootoutEvents.cell_size.

  • Produces: candidate contract res://fx/weapons/candidates/railgun/<slug>/mark.gd — no-arg _init(), func draw_marks(enemies: EnemyPool, now: float) -> void; FxManager.mark_layer: Node (null = dormant), FxManager.draw_marks(enemies: EnemyPool, now: float) -> void.

  • Step 1: Write the failing test — tests/test_mark_hook.gd

extends GutTest
# The Railgun mark (+20% damage from every source while mark_timer > 0) shipped with no renderer.
# The promoted mark layer is driven once per frame through FxManager.draw_marks. Pins: dormant with no
# layer, forwards when one is set, and steps aside at quality levels where v2 looks are off.
class StubMark extends Node:
var calls := 0
var last_now := -1.0
func draw_marks(_enemies: EnemyPool, now: float) -> void:
calls += 1
last_now = now
func _fx() -> FxManager:
var fx := FxManager.new()
add_child_autofree(fx)
return fx
func test_no_layer_is_a_noop() -> void:
var fx := _fx()
fx.draw_marks(EnemyPool.new(8), 1.0) # must not error
assert_null(fx.mark_layer)
func test_forwards_to_the_layer_when_v2_is_active() -> void:
var fx := _fx()
var stub := StubMark.new()
add_child_autofree(stub)
fx.mark_layer = stub
fx.v2_quality_ok = true
var was := VisualLook.weapon_fx_v2
VisualLook.weapon_fx_v2 = true
fx.draw_marks(EnemyPool.new(8), 2.5)
VisualLook.weapon_fx_v2 = was
assert_eq(stub.calls, 1)
assert_almost_eq(stub.last_now, 2.5, 0.0001)
func test_skips_when_v2_quality_is_off() -> void:
var fx := _fx()
var stub := StubMark.new()
add_child_autofree(stub)
fx.mark_layer = stub
fx.v2_quality_ok = false
fx.draw_marks(EnemyPool.new(8), 2.5)
assert_eq(stub.calls, 0)
  • Step 2: Run — expect FAIL (mark_layer not a member).

  • Step 3: Implement the hook in fx/fx_manager.gd, directly after func v2_look(wid: String): and its body:

# ── Railgun mark layer (FX v2 batch 2) ───────────────────────────────────────
# The mark is per-ENEMY state (enemies.mark_timer / mark_amt), not an event, so it can't ride
# spawn_event: an immediate-mode WeaponFxLayer redraws every marked enemy each frame. Null until the
# batch-2 shootout winner is promoted — the call site in main.gd is live but dormant.
var mark_layer: Node = null
func draw_marks(enemies: EnemyPool, now: float) -> void:
if mark_layer == null or not v2_active():
return
mark_layer.draw_marks(enemies, now)

In main.gd _process, immediately after the line fx_layer.advance(1.0 / 60.0) # keep decaying already-spawned fx ... (inside the same if fx_layer != null: block), add:

fx_layer.draw_marks(sim.enemies, sim.run_time) # Railgun mark (dormant until a look is promoted)
  • Step 4: Run — expect PASS.

  • Step 5: Mark board in shootout.gd. When _state == "mark" (Railgun only), _ready builds a different board: rows = candidates that have mark.gd (no v1 row — v1 draws nothing, which is the bug; print [shootout] v1 has no mark renderer once), columns = ShootoutEvents.MARK_STATES, cell 360×360, output shootout_railgun_mark.png. Per cell:

func _mark_cell(root: Node2D, centre: Vector2, script: GDScript, state: Array) -> void:
# A small cluster of enemies, all in the column's mark state, so "readable in a swarm" is judged
# on a cluster, not a lone dot.
var pool := EnemyPool.new(8)
var offs := [Vector2(-60, -30), Vector2(10, -50), Vector2(55, 20), Vector2(-20, 45), Vector2(-75, 40)]
for o in offs:
var i := pool.add(centre + o, Vector2.ZERO, 14.0, 10.0)
pool.mark_timer[i] = WeaponRailgun.MARK_DURATION * float(state[0])
pool.mark_amt[i] = float(state[1])
for o in offs:
_dot(root, centre + o, Color(1.0, 0.32, 0.5) * 1.3, 0.42) # same enemy dot as the fire boards
var layer = script.new()
root.add_child(layer)
layer.draw_marks(pool, 1.0)

(Factor the existing dot-sprite code in _targets into _dot(root, pos, col, scale) and reuse it.) Column captions: fresh, half, expiring, stacked ×2 via WeaponVfxHarness.label at the top of each column. The pool’s add() positional args beyond d default fine here — this pool is never ticked or killed, so the biomass trap does not apply.

Smoke-check with a throwaway stub candidate (delete before commit): create fx/weapons/candidates/railgun/zz_stub/ (NOT a _-prefixed name — _slugs() skips those) containing a minimal look.gd (spawn_event returns false) + mark.gd, both extends WeaponFxLayer with super(preload("res://fx/weapons/blaster_flash_mat.tres").duplicate() as ShaderMaterial, 64), where draw_marks puts one quad per marked enemy. Run WEAPON=railgun STATE=mark …shootout.tscn, confirm the PNG shows quads on the dots with no SCRIPT ERROR, then rm -rf fx/weapons/candidates/railgun/zz_stub.

  • Step 6: Commit — fx/fx_manager.gd main.gd tools/weapon_vfx/shootout.gd tests/test_mark_hook.gd, message feat(fx): dormant Railgun mark hook + shootout mark board.

Task 3: Thunder Wake puddles + Firestorm zones become visible

Section titled “Task 3: Thunder Wake puddles + Firestorm zones become visible”

Files:

  • Modify: sim/weapon_wildfire.gd (add getter after the _burn_zones var’s last use, e.g. after func cooldown_frac)
  • Modify: render/zone_renderer.gd
  • Modify: tools/vfx_library/library_manifest.gd
  • Test: tests/test_zone_renderer_weapon_zones.gd

Interfaces:

  • Produces: WeaponWildfire.burn_zones() -> Array[Dictionary]; ZoneRenderer.weapon_zone_discs(sim: Sim) -> Array of {"pos": Vector2, "radius": float, "frac": float, "element": int, "kind": String} where kind ∈ "thunder_wake", "firestorm".

  • Step 1: Write the failing test — tests/test_zone_renderer_weapon_zones.gd

extends GutTest
# Thunder Wake puddles and Firestorm zones lived on the weapon instances, never in sim.zones, so
# nothing drew them (red MISSING cards on the VFX library). ZoneRenderer.weapon_zone_discs is the pure
# read the renderer draws from.
func _sim() -> Sim:
return Sim.new(1, SimContentFixture.db())
func test_no_weapon_zones_no_discs() -> void:
assert_eq(ZoneRenderer.weapon_zone_discs(_sim()).size(), 0)
func test_thunder_wake_puddle_becomes_a_disc() -> void:
var sim := _sim()
var tw: WeaponThunderWake = sim.pilots[0].arsenal.thunder_wake
if tw == null:
pending("fixture content has no thunder_wake weapon")
return
tw.zones().append({"pos": Vector2(40, 10), "radius": 50.0, "remaining": 0.75, "duration": 1.5, "dps": 4.0})
var discs := ZoneRenderer.weapon_zone_discs(sim)
assert_eq(discs.size(), 1)
assert_eq(String(discs[0]["kind"]), "thunder_wake")
assert_almost_eq(float(discs[0]["frac"]), 0.5, 0.001, "fades on remaining / duration")
assert_eq(int(discs[0]["element"]), tw.element_idx)
func test_firestorm_zone_becomes_a_disc() -> void:
var sim := _sim()
var wf: WeaponWildfire = sim.pilots[0].arsenal.wildfire
if wf == null:
pending("fixture content has no wildfire weapon")
return
wf.burn_zones().append({"pos": Vector2(-5, 5), "radius": WeaponWildfire.FIRESTORM_ZONE_RADIUS,
"remaining": WeaponWildfire.FIRESTORM_ZONE_DURATION * 0.25})
var discs := ZoneRenderer.weapon_zone_discs(sim)
assert_eq(discs.size(), 1)
assert_eq(String(discs[0]["kind"]), "firestorm")
assert_almost_eq(float(discs[0]["frac"]), 0.25, 0.001)
assert_eq(int(discs[0]["element"]), wf.element_idx)

Before writing it, confirm the fixture: /usr/bin/grep -n "thunder_wake\|wildfire" tests/sim_content_fixture.gd (or wherever SimContentFixture lives). If the fixture lacks those weapons, build the sim from ContentLoader.load_from_path("res://data/bible.json") instead — do NOT leave the tests pending.

  • Step 2: Run — expect FAIL (burn_zones / weapon_zone_discs missing).

  • Step 3: Implement. In sim/weapon_wildfire.gd:

# Read-only view for ZoneRenderer (Firestorm zones were invisible — no render consumer). Render
# must never mutate it; the sim owns the array.
func burn_zones() -> Array[Dictionary]:
return _burn_zones

In render/zone_renderer.gd, add the helper above func _draw():

# Weapon-owned persistent zones that live on the weapon instance, not in sim.zones: Thunder Wake's
# puddles and Wildfire's Firestorm zones. Pure read, one entry per live zone, for _draw() and tests.
static func weapon_zone_discs(sim: Sim) -> Array:
var out: Array = []
for p in sim.pilots:
var tw: WeaponThunderWake = p.arsenal.thunder_wake
if tw != null:
for z in tw.zones():
out.append({"pos": z["pos"], "radius": float(z["radius"]), "kind": "thunder_wake",
"element": tw.element_idx,
"frac": clampf(float(z["remaining"]) / maxf(float(z.get("duration", 1.5)), 0.001), 0.0, 1.0)})
var wf: WeaponWildfire = p.arsenal.wildfire
if wf != null:
for z in wf.burn_zones():
out.append({"pos": z["pos"], "radius": float(z["radius"]), "kind": "firestorm",
"element": wf.element_idx,
"frac": clampf(float(z["remaining"]) / WeaponWildfire.FIRESTORM_ZONE_DURATION, 0.0, 1.0)})
return out

In _draw(), after the for zone in _sim.zones: loop and before the FunZo block:

# Thunder Wake puddles + Firestorm zones (FX v2 batch 2 — were invisible). Same disc language as
# reaction terrain; Firestorm adds a slow ember flicker on the rim so it doesn't read as a
# Supernova remnant crater, Thunder Wake a faint inner ring so it reads as charged ground.
for d in weapon_zone_discs(_sim):
var dcol := ElementPalette.color_for(_content, int(d["element"]))
var df: float = float(d["frac"])
var dr: float = float(d["radius"])
var dpos: Vector2 = d["pos"]
if low_detail:
draw_arc(dpos, dr, 0.0, TAU, 28, Color(dcol.r, dcol.g, dcol.b, 0.6 * df), 2.0, true)
continue
draw_circle(dpos, dr, Color(dcol.r, dcol.g, dcol.b, 0.10 * df))
if String(d["kind"]) == "firestorm":
var flick := 0.75 + 0.25 * sin(_sim.run_time * 9.0 + dpos.x * 0.05)
draw_circle(dpos, dr * 0.55, Color(dcol.r, dcol.g, dcol.b, 0.12 * df * flick))
draw_arc(dpos, dr, 0.0, TAU, 40, Color(dcol.r, dcol.g, dcol.b, 0.55 * df * flick), 2.5, true)
else:
draw_arc(dpos, dr * 0.62, 0.0, TAU, 32, Color(dcol.r, dcol.g, dcol.b, 0.35 * df), 1.5, true)
draw_arc(dpos, dr, 0.0, TAU, 40, Color(dcol.r, dcol.g, dcol.b, 0.5 * df), 2.0, true)
  • Step 4: Run — expect PASS. Then the determinism trio (getter only — must be unchanged):

for f in test_determinism test_determinism_survival test_determinism_coop; do godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/$f.gd -gexit 2>&1 | tail -3; done

  • Step 5: Manifest. In tools/vfx_library/library_manifest.gd: change the wildfire_zone and thunder_wake_zone entries’ status from "MISSING" to "CURRENT", source text to "… · render/zone_renderer.gd weapon_zone_discs", and in RATINGS set "wildfire_zone": 3, "thunder_wake_zone": 3. Check whether the entry’s group ("MISSING") must change too — read tests/test_vfx_library_manifest.gd for what it asserts about MISSING cards (e.g. “rating 0 iff MISSING”) and satisfy it; move the two entries into the WORLD/zone group if the test ties group to status. Run that test.

  • Step 6: Eyeball in situ: godot --path . --rendering-method mobile res://tools/vfx_library/library.tscn (or its capture mode per tools/vfx_library/ README/header) — confirm both cards now show a disc. Restore project.godot.

  • Step 7: Commit — the four files + test, message feat(render): draw Thunder Wake puddles and Firestorm zones.


Task 4: Batch-2 brief addendum + doc correction

Section titled “Task 4: Batch-2 brief addendum + doc correction”

Files:

  • Create: tools/weapon_vfx/BATCH2_ADDENDUM.md

  • Modify: docs/RELEASE-1.0.1.md

  • Step 1: Write the addendum (tools/weapon_vfx/BATCH2_ADDENDUM.md), verbatim:

# FX v2 batch 2 addendum — Beam + Railgun
Read after `CANDIDATE_BRIEF.md`. Where they differ, this wins for batch 2.
## Licence (Chris, 2026-10-01)
Full artistic licence. You are also explicitly invited to propose changes to the weapon's MECHANICS
if they would let the VFX be better (e.g. a beam that sweeps instead of snapping, a lance that
lingers as a rail, a mark that pulses on damage). Write them in `CANDIDATE.md` § Mechanic proposal
with what changes, why the effect gets better, and the gameplay/balance cost. Prototype in your own
folder behind a `const` if your look depends on it — never edit `/sim`. Chris reviews proposals
separately after the blind pick; none ship by default.
## Beam (`sim/weapon_beam.gd`)
- Fires at the NEAREST enemy within reach, cooldown 0.1 s (÷ fire rate) — it is near-continuous, so
instances overlap heavily. Budget your CAP and fill accordingly; a look that whites out at 10 Hz fails.
- Event `beam`: `pos` (pilot), `dir` (unit), `length` (420, ×1.4 evolved, ×reach mod), `width`
(HALF-width in px: 20, ×2.5 evolved, ×width mod) — the hit test is `perp <= width`, so draw the
damaging core at that half-width. The current renderer ignores width; yours must not.
- Element light. Rungs: light/width (5), void/reach (10), light/evolve (18). Evolved = **Lance**: a
thick, long cutting beam — it must read as a different, heavier form.
## Railgun (`sim/weapon_railgun.gd`)
- Fires at the FARTHEST enemy within 900 px; every lance pierces the whole line. Slower cadence —
each shot should feel like an event.
- Event `railgun_lance`, one per lance: `pos`, `dir`, `length` 900, `width` (HALF-width 30; ×0.6 when
evolved). Evolved = **Prism Lance**: two thinner lances, the second at +0.35 rad.
- Element light. Rungs: light/mark (5), aether/lances (10 — aether's motif is NOT built; it renders as
a no-op, don't depend on it), light/evolve (18).
### Railgun only: the mark (required)
Enemies pierced by a lance are marked: `enemies.mark_timer[i]` (seconds left; 0.6 base, ×1.5
evolved, longer with the `mark` mod) and `enemies.mark_amt[i]` (+damage taken from ALL sources:
0.20 single, compounds to 0.44, 0.73, … with the `mark` mod). Today it is invisible.
Deliver, in your folder, `mark.gd` + `mark.gdshader` + `mark_mat.tres`:
- `mark.gd`: `extends WeaponFxLayer`, no `class_name`, no-arg `_init()` calling
`super(preload(<your mark_mat.tres>).duplicate() as ShaderMaterial, CAP)`, and
`func draw_marks(enemies: EnemyPool, now: float) -> void` that does `begin()`, one `put(...)` per
enemy with `mark_timer[i] > 0.0` (loop `range(enemies.count)`), then `commit()`. Called once per
rendered frame. Size the quad from `enemies.radius[i]`.
- `mark_timer` drives the fade-out; a stacked mark (`mark_amt > 0.25`) must read visibly stronger.
- It is a target tell over a swarm: must be readable at a glance and must not white out 40 marked
enemies. Tint = the weapon's light colour.
- Verify: `WEAPON=railgun STATE=mark ONLY=<SLUG> scripts/dev/godot-window-lock.sh godot --path . --rendering-method mobile res://tools/weapon_vfx/shootout.tscn`
→ `tools/weapon_vfx/out/shootout_railgun_mark.png`. A Railgun candidate without a mark fails.
## Boards
`STATE=p0`, `STATE=evolved` (both weapons) and `STATE=mark` (Railgun). Look at every PNG you produce
and iterate at least once. Also compile-check your shaders under `--rendering-method gl_compatibility`
(run the same board command with that flag) — the web path is stricter (no `pow()` on a negative base,
etc.).

Before saving: confirm EnemyPool exposes radius (/usr/bin/grep -n "^var radius" sim/entity_pool.gd sim/enemy_pool.gd); if the field is named differently, use the real name in the addendum.

  • Step 2: Correct docs/RELEASE-1.0.1.md. Find the sentence containing Beam Lance ignores width — both fixed by the FX v2 batch 1 merge and change it so only the Blade Tempest crescent is credited to batch 1, adding: Beam Lance's width is fixed by FX v2 batch 2 (branch fx-v2-batch2-beam-railgun). In the “Seven player-facing mechanics have no renderer” item, append: Firestorm zone + Thunder Wake puddle drawn by batch 2 (ZoneRenderer.weapon_zone_discs, <commit>); the Railgun mark lands with the batch-2 Railgun winner. (fill <commit> with Task 3’s hash).

  • Step 3: Gate + commit. scripts/gate.sh 2>&1 | tail -15 must pass (also runs the docs-site frontmatter + wiring tests). Commit both files, message docs(fx): batch-2 candidate addendum; correct Beam Lance status.


Orchestrator task (the controller does this, not an implementer subagent).

  • Step 1: Dispatch four agents in ONE message (parallel), each with isolation: "worktree", subagent_type: "bh-implementer", and model = fable / opus / sonnet / haiku. Slugs (folder names): fable51, opus55, sonnet55, haiku45. Prompt for each (substitute SLUG):
WEAPON=beam, SLUG=<slug>. You are one of several independent candidates in a blind shootout for the
Beam weapon's look in Dark Cosmos (Godot 4.7, typed GDScript). First run
`git merge --ff-only fx-v2-batch2-beam-railgun` (or `git reset --hard fx-v2-batch2-beam-railgun` if your
worktree has no commits) so you have the batch-2 harness. Then read and follow, in order:
tools/weapon_vfx/CANDIDATE_BRIEF.md and tools/weapon_vfx/BATCH2_ADDENDUM.md. Everything you write goes
in fx/weapons/candidates/beam/<slug>/ and nowhere else. You have full artistic licence, including
proposing mechanic changes in CANDIDATE.md. Look at your own rendered PNGs and iterate at least once.
When done: scripts/gate.sh green, git status clean outside your folder, commit your folder on your
branch, and report the branch name + commit hash + a 3-line summary. Do not push.
  • Step 2: Collect. For each returned branch: git diff --stat fx-v2-batch2-beam-railgun..<branch> must touch ONLY fx/weapons/candidates/beam/<slug>/ (reject + re-dispatch otherwise). Bring folders in with git checkout <branch> -- fx/weapons/candidates/beam/<slug>.

  • Step 3: Blind labels. Shuffle with a fresh random order and write fx/weapons/candidates/beam/_labels.json (slug → letter A–D) — do not print the mapping in the conversation.

Terminal window
python3 -c "import json,random; s=['fable51','opus55','sonnet55','haiku45']; random.shuffle(s); json.dump({k:'ABCD'[i] for i,k in enumerate(s)}, open('fx/weapons/candidates/beam/_labels.json','w'), indent=2)"
  • Step 4: Verify the merged set. godot --headless --path . --import, scripts/gate.sh | tail -15 green, then render WEAPON=beam STATE=p0 and STATE=evolved under mobile AND gl_compatibility; read each PNG; any candidate with a compile error or blank row is reported, not silently dropped. Restore project.godot. Commit candidates + labels: feat(fx): four Beam look candidates for the blind shootout.

Task 6: Railgun shootout — four candidates

Section titled “Task 6: Railgun shootout — four candidates”

Identical to Task 5 with WEAPON=railgun, folder fx/weapons/candidates/railgun/, a SEPARATE fresh shuffle for _labels.json (independent of Beam’s), and the additional STATE=mark board in Step 4. A candidate missing mark.gd is sent back once with “the mark is required — see BATCH2_ADDENDUM.md”. Dispatch only after Task 5 Step 4 is committed (staggered, so window-lock captures don’t queue).

  • Push the branch (git push -u origin fx-v2-batch2-beam-railgun).
  • Present shootout_beam_{p0,evolved}.png, shootout_railgun_{p0,evolved,mark}.png (send via SendUserFile) — letters only, no model names, plus each candidate’s CANDIDATE.md “The idea” lines re-labelled by letter.
  • Collect all mechanic proposals into one list by letter for Chris to approve/reject separately.
  • Wait for Chris’s picks. Promotion (spec Part D) is the next plan.

For a fresh session with no conversation context (planning session 2026-10-01).

Decisions Chris made in conversation (beyond the spec):

  • Scope = Beam + Railgun looks + ALL three invisible mechanics (mark, Thunder Wake puddle, Firestorm zone) — “Beam + Railgun + all 3 invisible”.
  • Process = blind shootout, Chris’s words: “lets do a blind shootout of fable 5.1 opus 5.5 sonnet 5.5 and haiku for this, give all of them artistic licence and also if they are inspired they can suggest changes to the mechanics of the weapon to allow the vfx to be better”. Agent model values: fable, opus, sonnet, haiku. Mechanic changes are PROPOSALS only (written, optionally prototyped in-folder); none ship without Chris.
  • Approved: the work split (prep → staggered shootout Beam then Railgun → zones in parallel → promotion after pick), Railgun candidates own the mark, zones single-author, no in-game candidate switch this round.
  • Rejected: single-author batch-1 style; hybrid; generalising the Pulse in-game look switch now (it’s uncommitted on pulse-shootout and would collide).

Authorised vs needs same-day OK: Implementing Tasks 1-6 in this worktree and committing on fx-v2-batch2-beam-railgun is authorised. Pushing the branch in Task 7 is part of the approved plan. NOT authorised: merging to main, device installs / bh-deploy, store builds, promoting a winner — all need Chris.

Already done: worktree .claude/worktrees/fx-v2-batch2 created off origin/main 2016a562; upstream unset (first push needs -u origin fx-v2-batch2-beam-railgun); spec committed cd87978e; this plan committed with this handoff.

Environment gotchas:

  • Another session (a Droid process) may be live in the main checkout on pulse-shootout with uncommitted Pulse look-switch edits (fx_manager.gd, main.gd, dev_flags.gd, debug_settings_panel.gd, tools/weapon_vfx/ingame.gd). Never touch that checkout. Expect a merge conflict in fx/fx_manager.gd / main.gd later when both branches land — resolve then, not now.
  • Candidate agents run in their own worktrees forked from whatever base the harness gives them — the dispatch prompt tells them to fast-forward to fx-v2-batch2-beam-railgun first; verify each returned branch touched only its folder.
  • isolation: "remote" silently runs locally — don’t use it; use "worktree".
  • Windowed captures must go through scripts/dev/godot-window-lock.sh (App Nap gives black PNGs otherwise).
  • Use /usr/bin/grep, not grep (gitignore-honouring shim).

Open questions for Chris: none before Task 7. At Task 7: the picks per weapon + yes/no on each mechanic proposal.

Next action: Task 1 Step 1 (write tests/test_shootout_events.gd). Execution mode: subagent-driven (Tasks 1-4 via bh-implementer with model: "opus"; Tasks 5-7 are controller-run). Consult skill bh-sdd-concurrent before Task 1.