Neon Visual Overhaul Implementation Plan
Neon Visual Overhaul Implementation Plan
Section titled “Neon Visual Overhaul 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 Bullet Heaven a dark-neon, Chess-Defense-style look — additive core+halo glow on every entity, element-driven colour, an Orbitron/JetBrains-Mono neon UI, and a pooled FX vocabulary — as a render/UI-only overhaul plus one determinism-neutral change to the sim’s FX-event channel.
Architecture: Render reads sim state only. Two new pure render helpers (ElementPalette, GlowTexture) feed a second additive MultiMesh “halo” layer per swarm and a pooled FxManager. The sim’s per-tick fx_bursts list is generalised to a typed fx_events list carrying reaction/death/pickup positions + tint element; it is non-hashed, RNG-free, and absent from snapshot_string()/state_checksum(), so the determinism trace is byte-identical.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (headless), MultiMesh, CanvasItemMaterial additive blend, Orbitron + JetBrains Mono (OFL variable fonts).
Global Constraints
Section titled “Global Constraints”- Determinism is the keystone. The seed-1234 / 600-tick baseline MUST stay identical:
snapshot_stringtracehash()= 1314757315, endstate_checksum()= 1949813464. Verify with the throwaway script in Task 5.tests/test_determinism.gd+tests/test_determinism_checksum.gdstay green. /simstays pure — everysim/fileextends RefCounted; NO Node/render/Input/Engine/Time/File/JSON APIs.Color/Vector2are value types and allowed, but the new colour helpers live inrender/, NOTsim/. The only sim change is thefx_eventslist (render-facing data, no RNG, not hashed).- One-way data flow: render/UI may ONLY read sim state; the sole sim mutations remain
sim.tick(input)andsim.apply_upgrade(id).fx_eventsis read by render, never fed back. - Web demo must not regress — the public demo runs
gl_compatibility(WebGL2). Glow is additive textures (renderer-agnostic).WorldEnvironmentbloom (Task 11) must be inert under compatibility (no crash, no visual change). - Performance: swarm reaches thousands of entities. Glow adds at most ONE extra MultiMesh layer per swarm (2 draw calls/swarm). All transient FX are pooled and capped per frame. No per-entity node spawning in the swarm path.
- GUT push_error rule: an un-asserted
push_errorFAILS a test. None of the new paths shouldpush_error; if a test must exercise an erroring seam, consume it withassert_push_error(...). - Test-count guard: every
tests/test_*.gdmust be counted by GUT. After adding test files, runscripts/check-test-count.sh(andgodot --headless --path . --importif a newclass_namewas dropped). - Fonts are OFL — commit the
.ttfAND theOFL.txtlicence files. - Run a single test:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit(exit 0 = pass). - Run full suite:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit.
Task 1: ElementPalette (render-side colour mapping)
Section titled “Task 1: ElementPalette (render-side colour mapping)”Files:
- Create:
render/element_palette.gd - Test:
tests/test_element_palette.gd
Interfaces:
-
Consumes:
ContentDB.element_at(idx) -> Dictionary(has"color"hex like"#ff6a4d"),ContentDB.element_index(id) -> int,ContentDB.element_count() -> int. -
Produces:
class_name ElementPalettewithconst NEUTRAL := Color(1.0, 0.32, 0.46),const GEM := Color(0.5, 1.0, 0.6),const PLAYER_CORE := Color(0.95, 0.98, 1.0),const PLAYER_HALO := Color(0.35, 0.85, 1.0), andstatic func color_for(content: ContentDB, element_idx: int) -> Color. -
Step 1: Write the failing test —
tests/test_element_palette.gd
extends GutTest
func test_known_element_returns_its_hex_color() -> void: var db := SimContentFixture.db() var idx := db.element_index("fire") assert_gte(idx, 0, "fixture must contain the fire element") var expected := Color.from_string("#ff6a4d", Color.MAGENTA) assert_eq(ElementPalette.color_for(db, idx), expected)
func test_neutral_for_no_aura() -> void: var db := SimContentFixture.db() assert_eq(ElementPalette.color_for(db, -1), ElementPalette.NEUTRAL)
func test_neutral_for_out_of_range_index() -> void: var db := SimContentFixture.db() assert_eq(ElementPalette.color_for(db, 99999), ElementPalette.NEUTRAL)
func test_null_content_is_safe() -> void: assert_eq(ElementPalette.color_for(null, 0), ElementPalette.NEUTRAL)- Step 2: Run it, verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_element_palette.gd -gexit
Expected: FAIL (ElementPalette not found).
- Step 3: Implement —
render/element_palette.gd
class_name ElementPaletteextends RefCounted
# Render-side element -> Color mapping. Colours come from bible.json (ContentDB),# never a hardcoded table. Pure + headless-testable; never push_errors.const NEUTRAL := Color(1.0, 0.32, 0.46) # auraless enemy "threat" magentaconst GEM := Color(0.5, 1.0, 0.6)const PLAYER_CORE := Color(0.95, 0.98, 1.0)const PLAYER_HALO := Color(0.35, 0.85, 1.0)
static func color_for(content: ContentDB, element_idx: int) -> Color: if content == null or element_idx < 0: return NEUTRAL var e := content.element_at(element_idx) var hex: String = e.get("color", "") if hex == "": return NEUTRAL return Color.from_string(hex, NEUTRAL)- Step 4: Run it, verify it passes
Run the Step-2 command. Expected: PASS (4 tests).
- Step 5: Commit
git add render/element_palette.gd tests/test_element_palette.gdgit commit -m "feat(render): ElementPalette — element idx -> Color from bible.json"Task 2: GlowTexture (shared additive glow texture)
Section titled “Task 2: GlowTexture (shared additive glow texture)”Files:
- Create:
render/glow_texture.gd - Test:
tests/test_glow_texture.gd
Interfaces:
-
Produces:
class_name GlowTexturewithstatic func build(size: int) -> ImageTexture(radial white→transparent) andstatic func shared() -> Texture2D(cached 64px instance). -
Step 1: Write the failing test —
tests/test_glow_texture.gd
extends GutTest
func test_build_size_and_falloff() -> void: var tex := GlowTexture.build(16) var img := tex.get_image() assert_eq(img.get_width(), 16) assert_eq(img.get_height(), 16) assert_gt(img.get_pixel(8, 8).a, 0.8, "centre should be near-opaque") assert_lt(img.get_pixel(0, 0).a, 0.1, "corner should be near-transparent")
func test_shared_is_cached() -> void: assert_same(GlowTexture.shared(), GlowTexture.shared())- Step 2: Run it, verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_glow_texture.gd -gexit
Expected: FAIL (GlowTexture not found).
- Step 3: Implement —
render/glow_texture.gd
class_name GlowTextureextends RefCounted
# A soft radial white->transparent texture, tinted + additive-blended to make the# core/halo neon glow. Built once on the CPU (works headless), shared by the swarm# halo layer and the FxManager.static var _shared: Texture2D = null
static func shared() -> Texture2D: if _shared == null: _shared = build(64) return _shared
static func build(size: int) -> ImageTexture: var img := Image.create(size, size, false, Image.FORMAT_RGBA8) var c := float(size - 1) * 0.5 var maxd := maxf(c, 1.0) for y in range(size): for x in range(size): var d := Vector2(float(x) - c, float(y) - c).length() / maxd var a := clampf(1.0 - d, 0.0, 1.0) a = a * a # softer, rounder falloff img.set_pixel(x, y, Color(1.0, 1.0, 1.0, a)) return ImageTexture.create_from_image(img)-
Step 4: Run it, verify it passes — Step-2 command. Expected: PASS (2 tests).
-
Step 5: Commit
git add render/glow_texture.gd tests/test_glow_texture.gdgit commit -m "feat(render): GlowTexture — shared additive radial glow texture"Task 3: SwarmRenderer additive halo layer + per-instance colours
Section titled “Task 3: SwarmRenderer additive halo layer + per-instance colours”Files:
- Modify:
render/swarm_renderer.gd(whole file) - Test:
tests/test_swarm_renderer.gd(extend)
Interfaces:
-
Consumes:
GlowTexture.shared()(Task 2),EntityPool.pos,EntityPool.count. -
Produces:
SwarmRenderer.configure(mesh_radius: float, color: Color)(now also buildsvar halo: MultiMeshInstance2D), and a NEW signaturesync(pool: EntityPool, colors: Variant) -> voidwherecolorsis either a singleColor(all instances) or aPackedColorArray(per-instance, indexed byi). Core gets a brightened tint, halo gets the element colour at lower alpha.const HALO_SCALE := 3.0. -
Step 1: Write the failing test — replace
tests/test_swarm_renderer.gdwith:
extends GutTest
func test_sync_sets_both_layers_instance_count() -> void: var r := SwarmRenderer.new() r.configure(14.0, Color.WHITE) assert_not_null(r.halo, "halo layer must exist after configure") var pool := EntityPool.new(4) pool.add(Vector2(100, 50), Vector2.ZERO, 14.0, 0.0) pool.add(Vector2(-30, 0), Vector2.ZERO, 14.0, 0.0) r.sync(pool, Color.RED) assert_eq(r.multimesh.instance_count, 2, "core instance count") assert_eq(r.halo.multimesh.instance_count, 2, "halo instance count") # NOTE: per-instance transform/color read-back returns zeros under --headless # (dummy RenderingServer). Pixel placement is verified by playtest (Task 10). r.free()
func test_sync_accepts_per_instance_color_array() -> void: var r := SwarmRenderer.new() r.configure(8.0, Color.WHITE) var pool := EntityPool.new(8) for n in range(3): pool.add(Vector2(n * 10, 0), Vector2.ZERO, 8.0, 0.0) var cols := PackedColorArray([Color.RED, Color.GREEN, Color.BLUE]) r.sync(pool, cols) # must not error on array input assert_eq(r.multimesh.instance_count, 3) r.free()
func test_resync_shrinks_both_layers() -> void: var r := SwarmRenderer.new() r.configure(8.0, Color.WHITE) var pool := EntityPool.new(8) for n in range(5): pool.add(Vector2(n * 10, 0), Vector2.ZERO, 8.0, 0.0) r.sync(pool, Color.RED) pool.remove_at(0) r.sync(pool, Color.RED) assert_eq(r.multimesh.instance_count, 4) assert_eq(r.halo.multimesh.instance_count, 4) r.free()-
Step 2: Run it, verify it fails — Expected: FAIL (
r.halonull /syncarity). -
Step 3: Implement — replace
render/swarm_renderer.gdwith:
class_name SwarmRendererextends MultiMeshInstance2D
const HALO_SCALE := 3.0
var halo: MultiMeshInstance2D
func configure(mesh_radius: float, color: Color) -> void: multimesh = _make_mm(mesh_radius) modulate = color # main passes Color.WHITE; per-instance color drives brightness halo = MultiMeshInstance2D.new() halo.multimesh = _make_mm(mesh_radius * HALO_SCALE) halo.texture = GlowTexture.shared() var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD halo.material = mat halo.show_behind_parent = true # halo renders behind the bright core add_child(halo)
func _make_mm(mesh_radius: float) -> MultiMesh: var quad := QuadMesh.new() quad.size = Vector2(mesh_radius * 2.0, mesh_radius * 2.0) var mm := MultiMesh.new() mm.mesh = quad mm.transform_format = MultiMesh.TRANSFORM_2D mm.use_colors = true mm.instance_count = 0 return mm
func sync(pool: EntityPool, colors: Variant) -> void: var n := pool.count multimesh.instance_count = n halo.multimesh.instance_count = n var single := colors is Color for i in range(n): var t := Transform2D(0.0, pool.pos[i]) multimesh.set_instance_transform_2d(i, t) halo.multimesh.set_instance_transform_2d(i, t) var col: Color = colors if single else colors[i] multimesh.set_instance_color(i, col.lerp(Color.WHITE, 0.5)) # bright core halo.multimesh.set_instance_color(i, Color(col.r, col.g, col.b, 0.6)) # additive halo-
Step 4: Run it, verify it passes — Step-1 command. Expected: PASS (3 tests).
-
Step 5: Commit
git add render/swarm_renderer.gd tests/test_swarm_renderer.gdgit commit -m "feat(render): SwarmRenderer additive halo layer + per-instance colours"Task 4: Wire element-driven entity colours in main.gd
Section titled “Task 4: Wire element-driven entity colours in main.gd”Files:
- Modify:
main.gd(_new_run,_process, add helpers + fields)
Interfaces:
-
Consumes:
ElementPalette(Task 1),SwarmRenderer.sync(pool, colors)(Task 3),Sim.enemies.aura_element,Sim.pulse_element_idx,Sim.content.element_count(). -
Produces: per-frame element-driven colours — enemies tinted by aura (neutral if -1), projectiles tinted to the pulse (lightning) element, gems
ElementPalette.GEM. -
Step 1: Add fields + LUT. In
main.gd, add near the othervardeclarations (after line 13):
var _element_color_lut: PackedColorArray = PackedColorArray()var _proj_color: Color = ElementPalette.NEUTRAL- Step 2: Build the LUT in
_new_run. Aftersim = Sim.new(20260621, content)(line 27), add:
_element_color_lut.resize(sim.content.element_count()) for k in range(sim.content.element_count()): _element_color_lut[k] = ElementPalette.color_for(sim.content, k) _proj_color = ElementPalette.color_for(sim.content, sim.pulse_element_idx)- Step 3: Replace the sync calls in
_process(lines 85-87):
enemy_renderer.sync(sim.enemies, _enemy_colors()) proj_renderer.sync(sim.projectiles, _proj_color) gem_renderer.sync(sim.gems, ElementPalette.GEM)- Step 4: Add the per-frame enemy colour builder. Add this method to
main.gd:
func _enemy_colors() -> PackedColorArray: var n := sim.enemies.count var out := PackedColorArray() out.resize(n) for i in range(n): var el := sim.enemies.aura_element[i] out[i] = _element_color_lut[el] if el >= 0 and el < _element_color_lut.size() else ElementPalette.NEUTRAL return out- Step 5: Boot smoke test — no SCRIPT ERROR, sim still ticks:
Run: godot --headless --path . --quit-after 120 2>&1 | grep -i "SCRIPT ERROR" && echo "FAIL: script error" || echo "boot OK"
Expected: boot OK.
- Step 6: Commit
git add main.gdgit commit -m "feat(render): element-driven entity colours (enemies by aura, projectiles by element)"Task 5: Generalise the sim FX-event channel (determinism-gated)
Section titled “Task 5: Generalise the sim FX-event channel (determinism-gated)”Files:
- Modify:
sim/sim.gd(renamefx_bursts→fx_events, populate reaction/death/pickup, new_reaction_burstarg) - Modify:
sim/weapon_nova.gd(pass element idx) - Modify:
main.gd(consumer at line 93 — temporary, finalised in Task 6) - Test:
tests/test_fx_events.gd
Interfaces:
-
Produces:
Sim.fx_events: Array[Dictionary], cleared eachtick(). Event shapes:{"kind":"reaction", "pos":Vector2, "element":int}{"kind":"death", "pos":Vector2, "element":int}(dead enemy’saura_element, -1 = neutral){"kind":"pickup", "pos":Vector2, "element":int}(always -1)- New signature
Sim._reaction_burst(center: Vector2, magnitude: float, generic: bool, element_idx: int) -> void.
-
Step 1: Write the failing test —
tests/test_fx_events.gd
extends GutTest
func test_death_event_recorded_on_sweep() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.fx_events.clear() var ei := sim.enemies.add(Vector2(10, 20), Vector2.ZERO, sim.enemy_radius, -1.0) # hp<=0 => dead sim.enemies.aura_element[ei] = -1 sim._sweep_dead() assert_eq(sim.fx_events.size(), 1) assert_eq(sim.fx_events[0]["kind"], "death") assert_eq(sim.fx_events[0]["pos"], Vector2(10, 20)) assert_eq(sim.fx_events[0]["element"], -1)
func test_pickup_event_recorded_on_collect() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.fx_events.clear() sim.gems.add(sim.player.pos, Vector2.ZERO, Sim.GEM_RADIUS, 1.0) sim._collect_gems() var found := false for ev in sim.fx_events: if ev["kind"] == "pickup" and ev["pos"] == sim.player.pos: found = true assert_true(found, "a pickup event at the player position must be recorded")
func test_reaction_event_carries_element() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.fx_events.clear() sim._reaction_burst(Vector2(5, 5), 10.0, false, sim.pulse_element_idx) assert_eq(sim.fx_events.size(), 1) assert_eq(sim.fx_events[0]["kind"], "reaction") assert_eq(sim.fx_events[0]["pos"], Vector2(5, 5)) assert_eq(sim.fx_events[0]["element"], sim.pulse_element_idx)-
Step 2: Run it, verify it fails — Expected: FAIL (
fx_eventsnot defined /_reaction_burstarity). -
Step 3: Edit
sim/sim.gd.
Replace line 33:
var fx_events: Array[Dictionary] = []Replace line 70 (fx_bursts.clear()):
fx_events.clear()Replace _reaction_burst (lines 136-142):
func _reaction_burst(center: Vector2, magnitude: float, generic: bool, element_idx: int) -> void: fx_events.append({"kind": "reaction", "pos": center, "element": element_idx}) var radius := GENERIC_REACTION_RADIUS if generic else REACTION_BURST_RADIUS var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * mods.reaction_damage_mult var hits := hash.query_circle(center, radius, enemies) for ei in hits: _damage_enemy(ei, amount)Update the caller in _resolve_collisions (line 123):
_reaction_burst(ev["center"], ev["magnitude"], ev["generic"], pulse_element_idx)Update _sweep_dead (lines 154-161) to record the death position+element BEFORE removal:
func _sweep_dead() -> void: var i := enemies.count - 1 while i >= 0: if enemies.data[i] <= 0.0: fx_events.append({"kind": "death", "pos": enemies.pos[i], "element": enemies.aura_element[i]}) gems.add(enemies.pos[i], Vector2.ZERO, GEM_RADIUS, _gem_xp) kills += 1 enemies.remove_at(i) i -= 1Update _collect_gems (the pickup branch, lines 166-169) to record the pickup BEFORE removal:
if player.pos.distance_squared_to(gems.pos[i]) <= pr2: fx_events.append({"kind": "pickup", "pos": gems.pos[i], "element": -1}) player.xp += gems.data[i] gems.remove_at(i)- Step 4: Edit
sim/weapon_nova.gd— update the_reaction_burstcall (line 29):
sim._reaction_burst(ev["center"], ev["magnitude"], ev["generic"], sim.nova_element_idx)- Step 5: Edit
main.gd— update the temporary consumer (line 93) so the project still runs (Task 6 replaces this fully):
for ev in sim.fx_events: if ev["kind"] == "reaction": var flash := _Flash.new() flash.position = ev["pos"] fx_layer.add_child(flash)- Step 6: Run the new test + determinism tests, verify PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_events.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitExpected: all PASS.
- Step 7: Prove the trace is byte-identical to baseline. Create
/tmp/trace_hash.gd:
extends SceneTreefunc _init() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var trace := "" for i in range(600): var dir := Vector2(cos(float(i) * 0.05), sin(float(i) * 0.03)) if dir.length() > 0.0: dir = dir.normalized() sim.tick(InputState.new(dir)) trace += sim.snapshot_string() + "\n" print("TRACE_HASH=", hash(trace)) print("CHECKSUM_END=", sim.state_checksum()) quit()Run: godot --headless --path . -s /tmp/trace_hash.gd 2>&1 | grep -E "TRACE_HASH|CHECKSUM_END"
Expected EXACTLY: TRACE_HASH=1314757315 and CHECKSUM_END=1949813464. If either differs, the sim was perturbed — STOP and investigate (do not proceed).
- Step 8: Commit
git add sim/sim.gd sim/weapon_nova.gd main.gd tests/test_fx_events.gdgit commit -m "feat(sim): generalise fx_bursts -> typed fx_events (reaction/death/pickup); determinism unchanged"Task 6: FxManager — pooled reaction rings, death pops, pickup sparkles
Section titled “Task 6: FxManager — pooled reaction rings, death pops, pickup sparkles”Files:
- Create:
fx/fx_manager.gd - Modify:
main.gd(replace_Flash+ the fx_layer loop withFxManager) - Test:
tests/test_fx_manager.gd
Interfaces:
-
Consumes:
GlowTexture.shared()(Task 2),ElementPalette.color_for(Task 1),Sim.fx_events(Task 5),ContentDB. -
Produces:
class_name FxManager extends Node2Dwithconst POOL_SIZE := 256,const DEATH_CAP := 8,func setup(content: ContentDB) -> void,func consume(events: Array) -> void,func advance(dt: float) -> void,func active_count() -> int. -
Step 1: Write the failing test —
tests/test_fx_manager.gd
extends GutTest
func _make() -> FxManager: var fx := FxManager.new() fx.setup(SimContentFixture.db()) add_child_autofree(fx) # triggers _ready() -> builds the pool return fx
func test_pool_built_to_fixed_size() -> void: var fx := _make() assert_eq(fx.get_child_count(), FxManager.POOL_SIZE, "pool nodes pre-allocated")
func test_one_death_spawns_one_spark() -> void: var fx := _make() fx.consume([{"kind": "death", "pos": Vector2.ZERO, "element": -1}]) assert_eq(fx.active_count(), 1)
func test_death_cap_respected() -> void: var fx := _make() var evs: Array = [] for i in range(20): evs.append({"kind": "death", "pos": Vector2.ZERO, "element": -1}) fx.consume(evs) assert_eq(fx.active_count(), FxManager.DEATH_CAP, "surplus deaths drop, kills still counted in sim")
func test_reaction_and_pickup_not_capped_by_death_cap() -> void: var fx := _make() fx.consume([ {"kind": "reaction", "pos": Vector2.ZERO, "element": 0}, {"kind": "pickup", "pos": Vector2.ZERO, "element": -1}, ]) assert_eq(fx.active_count(), 2)
func test_sparks_return_to_pool_after_expiry() -> void: var fx := _make() fx.consume([{"kind": "reaction", "pos": Vector2.ZERO, "element": 0}]) for _i in range(60): fx.advance(1.0 / 60.0) # 1s, longer than any spark life assert_eq(fx.active_count(), 0, "expired sparks return to the pool") assert_eq(fx.get_child_count(), FxManager.POOL_SIZE, "pool size constant (reuse, no leak)")-
Step 2: Run it, verify it fails — Expected: FAIL (
FxManagernot found). -
Step 3: Implement —
fx/fx_manager.gd
class_name FxManagerextends Node2D
# Pooled additive sparks driven by Sim.fx_events. One primitive (a fading/scaling# additive Sprite2D) serves reaction rings, death pops and pickup sparkles, varied# by size/life/tint. Pool is fixed-size; deaths are capped per frame so mass-kills# stay 60fps (surplus deaths skip the visual; the kill is still counted in the sim).const POOL_SIZE := 256const DEATH_CAP := 8
var _content: ContentDBvar _free: Array[Sprite2D] = []var _active: Array = [] # [{node:Sprite2D, life:float, max_life:float, s0:float, s1:float, color:Color}]
func setup(content: ContentDB) -> void: _content = content
func _ready() -> void: 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)
func active_count() -> int: return _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)
func _spawn(pos: Vector2, color: Color, life: float, s0: float, s1: float) -> void: if _free.is_empty(): return # pool exhausted: drop (bounded by design) 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 advance(dt: float) -> void: 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 := 1.0 - a["life"] / a["max_life"] # 0 -> 1 over life var sc: float = lerpf(a["s0"], 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-
Step 4: Run it, verify it passes — Step-1 command. Expected: PASS (5 tests).
-
Step 5: Wire into
main.gd. Replace thefx_layersetup (lines 45-46) with anFxManager:
fx_layer = FxManager.new() fx_layer.setup(content) add_child(fx_layer)Change the field type (line 13) var fx_layer: Node2D → var fx_layer: FxManager.
Replace the _process FX block (the old lines 89-96, now the temporary loop from Task 5) with:
fx_layer.consume(sim.fx_events) fx_layer.advance(1.0 / 60.0)Delete the class _Flash extends Node2D: ... block (old lines 116-131) — it is no longer used.
-
Step 6: Boot smoke —
godot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expectedboot OK. -
Step 7: Commit
git add fx/fx_manager.gd main.gd tests/test_fx_manager.gdgit commit -m "feat(fx): pooled FxManager — reaction rings, capped death pops, pickup sparkles"Task 7: ScreenFeedback — damage vignette, low-HP border, camera shake
Section titled “Task 7: ScreenFeedback — damage vignette, low-HP border, camera shake”Files:
- Create:
fx/screen_feedback.gd - Modify:
main.gd(instantiate, detect player-HP drop, drive shake/vignette/border) - Test:
tests/test_screen_feedback.gd
Interfaces:
-
Produces:
class_name ScreenFeedback extends CanvasLayerwithfunc flash_damage() -> void,func set_low_hp(on: bool) -> void,func advance(dt: float) -> void,func vignette_alpha() -> float,func border_visible() -> bool, andfunc take_shake_offset() -> Vector2(a decaying camera offset;flash_damage()also kicks the shake). -
Consumes (in main):
Sim.player.hp,Sim.player.max_hp,Camera2D.offset. -
Step 1: Write the failing test —
tests/test_screen_feedback.gd
extends GutTest
func _make() -> ScreenFeedback: var sf := ScreenFeedback.new() add_child_autofree(sf) return sf
func test_flash_damage_raises_vignette_then_decays() -> void: var sf := _make() sf.flash_damage() assert_gt(sf.vignette_alpha(), 0.0) for _i in range(60): sf.advance(1.0 / 60.0) assert_almost_eq(sf.vignette_alpha(), 0.0, 0.01)
func test_low_hp_toggles_border() -> void: var sf := _make() assert_false(sf.border_visible()) sf.set_low_hp(true) assert_true(sf.border_visible()) sf.set_low_hp(false) assert_false(sf.border_visible())
func test_shake_kicks_then_settles() -> void: var sf := _make() sf.flash_damage() for _i in range(120): sf.advance(1.0 / 60.0) assert_almost_eq(sf.take_shake_offset().length(), 0.0, 0.01)-
Step 2: Run it, verify it fails — Expected: FAIL (
ScreenFeedbacknot found). -
Step 3: Implement —
fx/screen_feedback.gd
class_name ScreenFeedbackextends CanvasLayer
# Screen-space player feedback: a red damage vignette flash, a pulsing low-HP edge# border, and a decaying camera-shake offset (read by main and applied to the camera).const SHAKE_KICK := 8.0const VIGNETTE_PEAK := 0.5const BORDER_COLOR := Color(1.0, 0.2, 0.2)const VIGNETTE_COLOR := Color(1.0, 0.1, 0.15)
var _vignette: ColorRectvar _border: Panelvar _vig_a := 0.0var _shake := 0.0var _low := falsevar _pulse := 0.0
func _ready() -> void: layer = 20 _vignette = ColorRect.new() _vignette.set_anchors_preset(Control.PRESET_FULL_RECT) _vignette.color = Color(VIGNETTE_COLOR.r, VIGNETTE_COLOR.g, VIGNETTE_COLOR.b, 0.0) _vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_vignette) var sb := StyleBoxFlat.new() sb.draw_center = false sb.set_border_width_all(10) sb.border_color = BORDER_COLOR _border = Panel.new() _border.set_anchors_preset(Control.PRESET_FULL_RECT) _border.mouse_filter = Control.MOUSE_FILTER_IGNORE _border.add_theme_stylebox_override("panel", sb) _border.visible = false add_child(_border)
func flash_damage() -> void: _vig_a = VIGNETTE_PEAK _shake = SHAKE_KICK if _vignette != null: _vignette.color = Color(VIGNETTE_COLOR.r, VIGNETTE_COLOR.g, VIGNETTE_COLOR.b, _vig_a)
func set_low_hp(on: bool) -> void: _low = on if _border != null: _border.visible = on
func advance(dt: float) -> void: _vig_a = maxf(_vig_a - dt * 2.0, 0.0) if _vignette != null: _vignette.color = Color(VIGNETTE_COLOR.r, VIGNETTE_COLOR.g, VIGNETTE_COLOR.b, _vig_a) _shake = maxf(_shake - dt * SHAKE_KICK * 2.0, 0.0) if _low and _border != null: _pulse += dt * 3.0 _border.modulate.a = 0.45 + 0.35 * sin(_pulse)
func vignette_alpha() -> float: return _vig_a
func border_visible() -> bool: return _border != null and _border.visible
func take_shake_offset() -> Vector2: if _shake <= 0.0: return Vector2.ZERO # Deterministic-free jitter is fine here (render only). Vary by frame parity. _pulse += 0.7 return Vector2(cos(_pulse * 12.9), sin(_pulse * 7.3)) * _shake-
Step 4: Run it, verify it passes — Step-1 command. Expected: PASS (3 tests).
-
Step 5: Wire into
main.gd. Add a field:var screen_fx: ScreenFeedbackandvar _last_hp: float = 0.0. In_new_run, afterresultsis added:
screen_fx = ScreenFeedback.new() add_child(screen_fx) _last_hp = sim.player.hpIn _process, after fx_layer.advance(...), add:
if sim.player.hp < _last_hp - 0.001: screen_fx.flash_damage() _last_hp = sim.player.hp screen_fx.set_low_hp(sim.player.hp <= sim.player.max_hp * 0.3) screen_fx.advance(1.0 / 60.0) camera.offset = screen_fx.take_shake_offset()-
Step 6: Boot smoke —
godot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expectedboot OK. -
Step 7: Commit
git add fx/screen_feedback.gd main.gd tests/test_screen_feedback.gdgit commit -m "feat(fx): ScreenFeedback — damage vignette, low-HP border, camera shake"Task 8: Fonts + NeonTheme + HUD restyle
Section titled “Task 8: Fonts + NeonTheme + HUD restyle”Files:
- Create:
fonts/Orbitron-VariableFont_wght.ttf,fonts/JetBrainsMono-VariableFont_wght.ttf,fonts/OFL-Orbitron.txt,fonts/OFL-JetBrainsMono.txt - Create:
ui/theme/neon_theme.gd - Modify:
ui/hud.gd - Test:
tests/test_neon_theme.gd
Interfaces:
-
Produces:
class_name NeonThemewithconst CYAN := Color(0.2, 0.9, 1.0),static func get_theme() -> Theme(cached),static func title_font() -> FontFile,static func mono_font() -> FontFile. -
Step 1: Download the fonts + licences (verified 200 OK on 2026-06-23):
mkdir -p fontscurl -sL -o fonts/Orbitron-VariableFont_wght.ttf "https://github.com/google/fonts/raw/main/ofl/orbitron/Orbitron%5Bwght%5D.ttf"curl -sL -o fonts/JetBrainsMono-VariableFont_wght.ttf "https://github.com/google/fonts/raw/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf"curl -sL -o fonts/OFL-Orbitron.txt "https://github.com/google/fonts/raw/main/ofl/orbitron/OFL.txt"curl -sL -o fonts/OFL-JetBrainsMono.txt "https://github.com/google/fonts/raw/main/ofl/jetbrainsmono/OFL.txt"# sanity: both ttf are real (tens/hundreds of KB), not HTML error pagesls -la fonts/file fonts/*.ttf # expect "TrueType Font data" / "OpenType"Then import so Godot generates .import sidecars: godot --headless --path . --import.
- Step 2: Write the failing test —
tests/test_neon_theme.gd
extends GutTest
func test_theme_has_button_stylebox_and_default_font() -> void: var t := NeonTheme.get_theme() assert_not_null(t) assert_true(t.has_stylebox("normal", "Button"), "Button normal stylebox present") assert_not_null(t.default_font, "default font set")
func test_fonts_load() -> void: assert_not_null(NeonTheme.title_font(), "Orbitron loads") assert_not_null(NeonTheme.mono_font(), "JetBrains Mono loads")
func test_theme_is_cached() -> void: assert_same(NeonTheme.get_theme(), NeonTheme.get_theme())-
Step 3: Run it, verify it fails — Expected: FAIL (
NeonThemenot found). -
Step 4: Implement —
ui/theme/neon_theme.gd
class_name NeonThemeextends RefCounted
# One code-built neon Theme (cleaner + unit-testable than a hand-authored .tres):# Orbitron default for titles/buttons, JetBrains Mono exposed for HUD numbers,# dark-translucent rounded styleboxes with thin cyan neon borders.const CYAN := Color(0.2, 0.9, 1.0)const TEXT := Color(0.85, 0.97, 1.0)
static var _theme: Theme = nullstatic var _title: FontFile = nullstatic var _mono: FontFile = null
static func title_font() -> FontFile: if _title == null: _title = load("res://fonts/Orbitron-VariableFont_wght.ttf") return _title
static func mono_font() -> FontFile: if _mono == null: _mono = load("res://fonts/JetBrainsMono-VariableFont_wght.ttf") return _mono
static func get_theme() -> Theme: if _theme == null: _theme = _build() return _theme
static func _box(fill_a: float, border: float) -> StyleBoxFlat: var sb := StyleBoxFlat.new() sb.bg_color = Color(0.0, 0.0, 0.0, fill_a) sb.border_color = CYAN sb.set_border_width_all(int(border)) sb.set_corner_radius_all(12) sb.set_content_margin_all(12) return sb
static func _build() -> Theme: var t := Theme.new() t.default_font = title_font() t.default_font_size = 18 t.set_stylebox("normal", "Button", _box(0.55, 1)) t.set_stylebox("hover", "Button", _box(0.65, 2)) t.set_stylebox("pressed", "Button", _box(0.75, 2)) t.set_stylebox("focus", "Button", _box(0.65, 2)) t.set_color("font_color", "Button", TEXT) t.set_color("font_hover_color", "Button", Color.WHITE) t.set_color("font_color", "Label", TEXT) return t-
Step 5: Run it, verify it passes — Step-2 command. Expected: PASS (3 tests).
-
Step 6: Restyle the HUD — replace
ui/hud.gd_ready:
func _ready() -> void: _label = Label.new() _label.position = Vector2(16, 12) _label.add_theme_font_override("font", NeonTheme.mono_font()) _label.add_theme_font_size_override("font_size", 20) _label.add_theme_color_override("font_color", NeonTheme.CYAN) add_child(_label)(update_hud is unchanged.)
- Step 7: Count guard + HUD smoke
godot --headless --path . --importscripts/check-test-count.shExpected: test-count guard OK: N/N.
- Step 8: Commit
git add fonts/ ui/theme/neon_theme.gd ui/hud.gd tests/test_neon_theme.gdgit commit -m "feat(ui): embed Orbitron + JetBrains Mono (OFL); NeonTheme; mono neon HUD"Task 9: Restyle the level-up + results panels
Section titled “Task 9: Restyle the level-up + results panels”Files:
- Modify:
ui/level_up_panel.gd - Modify:
ui/results_panel.gd
Interfaces:
-
Consumes:
NeonTheme.get_theme(),NeonTheme.title_font()(Task 8). -
Note: per-card element tinting is deferred (no upgrade→element mapping is exposed by
Upgrades.choice_display; adding one is out of scope for a render cycle). Cards get uniform neon chrome — the look lands without touchingsim/. Logged in spec “Out of scope”. -
Step 1: Restyle
ui/level_up_panel.gd. In_ready, afteradd_child(center)set the theme; style the title and add spacing:
Replace _ready:
func _ready() -> void: layer = 10 theme = NeonTheme.get_theme() var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(center) _box = VBoxContainer.new() _box.add_theme_constant_override("separation", 12) center.add_child(_box) hide_panel()Replace the title lines in show_choices (lines 20-22):
var title := Label.new() title.text = "LEVEL UP" title.add_theme_font_override("font", NeonTheme.title_font()) title.add_theme_font_size_override("font_size", 32) title.add_theme_color_override("font_color", NeonTheme.CYAN) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _box.add_child(title)- Step 2: Restyle
ui/results_panel.gd. Replace_ready:
func _ready() -> void: layer = 10 theme = NeonTheme.get_theme() var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(center) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 14) center.add_child(box) _label = Label.new() _label.add_theme_font_override("font", NeonTheme.title_font()) _label.add_theme_font_size_override("font_size", 28) _label.add_theme_color_override("font_color", NeonTheme.CYAN) _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(_label) var b := Button.new() b.text = "Play again" b.pressed.connect(func() -> void: restart_requested.emit()) box.add_child(b) visible = false-
Step 3: Boot smoke —
godot --headless --path . --quit-after 120 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expectedboot OK. -
Step 4: Commit
git add ui/level_up_panel.gd ui/results_panel.gdgit commit -m "feat(ui): neon level-up + results panels (NeonTheme, Orbitron titles)"Task 10: Player ship glow + background vignette/grid glow
Section titled “Task 10: Player ship glow + background vignette/grid glow”Files:
- Modify:
main.gd(player node construction) - Modify:
render/arena_background.gd
Interfaces:
-
Consumes:
GlowTexture.shared()(Task 2),ElementPalette.PLAYER_CORE/PLAYER_HALO(Task 1). -
Step 1: Glowing player ship. In
main.gd_new_run, replace the player polygon block (lines 50-53) with a core polygon + additive halo:
var halo := Sprite2D.new() halo.texture = GlowTexture.shared() halo.scale = Vector2(0.9, 0.9) halo.modulate = Color(ElementPalette.PLAYER_HALO.r, ElementPalette.PLAYER_HALO.g, ElementPalette.PLAYER_HALO.b, 0.7) var hmat := CanvasItemMaterial.new() hmat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD halo.material = hmat player_node.add_child(halo) var poly := Polygon2D.new() poly.polygon = PackedVector2Array([Vector2(0, -18), Vector2(15, 12), Vector2(-15, 12)]) poly.color = ElementPalette.PLAYER_CORE player_node.add_child(poly)- Step 2: Background vignette + grid glow. Replace
render/arena_background.gd:
class_name ArenaBackgroundextends Node2D
const STEP: float = 100.0const LINE_COLOR: Color = Color(0.18, 0.6, 1.0, 0.32)const BORDER_COLOR: Color = Color(0.4, 0.85, 1.0, 0.7)
func _ready() -> void: # Additive blend makes the grid lines read as faint neon glow over the dark base. var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD material = mat
func _draw() -> void: var h := Sim_Const.ARENA_HALF var x := -h while x <= h: draw_line(Vector2(x, -h), Vector2(x, h), LINE_COLOR, 1.0) x += STEP var y := -h while y <= h: draw_line(Vector2(-h, y), Vector2(h, y), LINE_COLOR, 1.0) y += STEP draw_rect(Rect2(-h, -h, h * 2.0, h * 2.0), BORDER_COLOR, false, 3.0)- Step 3: Screen-space vignette. Add a dark-edge vignette to
ScreenFeedback._ready(so it is screen-space, not world-space) — insert before the_bordersetup:
var vig := ColorRect.new() vig.set_anchors_preset(Control.PRESET_FULL_RECT) vig.mouse_filter = Control.MOUSE_FILTER_IGNORE var vmat := ShaderMaterial.new() var sh := Shader.new() sh.code = "shader_type canvas_item;\nvoid fragment(){\n float d = distance(UV, vec2(0.5));\n COLOR = vec4(0.0, 0.0, 0.0, smoothstep(0.45, 0.95, d) * 0.55);\n}" vmat.shader = sh vig.material = vmat add_child(vig)(Place this as the FIRST child so the damage vignette + border draw over it.)
-
Step 4: Boot smoke —
godot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expectedboot OK. -
Step 5: Playtest verification (manual, not headless). Run
godot --path ., confirm by eye: entities glow (core+halo), enemies tint to aura colour when burning/shocked, reactions flash a coloured ring, death pops fire, the player ship glows, the grid glows faintly, the HUD is mono-neon, taking damage flashes red + shakes, low HP shows the red border, level-up/results panels are neon cards. (Headless cannot verify pixels — documented MultiMesh read-back gotcha.) -
Step 6: Commit
git add main.gd render/arena_background.gd fx/screen_feedback.gdgit commit -m "feat(render): glowing player ship + neon grid glow + screen vignette"Task 11 (OPTIONAL, lowest priority — cuttable): Desktop WorldEnvironment bloom
Section titled “Task 11 (OPTIONAL, lowest priority — cuttable): Desktop WorldEnvironment bloom”Files:
- Modify:
main.gd(add aWorldEnvironmentin_new_run)
Interfaces: none new. Must be INERT under gl_compatibility (web + current default) — no crash, no visual change. Engages only under a glow-capable renderer (Forward+/Mobile desktop run).
- Step 1: Add the environment. In
main.gd_new_run, afteradd_child(ArenaBackground.new()):
var world_env := WorldEnvironment.new() var env := Environment.new() env.background_mode = Environment.BG_CANVAS env.glow_enabled = true env.glow_intensity = 0.9 env.glow_bloom = 0.2 env.glow_blend_mode = Environment.GLOW_BLEND_MODE_ADDITIVE world_env.environment = env add_child(world_env)- Step 2: Verify the web/compat path does not regress. Confirm the headless (compatibility) boot still runs clean:
godot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK"
Expected boot OK. (Bloom is simply not applied under compatibility; that is expected and correct.)
- Step 3: Commit
git add main.gdgit commit -m "feat(render): optional desktop WorldEnvironment bloom (inert under web compat)"Final verification (run before finishing the branch)
Section titled “Final verification (run before finishing the branch)”- Full suite green:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit(exit 0). - Test-count guard:
scripts/check-test-count.sh→OK: N/N(no stale-class-cache drop; 6 new test files expected: element_palette, glow_texture, fx_events, fx_manager, screen_feedback, neon_theme —test_swarm_renderer.gdwas edited not added). - Determinism unchanged:
/tmp/trace_hash.gdprintsTRACE_HASH=1314757315andCHECKSUM_END=1949813464. - Boot smoke clean:
godot --headless --path . --quit-after 300 2>&1 | grep -c "SCRIPT ERROR"→0. - Manual playtest pass (Task 10 Step 5 checklist).
- Deploy (after merge, on Chris’s go):
scripts/deploy-demo.shre-exports the Web build + uploads to R2. Noseed.js/content change, so the site Bible/landing deploy is not required.
Self-review notes (planner)
Section titled “Self-review notes (planner)”- Spec coverage: glow (T2/T3), element colour (T1/T4), fx_events sim touch (T5), reaction rings + death pops + pickup sparkles (T6), shake + vignette + low-HP border (T7), fonts + theme + HUD (T8), panels (T9), player ship + background (T10), desktop bloom (T11). All spec sections mapped.
- Deviation 1: theme is code-built (
neon_theme.gd) not a.tres— cleaner, unit-testable, satisfies “one Theme resource”. - Deviation 2: per-card element tinting on level-up cards is deferred (no upgrade→element seam without touching
sim/); uniform neon cards still deliver the look. Both noted in spec “Out of scope” / Task 9. - Type consistency:
sync(pool, colors)arity is defined in T3 and used in T4/T6 contexts;fx_eventsdict shape is identical across T5 (producer) and T6 (consumer);_reaction_burst(...; element_idx)updated at both call sites (sim.gd + weapon_nova.gd).