Skip to content

HUD Polish, Weapon Feedback & Player Rotation — Implementation Plan

HUD Polish, Weapon Feedback & Player Rotation — Implementation Plan

Section titled “HUD Polish, Weapon Feedback & Player Rotation — 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: Give the game a prominent neon HUD, visible weapon status with cooldown arcs, an obvious nova AoE ring, a ship that rotates with movement, and a toggleable F2 debug overlay; also update the live site copy to show M2 is in progress.

Architecture: All changes are render/UI-only except two determinism-neutral sim additions (cooldown_frac() pure getters and one fx_events.append in weapon_nova). New UI nodes are code-built CanvasLayers following the existing pattern. FxManager gains a second pool of ring Node2D children for nova rings.

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 headless tests, CanvasItemMaterial.BLEND_MODE_ADD for additive glow.

  • Determinism keystone: tests/test_determinism.gd trace hash() = 1314757315; tests/test_determinism_checksum.gd state_checksum() = 1949813464. Both must stay identical after every task that touches /sim.
  • /sim is pure: every sim file extends RefCounted — no Node/render/Input/Engine/Time/File/JSON APIs. cooldown_frac() getters are read-only pure functions; the nova fx_events.append is determinism-neutral (event list is excluded from checksum/snapshot).
  • All UI is code-built: no .tscn or .tres files. Follow the pattern in ui/hud.gd, ui/level_up_panel.gd.
  • Fonts: NeonTheme.mono_font() for numbers/stats; NeonTheme.title_font() (Orbitron) for labels/names. NeonTheme is in ui/theme/neon_theme.gd.
  • NeonTheme constants available: NeonTheme.CYAN = Color(0.2, 0.9, 1.0), NeonTheme.TEXT = Color(0.85, 0.97, 1.0).
  • Test runner: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit. Run scripts/check-test-count.sh before declaring green (guards against stale class cache dropping test files silently).
  • Import step required whenever a new class_name is added: godot --headless --path . --import before running tests.
  • Boot smoke: godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR" must return 0.
  • Web demo must not regress — all changes are renderer-agnostic.
  • update_hud(sim: Sim) -> void call signature in main.gd must remain unchanged.

File Status Purpose
bullet-heaven-site/index.html Modify Play-bar label + roadmap M2 block
ui/hud.gd Replace HP bar + level badge + centred timer + kills
ui/weapon_panel.gd Create Two weapon slots with cooldown arcs
ui/debug_overlay.gd Create F2-toggled sim state dump
sim/weapon_pulse.gd Modify cooldown_frac() -> float getter
sim/weapon_nova.gd Modify cooldown_frac() -> float + nova fx_event
fx/fx_manager.gd Modify _RingNode inner class + ring pool for nova
main.gd Modify Wire new panels; player rotation; upgrade tracking; F2 input
tests/test_hud.gd Create 3 HUD tests
tests/test_weapon_panel.gd Create 3 weapon getter tests
tests/test_fx_manager.gd Modify 3 new nova ring tests
tests/test_debug_overlay.gd Create 3 debug overlay tests

Files:

  • Modify: bullet-heaven-site/index.html lines 88–92, 123, 164–165

Interfaces:

  • Consumes: nothing

  • Produces: nothing (standalone copy change)

  • Step 1: Add .phase.active CSS rule

Locate the existing block at line 88–92 in bullet-heaven-site/index.html:

.phase{border:1px solid var(--line);border-radius:14px;padding:20px;background:var(--panel)}
.phase .tag{font-family:'Chakra Petch';text-transform:uppercase;letter-spacing:.1em;font-size:12px;padding:3px 9px;border-radius:6px}
.phase.done .tag{background:rgba(90,255,158,.14);color:var(--green);border:1px solid rgba(90,255,158,.4)}
.phase.next .tag{background:rgba(108,200,255,.14);color:var(--cyan);border:1px solid rgba(108,200,255,.4)}
.phase.later .tag{background:rgba(176,106,255,.14);color:var(--purple);border:1px solid rgba(176,106,255,.4)}

Add after .phase.later .tag{...}:

.phase.active{border-color:rgba(255,227,77,.35)}.phase.active .tag{background:rgba(255,227,77,.14);color:var(--gold);border:1px solid rgba(255,227,77,.4)}

