Skip to content

Neon Visual Overhaul — Design Spec

Date: 2026-06-23 Status: Approved (brainstorm) Cycle: M2 cycle 6 (presentation/feel pass — first non-systems cycle)

Make Bullet Heaven look and feel like Chris’s chess-defense-td: a dark-neon aesthetic with additive “lightsaber” glow on every entity, element-driven colour, an Orbitron/JetBrains-Mono UI, neon UI chrome, and a richer (but cheap) FX vocabulary. This is a render/UI-only overhaul plus one additive, determinism-neutral change to the sim’s existing FX-event channel.

  • Determinism keystone is preserved. The 600-tick trace hash for seed 1234 stays byte-identical (snapshot_string trace hash() = 1314757315, end state_checksum() = 1949813464). tests/test_determinism.gd and tests/test_determinism_checksum.gd are the hard acceptance gate for every task that touches sim/.
  • /sim stays pureextends RefCounted, no Node/render/Input/Engine/Time/File/JSON APIs. The only sim change permitted by this spec is extending the per-tick FX-event list (already non-hashed render-facing data); it carries no RNG and is not added to snapshot_string() or state_checksum().
  • One-way data flow. Render/UI may only READ sim state; the sole sim mutations remain sim.tick(input) and sim.apply_upgrade(id). The FX-event list is read by render, never fed back.
  • Web demo must not regress. The public demo runs the gl_compatibility (WebGL2) renderer. The load-bearing glow is renderer-agnostic (additive textures), so it works on web. WorldEnvironment bloom is a desktop-only enhancement that is inert (no-op, no crash) under compatibility.
  • Performance. The swarm reaches thousands of entities. Glow adds at most one extra MultiMesh layer per swarm type (2 draw calls/swarm). All transient FX are pooled and per-frame capped. No per-entity node spawning in the swarm path.
  • Fonts are OFL (Orbitron, JetBrains Mono); commit the .ttf plus their OFL.txt licence files.
  • Content style (no em-dashes etc.) applies to any player-facing copy added; not to code.
  • Renderer gl_compatibility; clear colour Color(0.03,0.03,0.06,1); no WorldEnvironment.
  • render/swarm_renderer.gd — one MultiMeshInstance2D per swarm, QuadMesh, use_colors=true, per-frame sync().
  • Entity colours hardcoded in main.gd: enemies Color(1.0,0.3,0.5), projectiles Color(0.6,0.95,1.0), gems Color(0.5,1.0,0.6).
  • Player: flat Polygon2D triangle, Color(0.9,0.95,1.0) (main.gd:48-53).
  • render/arena_background.gd — grid (Color(0.15,0.55,0.9,0.25), step 100) + border, plain _draw(), no vignette/glow.
  • HUD/UI: default Godot font, no Theme. ui/hud.gd (font size 20), ui/level_up_panel.gd, ui/results_panel.gd use default Button/Label chrome.
  • FX: single _Flash orange dodecagon (main.gd:116-130) driven by sim.fx_bursts (Array[Vector2], centres only).
  • Element data: bible.json data.elements[] each has a color hex; exposed via ContentDB.element_at(idx)/element(id).

A. The sim FX-event channel (the one sim touch)

Section titled “A. The sim FX-event channel (the one sim touch)”

Replace Sim.fx_bursts: Array[Vector2] with a typed, non-hashed per-tick event list:

sim/sim.gd
var fx_events: Array[Dictionary] = [] # cleared at top of tick()
# event shapes:
# {"kind":"reaction", "pos":Vector2, "element":int} # element idx for tint (-1 = generic)
# {"kind":"death", "pos":Vector2, "element":int} # dead enemy's aura element (-1 = neutral)
# {"kind":"pickup", "pos":Vector2, "element":int} # always -1 (XP green); pos = gem position
  • _reaction_burst(center, magnitude, generic, element_idx) appends a reaction event with the reaction’s applied-element index for tint (was: centre only).
  • _sweep_dead() appends a death event per removed enemy (pos = enemies.pos[i], element = enemies.aura_element[i]).
  • The gem-collect phase appends a pickup event per collected gem (pos = gem position).
  • These are populated deterministically (driven by sweep/collect/reaction, no RNG) and are NOT included in snapshot_string() or state_checksum(). Determinism tests must still match the baseline (trace 1314757315, checksum 1949813464).

Rationale: the sim already exposed a per-tick render-facing FX list (fx_bursts); this generalises it. Render needs death/pickup positions because the swap-remove pool can’t be diffed frame-to-frame from the render side.

