Neon Visual Overhaul — Design Spec
Neon Visual Overhaul — Design Spec
Section titled “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.
Non-negotiable constraints (global)
Section titled “Non-negotiable constraints (global)”- Determinism keystone is preserved. The 600-tick trace hash for seed 1234 stays byte-identical (snapshot_string trace
hash()= 1314757315, endstate_checksum()= 1949813464).tests/test_determinism.gdandtests/test_determinism_checksum.gdare the hard acceptance gate for every task that touchessim/. /simstays pure —extends 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 tosnapshot_string()orstate_checksum().- One-way data flow. Render/UI may only READ sim state; the sole sim mutations remain
sim.tick(input)andsim.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.WorldEnvironmentbloom 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
.ttfplus theirOFL.txtlicence files. - Content style (no em-dashes etc.) applies to any player-facing copy added; not to code.
Current state (baseline being replaced)
Section titled “Current state (baseline being replaced)”- Renderer
gl_compatibility; clear colourColor(0.03,0.03,0.06,1); noWorldEnvironment. render/swarm_renderer.gd— oneMultiMeshInstance2Dper swarm,QuadMesh,use_colors=true, per-framesync().- Entity colours hardcoded in
main.gd: enemiesColor(1.0,0.3,0.5), projectilesColor(0.6,0.95,1.0), gemsColor(0.5,1.0,0.6). - Player: flat
Polygon2Dtriangle,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.gduse defaultButton/Labelchrome. - FX: single
_Flashorange dodecagon (main.gd:116-130) driven bysim.fx_bursts(Array[Vector2], centres only). - Element data:
bible.json data.elements[]each has acolorhex; exposed viaContentDB.element_at(idx)/element(id).
Architecture
Section titled “Architecture”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:
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 areactionevent with the reaction’s applied-element index for tint (was: centre only)._sweep_dead()appends adeathevent per removed enemy (pos =enemies.pos[i], element =enemies.aura_element[i]).- The gem-collect phase appends a
pickupevent per collected gem (pos = gem position). - These are populated deterministically (driven by sweep/collect/reaction, no RNG) and are NOT included in
snapshot_string()orstate_checksum(). Determinism tests must still match the baseline (trace1314757315, checksum1949813464).
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-ishstatic func player_halo() -> Color # cyanPure, 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.
C. Glow rendering (core + additive halo)
Section titled “C. Glow rendering (core + additive halo)”render/glow_texture.gd(class_name GlowTexture) — generates a soft radialImageTextureonce 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.render/swarm_renderer.gdgains an optional halo layer: a secondMultiMeshInstance2Dchild whoseQuadMeshuses the glow texture, aCanvasItemMaterialwithblend_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 assertinstance_count+ resync (documented MultiMesh-readback gotcha); placement verified by playtest.main.gdwires per-instance colours fromElementPalette: 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 byenemies.aura_element[i](neutral when -1), gems bygem_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.
D. Typography & UI chrome
Section titled “D. Typography & UI chrome”- 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+PanelContainerStyleBoxFlat:- fill
Color(0,0,0,0.55)with a faint tint overlay, - thin neon border (
border_width ≈ 1,border_colorcyanColor(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.
- fill
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 (theButtonstylebox); 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.tresat 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
Line2Dcircle) 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
pickupevents; 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
CanvasLayerabove 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 polish —
arena_background.gdgains a static radial vignette overlay (darken edges) and brighter/glowing grid (additiveCanvasItemMaterialon the grid draw, or a second faint offset pass). Keeps the dark#0a0a0fbase.
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.
Testing
Section titled “Testing”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.gd—ElementPalette.color_forreturns the rightColorfor a known element idx (parse#ff6a4d), neutral for -1 / out-of-range / missing-color, neverpush_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_countmatches pool count aftersync(); resync shrinks/grows correctly.tests/test_fx_events.gd— after a tick that kills an enemy,sim.fx_eventscontains adeathevent with that enemy’s position+element; a reaction tick yields areactionevent with the reaction element; a gem collect yields apickupevent. (Use the non-erroring seams; consume any expectedpush_error.)tests/test_fx_manager.gd— adeathevent 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.gd—neon_theme.tresloads; fonts load; HUD applies the theme (smoke).
Hard gates (must stay green/identical):
tests/test_determinism.gd+tests/test_determinism_checksum.gd→ trace1314757315/ checksum1949813464unchanged (proves thefx_eventschange is determinism-neutral).scripts/check-test-count.sh→ GUT runs everytests/test_*.gd(no stale-class-cache silent drop).godot --headless --path . --quit-after <frames>boot smoke → noSCRIPT ERROR.
Phasing (one spec, independently-landable tasks)
Section titled “Phasing (one spec, independently-landable tasks)”ElementPalette+GlowTexture(pure helpers + unit tests).- Swarm halo layer in
swarm_renderer.gd; wire element/aura/gem colours inmain.gd(glow visible). - Extend the sim FX-event channel (
fx_events+ reaction/death/pickup population); determinism gate. FxManager+ reaction rings + death pops + pickup sparkles (consumefx_events).- Camera/overlay feedback (shake, damage vignette, low-HP border) from player state.
- Fonts +
neon_theme.tres+ restyle HUD/level-up/results. - Player ship redraw + background vignette/grid glow.
- (Optional) desktop
WorldEnvironmentbloom.
Out of scope (YAGNI / follow-ups)
Section titled “Out of scope (YAGNI / follow-ups)”- 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.
Deploy
Section titled “Deploy”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.