Skip to content

HUD Polish, Weapon Feedback & Player Rotation — Design Spec

HUD Polish, Weapon Feedback & Player Rotation — Design Spec

Section titled “HUD Polish, Weapon Feedback & Player Rotation — Design Spec”

Date: 2026-06-23 Status: Approved Cycle: M2 cycle 7 (UI clarity & feel pass)

Make the game readable and believable: a prominent neon HUD, visible weapon status, an obvious nova AoE ring, a player ship that rotates with movement, and a toggleable debug overlay. Also update the live site copy to reflect M2 progress.

  • Determinism keystone preserved. 600-tick trace hash() = 1314757315, state_checksum() = 1949813464. No changes to /sim game logic — only render-facing additions (cooldown_frac() getters on weapons; one fx_events.append for the nova ring).
  • /sim stays pure. Every sim file extends RefCounted, no Node/render/Input/Engine/Time/File/JSON APIs. The two weapon changes (public getter + nova event append) are determinism-neutral because they carry no RNG and the event list is excluded from checksum/snapshot.
  • One-way data flow. All new UI reads sim state; nothing writes back.
  • All UI is code-built (no .tscn or .tres). Follow existing patterns: CanvasLayer root, child Control/Node2D nodes created in _ready().
  • Web demo must not regress. All changes are renderer-agnostic (no Forward+-only features).
  • Fonts. Use NeonTheme.mono_font() for numbers, NeonTheme.title_font() for labels.
File Action Purpose
bullet-heaven-site/index.html Modify Fix “Milestone 1” copy + roadmap
ui/hud.gd Replace Prominent HP bar + level + timer + kills
ui/weapon_panel.gd Create Weapon slots with cooldown arcs
ui/debug_overlay.gd Create F2-toggled sim state dump
sim/weapon_pulse.gd Modify Add cooldown_frac() -> float getter
sim/weapon_nova.gd Modify Add cooldown_frac() -> float + emit nova fx_event
fx/fx_manager.gd Modify Handle "nova" event: pooled expanding ring
main.gd Modify Wire new panels; player rotation; track applied upgrades

T1 — Site copy fix (bullet-heaven-site/index.html)

Section titled “T1 — Site copy fix (bullet-heaven-site/index.html)”

Two changes, no other files touched:

Line 123 play-bar label:

Live demo — Milestone 1 core loop

Live demo — M2 in progress (elements · reactions · neon)

Lines 164–165 roadmap blocks — Milestone 1 gets expanded bullets and Milestone 2 moves to “In Progress”:

<!-- Milestone 1 — was "next", now "done" (unchanged tag) -->
<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>
<!-- Milestone 2 — change tag from "next" to "active" style -->
<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>

Add a CSS rule for .phase.active that distinguishes it from both .done and .next — e.g. a gold/amber border to read “in flight”. Look at the existing .phase.done and .phase.next rules to match the pattern.

Tests: none (copy only). Verify visually in browser.


Replace the single text label with four code-built sub-panels inside the same CanvasLayer:

A fixed-size container (Control, size 240×40):

  • Background: ColorRect at (0,0) size (240,40), Color(0,0,0,0.55) — dark backing
  • HP fill: ColorRect at (4,4) height 32, width = 234 * (hp/max_hp) updated each frame.
    • Fill colour: NeonTheme.CYAN at full health → Color(1.0,0.25,0.2) at ≤30% (lerp between the two based on fraction)
  • HP label: Label centred over the bar, text "HP %d / %d" % [int(hp), int(max_hp)], JetBrains Mono 16px, white, MOUSE_FILTER_IGNORE
  • Level badge: Label to the right of bar, text "Lv %d" % level, Orbitron 18px, NeonTheme.CYAN

A Label horizontally centred at screen top (anchor CENTER), Orbitron 28px, NeonTheme.CYAN, text "%02d:%02d" % [t/60, t%60]. Position at y=12.

Label, JetBrains Mono 18px, text "✕ %d" % kills, right-aligned. x = screen_width - 16 (use get_viewport_rect().size.x in _ready()).