B. Element → colour mapping (render-side)

Section titled “B. Element → colour mapping (render-side)”

New render/element_palette.gd (class_name ElementPalette, extends RefCounted but render-side, may use Color):

static func color_for(content: ContentDB, element_idx: int) -> Color
# element_idx >= 0 -> Color(content.element_at(idx)["color"]) (Godot parses "#rrggbb")
# element_idx == -1 or missing/invalid -> NEUTRAL (a pale threat magenta, e.g. Color(1.0,0.32,0.46))
static func gem_color() -> Color # XP green Color(0.5,1.0,0.6)
static func player_core() -> Color # white-ish
static func player_halo() -> Color # cyan

Pure, headless-unit-testable (hex string -> Color, neutral fallback, invalid input -> neutral, never errors). No new colour table in code — element colours come from bible.json.

  1. render/glow_texture.gd (class_name GlowTexture) — generates a soft radial ImageTexture once at load: an N×N (e.g. 64) image, alpha = smooth radial falloff (bright/opaque centre → transparent edge), RGB white. Returned for reuse and additive tinting. Headless-testable: asserts texture size, centre pixel near-opaque, corner pixel transparent.
  2. render/swarm_renderer.gd gains an optional halo layer: a second MultiMeshInstance2D child whose QuadMesh uses the glow texture, a CanvasItemMaterial with blend_mode = BLEND_MODE_ADD, sized larger than the core quad (e.g. core radius × ~3). It syncs from the SAME pool each frame (same transforms, scaled up) with per-instance colour = the entity’s element/aura colour. The existing core quad stays (bright centre). Net: 2 MultiMesh layers per swarm, 6 total. Headless can only assert instance_count + resync (documented MultiMesh-readback gotcha); placement verified by playtest.
  3. main.gd wires per-instance colours from ElementPalette: projectiles by weapon element (sim.pulse_element_idx/sim.nova_element_idx — but projectiles are one pool; tint per-projectile by the element stored at fire time if available, else the pulse element as the default stream), enemies by enemies.aura_element[i] (neutral when -1), gems by gem_color(). Core layer keeps a brightened tint; halo layer takes the element colour at lower alpha.

Projectile element note: the projectile pool does not currently store a per-projectile element. For this cycle, tint all projectiles to the pulse (lightning) element colour as the single projectile stream, and the nova AoE pulse (a separate weapon drawing) to the fire colour. Per-projectile element tinting is out of scope (would need a sim column) and is logged as a follow-up.

  • Add fonts/Orbitron-Bold.ttf, fonts/Orbitron-Regular.ttf, fonts/JetBrainsMono-Regular.ttf (+ fonts/OFL-Orbitron.txt, fonts/OFL-JetBrainsMono.txt).
  • New ui/theme/neon_theme.tres (Theme): default font Orbitron; a mono variation for HUD numbers; Button + Panel + PanelContainer StyleBoxFlat:
    • fill Color(0,0,0,0.55) with a faint tint overlay,
    • thin neon border (border_width ≈ 1, border_color cyan Color(0.2,0.9,1.0,0.9)),
    • corner_radius ≈ 12,
    • content margins for breathing room; a subtle glow via expand_margin + a lighter outer box where practical.
  • ui/hud.gd — HUD numbers (Time/Lv/HP/Kills/Enemies) in JetBrains Mono; labels in Orbitron; neon text colour; keep the existing format/positions.
  • ui/level_up_panel.gd — choices become neon cards (the Button stylebox); where a choice maps to an element, tint its border to that element’s colour (ElementPalette); title “LEVEL UP” in Orbitron.
  • ui/results_panel.gd — same chrome; “RUN OVER” title; “Play again” as a prominent neon button.
  • Apply neon_theme.tres at the roots of HUD/LevelUpPanel/ResultsPanel.

E. FX vocabulary (pooled, capped, render-only)

Section titled “E. FX vocabulary (pooled, capped, render-only)”

