Weapon FX v2 batch 2 — implementation plan (prep, zones, Beam + Railgun shootout)
Weapon FX v2 batch 2 Implementation Plan
Section titled “Weapon FX v2 batch 2 Implementation Plan”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.
Global Constraints
Section titled “Global Constraints”- Work in worktree
.claude/worktrees/fx-v2-batch2, branchfx-v2-batch2-beam-railgun. Never touchpulse-shootoutor its uncommitted files. /simis 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.godotandgit checkout -- project.godotbefore committing. - Never pull a full GUT run into context:
scripts/check-test-count.shor| 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 structure
Section titled “File structure”| 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 withwid/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 ShootoutEventsextends 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 ptsCheck 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.gdto use the helper. Replace theNODESconst,_element,_links,_eventand_targetsfunctions 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):
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; donegit diff --quiet project.godot || git checkout -- project.godotOpen 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, messagetools(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(nearfunc v2_look(— add after it) - Modify:
main.gd(in_process, right afterfx_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_layernot a member). -
Step 3: Implement the hook in
fx/fx_manager.gd, directly afterfunc 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),_readybuilds a different board: rows = candidates that havemark.gd(no v1 row — v1 draws nothing, which is the bug; print[shootout] v1 has no mark rendereronce), columns =ShootoutEvents.MARK_STATES, cell 360×360, outputshootout_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, messagefeat(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_zonesvar’s last use, e.g. afterfunc 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) -> Arrayof{"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_discsmissing). -
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_zonesIn 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 outIn _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 thewildfire_zoneandthunder_wake_zoneentries’ status from"MISSING"to"CURRENT", source text to"… · render/zone_renderer.gd weapon_zone_discs", and inRATINGSset"wildfire_zone": 3, "thunder_wake_zone": 3. Check whether the entry’s group ("MISSING") must change too — readtests/test_vfx_library_manifest.gdfor what it asserts about MISSING cards (e.g. “rating 0 iff MISSING”) and satisfy it; move the two entries into theWORLD/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 pertools/vfx_library/README/header) — confirm both cards now show a disc. Restoreproject.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 MECHANICSif they would let the VFX be better (e.g. a beam that sweeps instead of snapping, a lance thatlingers as a rail, a mark that pulses on damage). Write them in `CANDIDATE.md` § Mechanic proposalwith what changes, why the effect gets better, and the gameplay/balance cost. Prototype in your ownfolder behind a `const` if your look depends on it — never edit `/sim`. Chris reviews proposalsseparately 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.5evolved, 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 produceand 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 containingBeam Lance ignores width — both fixed by the FX v2 batch 1 mergeand 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 -15must pass (also runs the docs-site frontmatter + wiring tests). Commit both files, messagedocs(fx): batch-2 candidate addendum; correct Beam Lance status.
Task 5: Beam shootout — four candidates
Section titled “Task 5: Beam shootout — four candidates”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", andmodel=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 theBeam 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 yourworktree 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 goesin fx/weapons/candidates/beam/<slug>/ and nowhere else. You have full artistic licence, includingproposing 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 yourbranch, 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 ONLYfx/weapons/candidates/beam/<slug>/(reject + re-dispatch otherwise). Bring folders in withgit 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.
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 -15green, then renderWEAPON=beam STATE=p0andSTATE=evolvedundermobileANDgl_compatibility; read each PNG; any candidate with a compile error or blank row is reported, not silently dropped. Restoreproject.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).
Task 7: Hand the boards to Chris — STOP
Section titled “Task 7: Hand the boards to Chris — STOP”- 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’sCANDIDATE.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.
Execution handoff
Section titled “Execution handoff”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
modelvalues: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-shootoutand 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-shootoutwith 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 infx/fx_manager.gd/main.gdlater 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-railgunfirst; 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, notgrep(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.