(--gold = #ffe34d — already defined in the CSS vars at line 19.)

  • Step 2: Update the play-bar label (line 123)

Find:

<div class="play-bar"><span><span class="dot"></span>Live demo — Milestone 1 core loop</span>

Replace with:

<div class="play-bar"><span><span class="dot"></span>Live demo — M2 in progress (elements · reactions · neon)</span>
  • Step 3: Update roadmap blocks (lines 164–165)

Find:

<div class="phase done"><span class="tag">✓ Shipped</span><h3>Milestone 1 — Core loop</h3><ul><li>Move, auto-fire, auto-target</li><li>Spawning enemies, spatial-hash collision</li><li>XP, level-up upgrade picker</li><li>Deterministic, headless-tested sim</li></ul></div>
<div class="phase next"><span class="tag">▶ Next</span><h3>Milestone 2 — Depth</h3><ul><li>The 14-element reaction engine</li><li>More weapons, mods, evolutions</li><li>Enemy variety, elites, bosses</li><li>Meta-progression + unlocks</li></ul></div>

Replace with:

<div class="phase done"><span class="tag">✓ Shipped</span><h3>Milestone 1 — Core loop</h3><ul><li>Move, auto-fire, auto-target</li><li>Spawning enemies, spatial-hash collision</li><li>XP, level-up upgrade picker</li><li>Deterministic, headless-tested sim</li></ul></div>
<div class="phase active"><span class="tag">▶ In Progress</span><h3>Milestone 2 — Depth</h3><ul><li>✓ Data-driven content pipeline (bible.json)</li><li>✓ 14-element reaction engine (auras, stacks, Plasma)</li><li>✓ Transformative mods (Overcharge, Catalyst, Lingering)</li><li>✓ Neon visual overhaul (glow, fonts, FX vocabulary)</li><li>More weapons, mods, evolutions</li><li>Enemy variety, elites, bosses</li><li>Meta-progression + unlocks</li></ul></div>
  • Step 4: Deploy the site update
Terminal window
cd ~/Claude/bullet-heaven-site && bash scripts/deploy-site.sh

Expected: deploys successfully, live site shows updated label and roadmap.

  • Step 5: Commit
Terminal window
cd ~/Claude/bullet-heaven-site
git add index.html
git commit -m "chore(site): update play-bar label + M2 roadmap block to reflect progress"

Files:

  • Modify: ui/hud.gd (full replacement)
  • Create: tests/test_hud.gd

Interfaces:

  • Consumes: NeonTheme.mono_font(), NeonTheme.title_font(), NeonTheme.CYAN; sim.player.hp, sim.player.max_hp, sim.player.level, sim.run_time, sim.kills

  • Produces: class_name Hud extends CanvasLayer; func update_hud(sim: Sim) -> void (signature unchanged — main.gd calls this)

  • Step 1: Write failing tests

Create tests/test_hud.gd:

extends GutTest
func _make_sim() -> Sim:
return Sim.new(1, SimContentFixture.db())
func test_update_hud_full_hp_fill_is_max() -> void:
var hud := Hud.new()
add_child_autofree(hud)
var sim := _make_sim()
sim.player.hp = sim.player.max_hp
hud.update_hud(sim)
# Fill width should be near-maximum (234px at full HP)
assert_gt(hud.hp_fill_width(), 230.0, "full HP = near-max fill width")
func test_update_hud_low_hp_fill_color_is_reddish() -> void:
var hud := Hud.new()
add_child_autofree(hud)
var sim := _make_sim()
sim.player.hp = sim.player.max_hp * 0.2 # 20% HP, below 30% threshold
hud.update_hud(sim)
var col := hud.hp_fill_color()
assert_gt(col.r, col.g, "low HP fill is reddish (r > g)")
func test_update_hud_no_push_error() -> void:
var hud := Hud.new()
add_child_autofree(hud)
hud.update_hud(_make_sim())
assert_no_push_errors()
  • Step 2: Run tests — expect FAIL (Hud lacks test seam methods)
Terminal window
godot --headless --path . --import 2>/dev/null
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit 2>&1 | tail -10

Expected: failures on hp_fill_width / hp_fill_color not found.

  • Step 3: Replace ui/hud.gd with the new implementation
class_name Hud
extends CanvasLayer
const BAR_W: float = 234.0 # usable fill width inside 240px backing
const BAR_H: float = 32.0
var _hp_fill: ColorRect
var _hp_label: Label
var _level_label: Label
var _timer_label: Label
var _kills_label: Label
func _ready() -> void:
var vp := get_viewport_rect().size
# ── HP bar (top-left) ──────────────────────────────────────────────
var bar_backing := ColorRect.new()
bar_backing.position = Vector2(16, 12)
bar_backing.size = Vector2(240, 40)
bar_backing.color = Color(0, 0, 0, 0.55)
bar_backing.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bar_backing)
_hp_fill = ColorRect.new()
_hp_fill.position = Vector2(19, 16) # 3px inset on each side
_hp_fill.size = Vector2(BAR_W, BAR_H)
_hp_fill.color = NeonTheme.CYAN
_hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar_backing.add_child(_hp_fill)
_hp_label = Label.new()
_hp_label.position = Vector2(0, 0)
_hp_label.size = Vector2(240, 40)
_hp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_hp_label.add_theme_font_override("font", NeonTheme.mono_font())
_hp_label.add_theme_font_size_override("font_size", 15)
_hp_label.add_theme_color_override("font_color", NeonTheme.TEXT)
_hp_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar_backing.add_child(_hp_label)
# ── Level badge (right of HP bar) ──────────────────────────────────
_level_label = Label.new()
_level_label.position = Vector2(264, 12)
_level_label.size = Vector2(80, 40)
_level_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_level_label.add_theme_font_override("font", NeonTheme.title_font())
_level_label.add_theme_font_size_override("font_size", 18)
_level_label.add_theme_color_override("font_color", NeonTheme.CYAN)
_level_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_level_label)
# ── Run timer (top-centre) ─────────────────────────────────────────
_timer_label = Label.new()
_timer_label.position = Vector2(vp.x / 2.0 - 80, 10)
_timer_label.size = Vector2(160, 40)
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timer_label.add_theme_font_override("font", NeonTheme.title_font())
_timer_label.add_theme_font_size_override("font_size", 28)
_timer_label.add_theme_color_override("font_color", NeonTheme.CYAN)
_timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_timer_label)
# ── Kill counter (top-right) ───────────────────────────────────────
_kills_label = Label.new()
_kills_label.position = Vector2(vp.x - 160, 12)
_kills_label.size = Vector2(144, 40)
_kills_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_kills_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_kills_label.add_theme_font_override("font", NeonTheme.mono_font())
_kills_label.add_theme_font_size_override("font_size", 18)
_kills_label.add_theme_color_override("font_color", NeonTheme.CYAN)
_kills_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_kills_label)
func update_hud(sim: Sim) -> void:
var frac: float = clampf(sim.player.hp / maxf(sim.player.max_hp, 1.0), 0.0, 1.0)
_hp_fill.size.x = BAR_W * frac
_hp_fill.color = _hp_color(frac)
_hp_label.text = "HP %d / %d" % [int(sim.player.hp), int(sim.player.max_hp)]
_level_label.text = "Lv %d" % sim.player.level
var t := int(sim.run_time)
_timer_label.text = "%02d:%02d" % [t / 60, t % 60]
_kills_label.text = "✕ %d" % sim.kills
# Test seams ──────────────────────────────────────────────────────────────
func hp_fill_width() -> float:
return _hp_fill.size.x
func hp_fill_color() -> Color:
return _hp_fill.color
# Private ─────────────────────────────────────────────────────────────────
func _hp_color(frac: float) -> Color:
# Cyan at full health → red at ≤30%
if frac > 0.3:
return NeonTheme.CYAN
# lerp from red (frac=0) toward cyan (frac=0.3)
var t: float = frac / 0.3
return Color(1.0, 0.25, 0.2).lerp(NeonTheme.CYAN, t)
  • Step 4: Run tests — expect PASS
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit 2>&1 | tail -10