A render-side fx/fx_manager.gd (class_name FxManager, Node2D) consuming sim.fx_events each frame, plus camera/overlay feedback derived from player state:

  • Reaction ring — expanding additive ring (a ring texture or Line2D circle) tinted by the event’s element colour; pooled instances; ~0.25s fade+scale. Replaces _Flash.
  • Death pop — short additive glow flash (the glow texture) at the death position, tinted to the death event’s element (neutral if -1); pooled; hard cap 8 per frame (surplus death events skip the pop but still count as kills in sim).
  • Pickup sparkle — tiny additive flash at the pickup position (or at the player) on pickup events; pooled; capped.
  • Screen shake — small camera offset impulse on player contact-hit (detected from player HP decreasing frame-over-frame) and on large reactions; decays over a few frames; camera-only.
  • Damage vignette — red full-screen overlay (a CanvasLayer above gameplay) flashed on player-HP decrease; fades.
  • Low-HP border — pulsing red screen-edge frame shown while player HP is below a threshold (e.g. 30%).
  • Player ship — redraw the player as a glowing craft: keep a crisp core polygon (white-ish) and add an additive halo sprite child (glow texture, element/cyan tint).
  • Background polisharena_background.gd gains a static radial vignette overlay (darken edges) and brighter/glowing grid (additive CanvasItemMaterial on the grid draw, or a second faint offset pass). Keeps the dark #0a0a0f base.

F. Desktop bloom (optional, lowest priority, cuttable)

Section titled “F. Desktop bloom (optional, lowest priority, cuttable)”

Add a WorldEnvironment (or set the env in main.tscn) with Environment glow enabled (bloom on bright additive areas). Inert under gl_compatibility (web + default desktop) — no visual change, no crash. Engages only when the project is run under a glow-capable renderer (Forward+/Mobile). Renderer defaults are left unchanged so neither the web demo nor the current desktop build regresses. If wiring it proves to risk the web export, drop this task; the additive textures already carry the look.

Headless cannot read back MultiMesh transforms/colours (documented gotcha), so visual placement is verified by playtest + a Playwright screenshot of the web build (console-error check + screenshot), not asserted in GUT.

Headless GUT unit tests to add:

  • tests/test_element_palette.gdElementPalette.color_for returns the right Color for a known element idx (parse #ff6a4d), neutral for -1 / out-of-range / missing-color, never push_errors.
  • tests/test_glow_texture.gd — generated texture has expected size; centre pixel alpha high; corner pixel alpha ~0.
  • tests/test_swarm_renderer.gd (extend) — halo layer present; instance_count matches pool count after sync(); resync shrinks/grows correctly.
  • tests/test_fx_events.gd — after a tick that kills an enemy, sim.fx_events contains a death event with that enemy’s position+element; a reaction tick yields a reaction event with the reaction element; a gem collect yields a pickup event. (Use the non-erroring seams; consume any expected push_error.)
  • tests/test_fx_manager.gd — a death event yields exactly one pooled pop; >8 death events in a frame yield ≤8 pops (cap honoured); pool reuse (no unbounded node growth).
  • tests/test_neon_theme.gdneon_theme.tres loads; fonts load; HUD applies the theme (smoke).

Hard gates (must stay green/identical):

  • tests/test_determinism.gd + tests/test_determinism_checksum.gd → trace 1314757315 / checksum 1949813464 unchanged (proves the fx_events change is determinism-neutral).
  • scripts/check-test-count.sh → GUT runs every tests/test_*.gd (no stale-class-cache silent drop).
  • godot --headless --path . --quit-after <frames> boot smoke → no SCRIPT ERROR.

Phasing (one spec, independently-landable tasks)

Section titled “Phasing (one spec, independently-landable tasks)”
  1. ElementPalette + GlowTexture (pure helpers + unit tests).
  2. Swarm halo layer in swarm_renderer.gd; wire element/aura/gem colours in main.gd (glow visible).
  3. Extend the sim FX-event channel (fx_events + reaction/death/pickup population); determinism gate.
  4. FxManager + reaction rings + death pops + pickup sparkles (consume fx_events).
  5. Camera/overlay feedback (shake, damage vignette, low-HP border) from player state.
  6. Fonts + neon_theme.tres + restyle HUD/level-up/results.
  7. Player ship redraw + background vignette/grid glow.
  8. (Optional) desktop WorldEnvironment bloom.
  • Per-projectile element tinting (needs a sim column) — projectiles use the single pulse-stream colour this cycle.
  • New gameplay, weapons, enemies, mods, or evolutions — purely presentation.
  • Audio.
  • Switching the default renderer to Forward+ (would jeopardise the web demo).
  • Animated/SDF glyphs or sprite-atlas art — vector/additive only.

After merge, the look reaches the public demo via scripts/deploy-demo.sh (re-export Web build + R2 upload). No seed.js/content change, so the site Bible/landing deploy is not required unless copy changes.