Skip to content

HUD Elegance Pass 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: Reduce the live-gameplay HUD to survival-critical info by default, escalate visual weight with actual urgency, and replace the warp ability icon with a diegetic ship-thruster indicator.

Architecture: Six sequential tasks, each touching a small, well-scoped set of files: remove dead code (the superseded weapon-info overlay), consolidate the top-center HP block down to HP-only with urgency-driven prominence, relocate level display to two existing screens, add a lightweight level-up celebration via the existing FxManager/fx_events render pipeline, make the ship’s own thruster show warp readiness, and gate the weapon/drone docks behind a single toggle. All render/UI-side — no /sim changes, so determinism is unaffected by construction throughout.

Tech Stack: Godot 4.6 / GDScript, GUT 9.6.0 (headless test runner).

  • Render/UI-side only — no task in this plan touches /sim. Re-verify tests/test_determinism_checksum.gd after each task as a sanity check (expected: no change).
  • Follow DESIGN.md (project root) for any new visual element: near-black translucent panels, restrained 1–2px accent borders, NeonTheme.CYAN as the default accent, NeonTheme.title_font()/mono_font() for labels/numbers.
  • The EVE-style drone combat rework raised during design discussion is explicitly OUT OF SCOPE for this plan — decoy/drone simulation behavior does not change here, only its HUD visibility.
  • Anchored Controls via set_anchors_preset — never hardcoded pixel positions assuming a specific window size (existing project convention).
  • Bump Sim_Const.BUILD only if/when this ships to a device — not required mid-plan.

Task 1: Remove the superseded weapon-info overlay

Section titled “Task 1: Remove the superseded weapon-info overlay”

Files:

  • Delete: ui/weapon_info_overlay.gd
  • Delete: tests/test_weapon_info.gd
  • Modify: main.gd

Interfaces:

  • Produces: Y (controller) and V (keyboard) are free of any binding after this task — Task 6 binds them to the new tactical-HUD toggle.

  • Step 1: Confirm current test baseline

Terminal window
bash scripts/check-test-count.sh

Expected: all green, note the script/test counts to compare against after this task’s removals (both counts should drop by exactly 1 script; test count drops by however many tests were in test_weapon_info.gd).

  • Step 2: Delete the overlay class and its test file
Terminal window
rm ui/weapon_info_overlay.gd ui/weapon_info_overlay.gd.uid tests/test_weapon_info.gd tests/test_weapon_info.gd.uid