Expected: 3 passed, 0 failed.

  • Step 5: Run determinism tests to confirm no sim regression
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5

Expected: both pass.

  • Step 6: Commit
Terminal window
git add ui/hud.gd tests/test_hud.gd
git commit -m "feat(hud): redesign with HP bar, level badge, centred timer, kill counter"

Files:

  • Modify: sim/weapon_pulse.gd (add getter)
  • Modify: sim/weapon_nova.gd (add getter only — nova event added in Task 4)
  • Create: ui/weapon_panel.gd
  • Modify: main.gd (wire WeaponPanel)
  • Create: tests/test_weapon_panel.gd

Interfaces:

  • Consumes: sim.weapon: WeaponPulse, sim.nova: WeaponNova, sim.pulse_element_idx: int, sim.nova_element_idx: int, sim.content: ContentDB, ElementPalette.color_for(content, idx)

  • Produces:

    • WeaponPulse.cooldown_frac() -> float
    • WeaponNova.cooldown_frac() -> float
    • class_name WeaponPanel extends CanvasLayer; func update_panel(pulse: WeaponPulse, nova: WeaponNova, pulse_el: int, nova_el: int, content: ContentDB, dmg_mult: float) -> void
  • Step 1: Write failing tests

Create tests/test_weapon_panel.gd:

extends GutTest
func _pulse_def() -> Dictionary:
return {"base_damage": 10.0, "cooldown_s": 2.0, "projectile_speed": 300.0,
"projectile_radius": 6.0, "lifetime_s": 1.5}
func _nova_def() -> Dictionary:
return {"base_damage": 8.0, "cooldown_s": 3.0, "area": 200.0}
func test_pulse_cooldown_frac_full_when_fresh() -> void:
var w := WeaponPulse.new(_pulse_def())
assert_almost_eq(w.cooldown_frac(), 1.0, 0.001, "fresh weapon = 1.0 (ready)")
func test_pulse_cooldown_frac_zero_when_spent() -> void:
var w := WeaponPulse.new(_pulse_def())
w._timer = w.cooldown # simulate just-fired
assert_almost_eq(w.cooldown_frac(), 0.0, 0.001, "just fired = 0.0")
func test_nova_cooldown_frac_full_when_fresh() -> void:
var w := WeaponNova.new(_nova_def())
assert_almost_eq(w.cooldown_frac(), 1.0, 0.001, "fresh nova = 1.0 (ready)")
  • Step 2: Run tests — expect FAIL
Terminal window
godot --headless --path . --import 2>/dev/null
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_panel.gd -gexit 2>&1 | tail -10

Expected: failures on cooldown_frac not found.

  • Step 3: Add cooldown_frac() to sim/weapon_pulse.gd

Add at the end of the file, before the final closing (after update):

func cooldown_frac() -> float:
return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)
  • Step 4: Add cooldown_frac() to sim/weapon_nova.gd

Add at the end of the file:

func cooldown_frac() -> float:
return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)
  • Step 5: Run tests — expect PASS
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_panel.gd -gexit 2>&1 | tail -10

Expected: 3 passed, 0 failed.

  • Step 6: Run determinism tests
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5

Expected: both pass (pure read-only getters, no state change).

  • Step 7: Create ui/weapon_panel.gd
class_name WeaponPanel
extends CanvasLayer
# ── Inner: cooldown arc drawn via _draw() ────────────────────────────────
class _CooldownArc extends Node2D:
var frac: float = 0.0
var col: Color = NeonTheme.CYAN
func _draw() -> void:
if frac <= 0.0:
return
draw_arc(Vector2.ZERO, 24.0, -PI / 2.0, -PI / 2.0 + TAU * frac, 48, col, 3.0, true)
const SLOT_W: float = 120.0
const SLOT_H: float = 70.0
const SLOT_GAP: float = 12.0
var _name_labels: Array[Label] = []
var _stat_labels: Array[Label] = []
var _arcs: Array[_CooldownArc] = []
func _ready() -> void:
var vp := get_viewport_rect().size
# Two slots: left = pulse, right = nova
# Total width = SLOT_W*2 + SLOT_GAP, centred at vp.x/2
var x0: float = vp.x / 2.0 - SLOT_W - SLOT_GAP / 2.0
var y0: float = vp.y - SLOT_H - 16.0
for i in range(2):
var sx: float = x0 + i * (SLOT_W + SLOT_GAP)
_build_slot(sx, y0, i)
func _build_slot(x: float, y: float, idx: int) -> void:
# Dark backing
var backing := ColorRect.new()
backing.position = Vector2(x, y)
backing.size = Vector2(SLOT_W, SLOT_H)
backing.color = Color(0, 0, 0, 0.55)
backing.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(backing)
# Neon border panel (1px, rounded)
var border := Panel.new()
border.position = Vector2(x, y)
border.size = Vector2(SLOT_W, SLOT_H)
border.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sb := StyleBoxFlat.new()
sb.bg_color = Color.TRANSPARENT
sb.border_width_left = 1; sb.border_width_right = 1
sb.border_width_top = 1; sb.border_width_bottom = 1
sb.border_color = NeonTheme.CYAN
sb.corner_radius_top_left = 8; sb.corner_radius_top_right = 8
sb.corner_radius_bottom_left = 8; sb.corner_radius_bottom_right = 8
border.add_theme_stylebox_override("panel", sb)
add_child(border)
# Weapon name label
var name_lbl := Label.new()
name_lbl.position = Vector2(x + 8, y + 6)
name_lbl.size = Vector2(SLOT_W - 16, 20)
name_lbl.add_theme_font_override("font", NeonTheme.title_font())
name_lbl.add_theme_font_size_override("font_size", 13)
name_lbl.add_theme_color_override("font_color", NeonTheme.TEXT)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(name_lbl)
_name_labels.append(name_lbl)
# Stat label (damage / area)
var stat_lbl := Label.new()
stat_lbl.position = Vector2(x + 8, y + 28)
stat_lbl.size = Vector2(SLOT_W - 44, 18)
stat_lbl.add_theme_font_override("font", NeonTheme.mono_font())
stat_lbl.add_theme_font_size_override("font_size", 12)
stat_lbl.add_theme_color_override("font_color", NeonTheme.CYAN)
stat_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(stat_lbl)
_stat_labels.append(stat_lbl)
# Cooldown arc (centred right of slot)
var arc := _CooldownArc.new()
arc.position = Vector2(x + SLOT_W - 30, y + SLOT_H / 2.0)
add_child(arc)
_arcs.append(arc)
func update_panel(
pulse: WeaponPulse, nova: WeaponNova,
pulse_el: int, nova_el: int,
content: ContentDB, dmg_mult: float) -> void:
# Slot 0 — WeaponPulse
_name_labels[0].text = "Lightning"
_stat_labels[0].text = "dmg %.0f" % (pulse.base_damage * dmg_mult)
_arcs[0].frac = pulse.cooldown_frac()
_arcs[0].col = ElementPalette.color_for(content, pulse_el)
_arcs[0].queue_redraw()
# Update slot border colour to pulse element
_tint_border(0, ElementPalette.color_for(content, pulse_el))
# Slot 1 — WeaponNova
_name_labels[1].text = "Fire Nova"
_stat_labels[1].text = "dmg %.0f r%.0f" % [nova.base_damage * dmg_mult, nova.area]
_arcs[1].frac = nova.cooldown_frac()
_arcs[1].col = ElementPalette.color_for(content, nova_el)
_arcs[1].queue_redraw()
_tint_border(1, ElementPalette.color_for(content, nova_el))
func _tint_border(slot_idx: int, col: Color) -> void:
# The border Panel is the child at index: 1 + slot_idx * 4 + 1 = slot_idx*4+1
# Simpler: store border refs during _build_slot
pass # Element tinting via arc colour is sufficient; border stays cyan

