Skip to content

Warden rebuild (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 Survival Warden a scarier, smarter boss with a Nova-look body and player-grade attack FX.

Architecture: Sim changes stay in sim/boss_warden.gd + sim/boss_state.gd, all new behaviour gated on BossWarden.survival(sim). Render: a Nova bucket for the Warden core in ArchetypeRenderer, a new render/warden_body_renderer.gd for the rotating ring layers, and new WeaponFxLayer looks in fx/boss/ driven by a new fx/boss/warden_fx.gd coordinator that replaces BossRenderer’s _draw() while VisualLook.warden_v2 is on.

Tech Stack: Godot 4.7 typed GDScript, GUT, canvas_item .gdshader, Python/PIL bake tool.

Spec: docs/superpowers/specs/2026-10-01-warden-rebuild-design.md (read it first).

  • /sim is pure logic: extends RefCounted, no Node/Engine/Time/Input APIs. Ticks on Sim_Const.DT.
  • All randomness via sim.rng (SeededRng). Never upgrade_rng/drone_rng/player_rng for the boss.
  • Tutorial/story Warden (sim.story != null) behaviour must be byte-identical to today: 4-attack cycle (idx+1) % TUTORIAL_ATTACK_COUNT, same speeds/damage/telegraphs. tests/test_warden_survival.gd::test_story_warden_is_unchanged must stay green unmodified.
  • Determinism baselines (tests/test_determinism*.gd) must not change. If one does, STOP and report — do not re-pin.
  • Tunable numbers are static var (remote-config convention) and get a row in net/tunables_registry.gd next to the existing BOSS_*/SV_* rows (same format as those rows).
  • No ShaderMaterial.new() / Shader.new() in render/ui/fx/main.gd; every .gdshader referenced by a saved _mat.tres (tests/test_baked_shader_materials.gd).
  • Shader rules (from Weapon FX v2 reviews): no pow() on a possibly-negative base; fade to 0 before any discard/extent edge (no hard rims); smoothstep edges in the right order; per-instance COLOR/CUSTOM must survive half-float precision (web); multiply final rgb by BASE_DIM uniform like the player looks.
  • Every WeaponFxLayer look: extends WeaponFxLayer, MAT_PATH/CAP consts, _init(): super(preload(MAT_PATH).duplicate() as ShaderMaterial, CAP) — copy fx/weapons/blaster_flash_look.gd’s shape.
  • Hostile palette: body #ff2e57, core white-hot, rim violet #8a2be2. Never a player element accent hue.
  • Work in your own worktree .claude/worktrees/<task-slug> branched off the integration branch warden-rebuild; never in the main checkout.
  • Tests: run scripts/check-test-count.sh (never pull a raw GUT run into context); scripts/gate.sh before reporting done.
  • After any windowed Godot run: git checkout -- project.godot audio/bus_layout.tres.
  • Commit messages via git commit -F <file>, ending with the Co-Authored-By / Claude-Session lines.
File Responsibility
sim/boss_state.gd + ATTACK_FAN, ATTACK_LUNGE, deck state (last_attack, since_summon, strafe_sign, lunge_dir, lunge_t, volley_left, volley_t, second_ring_t, second_swing_t, fan_dir)
sim/boss_warden.gd deck pick, new attacks, strafe/reposition, phase 2, missile soak rule, telemetry tags, extended render_info
sim/warden_deck.gd (new) pure weighted pick by distance band (static funcs, testable alone)
sim/sim.gd _hurt_player gets boss_attack tag → boss_dmg_by_attack; boss_log carries it
net/run_timeline.gd boss entry gains the dmg map
tools/nova_units/nova_unit_designs.py warden core + two ring designs, 512 px export
render/archetype_renderer.gd Warden Nova bucket
render/warden_body_renderer.gd (new) rotating ring sprites, state-driven
render/visual_look.gd, ui/debug_settings_panel.gd warden_v2 A/B toggle
fx/boss/*.gd/.gdshader/_mat.tres (new) warden_bolt, warden_drone, warden_missile, warden_burst, warden_swing, warden_charge, warden_lane looks
fx/boss/warden_fx.gd (new) Node2D coordinator owning the 7 layers; fed by main each frame
render/boss_renderer.gd early-return in _draw() when VisualLook.warden_v2
main.gd wiring: hide boss-sourced dots, feed WardenFx + WardenBodyRenderer
render/prewarm_pass.gd warm the 7 new pipelines
tools/vfx_library/library_manifest.gd, data/bestiary.json, docs housekeeping

render_info contract (produced by Tasks 1-3, consumed by Tasks 5-7)

Section titled “render_info contract (produced by Tasks 1-3, consumed by Tasks 5-7)”

BossWarden.render_info(sim) keeps every existing key and adds:

"lane": {} or {"kind": "fan"|"lunge", "origin": Vector2, "dir": Vector2, "length": float,
"half_angle": float (radians; 0 for lunge), "progress": float 0..1}
"double_swing_t": float # seconds until the enraged second swing lands, -1 if none pending
"strafing": bool
"telegraph_kind": int # BossState.ATTACK_* being telegraphed, -1 if not in TELEGRAPH

Task 1: Range-aware attack deck (Survival)

Section titled “Task 1: Range-aware attack deck (Survival)”

Files:

  • Create: sim/warden_deck.gd
  • Modify: sim/boss_state.gd, sim/boss_warden.gd (REST → next-attack transition, ~line 134-138), net/tunables_registry.gd
  • Test: tests/test_warden_deck.gd (new); update tests/test_boss.gd::test_boss_cycles_attacks and test_boss_cycles_through_every_attack to pin the TUTORIAL cycle (set sim.story = StoryState.new() before spawn) — their intent was the cycle, which is now tutorial-only.

Interfaces:

  • Produces: BossState.ATTACK_FAN := 6, BossState.ATTACK_LUNGE := 7, BossState.ATTACK_SLAM := 8 (enraged double swing, reuses swing hit), ATTACK_COUNT := 9; TUTORIAL_ATTACK_COUNT stays 4. BossState vars last_attack: int = -1, since_summon: int = 99.
  • Produces: WardenDeck.band(dist: float) -> int (0 close, 1 mid, 2 far), WardenDeck.pick(sim: Sim, dist: float, enraged: bool, summon_ok: bool) -> int.
  • Produces statics on BossWarden: DECK_CLOSE := 300.0, DECK_FAR := 600.0 (static var).

Weights (static var Dictionaries on WardenDeck, attack_id → weight):

  • close: SWING 4, SLAM 3 (enraged only), FAN 2, BARRAGE 1
  • mid: FAN 4, SPIRAL 3, BARRAGE 2, MISSILES 2, SWARM 1
  • far: LUNGE 4, MISSILES 3, SWARM 3, SUMMON 1

Rules: drop last_attack; drop SLAM unless enraged; drop SUMMON unless summon_ok (= since_summon >= 3 and summoned alive < SUMMON_MAX_ADDS). Weighted pick: r = sim.rng.randf() * total, walk weights in ascending attack-id order (fixed order = deterministic). If every weight is dropped, return SWING.

  • Step 1: Write failing tests in tests/test_warden_deck.gd:
extends GutTest
func _sim() -> Sim:
var s := Sim.new(7, SimContentFixture.db())
s.enable_survival()
s.enemies.count = 0
s.enemy_proj.count = 0
return s
func _counts(sim: Sim, dist: float, enraged: bool, summon_ok: bool, n: int) -> Dictionary:
var c := {}
for _i in range(n):
var a := WardenDeck.pick(sim, dist, enraged, summon_ok)
c[a] = int(c.get(a, 0)) + 1
sim.boss.last_attack = -1
return c
func test_bands() -> void:
assert_eq(WardenDeck.band(100.0), 0)
assert_eq(WardenDeck.band(450.0), 1)
assert_eq(WardenDeck.band(900.0), 2)
func test_far_never_swings() -> void:
var c := _counts(_sim(), 900.0, false, true, 400)
assert_false(c.has(BossState.ATTACK_SWING), "no swing from 900px")
assert_true(c.has(BossState.ATTACK_LUNGE))
func test_close_prefers_swing_and_no_lunge() -> void:
var c := _counts(_sim(), 150.0, false, true, 400)
assert_gt(int(c.get(BossState.ATTACK_SWING, 0)), int(c.get(BossState.ATTACK_BARRAGE, 0)))
assert_false(c.has(BossState.ATTACK_LUNGE))
assert_false(c.has(BossState.ATTACK_SLAM), "slam is enraged-only")
func test_slam_only_when_enraged() -> void:
var c := _counts(_sim(), 150.0, true, true, 400)
assert_true(c.has(BossState.ATTACK_SLAM))
func test_never_repeats_last_attack() -> void:
var sim := _sim()
for _i in range(200):
var a := WardenDeck.pick(sim, 450.0, false, true)
assert_ne(a, sim.boss.last_attack)
sim.boss.last_attack = a
func test_summon_gated() -> void:
var c := _counts(_sim(), 900.0, false, false, 400)
assert_false(c.has(BossState.ATTACK_SUMMON))
func test_same_seed_same_sequence() -> void:
var a := _sim()
var b := _sim()
for _i in range(50):
assert_eq(WardenDeck.pick(a, 450.0, false, true), WardenDeck.pick(b, 450.0, false, true))
func test_survival_rest_picks_from_deck_by_range() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.enemies.pos[bi] = sim.player.pos + Vector2(900, 0)
sim.boss.phase = BossState.PHASE_REST
sim.boss.timer = 10.0
sim.boss_warden.update(sim, Sim_Const.DT)
assert_ne(sim.boss.attack_idx, BossState.ATTACK_SWING)
assert_eq(sim.boss.last_attack, BossState.ATTACK_SWING, "last_attack records the one just finished")
func test_story_rest_still_cycles() -> void:
var sim := _sim()
sim.story = StoryState.new()
sim.boss_warden.spawn(sim)
sim.boss.phase = BossState.PHASE_REST
sim.boss.timer = 10.0
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.boss.attack_idx, BossState.ATTACK_BARRAGE)
  • Step 2: Run scripts/check-test-count.sh — expect the new file’s tests to FAIL (WardenDeck undefined).
  • Step 3: Implement sim/warden_deck.gd (class_name WardenDeck extends RefCounted, static funcs + static var weight dicts per the rules above). In BossState add the constants/vars and reset them in reset(). In boss_warden.gd PHASE_REST: if survival(sim): sim.boss.last_attack = sim.boss.attack_idx; since_summon += 1 (reset to 0 when SUMMON picked); compute summon_ok; attack_idx = WardenDeck.pick(...) with dist to _nearest_pilot. Else keep (idx+1) % attack_count(sim). Until Tasks 2-3 land, fire() treats FAN/LUNGE/SLAM as no-ops (Task 2/3 fill them) — add elif arms with a pass and a comment naming the task. attack_count() for survival stays used only by story; leave it returning ATTACK_COUNT.
  • Step 4: Update tests/test_boss.gd cycle tests to set story first (see Files). Run scripts/check-test-count.sh → all green.
  • Step 5: Determinism check — tests/test_determinism*.gd pass unchanged.
  • Step 6: Commit feat(warden): range-aware attack deck for the Survival Warden.

Task 2: New attacks — aimed fan, closing barrage, lunge, slam

Section titled “Task 2: New attacks — aimed fan, closing barrage, lunge, slam”

Files:

  • Modify: sim/boss_warden.gd (fire(), PHASE_ACTIVE, render_info), sim/boss_state.gd, net/tunables_registry.gd
  • Test: tests/test_warden_attacks.gd (new)

Interfaces:

  • Consumes: Task 1 constants.
  • Produces statics: FAN_VOLLEYS := 3, FAN_VOLLEYS_ENRAGED := 5, FAN_SHOTS := 7, FAN_HALF_ANGLE := deg_to_rad(25.0), FAN_VOLLEY_GAP := 0.18, FAN_SPEED := 380.0, FAN_LEAD_MAX := 0.9 (max seconds of lead), BARRAGE_RING2_DELAY := 0.12, LUNGE_SPEED := 760.0, LUNGE_LENGTH := 620.0, LUNGE_DMG := 40.0, LUNGE_BURST := 12, SLAM_SECOND_DELAY := 0.45.
  • Produces: func predict_aim(sim: Sim, from: Vector2, speed: float) -> Vector2 (unit dir to the pilot’s predicted position using pilot.velocity, lead time min(dist/speed, FAN_LEAD_MAX)).
  • Produces the render_info keys lane, double_swing_t, telegraph_kind (see contract).

Behaviour (Survival only; story never selects these ids):

  • FAN: on fire, latch fan_dir = predict_aim; volley_left = volleys, fire volley 0 immediately, subsequent volleys every FAN_VOLLEY_GAP during ACTIVE (extend ACTIVE until volley_left == 0). Each volley re-aims with predict_aim. 7 shots evenly across [-HALF, +HALF] via sim._boss_proj(bpos, dir*FAN_SPEED, sim.SHOOTER_PROJ_RADIUS, sim.SHOOTER_PROJ_LIFETIME, sim.SHOOTER_PROJ_DAMAGE, EnemyPool.TYPE_BOSS). Telegraph lane = fan cone along the predicted dir (re-predicted each tick during TELEGRAPH, latched at fire).

  • BARRAGE (Survival): ring 1 as today; ring 2 offset by PI / count, fired BARRAGE_RING2_DELAY later (second_ring_t timer in ACTIVE). Story barrage unchanged (single ring).

  • LUNGE: telegraph latches lunge_dir = predict_aim(sim, bpos, LUNGE_SPEED), lane {kind:"lunge", length: LUNGE_LENGTH}. ACTIVE: move at LUNGE_SPEED along lunge_dir until LUNGE_LENGTH travelled (extend ACTIVE as needed); one hit max (fired guard) if pilot within BOSS_RADIUS + pilot.radius of boss → _hurt_player(LUNGE_DMG, "boss", ...); at end, radial burst of LUNGE_BURST boss shots. Respect arena clamp the same way approach movement does (check how sim.enemies.pos is clamped for other movers; if none, clamp to sim.arena bounds if present — read sim.gd first).

  • SLAM: identical to SWING for the first hit, then second_swing_t = SLAM_SECOND_DELAY; when it elapses a second swing_hit (with its own fired guard, second_fired) at the boss’s current position. Emits boss_swing_hit fx_event each landed hit, and a new {"kind":"boss_swing_start","pos":bpos} fx_event at each swing’s ACTIVE start (for the FX).

  • Step 1: Failing tests tests/test_warden_attacks.gd (reuse _sim()/_fire() from test_warden_survival.gd, copied in):

func test_fan_aims_ahead_of_a_moving_pilot() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.enemies.pos[bi] = sim.player.pos + Vector2(-500, 0)
sim.player.velocity = Vector2(0, 260)
_fire(sim, BossState.ATTACK_FAN)
assert_eq(sim.enemy_proj.count, BossWarden.FAN_SHOTS)
var mean := Vector2.ZERO
for j in range(sim.enemy_proj.count):
mean += sim.enemy_proj.vel[j].normalized()
assert_gt(mean.normalized().y, 0.15, "the fan leads a pilot moving down")
func test_fan_fires_all_volleys() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
_fire(sim, BossState.ATTACK_FAN)
for _k in range(60):
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.enemy_proj.count, BossWarden.FAN_SHOTS * BossWarden.FAN_VOLLEYS)
func test_survival_barrage_fires_offset_second_ring() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
_fire(sim, BossState.ATTACK_BARRAGE)
for _k in range(12):
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.enemy_proj.count, BossWarden.SV_BARRAGE_COUNT * 2)
var a0 := sim.enemy_proj.vel[0].angle()
var a1 := sim.enemy_proj.vel[BossWarden.SV_BARRAGE_COUNT].angle()
assert_almost_eq(absf(angle_difference(a0, a1)), PI / BossWarden.SV_BARRAGE_COUNT, 0.01)
func test_lunge_travels_and_hits_pilot_in_path() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.enemies.pos[bi] = sim.player.pos + Vector2(-400, 0)
sim.player.velocity = Vector2.ZERO
var hp0 := sim.player.hp
_fire(sim, BossState.ATTACK_LUNGE)
for _k in range(60):
sim.boss_warden.update(sim, Sim_Const.DT)
assert_lt(sim.player.hp, hp0, "standing in the lane gets hit")
assert_gt(sim.enemy_proj.count, 0, "lunge ends in a burst")
func test_slam_hits_twice_with_a_gap() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.enemies.pos[bi] = sim.player.pos + Vector2(100, 0)
sim.boss.enraged = true
_fire(sim, BossState.ATTACK_SLAM)
var hits := 0
for _k in range(60):
sim.player.inv = 0.0
sim.player.iframe_timer = 0.0
var hp := sim.player.hp
sim.boss_warden.update(sim, Sim_Const.DT)
if sim.player.hp < hp: hits += 1
assert_eq(hits, 2)
func test_story_barrage_single_ring() -> void:
var sim := _sim()
sim.story = StoryState.new()
sim.boss_warden.spawn(sim)
_fire(sim, BossState.ATTACK_BARRAGE)
for _k in range(12):
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.enemy_proj.count, BossWarden.BOSS_BARRAGE_COUNT)
func test_render_info_lane_during_fan_telegraph() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
sim.boss.attack_idx = BossState.ATTACK_FAN
sim.boss.phase = BossState.PHASE_TELEGRAPH
sim.boss.timer = 0.1
sim.boss_warden.update(sim, Sim_Const.DT)
var info := sim.boss_warden.render_info(sim)
assert_eq(info["lane"]["kind"], "fan")
assert_eq(info["telegraph_kind"], BossState.ATTACK_FAN)

(If sim.player.velocity isn’t written by the sim outside tick, the test sets it directly — that is the field predict_aim reads. Verify enemy_proj.vel is the field name in sim/enemy_proj_pool.gd and adapt the test only if it is named differently.)

  • Step 2: Run → FAIL.
  • Step 3: Implement per Behaviour. Telegraph time for FAN/LUNGE/SLAM = BOSS_TELEGRAPH_TIME * cadence (same as other non-barrage attacks).
  • Step 4: Run scripts/check-test-count.sh → green; determinism unchanged.
  • Step 5: Commit feat(warden): aimed fan, gap-closing barrage, lunge and enraged slam.

Task 3: Movement while attacking, phase 2, no missile soak

Section titled “Task 3: Movement while attacking, phase 2, no missile soak”

Files:

  • Modify: sim/boss_warden.gd, sim/boss_state.gd, data/bible.json (warden speed 88 → 120), net/tunables_registry.gd
  • Test: tests/test_warden_movement.gd (new); update tests/test_warden_survival.gd::test_warden_dashes_to_close_a_gap only if the approach dash is removed — KEEP the approach dash (the lunge is an attack; the dash is gap-closing), so that test should stay green.

Interfaces:

  • Produces statics: STRAFE_MULT := 0.6, PREFERRED_RANGE := {0: 200.0, 1: 450.0, 2: 700.0} keyed by the band of the NEXT attack’s home band (FAN/SPIRAL/BARRAGE/MISSILES/SWARM → mid, SWING/SLAM → close, LUNGE/SUMMON → far); render_info["strafing"].
  • BossState.strafe_sign: float = 1.0 flips each attack.

Behaviour (Survival only):

  • TELEGRAPH of ranged attacks (FAN, SPIRAL, BARRAGE, MISSILES, SWARM): move tangentially around the pilot at SV_SPEED * STRAFE_MULT * strafe_sign (keep current distance). SWING/SLAM/LUNGE telegraphs stay planted (they’re the readable ones).

  • APPROACH: move toward PREFERRED_RANGE[home band of attack_idx] instead of straight onto the pilot (approach away if too close for a ranged pick, closer for swing). Approach time stays BOSS_APPROACH_TIME.

  • Enraged: barrage telegraph = BOSS_FIRE_TELEGRAPH_TIME * cadence (Survival only — story keeps 2.0 s). Pin a guard: survival enraged ≥ 0.45 s.

  • Enraged paired attack: when MISSILES fires while enraged, also fire one SPIRAL cast.

  • Survival missiles: update_missiles ignores drones when survival(sim) (home on pilot, never soaked). Story unchanged.

  • Step 1: Failing tests tests/test_warden_movement.gd:

func test_strafes_during_ranged_telegraph() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.enemies.pos[bi] = sim.player.pos + Vector2(450, 0)
sim.boss.attack_idx = BossState.ATTACK_FAN
sim.boss.phase = BossState.PHASE_TELEGRAPH
sim.boss.timer = 0.0
var p0 := sim.enemies.pos[bi]
for _k in range(20):
sim.boss_warden.update(sim, Sim_Const.DT)
var p1 := sim.enemies.pos[bi]
assert_gt(p0.distance_to(p1), 20.0, "moves while winding up")
assert_almost_eq(p1.distance_to(sim.player.pos), 450.0, 25.0, "keeps its range")
assert_true(sim.boss_warden.render_info(sim)["strafing"])
func test_swing_telegraph_is_planted() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.boss.attack_idx = BossState.ATTACK_SWING
sim.boss.phase = BossState.PHASE_TELEGRAPH
sim.boss.timer = 0.0
var p0 := sim.enemies.pos[bi]
for _k in range(20):
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.enemies.pos[bi], p0)
func test_approach_backs_off_for_a_ranged_attack() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
var bi := sim.boss_rotation.boss_index(sim)
sim.enemies.pos[bi] = sim.player.pos + Vector2(120, 0)
sim.boss.attack_idx = BossState.ATTACK_FAN
sim.boss.phase = BossState.PHASE_APPROACH
sim.boss.timer = 0.1
for _k in range(60):
sim.boss_warden.update(sim, Sim_Const.DT)
assert_gt(sim.enemies.pos[bi].distance_to(sim.player.pos), 200.0)
func test_enraged_survival_barrage_telegraph_shortens() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
sim.boss.enraged = true
sim.boss.attack_idx = BossState.ATTACK_BARRAGE
sim.boss.phase = BossState.PHASE_TELEGRAPH
sim.boss.timer = BossWarden.BOSS_FIRE_TELEGRAPH_TIME * BossWarden.BOSS_ENRAGE_TIME_MULT + 0.02
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.boss.phase, BossState.PHASE_ACTIVE)
func test_story_barrage_telegraph_unchanged_when_enraged() -> void:
var sim := _sim()
sim.story = StoryState.new()
sim.boss_warden.spawn(sim)
sim.boss.enraged = true
sim.boss.attack_idx = BossState.ATTACK_BARRAGE
sim.boss.phase = BossState.PHASE_TELEGRAPH
sim.boss.timer = 1.2
sim.boss_warden.update(sim, Sim_Const.DT)
assert_eq(sim.boss.phase, BossState.PHASE_TELEGRAPH)
func test_enraged_missiles_pair_with_spiral() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
sim.boss.enraged = true
_fire(sim, BossState.ATTACK_MISSILES)
assert_eq(sim.boss_missiles.size(), BossWarden.SV_MISSILE_COUNT)
assert_gt(sim.enemy_proj.count, 0, "a spiral cast rides along")
func test_survival_missiles_ignore_drones() -> void:
var sim := _sim()
sim.boss_warden.spawn(sim)
# Put a missile right on a fake drone position and confirm it is NOT consumed by it.
# Read sim/drone_director.gd for how to add a drone in a test (see tests/test_drones*.gd for a helper);
# place the drone 30px from the missile and the pilot 600px away; tick update_missiles 5 times;
# assert boss_missiles.size() == 1. Then the same setup with sim.story = StoryState.new():
# assert boss_missiles.size() == 0 (story keeps the soak).

The last test’s drone setup comes from the existing drone test helper (find it with /usr/bin/grep -rln "drones.append\|add_drone" tests/); it is the only test in this plan whose setup lines are left to the implementer, because the drone fixture API must be read from the real tests.

  • Step 2: Run → FAIL.
  • Step 3: Implement. Update data/bible.json warden speed to 120 and BossWarden.SV_SPEED to 120.0 (the bible value wins at spawn; the const is the test reference). Run tools/balance sync if the bible has a generated mirror — check docs/architecture/content-and-elemental.md for the bible→game rule before editing.
  • Step 4: Run scripts/check-test-count.sh → green; determinism unchanged.
  • Step 5: Commit feat(warden): strafes and repositions, real phase 2, missiles no longer soaked.

Files:

  • Modify: sim/sim.gd (_hurt_player signature + enemy-proj hit site ~line 3086, contact-damage site for TYPE_BOSS, boss_log), sim/boss_warden.gd (pass tags), net/run_timeline.gd
  • Test: tests/test_warden_telemetry.gd (new), extend tests/test_run_timeline_sim.gd

Interfaces:

  • _hurt_player(amount, source="", is_dot=false, ignore_armor=false, pilot=player, boss_attack: String = "") — when boss_attack != "", add the TAKEN amount (post gate + armor, pre min-hull clamp) to boss_dmg_by_attack[boss_attack] and boss_hits_by_attack[boss_attack] += 1 (only if taken > 0). Both are Dictionary on Sim, reset on each Warden spawn, and copied into the boss’s boss_log row as a 5th element {"dmg": {...}, "hits": {...}} at kill/despawn/run end.

  • Tags: swing, slam, lunge, missile, shot (any enemy_proj with source_type == TYPE_BOSS and is_homing != 2), swarm (is_homing == 2 from the boss), contact.

  • RunTimeline.to_dict() boss entries become [name, spawn, kill, dmg_map] where dmg_map is the dmg dict rounded to ints (omit hits to keep payload small? NO — include both: {"d": {...}, "h": {...}}).

  • death_cause stays "boss".

  • Step 1: Failing tests: swing hit records boss_dmg_by_attack["swing"] > 0; a boss enemy_proj hit records shot; a swarm drone hit records swarm; story-mode hits also record (cheap, harmless); RunTimeline.to_dict() emits the 4th element. Write them in the style of tests/test_run_timeline_sim.gd:106.

  • Step 2-4: run → fail → implement → green; determinism unchanged (telemetry dicts are not in state_checksum — confirm by grepping state_checksum).

  • Step 5: Check telemetry/src/worker.js stores timeline_json verbatim (no schema change needed) and that ~/Claude/bullet-heaven-site/privacy/ already covers “per-run gameplay summaries including boss encounters”. If the wording does not cover damage taken, add one bullet to the plan’s final report (do NOT edit the site repo in this task).

  • Step 6: Commit feat(telemetry): per-attack Warden damage in the run timeline.


Task 5: Nova body — design, bake, renderer, A/B toggle

Section titled “Task 5: Nova body — design, bake, renderer, A/B toggle”

Files:

  • Modify: tools/nova_units/nova_unit_designs.py (3 designs + --export size override for them), render/archetype_renderer.gd, render/visual_look.gd, ui/debug_settings_panel.gd, main.gd
  • Create: render/warden_body_renderer.gd, render/enemy_sprites/nova/warden_{body,glow}.png, warden_ring_inner_{body,glow}.png, warden_ring_outer_{body,glow}.png (+ .import via a headless import)
  • Test: tests/test_warden_body_renderer.gd (new), extend tests/test_archetype_renderer.gd, tests/test_main.gd parity tests stay green

Design recipe (nova_unit_designs.py, same helpers P, ngon, ring_segment, blade_halves, inset, facet_shade):

  • warden() core: outer hub ring ring_segment(0.20, 0.27, ...) as 8 plates; iris ngon(16, 0.17); pupil = vertical almond (two arcs) radius 0.07; 4 small rivet plates at 45°. Colour #ff2e57.
  • warden_ring_inner(): 8 locking plates ring_segment(0.29, 0.36, a, a+tau/8-0.06) each with a notch; nothing inside 0.28 (transparent so the core shows).
  • warden_ring_outer(): 6 heavy blades blade_halves(a, 0.38, 0.49, 0.16) + a thin broken ring ring_segment(0.375, 0.39, ...) in 12 dashes.
  • Export at 512 px for these three (the rings must share the core’s canvas size + BODY_R so they align when drawn at the same scale). Register in DESIGNS with colour #ff2e57.
  • Preview sheet: run the tool without --export into the scratchpad and include the PNG path in the task report for the controller to show Chris.

Renderer:

  • ArchetypeRenderer: add [EnemyPool.TYPE_BOSS, "warden"] to _NOVA_UNITS (update SHAPE_COUNT + its comment). shape_for already routes Nova when VisualLook.nova; additionally gate the Warden row on VisualLook.warden_v2 (classic sprite when off). Boss tint: keep _base_color_for_type — but calm Warden returns Color.WHITE today; under warden_v2 return ElementPalette.vivid(BOSS_COLOR) calm and a hotter vivid(BOSS_COLOR).lightened(0.15) enraged, so the pale Nova body reads crimson like the other units.

  • WardenBodyRenderer extends Node2D (z just above the swarm layer; read how main orders swarm_renderer): two Sprite2D pairs (body + additive glow) for inner/outer rings, scale so outer radius = boss radius × 1.15 (match NOVA_SIZE). func update_from(info: Dictionary, dt: float) -> void — sets position, rotations (idle: inner +0.6 rad/s, outer −0.35 rad/s; ×1.6 enraged), and state:

    • telegraph_kind SWING/SLAM: outer spin lerps to ×4 with telegraph, outer scale 1.0→1.15.
    • BARRAGE/FAN/SPIRAL: inner ring plates “part” = inner scale 1.0→1.12 with charge.
    • LUNGE: both rings stop and lean (scale.x 1.1 along lane dir — use a parent Node2D rotated to the lane).
    • tint = same colour main passes for the boss body.
    • hidden when not info.alive or not VisualLook.warden_v2 or not VisualLook.nova.
  • VisualLook.warden_v2 := true + Debug Settings row ["warden_v2", "Warden Look (A/B)"], desc “New Nova-look Warden body and attack FX. Off = the old sprite and primitive FX.”, match arm, seed — per the toggle recipe (GRAPHICS_TOGGLES/GRAPHICS_DESCS/apply_graphics_toggle/_seed_graphics_state in ui/debug_settings_panel.gd). Console parity is automatic via build_caps.

  • Step 1: Failing tests: ArchetypeRenderer.shape_for(TYPE_BOSS) is the Warden Nova shape when nova && warden_v2, SHAPE_WARDEN_SPRITE when warden_v2 = false (restore statics in after_each); WardenBodyRenderer.update_from with telegraph_kind = SWING, telegraph = 1.0 gives outer scale ≈ 1.15 and hides itself when alive = false; textures exist at the 6 paths.

  • Step 2: Run → FAIL. Step 3: design + --export + headless import (godot --headless --path . --import per docs/reference/running-and-testing.md) + renderer + toggle + main wiring (warden_body_renderer.update_from(binfo, delta) next to boss_renderer.update_boss at main.gd ~1982).

  • Step 4: scripts/check-test-count.sh green. Windowed capture: run tools/glow_bake/live_check_warden.tscn (and _enraged) under --rendering-method mobile, save PNGs to the scratchpad, list them in the report. Restore project.godot/audio/bus_layout.tres.

  • Step 5: Commit feat(warden): Nova-look body with state-driven rotating rings (A/B toggle).


Task 6: Projectile looks — bolt, drone, missile

Section titled “Task 6: Projectile looks — bolt, drone, missile”

Files:

  • Create: fx/boss/warden_bolt_look.gd + warden_bolt.gdshader + warden_bolt_mat.tres; same triple for warden_drone, warden_missile; fx/boss/warden_fx.gd
  • Modify: main.gd (_enemy_proj_colors: boss-sourced shots and is_homing == 2 swarm drones get a = 0 while VisualLook.warden_v2; create WardenFx beside boss_renderer; feed it each frame), render/boss_renderer.gd (skip missile drawing when warden_v2), render/prewarm_pass.gd (warm the 3 materials in the weapon-looks block, gated on warden_v2)
  • Test: tests/test_warden_fx.gd (new); tests/test_baked_shader_materials.gd must pass

Interfaces:

  • WardenFx extends Node2D: func sync_projectiles(sim: Sim) -> void (persistent looks: begin → put per boss-sourced enemy_proj / boss missile → commit), func on_events(events: Array) -> void (Task 7 fills), func update_boss(info: Dictionary) -> void (Task 7 fills). Owns layers as members bolt, drone, missile (+ Task 7’s).
  • Persistent put() encoding (document in each look’s header, half-float safe):
    • bolt: rot = velocity angle, half_size = Vector2(14, 5), tint = hostile body colour (enraged: lightened 0.2), custom = (seed in [0,1), speed/600, 0, 0).
    • drone: rot = velocity angle, half_size = Vector2(9, 3.5), custom.x = seed.
    • missile: rot = velocity angle, half_size = Vector2(26, 10) (quad covers body + exhaust; body at +x end), custom.x = seed, custom.y = remaining life fraction (flicker as it expires).
  • Caps: bolt 256, drone 32, missile 24.

Shader looks (canvas_item, unshaded, additive blend like the player looks, uniform float base_dim):

  • bolt: capsule along x, white core (|y| < 0.25) → crimson body → violet rim, tail fading toward −x; subtle noise flicker from fx/weapons/fx_noise.gdshaderinc.

  • drone: thin dart + comet trail toward −x, magenta-crimson, bright tip.

  • missile: arrow-head body at +x (white core, crimson plates), exhaust plume toward −x using noise, flicker with TIME.

  • Step 1: Failing tests: WardenFx.sync_projectiles on a sim with 3 boss shots + 2 swarm drones + 1 missile puts exactly 3/2/1 instances into bolt/drone/missile (read the layer’s instance count); with warden_v2 = false it puts 0; main._enemy_proj_colors() alpha is 0 for boss shots when v2 on and unchanged when off (use the existing tests/test_main.gd harness pattern).

  • Step 2-4: FAIL → implement → green. Windowed shader compile check under --rendering-method mobile AND gl_compatibility (open tools/glow_bake/live_check_warden.tscn or a run with apply_dev_command spawning the Warden; any shader error in the log = fail). Restore the two files.

  • Step 5: Commit feat(warden-fx): plasma bolt, drone and missile looks on the FX v2 layers.


Task 7: Attack looks — swing, charge, burst, lane (telegraphs + impacts)

Section titled “Task 7: Attack looks — swing, charge, burst, lane (telegraphs + impacts)”

Files:

  • Create: triples for warden_swing, warden_charge, warden_burst, warden_lane in fx/boss/
  • Modify: fx/boss/warden_fx.gd (update_boss, on_events), render/boss_renderer.gd (_draw() returns early after nothing when warden_v2 — all its drawing moves to WardenFx), main.gd (forward sim.render_fx_events to warden_fx.on_events the same way it forwards to telemetry; feed update_boss(binfo)), render/prewarm_pass.gd, fx/fx_manager.gd (“boss_missile_impact” → skip its legacy spark when warden_v2, WardenFx draws warden_burst)
  • Test: extend tests/test_warden_fx.gd

Behaviour (all drawn from render_info + events; render-only):

  • swing (persistent, 1 instance while telegraphing SWING/SLAM; event on hit): half_size = swing_range + 24. Telegraph: a danger ring at swing_range whose fill sweeps inward-out with telegraph (custom.x), jagged noise edge. Hit (boss_swing_hit event, and the second slam hit): 0.45 s event — crescent shear wave rotating a full turn while expanding to the ring, white leading edge.

  • charge (persistent during barrage wind-up winding_fire): converging ring from 2.4r → 1.05r + noise corona on the body, intensity = fire_charge; eruption (fire_active rising edge): 0.7 s event — plasma ring burst (reuse the Plasma Nova technique: read fx/weapons/plasma_nova.gdshader and adapt to the hostile palette, NOT include it).

  • burst (event): shock ring + 10 sparks, 0.4 s; spawned on boss_missile_impact, boss_missile_launch (smaller), reaction events named SUMMON/ENRAGED at the boss (bigger, 0.8 s), and the lunge’s end burst (add a {"kind":"boss_lunge_end","pos":...} fx_event in boss_warden.gd — fx_events are not in the checksum; confirm with grep).

  • lane (persistent while info.lane non-empty): fan = a wedge from origin with half_angle, flickering edge lines + faint fill rising with progress; lunge = a thin rectangle length × (boss radius × 2) with chevrons scrolling along dir.

  • Classic look (warden_v2 = false): BossRenderer draws exactly as today; WardenFx draws nothing.

  • Step 1: Failing tests: with a fake info in each telegraph state, update_boss puts 1 instance on the expected layer and 0 on the others; on_events([{"kind":"boss_swing_hit","pos":...}]) spawns 1 swing event; boss_missile_impact spawns a burst; with v2 off nothing spawns.

  • Step 2-4: FAIL → implement → green. Windowed compile check both renderers. Capture a short sequence (one PNG per attack telegraph + impact) with the live-check harness into the scratchpad; list paths.

  • Step 5: Commit feat(warden-fx): telegraph, swing, charge, burst and lane looks.


Files:

  • Modify: tools/vfx_library/library_manifest.gd (+ tests/test_vfx_library_manifest.gd EXPECTED_EFFECTS): update boss_warden + boss_attack_cues sources/descriptions; add entries warden_bolt, warden_drone, warden_missile, warden_swing, warden_charge, warden_burst, warden_lane (status REVIEW, rating 3 until Chris scores).
  • data/bestiary.json warden: stats hp 3600 / speed 120 / contact 34 / armor 6, desc lists the Survival attacks (swing, slam, aimed fan, barrage, missiles, spiral, swarm, lunge, summon), counter text mentions range (“it picks its attack by how far away you are”).
  • docs/architecture/enemies-bosses-survival.md: Warden section — deck, bands, new attacks, phase 2, FX files.
  • docs/RELEASE-1.0.1.md: one entry Warden rebuild (Nova look, FX, smarter Survival AI) ✅ DONE (<sha>).
  • Run scripts/gate.sh → green. Commit docs(warden): roster, bestiary and architecture for the rebuild.

  • Integration branch warden-rebuild off origin/main; each task in its own worktree off the branch tip, merged back after review. Tasks 1→2→3→4 are sequential (same files). Task 5 can run in parallel with 1-4. Tasks 6-7 after 3 (need render_info). Task 8 last.
  • Concurrent branches in flight touch fx/fx_manager.gd, render/prewarm_pass.gd, main.gd (fx-v2-promote-railgun, fx-perf-roster, perf-measure-fx). Keep edits to those files minimal and additive; re-check git log origin/main before the final merge.
  • BUILD numbers: do not bump in tasks. The controller bumps once at the end (scripts/bump-build.sh, past any unmerged branch’s number) for a device build.
  • Chris’s decisions (2026-10-01): asked to “rebuild [the Warden] with the nova look rather than the gemini graphic and make all its weapons have effects more like we have for the player … brief review on its actual weapons, dmg and behaviour because it needs to be more scary and increase tension for the player, it is a bit dumb feeling”. After the review: “yes lets plan and do all of that” = approval of the whole spec AND of the recommendation “Survival-only AI changes; new look + FX everywhere (tutorial included)”.
  • The current sprite is Recraft-sourced (commit b64f402a), not Gemini — told Chris; irrelevant to the work.
  • Rejected: raising damage numbers (data shows hit RATE is the problem); blind multi-model shootout for these looks (not requested; single author per task, A/B vs the old look via VisualLook.warden_v2).
  • Evidence used: D1 telemetry builds 208-235 (human Survival 7 fights, 0 Warden kills; autopilot 56 fights, 8 Warden kills). Scratch scripts were in the old session’s scratchpad (not kept).
  • Authorized: implement all 8 tasks, merge to main + push after gate (repo rule: push after every merge). Needs Chris same-day OK: device install / BUILD bump for an ATV test, any remote-config publish, editing the public privacy page.
  • Open for Chris after build: eyeball the Nova body preview + attack captures; score the new VFX-library entries; feel-check aggression (all weights/speeds are remote tunables).
  • Environment: many concurrent worktrees/branches in flight (fx-v2-promote-railgun, fx-perf-roster, perf-measure-fx, console-weapons, android-export) touching fx_manager/prewarm/main.gd — keep edits additive; read skill bh-sdd-concurrent before Task 1.
  • Next action: create branch warden-rebuild off origin/main, then dispatch Task 1 (and Task 5 in parallel) to bh-implementer agents (model opus) in their own worktrees.