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.
Non-negotiable constraints (global)
Section titled “Non-negotiable constraints (global)”- Determinism keystone preserved. 600-tick trace
hash() = 1314757315,state_checksum() = 1949813464. No changes to/simgame logic — only render-facing additions (cooldown_frac()getters on weapons; onefx_events.appendfor the nova ring). /simstays pure. Every sim fileextends 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
simstate; nothing writes back. - All UI is code-built (no
.tscnor.tres). Follow existing patterns:CanvasLayerroot, childControl/Node2Dnodes 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.
Architecture
Section titled “Architecture”Files changed or created
Section titled “Files changed or created”| 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.
T2 — HUD redesign (ui/hud.gd)
Section titled “T2 — HUD redesign (ui/hud.gd)”Replace the single text label with four code-built sub-panels inside the same CanvasLayer:
HP bar (top-left, x=16 y=12)
Section titled “HP bar (top-left, x=16 y=12)”A fixed-size container (Control, size 240×40):
- Background:
ColorRectat (0,0) size (240,40),Color(0,0,0,0.55)— dark backing - HP fill:
ColorRectat (4,4) height 32, width =234 * (hp/max_hp)updated each frame.- Fill colour:
NeonTheme.CYANat full health →Color(1.0,0.25,0.2)at ≤30% (lerpbetween the two based on fraction)
- Fill colour:
- HP label:
Labelcentred over the bar, text"HP %d / %d" % [int(hp), int(max_hp)], JetBrains Mono 16px, white,MOUSE_FILTER_IGNORE - Level badge:
Labelto the right of bar, text"Lv %d" % level, Orbitron 18px,NeonTheme.CYAN
Timer (top-centre)
Section titled “Timer (top-centre)”A Label horizontally centred at screen top (anchor CENTER), Orbitron 28px, NeonTheme.CYAN, text "%02d:%02d" % [t/60, t%60]. Position at y=12.
Kill counter (top-right, anchored right)
Section titled “Kill counter (top-right, anchored right)”Label, JetBrains Mono 18px, text "✕ %d" % kills, right-aligned. x = screen_width - 16 (use get_viewport_rect().size.x in _ready()).
update_hud(sim: Sim) signature unchanged
Section titled “update_hud(sim: Sim) signature unchanged”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.
ui/weapon_panel.gd
Section titled “ui/weapon_panel.gd”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
PanelwithStyleBoxFlat,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
_CooldownArcinnerNode2Dchild, 28px radius, drawn withdraw_arcin_draw():arc colour = element colour; frac =class _CooldownArc extends Node2D:var frac: float = 0.0var col: Color = Color.WHITEfunc _draw() -> void:draw_arc(Vector2.ZERO, 24.0, -PI/2, -PI/2 + TAU * frac, 48, col, 3.0, true)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_multnova.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)”Nova event emission (weapon_nova.gd)
Section titled “Nova event emission (weapon_nova.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().
Ring renderer (fx/fx_manager.gd)
Section titled “Ring renderer (fx/fx_manager.gd)”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.
T5 — Player rotation (main.gd)
Section titled “T5 — Player rotation (main.gd)”Pure render change — no sim touch.
Add field:
var _last_player_pos: Vector2 = Vector2.ZEROvar _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_posif 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.posThe 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)”ui/debug_overlay.gd
Section titled “ui/debug_overlay.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) )main.gd additions
Section titled “main.gd additions”var debug_overlay: DebugOverlayvar _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.
Testing summary
Section titled “Testing summary”Hard gates (must stay identical):
tests/test_determinism.gd+tests/test_determinism_checksum.gd→1314757315/1949813464scripts/check-test-count.sh→ alltests/test_*.gdrun (stale-cache guard)godot --headless --path . --quit-after 300→ zeroSCRIPT ERROR
New GUT tests:
tests/test_hud.gd(3 cases)tests/test_weapon_panel.gd(3 cases)tests/test_fx_manager.gdextended (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.
Phasing (6 tasks)
Section titled “Phasing (6 tasks)”- Site copy fix (
bullet-heaven-site/index.htmlonly) - HUD redesign (
ui/hud.gd) - Weapon panel (
sim/weapon_pulse.gd+sim/weapon_nova.gdgetters +ui/weapon_panel.gd+main.gdwiring) - Nova AoE ring (
sim/weapon_nova.gdevent +fx/fx_manager.gdring pool) - Player rotation (
main.gdonly) - Debug overlay (
ui/debug_overlay.gd+main.gdadditions)
Out of scope
Section titled “Out of scope”- Per-projectile element tracking (needs a sim column)
- Animated weapon evolution UI
- Mobile touch controls
- Audio feedback