Note: The _tint_border stub is intentional YAGNI — the arc colour already communicates the element. If element-tinted borders are desired later, store _borders: Array[Panel] in _build_slot. Do NOT over-engineer this now.

  • Step 8: Wire WeaponPanel in main.gd

Add field declarations near the top (after var screen_fx: ScreenFeedback):

var weapon_panel: WeaponPanel

In _new_run(), after add_child(screen_fx):

weapon_panel = WeaponPanel.new()
add_child(weapon_panel)

In _process(), after hud.update_hud(sim):

weapon_panel.update_panel(
sim.weapon, sim.nova,
sim.pulse_element_idx, sim.nova_element_idx,
sim.content, sim.player.damage_mult)
  • Step 9: Boot smoke test
Terminal window
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"

Expected: 0.

  • Step 10: Run full test suite
Terminal window
bash scripts/check-test-count.sh 2>&1 | tail -5

Expected: all pass, count guard OK.

  • Step 11: Commit
Terminal window
git add sim/weapon_pulse.gd sim/weapon_nova.gd ui/weapon_panel.gd main.gd tests/test_weapon_panel.gd
git commit -m "feat(weapons): cooldown_frac getters + weapon panel with cooldown arcs"

Files:

  • Modify: sim/weapon_nova.gd (add nova fx_event)
  • Modify: fx/fx_manager.gd (add _RingNode inner class + ring pool)
  • Modify: tests/test_fx_manager.gd (add 3 ring tests)

Interfaces:

  • Consumes: existing sim.fx_events: Array[Dictionary]; existing FxManager with _content: ContentDB, active_count() -> int

  • Produces:

    • sim.fx_events gains {"kind":"nova","pos":Vector2,"radius":float,"element":int} events
    • FxManager.ring_active_count() -> int
    • FxManager.RING_POOL_SIZE: int = 8
  • Step 1: Write failing tests (extend tests/test_fx_manager.gd)

Append to the existing tests/test_fx_manager.gd file (keep all existing tests, add these below):

func test_nova_event_activates_one_ring() -> void:
var fx := _make()
fx.consume([{"kind": "nova", "pos": Vector2.ZERO, "radius": 200.0, "element": 0}])
assert_eq(fx.ring_active_count(), 1, "one nova event = one ring active")
func test_nova_ring_expires_after_max_life() -> void:
var fx := _make()
fx.consume([{"kind": "nova", "pos": Vector2.ZERO, "radius": 200.0, "element": 0}])
# advance past max_life (0.35s)
for _i in range(30):
fx.advance(1.0 / 60.0) # 0.5s total
assert_eq(fx.ring_active_count(), 0, "ring returns to pool after max_life")
func test_nova_ring_pool_does_not_grow() -> void:
var fx := _make()
var many_evs: Array = []
for i in range(20):
many_evs.append({"kind": "nova", "pos": Vector2.ZERO, "radius": 150.0, "element": -1})
fx.consume(many_evs)
assert_lte(fx.ring_active_count(), FxManager.RING_POOL_SIZE, "ring pool is bounded")
  • Step 2: Run new tests — expect FAIL
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexit 2>&1 | tail -10