(the .uid files are Godot’s per-resource sidecar files; remove them alongside their .gd if present — ls ui/*.uid tests/*.uid first if unsure which exist).

  • Step 3: Remove every reference in main.gd

Remove the field declaration (currently near line 85):

var weapon_info: WeaponInfoOverlay # in-play weapon details (Y button / V), freezes the sim while open

Remove its instantiation (currently near line 798):

weapon_info = WeaponInfoOverlay.new()
add_child(weapon_info)

Remove the _toggle_weapon_info() function entirely (currently near line 1452):

# Toggle the in-play weapon-details overlay. Only during an active run (not on the
# menu / level-up / pause / game-over screens); the sim freezes while it's open.
func _toggle_weapon_info() -> void:
if sim == null or sim.game_over or _paused_for_menu or _paused_for_levelup or _story_won:
return
weapon_info.toggle(sim)

Remove its two _input() bindings (currently near lines 1421 and 1433-1435):

# Y button: toggle the in-play weapon-details overlay (freezes the sim while open).
if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_Y:
_toggle_weapon_info()
return

and

if event.physical_keycode == KEY_V:
_toggle_weapon_info()
return

Remove weapon_info.is_open() from the three stacking-guard conditions it appears in (_physics_process’s _frozen check, _check_codex_encounters, _check_teaser_event — search the file for weapon_info.is_open(), there are exactly 3 occurrences). Each is an or-chained boolean condition; delete just the or weapon_info.is_open() clause from each, leaving the rest of the condition intact. Example (the _physics_process one):

# Before:
var _frozen := sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu \
or weapon_info.is_open() or codex.is_open() or boss_teaser.is_open() or _warping
# After:
var _frozen := sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu \
or codex.is_open() or boss_teaser.is_open() or _warping
  • Step 4: Verify no references remain
Terminal window
grep -rn "weapon_info\|WeaponInfoOverlay\|_toggle_weapon_info" main.gd

Expected: no output (empty).

  • Step 5: Full suite + boot check
Terminal window
godot --headless --path . --import
bash scripts/check-test-count.sh
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: count guard passes with exactly one fewer script than before Step 1, boot check empty output.

  • Step 6: Commit
Terminal window
git add -A ui/weapon_info_overlay.gd tests/test_weapon_info.gd main.gd
git commit -m "chore: remove weapon-info overlay, superseded by Ship Configuration"

Task 2: HP bar becomes the sole top-center readout

Section titled “Task 2: HP bar becomes the sole top-center readout”

Files:

  • Modify: ui/hud.gd
  • Modify: tests/test_hud.gd

Interfaces:

  • Produces: Hud.HP_BACKING_W: float (244.0) — the new HP block’s total width, replacing XP_ROW_W’s role as the centering dimension. Hud._hp_prominence(frac: float) -> float (private, but exercised via update_hud()’s effect on _hp_group.modulate.a — expose a test seam func hp_group_alpha() -> float: return _hp_group.modulate.a).

  • Consumes: nothing from other tasks.

  • Step 1: Write the failing tests

Replace the entire content of tests/test_hud.gd with this (removes the now-invalid XP/level tests, updates the HP-width test for the new BAR_W, adds prominence tests):

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)
assert_almost_eq(hud.hp_fill_width(), Hud.BAR_W, 1.0, "full HP = 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_push_error_count(0, "update_hud should not push any errors")
# ── HP bar moved top-left → top-center (2026-07-03 HUD polish pass) ────────────────────────
func test_hp_group_is_anchored_top_center_not_top_left() -> void:
var hud := Hud.new()
add_child_autofree(hud)
assert_almost_eq(hud._hp_group.anchor_left, 0.5, 0.001, "HP block anchors to horizontal center")
assert_almost_eq(hud._hp_group.anchor_right, 0.5, 0.001, "HP block anchors to horizontal center")
assert_almost_eq(hud._hp_group.anchor_top, 0.0, 0.001, "HP block still anchors to the top edge")
func test_hp_group_is_horizontally_centered_on_its_own_width() -> void:
var hud := Hud.new()
add_child_autofree(hud)
# Now that level/XP are gone, the HP block's width is just the bar backing's width
# (Hud.HP_BACKING_W), not the old XP_ROW_W.
assert_almost_eq(hud._hp_group.offset_left, -Hud.HP_BACKING_W * 0.5, 0.5, "HP block centers on its own width")
# ── Boss HP bar must never overlap the (now top-center) player HP block ────────────────────
func test_boss_bar_sits_below_the_player_hp_block_with_no_overlap() -> void:
var hud := Hud.new()
add_child_autofree(hud)
# Player HP block's absolute bottom edge: hp_group.position.y + its local content height
# (34px HP row, no XP row underneath anymore -- bottom at local y=34).
var hp_block_bottom: float = hud._hp_group.position.y + 34.0
assert_gt(hud._boss_group.position.y, hp_block_bottom, "boss bar starts below the player HP block, not overlapping it")
# ── Kills/gold/DPS removed from the live HUD (moved to the results screen instead) ─────────
func test_kills_gold_dps_labels_no_longer_on_the_live_hud() -> void:
var hud := Hud.new()
add_child_autofree(hud)
hud.update_hud(_make_sim())
for c in hud.get_children():
if c is Label:
var t: String = (c as Label).text
assert_eq(t.find("kills"), -1, "no live 'kills' readout on the HUD")
assert_eq(t.find("gold"), -1, "no live 'gold' readout on the HUD")
assert_eq(t.find("DPS"), -1, "no live 'DPS' readout on the HUD")
# ── Warp ability glyph — kept only as pure geometry (see hud.gd); the icon widget itself
# moves to PlayerRenderer in a later task, so no glyph-rendering tests live here anymore.
# ── Level/XP removed from the live HUD entirely (2026-07-03 HUD elegance pass) ──────────────
func test_level_label_no_longer_exists_on_hud() -> void:
var hud := Hud.new()
add_child_autofree(hud)
hud.update_hud(_make_sim())
for c in hud.get_children():
if c is Label:
assert_eq((c as Label).text.begins_with("Lv "), false, "no 'Lv N' label on the live HUD")
# ── HP prominence scales with urgency: subtle at full HP, prominent as HP drops ─────────────
func test_hp_group_is_subtle_at_full_hp() -> void:
var hud := Hud.new()
add_child_autofree(hud)
var sim := _make_sim()
sim.player.hp = sim.player.max_hp
hud.update_hud(sim)
assert_lt(hud.hp_group_alpha(), 0.6, "full HP renders subtly, not at full opacity")
func test_hp_group_is_fully_prominent_at_low_hp() -> void:
var hud := Hud.new()
add_child_autofree(hud)
var sim := _make_sim()
sim.player.hp = sim.player.max_hp * 0.2 # below the 0.3 danger threshold
hud.update_hud(sim)
assert_almost_eq(hud.hp_group_alpha(), 1.0, 0.01, "low HP renders at full prominence")
func test_hp_group_prominence_ramps_between_thresholds() -> void:
var hud := Hud.new()
add_child_autofree(hud)
var sim := _make_sim()
sim.player.hp = sim.player.max_hp * 0.45 # midway in the 0.3-0.6 transitional band
hud.update_hud(sim)
var a := hud.hp_group_alpha()
assert_gt(a, 0.45, "transitional HP is more prominent than the full-HP baseline")
assert_lt(a, 1.0, "transitional HP is less prominent than the low-HP maximum")
  • Step 2: Run tests to verify they fail
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit

Expected: FAIL — Hud.HP_BACKING_W doesn’t exist yet, hp_group_alpha() doesn’t exist yet, and the full-HP-fill test’s expected value assumes the new (not-yet-written) BAR_W.

  • Step 3: Rewrite ui/hud.gd’s HP block + remove level/XP

Replace the const block (currently lines 7-15) with:

const BAR_W: float = 230.0 # widened HP fill (was 170) -- more presence on screen
const BAR_H: float = 22.0
const HP_BACKING_W: float = 244.0 # BAR_W + the same 6px/8px left/right inset as before
const HP_BACKING_H: float = 34.0
const BOSS_BAR_W: float = 460.0 # was 680 — smaller, top-aligned readout
const BOSS_BAR_H: float = 22.0 # was 30
const BOSS_BAR_Y: float = 88.0 # below the top-center player HP block (y 22-56), see _ready()

Replace the var block (currently lines 17-29) with:

var _hp_group: Control # the HP block; top-CENTER (2026-07-03, was top-left)
var _hp_fill: Panel
var _hp_fill_sb: StyleBoxFlat # mutated in place each frame (no per-tick alloc) — the colour seam
var _hp_label: Label
var _story_label: Label
var _banner_label: Label # centered wave/boss macro-loop banner (survival/crystal)
var _boss_group: Control
var _boss_fill: Panel
var _boss_label: Label # shows the boss's NAME (WARDEN / OVERSEER / …), not a generic "BOSS"

(_level_label, _xp_fill, _xp_fill_sb, _ability_bars are all removed — level/XP by this task, _ability_bars in a later task in this same plan, not yet.)

Replace the HP block construction in _ready() (currently lines 34-99, everything from the # ── HP bar + level comment through the end of the XP bar construction) with:

# ── HP bar (top-CENTER, anchored) ───────────────────────────────────
# Moved from top-left to top-center (2026-07-03, HUD polish pass) so the player's own vital
# stat reads as the primary top-of-screen readout. Level/XP removed from the live HUD
# entirely (2026-07-03, HUD elegance pass) — level now shows on the level-up choice panel
# and Ship Configuration screen instead; a level-up fires a brief cheer (see FxManager)
# rather than a persistent bar. The HP block is now just the bar itself, so it centers on
# HP_BACKING_W alone.
_hp_group = Control.new()
_hp_group.set_anchors_preset(Control.PRESET_CENTER_TOP)
_hp_group.position = Vector2(-HP_BACKING_W * 0.5, 22)
_hp_group.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_hp_group)
var bar_backing := Panel.new()
bar_backing.position = Vector2(0, 0)
bar_backing.size = Vector2(HP_BACKING_W, HP_BACKING_H)
bar_backing.add_theme_stylebox_override("panel", _bar_track_style(NeonTheme.CYAN))
bar_backing.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_group.add_child(bar_backing)
_hp_fill = Panel.new()
_hp_fill.position = Vector2(6, 6)
_hp_fill.size = Vector2(BAR_W, BAR_H)
_hp_fill_sb = _bar_fill_style(NeonTheme.CYAN)
_hp_fill.add_theme_stylebox_override("panel", _hp_fill_sb)
_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(HP_BACKING_W, HP_BACKING_H)
_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", 13)
_hp_label.add_theme_color_override("font_color", NeonTheme.TEXT)
_hp_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar_backing.add_child(_hp_label)
# Kills/gold/DPS were removed from the live HUD (2026-07-03, HUD polish pass) — they now
# live on the results screen (ResultsPanel.show_results/show_victory), which is where a
# run's final numbers actually matter; mid-run they were just top-right clutter. See
# ui/results_panel.gd for the DPS readout added there in the same pass.

Update the boss bar’s own comment (currently around lines 134-138) so the byte-offset math in the comment stays accurate — replace:

# ── Boss HP bar (top-centre, shown only while a boss is alive) ─────
# Sits BELOW the player's own HP+level+XP block (moved there 2026-07-03 when the player HP
# bar relocated from top-left to top-center) so the two never overlap when both are visible
# mid-fight — the player bar's block runs from y=22 to y=71 (34px HP row + 11px XP row); the
# boss bar starts at BOSS_BAR_Y=88, a clear 17px below that.

with:

# ── Boss HP bar (top-centre, shown only while a boss is alive) ─────
# Sits BELOW the player's own HP block (moved there 2026-07-03 when the player HP bar
# relocated from top-left to top-center) so the two never overlap when both are visible
# mid-fight — the player bar's block runs from y=22 to y=56 (34px HP row, no XP row
# anymore); the boss bar starts at BOSS_BAR_Y=88, a clear 32px below that.

Remove the _ability_bars field’s reference from the _ready() comment block above it is NOT part of this task (leave the _AbilityBars/warp icon code exactly as it is for now — a later task in this plan removes it). Do NOT touch that code in this task.

Replace update_hud() (currently lines 197-223) with:

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 = maxf(BAR_W * frac, 8.0 if frac > 0.0 else 0.0) # keep a rounded sliver visible
var col := _hp_color(frac)
_hp_fill_sb.bg_color = col
_hp_fill_sb.shadow_color = Color(col.r, col.g, col.b, 0.5)
# Low-HP danger pulse on the bar itself — a clear cue now that the screen red is gentler.
_hp_fill.modulate.a = (0.65 + 0.35 * sin(sim.run_time * 9.0)) if frac < 0.3 else 1.0
# Whole-block prominence scales with urgency: subtle/quiet at full HP, ramping to full
# visual weight as HP drops, using the SAME threshold shape as _hp_color so colour and
# prominence escalate together (2026-07-03, HUD elegance pass).
_hp_group.modulate.a = _hp_prominence(frac)
_hp_label.text = "HP %d / %d" % [int(sim.player.hp), int(sim.player.max_hp)]
if sim.story != null:
_story_label.visible = true
_story_label.text = _story_objective(sim)
else:
_story_label.visible = false
# Wave/boss banner (survival/crystal; empty in story mode → hidden).
var banner := sim.spawn_banner()
var btext := String(banner.get("text", ""))
if btext != "":
var bsecs := int(banner.get("seconds", 0))
_banner_label.text = btext + (" %d" % bsecs if bsecs > 0 else "")
_banner_label.visible = true
else:
_banner_label.visible = false

Remove the XP test-seam functions (xp_fill_width, xp_bar_max_width) from the “Test seams” section, and add the new prominence seam. Replace:

func hp_fill_width() -> float:
return _hp_fill.size.x
func hp_fill_color() -> Color:
return _hp_fill_sb.bg_color
func xp_fill_width() -> float:
return _xp_fill.size.x
func xp_bar_max_width() -> float:
return XP_FILL_W

with:

func hp_fill_width() -> float:
return _hp_fill.size.x
func hp_fill_color() -> Color:
return _hp_fill_sb.bg_color
func hp_group_alpha() -> float:
return _hp_group.modulate.a

Add the new pure _hp_prominence function right after the existing _hp_color function:

# Urgency-driven prominence: subtle/quiet at full HP, ramping to full visual weight as HP
# drops. Mirrors _hp_color's exact threshold shape (>0.6 healthy, 0.3-0.6 transitional,
# <0.3 danger) so colour and prominence escalate together, not on separate curves.
func _hp_prominence(frac: float) -> float:
if frac > 0.6:
return 0.45
if frac > 0.3:
var t: float = (frac - 0.3) / 0.3
return lerpf(1.0, 0.45, t)
return 1.0
  • Step 4: Run tests to verify they pass
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit

Expected: PASS, all tests green.

  • Step 5: Full suite + determinism + boot check
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: full suite green, determinism baseline UNCHANGED, boot check empty output.

  • Step 6: Commit
Terminal window
git add ui/hud.gd tests/test_hud.gd
git commit -m "feat(hud): HP bar becomes the sole top-center readout, prominence scales with urgency"

Task 3: Show level number on the level-up panels + Ship Configuration

Section titled “Task 3: Show level number on the level-up panels + Ship Configuration”

Files:

  • Modify: ui/level_up_panel.gd
  • Modify: ui/crystals_levelup_panel.gd
  • Modify: ui/ship_config_panel.gd
  • Modify: main.gd
  • Modify: tests/test_level_up_panel.gd (create if it doesn’t already exist — check first)
  • Modify: tests/test_ship_config_panel.gd

Interfaces:

  • Produces: LevelUpPanel.show_choices(choices: Array, level: int) -> void — signature change, one new required parameter.

  • Consumes: nothing from earlier tasks.

  • Step 1: Check for an existing level-up panel test file

Terminal window
find . -iname "test_level_up_panel.gd" -not -path "*/.claude/*"

If it exists, read it before writing Step 2’s tests so you extend rather than duplicate.

  • Step 2: Write the failing tests

Add this test to tests/test_level_up_panel.gd (create the file with this content if it doesn’t already exist; if it exists, add this as a new test function):

func test_show_choices_displays_the_current_level() -> void:
var p := LevelUpPanel.new()
add_child_autofree(p)
p.show_choices([], 7)
var found := false
for c in p._box.get_children():
if c is Label and (c as Label).text.find("7") != -1:
found = true
break
assert_true(found, "the level number appears somewhere in the panel")

(If tests/test_level_up_panel.gd doesn’t exist yet, start it with extends GutTest on its own first line before this function.)

Add this test to tests/test_ship_config_panel.gd (read the existing file first for its helper functions/fixtures — likely a _make_sim()-style helper already exists; reuse it):

func test_open_config_shows_the_current_level() -> void:
var p := ShipConfigPanel.new()
add_child_autofree(p)
var sim := Sim.new(1, SimContentFixture.db())
sim.player.level = 12
var meta := MetaState.new()
p.open_config(meta, sim)
assert_true(p._bonus_lbl.text.find("12") != -1, "the current level appears in the bonus/info label")
  • Step 3: Run tests to verify they fail
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_level_up_panel.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_config_panel.gd -gexit

Expected: FAIL — show_choices doesn’t accept a level arg yet; the bonus label doesn’t show a level yet.

  • Step 4: Add the level parameter to LevelUpPanel.show_choices

In ui/level_up_panel.gd, change the function signature and title text (currently lines 39-49):

func show_choices(choices: Array, level: int) -> void:
for c in _box.get_children():
c.queue_free()
var title := Label.new()
title.text = "LEVEL UP · Lv %d" % level
title.add_theme_font_override("font", NeonTheme.title_font())
title.add_theme_font_size_override("font_size", 52)
title.add_theme_color_override("font_color", NeonTheme.CYAN)
title.add_theme_color_override("font_outline_color", Color(0.06, 0.45, 0.95, 0.85))
title.add_theme_constant_override("outline_size", 12)
  • Step 5: Update CrystalsLevelUpPanel’s title to include the level

In ui/crystals_levelup_panel.gd, find the title construction (around line 141-148, inside show_for(sim: Sim, ids: Array) — this function already receives sim, so read the level directly, no signature change needed):

var title := Label.new()
title.text = "LEVEL UP · Lv %d" % sim.player.level
title.add_theme_font_override("font", NeonTheme.title_font())
title.add_theme_font_size_override("font_size", 38)
title.add_theme_color_override("font_color", NeonTheme.CYAN)
title.add_theme_color_override("font_outline_color", Color(0.1, 0.5, 0.9, 0.5))
title.add_theme_constant_override("outline_size", 8)
_left.add_child(title)

(only the title.text line changes — everything else in that block stays as-is.)

  • Step 6: Show level on Ship Configuration

In ui/ship_config_panel.gd, update open_config (currently lines 82-101) — change the _bonus_lbl.text line:

_bonus_lbl.text = ShipBonuses.label_for(ship_id) + (" · Lv %d" % sim.player.level if sim != null else "")

(this is a one-line change to the existing _bonus_lbl.text = ShipBonuses.label_for(ship_id) line — everything else in open_config stays the same.)

  • Step 7: Update the one call site in main.gd

Find level_up.show_choices(choices) inside _open_levelup() (currently near line 1252) and change it to:

level_up.show_choices(choices, sim.player.level)
  • Step 8: Run tests to verify they pass
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_level_up_panel.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_config_panel.gd -gexit

Expected: PASS.

  • Step 9: Full suite + boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: all green, boot check empty output.

  • Step 10: Commit
Terminal window
git add ui/level_up_panel.gd ui/crystals_levelup_panel.gd ui/ship_config_panel.gd main.gd tests/test_level_up_panel.gd tests/test_ship_config_panel.gd
git commit -m "feat(ui): show current level on the level-up panels and Ship Configuration"

Files:

  • Modify: fx/fx_manager.gd
  • Modify: main.gd
  • Modify: tests/test_fx_manager.gd

Interfaces:

  • Produces: FxManager.consume() handles a new event kind "level_up" (keys: pos: Vector2) — spawns a ring + a “LEVEL UP” label at that position, using the same _spawn_ring/_spawn_label primitives the existing "reaction" case already uses.

  • Consumes: nothing from earlier tasks. Entirely additive to FxManager.

  • Step 1: Check the existing fx_manager test file’s structure

Terminal window
grep -n "func test_\|_spawn_ring\|_spawn_label\|reaction_active_count\|label_active_count\|ring_active_count" tests/test_fx_manager.gd | head -30

Read enough of the surrounding test code to match its existing style (likely a _fx() helper constructing a fresh FxManager, and an accessor like ring_active_count() or similar already used to verify the "reaction" case spawns a ring — reuse whatever exists rather than inventing a new counting mechanism).

  • Step 2: Write the failing test

Add this test to tests/test_fx_manager.gd, adapting the exact assertion helper names to whatever Step 1 found already exists for counting active rings/labels (the test below uses placeholder names ring_active_count()/label_active_count() — replace with the real accessor names from Step 1’s grep output before running):

func test_level_up_event_spawns_a_ring_and_a_label() -> void:
var fx := FxManager.new()
add_child_autofree(fx)
fx.enabled = true
fx.consume([{"kind": "level_up", "pos": Vector2(100, 100)}])
assert_gt(fx.ring_active_count(), 0, "a level-up spawns a celebratory ring")
assert_gt(fx.label_active_count(), 0, "a level-up spawns a floating 'LEVEL UP' label")
  • Step 3: Run test to verify it fails
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexit

Expected: FAIL — the "level_up" kind isn’t handled yet (no ring/label spawned).

  • Step 4: Add the "level_up" case to FxManager.consume()

In fx/fx_manager.gd, add a new case to the match ev.get("kind", ""): block inside consume(). Place it in the “disposable juice” section (after the existing "pickup" case, before "phase_flicker", following the exact same guard-then-spawn pattern the "reaction" case already uses):

"level_up":
# A brief celebratory moment at the instant of leveling, before the upgrade
# choice panel opens (main._open_levelup) — NOT gated by `enabled`/juice caps,
# since it's a rare, meaningful event (not spam like death/pickup sparks) and
# should always show regardless of quality tier.
_spawn_ring(ev["pos"], REACTION_BURST_RING, NeonTheme.CYAN)
_spawn_label(ev["pos"], "LEVEL UP", NeonTheme.CYAN)
  • Step 5: Run test to verify it passes
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_manager.gd -gexit

Expected: PASS.

  • Step 6: Wire the trigger in main._open_levelup()

In main.gd, _open_levelup() (currently starting near line 1235), add the FX trigger right after the existing audio.level_up() call:

func _open_levelup() -> void:
_paused_for_levelup = true
if screen_fx != null:
screen_fx.set_suppressed(true) # clear the red damage overlay so the panel reads clearly
audio.level_up()
if fx_layer != null:
fx_layer.consume([{"kind": "level_up", "pos": player_node.position}])
# Always 3 choices per level-up (Chris: "change the upgrade to 3 per lvl" — crystals mode
# previously got a 4th slot; unified with every other mode).
var ids := sim.upgrade_system.roll_upgrade_choices(sim, 3)

(only the two new lines — if fx_layer != null: / fx_layer.consume(...) — are added; the rest of the function is unchanged.)

  • Step 7: Full suite + determinism + boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: all green, determinism baseline UNCHANGED (this never touches /sim_open_levelup is already a render-side function, and the new call only feeds FxManager, itself render-side), boot check empty output.

  • Step 8: Commit
Terminal window
git add fx/fx_manager.gd main.gd tests/test_fx_manager.gd
git commit -m "feat(fx): add a level-up cheer (ring + label burst) at the moment of leveling"

Task 5: Warp goes diegetic (ship thruster) — remove the ability-bar warp icon

Section titled “Task 5: Warp goes diegetic (ship thruster) — remove the ability-bar warp icon”

Files:

  • Modify: render/player_renderer.gd
  • Modify: ui/hud.gd
  • Modify: main.gd
  • Modify: tests/test_player_renderer.gd
  • Modify: tests/test_hud.gd

Interfaces:

  • Produces: PlayerRenderer.update_visual(level: int, dt: float, dash_ready: bool = false) -> void — adds an optional third parameter (default false, so every existing call site not updated by this task keeps working unchanged). PlayerRenderer.thruster_alpha() -> float — new test seam.

  • Consumes: nothing from earlier tasks.

  • Step 1: Write the failing tests

Add these tests to tests/test_player_renderer.gd (read the existing file first for its exact instantiation pattern — likely var p := PlayerRenderer.new(); add_child_autofree(p), matching what’s already used a few lines up in that file):

func test_thruster_is_dimmer_when_dash_not_ready() -> void:
var p := PlayerRenderer.new()
add_child_autofree(p)
p.update_visual(1, 1.0 / 60.0, false)
var dim_alpha := p.thruster_alpha()
p.update_visual(1, 1.0 / 60.0, true)
var bright_alpha := p.thruster_alpha()
assert_gt(bright_alpha, dim_alpha, "thruster is brighter when warp/dash is ready than when it's on cooldown")
func test_thruster_alpha_defaults_to_not_ready_look() -> void:
var p := PlayerRenderer.new()
add_child_autofree(p)
p.update_visual(1, 1.0 / 60.0) # dash_ready omitted -- must default to false, not crash
assert_gte(p.thruster_alpha(), 0.0, "defaults safely with no dash_ready argument")

Add this test to tests/test_hud.gd (append):

func test_ability_bars_no_longer_exist_on_hud() -> void:
var hud := Hud.new()
add_child_autofree(hud)
assert_null(hud._ability_bars, "the warp ability icon moved to the ship's own thruster")
  • Step 2: Run tests to verify they fail
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_renderer.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit

Expected: FAIL — update_visual doesn’t accept a third argument yet, thruster_alpha() doesn’t exist yet, _ability_bars still exists.

  • Step 3: Add the diegetic thruster-ready state to PlayerRenderer

In render/player_renderer.gd, add a new field near _thruster_phase (currently line 92):

var _thruster_accent: Color = Color(0.5, 0.85, 1.0, 0.7) # tier-driven base colour+alpha, recombined with the ready-state alpha each frame

In _rebuild(tier) (currently around line 222-223), replace:

# Thruster plume matches the tier hue.
_thruster.modulate = Color(accent.r, accent.g, accent.b, 0.7)

with:

# Thruster plume matches the tier hue. Base colour/alpha stored so update_visual can
# recombine it with the warp-ready alpha multiplier every frame (2026-07-03, diegetic
# warp indicator — replaces the old separate HUD ability icon).
_thruster_accent = Color(accent.r, accent.g, accent.b, 0.7)
_thruster.modulate = _thruster_accent

Replace update_visual (currently lines 146-187) with:

# Called every render frame by main with the player's current level, frame delta, and
# whether the warp/dash ability is currently off cooldown. dash_ready drives the thruster's
# brightness/pulse so "warp ready" reads off the ship itself (2026-07-03, HUD elegance pass
# — replaces the old separate ability-bar icon on the HUD).
func update_visual(level: int, dt: float, dash_ready: bool = false) -> void:
var tier := tier_for(level)
if tier != _tier:
var prev := _tier
_tier = tier
_rebuild(tier)
if prev >= 0 and tier > prev:
_flash_t = FLASH_LIFE # only on a real promotion, not the first build
# Orbit the accent orbs (independent of facing — reads as a spinning ring).
_accent_orbit += dt * ACCENT_SPIN
var ar: float = ACCENT_RADIUS[_tier]
var show_accents := not low_detail
for k in range(_accents.size()):
var a := _accents[k]
a.visible = show_accents
if show_accents:
var ang := _accent_orbit + float(k) / float(maxi(_accents.size(), 1)) * TAU
a.position = Vector2(cos(ang), sin(ang)) * ar
# Thruster plume: brighter, bigger, faster pulse when warp is ready; dimmer and calmer
# while on cooldown. The ready/not-ready distinction is the diegetic replacement for the
# old HUD warp icon.
_thruster_phase += dt * (16.0 if dash_ready else 9.0)
var amp: float = 0.28 if dash_ready else 0.14
var pulse := THRUSTER_BASE * (1.0 + amp * sin(_thruster_phase)) * (0.8 + 0.12 * _tier) * (1.2 if dash_ready else 0.8)
_thruster.scale = Vector2(pulse, pulse * 1.25)
var ready_alpha_mult: float = 1.0 if dash_ready else 0.55
_thruster.modulate = Color(_thruster_accent.r, _thruster_accent.g, _thruster_accent.b, _thruster_accent.a * ready_alpha_mult)
# Engine-core breathing + canopy shimmer — the craft reads as 'alive' even when idle.
_idle_phase += dt
if _core != null:
var beat: float = 0.82 + 0.18 * sin(_idle_phase * 4.5)
_core.self_modulate = Color(beat, beat, beat)
if _cockpit != null:
_cockpit.modulate.a = 0.55 + 0.35 * (0.5 + 0.5 * sin(_idle_phase * 6.0 + 1.2))
# Ascension flash: expand + fade.
if _flash_t > 0.0:
_flash_t -= dt
var ft: float = 1.0 - clampf(_flash_t / FLASH_LIFE, 0.0, 1.0) # 0 → 1
var fs: float = lerpf(0.6, 2.6, ft)
_flash.scale = Vector2(fs, fs)
_flash.modulate = Color(1.0, 1.0, 1.0, (1.0 - ft) * 0.9)
else:
_flash.modulate.a = 0.0

Add the new test seam right after set_facing (currently line 277-279):

func thruster_alpha() -> float:
return _thruster.modulate.a
  • Step 4: Remove _AbilityBars from ui/hud.gd

Remove the field declaration (currently line 29):

var _ability_bars: _AbilityBars # WARP/DRONE cooldown readout — non-touch only (touch shows it on the buttons)

Remove its instantiation in _ready() (currently lines 180-189):

# WARP / DRONE cooldown readout — small EVE-module-style charge icons, back under the weapon
# dock (top-left) rather than bottom-centre. Shown ONLY on non-touch (Apple TV / desktop); on
# a touchscreen the WARP/DRONE buttons themselves show the cooldown, so this stays off.
_ability_bars = _AbilityBars.new()
_ability_bars.set_anchors_preset(Control.PRESET_TOP_LEFT)
_ability_bars.size = Vector2(WeaponPanel.TILE_C, WeaponPanel.TILE_C) # just warp for now (drone module removed)
_ability_bars.position = Vector2(24, 216) # under the drone dock (DroneDock.DOCK_ORIGIN 24,150 + its row + hint)
_ability_bars.mouse_filter = Control.MOUSE_FILTER_IGNORE
_ability_bars.visible = not Platform.is_touch() # show on controller platforms (ATV/desktop), not iOS touch
add_child(_ability_bars)

Remove set_ability_state (currently lines 191-195):

# Feed the WARP/DRONE ability cooldown state to the HUD indicator (non-touch). main calls this each
# frame with the same values it feeds the touch buttons; the indicator only draws when visible.
func set_ability_state(dash_frac: float, dash_ready: bool, decoy_frac: float, decoy_ready: bool, decoy_active: bool) -> void:
if _ability_bars != null and _ability_bars.visible:
_ability_bars.set_state(dash_frac, dash_ready, decoy_frac, decoy_ready, decoy_active)

Remove the entire class _AbilityBars extends Control: block at the end of the file (from the # WARP / DRONE cooldown indicator... comment through the final _warp_glyph_points function’s closing line) — the whole class and everything in it goes away. Note: Task 2 already ran before this task and shifted some line numbers earlier in the file, so search for the literal comment/code text above rather than trusting exact line numbers.

  • Step 5: Update main.gd’s call sites

Update the player_renderer.update_visual call (currently line 1093) to pass the warp-ready signal:

player_renderer.update_visual(sim.player.level, delta, sim.player.dash_cd <= 0.0) # evolve the craft + show warp readiness

Remove the hud.set_ability_state(...) call (currently line 1196) — the HUD no longer has an ability indicator to feed:

hud.set_ability_state(dash_frac, dash_ready, decoy_frac, decoy_ready, decoy_active) # non-touch HUD readout

(delete this line entirely; touch_controls.set_ability_state(...) on the line above it is UNRELATED and must stay — touch’s own on-screen buttons still show their own cooldown state, that isn’t changing).

  • Step 6: Run tests to verify they pass
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_player_renderer.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_hud.gd -gexit

Expected: PASS.

  • Step 7: Full suite + determinism + boot check
Terminal window
godot --headless --path . --import
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: all green, determinism baseline UNCHANGED, boot check empty output.

  • Step 8: Commit
Terminal window
git add render/player_renderer.gd ui/hud.gd main.gd tests/test_player_renderer.gd tests/test_hud.gd
git commit -m "feat(player): warp readiness shows on the ship's own thruster, remove the HUD ability icon"

Task 6: Y-toggle for the tactical HUD (weapon dock + drone dock), plus spacing polish

Section titled “Task 6: Y-toggle for the tactical HUD (weapon dock + drone dock), plus spacing polish”

Files:

  • Modify: main.gd
  • Modify: ui/drone_dock.gd
  • Modify: tests/test_main.gd (check the exact filename first — see Step 1)

Interfaces:

  • Produces: main._tactical_hud_shown: bool, main._toggle_tactical_hud() -> void.

  • Consumes: Y (controller) and V (keyboard), both freed by Task 1.

  • Step 1: Find the right test file for main.gd-level behavior

Terminal window
find . -iname "test_main*.gd" -not -path "*/.claude/*"
grep -rln "func _toggle_pause\|_paused_for_menu\|weapon_panel.visible" tests/*.gd

Use whichever existing test file already exercises main.gd-level toggle behavior (e.g. how _toggle_pause/_paused_for_menu is tested) as the home for this task’s new test, matching its existing instantiation pattern for main.tscn/Main.

  • Step 2: Write the failing test

Add this test to the file found in Step 1 (adapt the exact Main instantiation boilerplate to match whatever pattern that file already uses for booting a full main scene headlessly):

func test_tactical_hud_starts_hidden_and_y_button_toggles_it() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("crystals")
assert_false(m.weapon_panel.visible, "weapon dock is hidden by default")
assert_false(m.drone_dock.visible, "drone dock is hidden by default")
m._toggle_tactical_hud()
assert_true(m.weapon_panel.visible, "toggling shows the weapon dock")
assert_true(m.drone_dock.visible, "toggling shows the drone dock")
m._toggle_tactical_hud()
assert_false(m.weapon_panel.visible, "toggling again hides it")
assert_false(m.drone_dock.visible, "toggling again hides it")
  • Step 3: Run test to verify it fails
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<the file from Step 1>.gd -gexit

Expected: FAIL — _toggle_tactical_hud doesn’t exist yet.

  • Step 4: Add the toggle state + function to main.gd

Add a new field near the other _paused_for_*/mode-state fields (search for var _paused_for_menu to find the right neighborhood and match its style):

var _tactical_hud_shown: bool = false # weapon/drone docks hidden by default; Y (or V) reveals them

Add the toggle function near _toggle_pause() (match that function’s location/style):

# Y (controller) / V (keyboard) toggles the weapon + drone dock visibility. Hidden by
# default so the live HUD stays minimal; the player summons loadout info on demand
# (2026-07-03, HUD elegance pass — Y/V were freed by removing the old weapon-info overlay).
func _toggle_tactical_hud() -> void:
_tactical_hud_shown = not _tactical_hud_shown
weapon_panel.visible = _tactical_hud_shown
drone_dock.visible = _tactical_hud_shown
if audio != null:
audio.ui_nav()

In _input(), add the new bindings where the old weapon-info ones used to be (the same spot Task 1 removed them from):

# Y button: toggle the tactical HUD (weapon + drone docks), hidden by default.
if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_Y:
_toggle_tactical_hud()
return

And in the keyboard section of _input() (where KEY_V used to call _toggle_weapon_info, removed by Task 1), add:

if event.physical_keycode == KEY_V:
_toggle_tactical_hud()
return

In _new_run(), find the two lines (currently near lines 452-453):

weapon_panel.visible = true # may have been hidden by _return_to_menu()
drone_dock.visible = true

and replace them with:

_tactical_hud_shown = false # hidden by default every fresh run
weapon_panel.visible = false
drone_dock.visible = false
  • Step 5: Apply a small spacing adjustment to DroneDock

In ui/drone_dock.gd, change DOCK_ORIGIN (currently line 17):

const DOCK_ORIGIN := Vector2(24, 162) # more breathing room below the weapon dock (was 150) --
# 2026-07-03, HUD elegance pass: with the ability-bar row
# removed, the weapon/drone docks are the only two groups
# left here, so a slightly more generous gap between them
# (was 68px between rows, now 80px) reads as two distinct
# groupings rather than one continuous stack.
  • Step 6: Run tests to verify they pass
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<the file from Step 1>.gd -gexit

Expected: PASS.

  • Step 7: Full suite + determinism + boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: all green, both determinism baselines UNCHANGED, boot check empty output.

  • Step 8: Manual editor playtest (not headless-verifiable)

Open the project in the Godot editor and press F5 (or godot --path .), start a Survival or Crystals run, and confirm:

  • The weapon dock and drone dock are NOT visible at run start.
  • Pressing a controller’s Y button (or V on keyboard) reveals both docks; pressing again hides them.
  • The HP bar at top-center is visibly wider than before, reads subtly at full HP, and becomes progressively more vivid/prominent as you take damage.
  • No level number or XP bar appears anywhere on the live HUD.
  • Leveling up shows a brief ring+“LEVEL UP” burst at the ship before the upgrade choice panel opens, and the choice panel’s title now includes the current level.
  • The ship’s thruster plume is visibly dimmer most of the time and brightens/pulses faster right after using dash, then dims again while dash is on cooldown.
  • Opening Ship Configuration (from the pause menu) shows the current level alongside the ship’s bonus text.

Report back what you saw before considering this task (and the whole plan) done — this is real render/input behavior a headless test cannot confirm.

  • Step 9: Commit
Terminal window
git add main.gd ui/drone_dock.gd tests/<the file from Step 1>.gd
git commit -m "feat(hud): Y/V toggles the tactical HUD (weapon + drone docks), hidden by default"

Self-Review Notes (for the plan author, already applied above)

Section titled “Self-Review Notes (for the plan author, already applied above)”
  • Spec coverage: every design-doc item has a task — dead-code removal (Task 1), HP bar width + prominence (Task 2), level/XP relocation to the level-up panels + Ship Config (Task 3), level-up cheer (Task 4), diegetic warp thruster (Task 5), Y-toggle group + layout spacing (Task 6). PRODUCT.md/DESIGN.md were already written and committed during brainstorming, not repeated here.
  • Placeholder scan: no TBD/TODO. The only “decided during implementation” items from the design doc (exact HP bar width, exact spacing values) are now committed to concrete numbers in Tasks 2 and 6 respectively, chosen by reasoning about the existing layout rather than left open.
  • Type consistency: PlayerRenderer.update_visual(level: int, dt: float, dash_ready: bool = false) is used identically in Task 5’s own code and its main.gd call site. LevelUpPanel.show_choices(choices: Array, level: int) matches its Task 3 call site. Hud.HP_BACKING_W/Hud._hp_prominence/Hud.hp_group_alpha() are introduced in Task 2 and not referenced elsewhere until Task 5’s own (separate, unrelated) _ability_bars removal reads hud._ability_bars directly — no cross-task name mismatches found.
  • Task ordering: Tasks 2 and 5 both edit ui/hud.gd’s _ready(), in different, non-overlapping regions (Task 2: the HP block; Task 5: the _ability_bars block) — safe to run sequentially. Task 6 depends on Task 1 having freed the Y/V bindings; Task 6 must not run before Task 1.