Skip to content

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).

  • Determinism is the keystone. The seed-1234 / 600-tick baseline MUST stay identical: snapshot_string trace hash() = 1314757315, end state_checksum() = 1949813464. Verify with the throwaway script in Task 5. tests/test_determinism.gd + tests/test_determinism_checksum.gd stay green.
  • /sim stays pure — every sim/ file extends RefCounted; NO Node/render/Input/Engine/Time/File/JSON APIs. Color/Vector2 are value types and allowed, but the new colour helpers live in render/, NOT sim/. The only sim change is the fx_events list (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) and sim.apply_upgrade(id). fx_events is read by render, never fed back.
  • Web demo must not regress — the public demo runs gl_compatibility (WebGL2). Glow is additive textures (renderer-agnostic). WorldEnvironment bloom (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_error FAILS a test. None of the new paths should push_error; if a test must exercise an erroring seam, consume it with assert_push_error(...).
  • Test-count guard: every tests/test_*.gd must be counted by GUT. After adding test files, run scripts/check-test-count.sh (and godot --headless --path . --import if a new class_name was dropped).
  • Fonts are OFL — commit the .ttf AND the OFL.txt licence 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 ElementPalette with const 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), and static func color_for(content: ContentDB, element_idx: int) -> Color.

  • Step 1: Write the failing testtests/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: Implementrender/element_palette.gd
class_name ElementPalette
extends 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" magenta
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)
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
Terminal window
git add render/element_palette.gd tests/test_element_palette.gd
git 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 GlowTexture with static func build(size: int) -> ImageTexture (radial white→transparent) and static func shared() -> Texture2D (cached 64px instance).

  • Step 1: Write the failing testtests/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: Implementrender/glow_texture.gd
class_name GlowTexture
extends 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

Terminal window
git add render/glow_texture.gd tests/test_glow_texture.gd
git 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 builds var halo: MultiMeshInstance2D), and a NEW signature sync(pool: EntityPool, colors: Variant) -> void where colors is either a single Color (all instances) or a PackedColorArray (per-instance, indexed by i). 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.gd with:

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.halo null / sync arity).

  • Step 3: Implement — replace render/swarm_renderer.gd with:

class_name SwarmRenderer
extends 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

Terminal window
git add render/swarm_renderer.gd tests/test_swarm_renderer.gd
git 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 other var declarations (after line 13):

var _element_color_lut: PackedColorArray = PackedColorArray()
var _proj_color: Color = ElementPalette.NEUTRAL
  • Step 2: Build the LUT in _new_run. After sim = 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
Terminal window
git add main.gd
git 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 (rename fx_burstsfx_events, populate reaction/death/pickup, new _reaction_burst arg)
  • 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 each tick(). Event shapes:

    • {"kind":"reaction", "pos":Vector2, "element":int}
    • {"kind":"death", "pos":Vector2, "element":int} (dead enemy’s aura_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 testtests/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_events not defined / _reaction_burst arity).

  • 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 -= 1

Update _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_burst call (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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_fx_events.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit

Expected: all PASS.

  • Step 7: Prove the trace is byte-identical to baseline. Create /tmp/trace_hash.gd:
extends SceneTree
func _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
Terminal window
git add sim/sim.gd sim/weapon_nova.gd main.gd tests/test_fx_events.gd
git 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 with FxManager)
  • 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 Node2D with const 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 testtests/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 (FxManager not found).

  • Step 3: Implementfx/fx_manager.gd

class_name FxManager
extends 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 := 256
const DEATH_CAP := 8
var _content: ContentDB
var _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 the fx_layer setup (lines 45-46) with an FxManager:

fx_layer = FxManager.new()
fx_layer.setup(content)
add_child(fx_layer)

Change the field type (line 13) var fx_layer: Node2Dvar 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 smokegodot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expected boot OK.

  • Step 7: Commit

Terminal window
git add fx/fx_manager.gd main.gd tests/test_fx_manager.gd
git 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 CanvasLayer with func flash_damage() -> void, func set_low_hp(on: bool) -> void, func advance(dt: float) -> void, func vignette_alpha() -> float, func border_visible() -> bool, and func 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 testtests/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 (ScreenFeedback not found).

  • Step 3: Implementfx/screen_feedback.gd

class_name ScreenFeedback
extends 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.0
const VIGNETTE_PEAK := 0.5
const BORDER_COLOR := Color(1.0, 0.2, 0.2)
const VIGNETTE_COLOR := Color(1.0, 0.1, 0.15)
var _vignette: ColorRect
var _border: Panel
var _vig_a := 0.0
var _shake := 0.0
var _low := false
var _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: ScreenFeedback and var _last_hp: float = 0.0. In _new_run, after results is added:

screen_fx = ScreenFeedback.new()
add_child(screen_fx)
_last_hp = sim.player.hp

In _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 smokegodot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expected boot OK.

  • Step 7: Commit

Terminal window
git add fx/screen_feedback.gd main.gd tests/test_screen_feedback.gd
git commit -m "feat(fx): ScreenFeedback — damage vignette, low-HP border, camera shake"

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 NeonTheme with const 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):

Terminal window
mkdir -p fonts
curl -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 pages
ls -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 testtests/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 (NeonTheme not found).

  • Step 4: Implementui/theme/neon_theme.gd

class_name NeonTheme
extends 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 = null
static var _title: FontFile = null
static 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
Terminal window
godot --headless --path . --import
scripts/check-test-count.sh

Expected: test-count guard OK: N/N.

  • Step 8: Commit
Terminal window
git add fonts/ ui/theme/neon_theme.gd ui/hud.gd tests/test_neon_theme.gd
git 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 touching sim/. Logged in spec “Out of scope”.

  • Step 1: Restyle ui/level_up_panel.gd. In _ready, after add_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 smokegodot --headless --path . --quit-after 120 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expected boot OK.

  • Step 4: Commit

Terminal window
git add ui/level_up_panel.gd ui/results_panel.gd
git 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 ArenaBackground
extends Node2D
const STEP: float = 100.0
const 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 _border setup:
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 smokegodot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo FAIL || echo "boot OK". Expected boot 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

Terminal window
git add main.gd render/arena_background.gd fx/screen_feedback.gd
git 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 a WorldEnvironment in _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, after add_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
Terminal window
git add main.gd
git 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.shOK: 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.gd was edited not added).
  • Determinism unchanged: /tmp/trace_hash.gd prints TRACE_HASH=1314757315 and CHECKSUM_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.sh re-exports the Web build + uploads to R2. No seed.js/content change, so the site Bible/landing deploy is not required.
  • 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_events dict shape is identical across T5 (producer) and T6 (consumer); _reaction_burst(...; element_idx) updated at both call sites (sim.gd + weapon_nova.gd).