Expected: new tests fail on ring_active_count not found.

  • Step 3: Add nova fx_event emission to sim/weapon_nova.gd

In update(), add the append immediately after the _timer = cooldown / ... line:

Current code (lines 17–20):

_timer -= dt
if _timer > 0.0:
return
_timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01)
sim.hash.rebuild(sim.enemies)

Replace with:

_timer -= dt
if _timer > 0.0:
return
_timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01)
sim.fx_events.append({"kind": "nova", "pos": sim.player.pos, "radius": area, "element": sim.nova_element_idx})
sim.hash.rebuild(sim.enemies)
  • Step 4: Run determinism tests — must still pass
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5

Expected: both pass (nova event is excluded from snapshot/checksum — verified by this test).

  • Step 5: Add _RingNode inner class and ring pool to fx/fx_manager.gd

The full updated file (preserve all existing code, add the highlighted sections):

class_name FxManager
extends Node2D
const POOL_SIZE := 256
const DEATH_CAP := 8
const RING_POOL_SIZE := 8 # nova fires at most once per ~2s; 8 is ample
# ── Inner: expanding additive ring for nova pulses ───────────────────────
class _RingNode extends Node2D:
var life: float = 0.0
var max_life: float = 0.35
var max_radius: float = 100.0
var col: Color = Color.WHITE
var active: bool = false
func _init() -> void:
var mat := CanvasItemMaterial.new()
mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
material = mat
visible = false
func reset(pos: Vector2, radius: float, c: Color) -> void:
position = pos
max_radius = radius
col = c
life = 0.0
active = true
visible = true
queue_redraw()
func tick(dt: float) -> bool: # returns true when expired
life += dt
queue_redraw()
if life >= max_life:
active = false
visible = false
return true
return false
func _draw() -> void:
if not active:
return
var t: float = float(life) / float(max_life)
var r: float = max_radius * t
var a: float = 1.0 - t
draw_arc(Vector2.ZERO, r, 0.0, TAU, 64, Color(col.r, col.g, col.b, a), 4.0, true)
# ── Sprite2D pool (sparks / pops / pickups) ──────────────────────────────
var _content: ContentDB
var _free: Array[Sprite2D] = []
var _active: Array = []
# ── Ring pool ─────────────────────────────────────────────────────────────
var _ring_free: Array = [] # Array of _RingNode
var _ring_active: Array = [] # Array of _RingNode
func setup(content: ContentDB) -> void:
_content = content
func _ready() -> void:
# Sprite2D pool
var tex := GlowTexture.shared()
for _i in range(POOL_SIZE):
var s := Sprite2D.new()
s.texture = tex
var mat := CanvasItemMaterial.new()
mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
s.material = mat
s.visible = false
add_child(s)
_free.append(s)
# Ring pool
for _i in range(RING_POOL_SIZE):
var r := _RingNode.new()
add_child(r)
_ring_free.append(r)
func active_count() -> int:
return _active.size()
func ring_active_count() -> int:
return _ring_active.size()
func consume(events: Array) -> void:
var deaths := 0
for ev in events:
match ev.get("kind", ""):
"reaction":
_spawn(ev["pos"], ElementPalette.color_for(_content, ev.get("element", -1)), 0.28, 0.4, 2.4)
"pickup":
_spawn(ev["pos"], ElementPalette.GEM, 0.18, 0.2, 0.7)
"death":
if deaths < DEATH_CAP:
deaths += 1
_spawn(ev["pos"], ElementPalette.color_for(_content, ev.get("element", -1)), 0.16, 0.3, 0.9)
"nova":
_spawn_ring(ev["pos"], float(ev.get("radius", 100.0)),
ElementPalette.color_for(_content, ev.get("element", -1)))
func _spawn(pos: Vector2, color: Color, life: float, s0: float, s1: float) -> void:
if _free.is_empty():
return
var s: Sprite2D = _free.pop_back()
s.position = pos
s.modulate = color
s.scale = Vector2(s0, s0)
s.visible = true
_active.append({"node": s, "life": life, "max_life": life, "s0": s0, "s1": s1, "color": color})
func _spawn_ring(pos: Vector2, radius: float, col: Color) -> void:
if _ring_free.is_empty():
return # pool full: drop (bounded, nova fires ~0.5/s max)
var r: _RingNode = _ring_free.pop_back()
r.reset(pos, radius, col)
_ring_active.append(r)
func advance(dt: float) -> void:
# Sprite2D sparks
var i := _active.size() - 1
while i >= 0:
var a: Dictionary = _active[i]
a["life"] -= dt
var s: Sprite2D = a["node"]
if a["life"] <= 0.0:
s.visible = false
_free.append(s)
_active.remove_at(i)
else:
var t: float = 1.0 - float(a["life"]) / float(a["max_life"])
var sc: float = lerpf(float(a["s0"]), float(a["s1"]), t)
s.scale = Vector2(sc, sc)
var col: Color = a["color"]
s.modulate = Color(col.r, col.g, col.b, 1.0 - t)
i -= 1
# Rings
var j := _ring_active.size() - 1
while j >= 0:
var rn: _RingNode = _ring_active[j]
if rn.tick(dt):
_ring_free.append(rn)
_ring_active.remove_at(j)
j -= 1
  • Step 6: Run all fx_manager tests — expect PASS