Rename the old _label approach; the method now updates the fill rect width, the three labels, and the HP fill colour. Keep update_hud(sim: Sim) -> void so main.gd doesn’t change its call site.

Tests: tests/test_hud.gd — (a) update_hud with full HP sets fill width to max; (b) HP ≤30% produces a reddish fill color (.r > .g); (c) no push_error.


T3 — Weapon panel (ui/weapon_panel.gd + weapon getters)

Section titled “T3 — Weapon panel (ui/weapon_panel.gd + weapon getters)”

Weapon getters (sim — determinism-neutral)

Section titled “Weapon getters (sim — determinism-neutral)”

Add to sim/weapon_pulse.gd:

func cooldown_frac() -> float:
return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)

Add identical method to sim/weapon_nova.gd.

These read _timer and cooldown — no RNG, not included in checksum. Determinism tests must still pass.

class_name WeaponPanel extends CanvasLayer

Two weapon slots side by side, centred at screen bottom (y = screen_height - 90). Each slot is a Control of size 120×70:

Slot layout (per weapon):

  • Dark backing ColorRect (full slot size, Color(0,0,0,0.55))
  • Thin neon border: a Panel with StyleBoxFlat, border_width_all=1, border_color = element_colour, corner_radius_all=8, bg_color = Color.TRANSPARENT
  • Weapon name Label (Orbitron 13px, white, top of slot)
  • Damage stat Label (JetBrains Mono 12px, cyan, "dmg %.0f" % effective_damage)
  • Cooldown arc: a _CooldownArc inner Node2D child, 28px radius, drawn with draw_arc in _draw():
    class _CooldownArc extends Node2D:
    var frac: float = 0.0
    var col: Color = Color.WHITE
    func _draw() -> void:
    draw_arc(Vector2.ZERO, 24.0, -PI/2, -PI/2 + TAU * frac, 48, col, 3.0, true)
    arc colour = element colour; frac = cooldown_frac() from the weapon.

update_panel(pulse: WeaponPulse, nova: WeaponNova, content: ContentDB) -> void

Called from main.gd in _process. Reads:

  • pulse.cooldown_frac(), pulse.base_damage * sim.player.damage_mult
  • nova.cooldown_frac(), nova.base_damage * sim.player.damage_mult, nova.area
  • Element colours from ElementPalette.color_for(content, sim.pulse_element_idx) / sim.nova_element_idx

Slot 0 = pulse (left), slot 1 = nova (right). Nova slot also shows "r %.0f" % area for the AoE radius.

Tests: tests/test_weapon_panel.gd — (a) cooldown_frac() returns 1.0 immediately after construction (timer starts at 0); (b) returns 0.0 immediately after firing (timer reset to cooldown); (c) panel instantiates without errors.


T4 — Nova AoE ring (sim/weapon_nova.gd + fx/fx_manager.gd)

Section titled “T4 — Nova AoE ring (sim/weapon_nova.gd + fx/fx_manager.gd)”

In update(), just before _timer = cooldown / ... resets, append to sim.fx_events:

sim.fx_events.append({"kind": "nova", "pos": sim.player.pos, "radius": area, "element": sim.nova_element_idx})

(Append before hits loop so the ring fires at the same moment as damage.)

This is the same pattern as death/pickup events — excluded from snapshot_string() / state_checksum().

Add an inner class _RingNode extends Node2D:

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 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 advance(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 = life / 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)

Pool of RING_POOL_SIZE = 8 rings (nova fires at most once per ~2s so 8 is ample). In consume(), match "nova" events and activate a free ring. In advance(), call ring.advance(dt) for each active ring.

Tests: tests/test_fx_manager.gd (extend) — (a) a "nova" event activates exactly one ring node; (b) ring becomes inactive after max_life seconds of advance() calls; (c) pool never grows beyond RING_POOL_SIZE.


Pure render change — no sim touch.

Add field:

var _last_player_pos: Vector2 = Vector2.ZERO
var _player_facing: float = 0.0 # radians; 0 = up (north)