Terminal window
godot --headless --path . --import 2>/dev/null
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexit 2>&1 | tail -10

Expected: all 8 tests pass (5 existing + 3 new).

  • Step 7: Run full suite + count guard
Terminal window
bash scripts/check-test-count.sh 2>&1 | tail -5

Expected: all pass.

  • Step 8: Commit
Terminal window
git add sim/weapon_nova.gd fx/fx_manager.gd tests/test_fx_manager.gd
git commit -m "feat(fx): nova AoE ring — expanding additive ring on nova fire"

Files:

  • Modify: main.gd only

Interfaces:

  • Consumes: sim.player.pos: Vector2 (existing)
  • Produces: player_node.rotation updated each _process frame

No new tests — render-only; verified by playtest. Boot smoke covers safety.

  • Step 1: Add fields to main.gd

After var _last_hp: float = 0.0 add:

var _last_player_pos: Vector2 = Vector2.ZERO
var _player_facing: float = 0.0
  • Step 2: Reset fields in _new_run()

In _new_run(), after _last_hp = sim.player.hp add:

_last_player_pos = sim.player.pos
_player_facing = 0.0
  • Step 3: Add rotation logic in _process()

In _process(), immediately after player_node.position = sim.player.pos:

var _move_delta: Vector2 = sim.player.pos - _last_player_pos
if _move_delta.length_squared() > 1.0:
# +PI/2 because the triangle tip is at Vector2(0,-18) = screen-north (angle 0 in Godot = right)
_player_facing = _move_delta.angle() + PI / 2.0
player_node.rotation = lerp_angle(player_node.rotation, _player_facing, 0.25)
_last_player_pos = sim.player.pos
  • Step 4: Boot smoke test
Terminal window
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"

Expected: 0.

  • Step 5: Run full suite
Terminal window
bash scripts/check-test-count.sh 2>&1 | tail -5

Expected: all pass, count guard OK.

  • Step 6: Commit
Terminal window
git add main.gd
git commit -m "feat(player): rotate ship sprite to face movement direction"

Files:

  • Create: ui/debug_overlay.gd
  • Modify: main.gd (wire overlay, track applied upgrades, handle F2 input)
  • Create: tests/test_debug_overlay.gd

Interfaces:

  • Consumes: sim: Sim, sim.player, sim.enemies.count, sim.projectiles.count, sim.gems.count, sim.enemies.aura_element: PackedInt32Array, sim.fx_events: Array[Dictionary], sim.content: ContentDB

  • Produces:

    • class_name DebugOverlay extends CanvasLayer
    • func toggle() -> void
    • func update_overlay(sim: Sim, applied: Array[String]) -> void
  • Step 1: Write failing tests

Create tests/test_debug_overlay.gd:

extends GutTest
func test_starts_hidden() -> void:
var overlay := DebugOverlay.new()
add_child_autofree(overlay)
assert_false(overlay.visible, "debug overlay hidden on start")
func test_toggle_twice_returns_to_hidden() -> void:
var overlay := DebugOverlay.new()
add_child_autofree(overlay)
overlay.toggle()
overlay.toggle()
assert_false(overlay.visible, "double-toggle = hidden")
func test_update_overlay_produces_text() -> void:
var overlay := DebugOverlay.new()
add_child_autofree(overlay)
overlay.toggle() # make visible so update runs
var sim := Sim.new(1, SimContentFixture.db())
overlay.update_overlay(sim, ["damage", "fire-rate"])
assert_gt(overlay.label_text().length(), 20, "overlay produces non-trivial text")
assert_no_push_errors()
  • Step 2: Run tests — expect FAIL
Terminal window
godot --headless --path . --import 2>/dev/null
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_overlay.gd -gexit 2>&1 | tail -10

Expected: FAIL (DebugOverlay not found).

  • Step 3: Create ui/debug_overlay.gd
class_name DebugOverlay
extends CanvasLayer
const PANEL_W: float = 310.0
const PANEL_H: float = 270.0
const MARGIN: float = 12.0
var _label: Label
func _ready() -> void:
layer = 128
visible = false
var vp := get_viewport_rect().size
var panel := Panel.new()
panel.position = Vector2(vp.x - PANEL_W - MARGIN, vp.y - PANEL_H - MARGIN)
panel.size = Vector2(PANEL_W, PANEL_H)
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0, 0, 0, 0.72)
sb.corner_radius_top_left = 8; sb.corner_radius_top_right = 8
sb.corner_radius_bottom_left = 8; sb.corner_radius_bottom_right = 8
panel.add_theme_stylebox_override("panel", sb)
add_child(panel)
_label = Label.new()
_label.position = Vector2(10, 8)
_label.size = Vector2(PANEL_W - 20, PANEL_H - 16)
_label.add_theme_font_override("font", NeonTheme.mono_font())
_label.add_theme_font_size_override("font_size", 13)
_label.add_theme_color_override("font_color", Color(0.7, 1.0, 0.7))
_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
panel.add_child(_label)
func toggle() -> void:
visible = !visible
func update_overlay(sim: Sim, applied: Array[String]) -> void:
var p := sim.player
# Aura breakdown
var aura_counts: Dictionary = {}
for i in range(sim.enemies.count):
var el: int = sim.enemies.aura_element[i]
aura_counts[el] = aura_counts.get(el, 0) + 1
var aura_lines := ""
for el in aura_counts:
var ename: String = "none"
if el >= 0:
ename = sim.content.element_at(el).get("id", "?")
aura_lines += " %s×%d\n" % [ename, aura_counts[el]]
if aura_lines == "":
aura_lines = " (none)\n"
_label.text = (
"=== DEBUG (F2) ===\n"
+ "pos %.0f, %.0f\n" % [p.pos.x, p.pos.y]
+ "hp %.1f / %.1f\n" % [p.hp, p.max_hp]
+ "lv %d xp %.1f/%.1f\n" % [p.level, p.xp, p.xp_to_next]
+ "dmg× %.2f fr× %.2f\n" % [p.damage_mult, p.fire_rate_mult]
+ "enemy %d proj %d gem %d\n" % [sim.enemies.count, sim.projectiles.count, sim.gems.count]
+ "auras:\n" + aura_lines
+ "fx/tk %d\n" % sim.fx_events.size()
+ "upgr: " + (", ".join(applied) if applied.size() > 0 else "none")
)
# Test seam
func label_text() -> String:
return _label.text if _label != null else ""
  • Step 4: Run tests — expect PASS
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_overlay.gd -gexit 2>&1 | tail -10

Expected: 3 passed, 0 failed.

  • Step 5: Wire DebugOverlay + upgrade tracking in main.gd

Field declarations — add after var weapon_panel: WeaponPanel:

var debug_overlay: DebugOverlay
var _applied_upgrades: Array[String] = []

In _new_run() — add after add_child(weapon_panel):

debug_overlay = DebugOverlay.new()
add_child(debug_overlay)
_applied_upgrades = []

In _process() — add after weapon_panel.update_panel(...):

if debug_overlay.visible:
debug_overlay.update_overlay(sim, _applied_upgrades)

Add _input() function (new function, add after _on_upgrade_chosen):

func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_F2:
debug_overlay.toggle()

In _on_upgrade_chosen() — add _applied_upgrades.append(id) as the first line:

func _on_upgrade_chosen(id: String) -> void:
_applied_upgrades.append(id)
sim.apply_upgrade(id)
level_up.hide_panel()
if sim.pending_levelups > 0:
_open_levelup()
else:
_paused_for_levelup = false
  • Step 6: Run full suite + count guard
Terminal window
bash scripts/check-test-count.sh 2>&1 | tail -5

Expected: all pass, count guard OK (should now be 42 test scripts, 147 tests or similar — trust the count guard).

  • Step 7: Boot smoke test
Terminal window
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"

Expected: 0.

  • Step 8: Run determinism tests one final time
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -5
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -5

Expected: 1314757315 / 1949813464 — unchanged.

  • Step 9: Commit
Terminal window
git add ui/debug_overlay.gd main.gd tests/test_debug_overlay.gd
git commit -m "feat(debug): F2 toggle overlay with sim state; track applied upgrades"

After all 6 tasks, before handing off:

Terminal window
# Full suite with count guard
bash scripts/check-test-count.sh 2>&1 | tail -8
# Boot smoke
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"
# Determinism (both)
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -3
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | tail -3

Then deploy the updated web demo:

Terminal window
bash scripts/deploy-demo.sh