In _process, after updating player_node.position:

var move_delta: Vector2 = sim.player.pos - _last_player_pos
if move_delta.length_squared() > 1.0:
_player_facing = move_delta.angle() + PI / 2.0 # +90° because polygon tip points up (y=-18)
player_node.rotation = lerp_angle(player_node.rotation, _player_facing, 0.25)
_last_player_pos = sim.player.pos

The threshold 1.0 (squared length) prevents jitter from floating-point noise when standing still. lerp_angle rate 0.25 (per frame at 60fps) gives a snappy but not instant rotation feel. Adjust if too sluggish.

The player polygon tip is at Vector2(0, -18) = up = angle 0 in Godot’s coordinate system. Movement right = angle 0 in move_delta.angle(), which would make the ship point right — wrong. Adding PI/2 rotates the reference so up-movement = ship points up. Verify with playtest.

Tests: none (render-only, verified by playtest). Boot smoke must still pass.


T6 — Debug overlay (ui/debug_overlay.gd + main.gd)

Section titled “T6 — Debug overlay (ui/debug_overlay.gd + main.gd)”

class_name DebugOverlay extends CanvasLayer

Constructed hidden (visible = false). layer = 128 (above everything).

Layout: a Panel (dark backing, StyleBoxFlat, Color(0,0,0,0.72), corner_radius_all=8) anchored bottom-right, size 300×260, margin 12px from edge. Contains a Label (JetBrains Mono 13px, Color(0.7,1.0,0.7) — green-ish for debug feel).

toggle() -> void: flips visible.

update_overlay(sim: Sim, applied: Array[String]) -> void — called every _process when 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_str := ""
for el in aura_counts:
var name := sim.content.element_at(el).get("id","?") if el >= 0 else "none"
aura_str += " %s×%d\n" % [name, aura_counts[el]]
_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]
+ "enemies %d\n" % sim.enemies.count
+ "proj %d\n" % sim.projectiles.count
+ "gems %d\n" % sim.gems.count
+ "auras:\n" + aura_str
+ "fx/tick %d\n" % sim.fx_events.size()
+ "upgrades: " + ", ".join(applied)
)
var debug_overlay: DebugOverlay
var _applied_upgrades: Array[String] = []

In _new_run(): construct DebugOverlay, add_child. Reset _applied_upgrades = [].

In _process():

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

In _input(event):

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(): _applied_upgrades.append(id) before calling sim.apply_upgrade.

Also wire WeaponPanel: construct in _new_run(), call weapon_panel.update_panel(sim.weapon, sim.nova, sim.content) in _process().

Tests: tests/test_debug_overlay.gd — (a) starts hidden; (b) toggle() twice returns to hidden; (c) update_overlay with a fresh sim produces non-empty label text without push_error.


Hard gates (must stay identical):

  • tests/test_determinism.gd + tests/test_determinism_checksum.gd1314757315 / 1949813464
  • scripts/check-test-count.sh → all tests/test_*.gd run (stale-cache guard)
  • godot --headless --path . --quit-after 300 → zero SCRIPT ERROR

New GUT tests:

  • tests/test_hud.gd (3 cases)
  • tests/test_weapon_panel.gd (3 cases)
  • tests/test_fx_manager.gd extended (3 new ring cases)
  • tests/test_debug_overlay.gd (3 cases)

After all tasks: run scripts/deploy-demo.sh to push the updated web build.

  1. Site copy fix (bullet-heaven-site/index.html only)
  2. HUD redesign (ui/hud.gd)
  3. Weapon panel (sim/weapon_pulse.gd + sim/weapon_nova.gd getters + ui/weapon_panel.gd + main.gd wiring)
  4. Nova AoE ring (sim/weapon_nova.gd event + fx/fx_manager.gd ring pool)
  5. Player rotation (main.gd only)
  6. Debug overlay (ui/debug_overlay.gd + main.gd additions)
  • Per-projectile element tracking (needs a sim column)
  • Animated weapon evolution UI
  • Mobile touch controls
  • Audio feedback