Skip to content

Bullet Heaven — rendering, visuals, telemetry & adaptive quality

Bullet Heaven — rendering, visuals, telemetry & adaptive quality

Section titled “Bullet Heaven — rendering, visuals, telemetry & adaptive quality”

Extracted from CLAUDE.md on 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. See CLAUDE.md § “Subsystem architecture — read on demand”.

Render/UI-only pass giving the game a chess-defense-td neon aesthetic. All changes are determinism-neutral (no sim logic modified except extending the per-tick FX-event list).

  • render/element_palette.gd (ElementPalette): render-side static helper, color_for(content, element_idx) reads hex from bible.json elements; falls back to NEUTRAL (magenta) for -1 or missing. Source of truth for all entity tinting.
  • render/glow_texture.gd (GlowTexture): generates a shared 64×64 soft-radial ImageTexture once at load; used by halo layers and FxManager.
  • render/swarm_renderer.gd — gained a halo layer: second MultiMeshInstance2D child, HALO_SCALE=3.0, CanvasItemMaterial with BLEND_MODE_ADD, syncs same transforms + element-tinted colours each frame. Core layer configure with Color.WHITE (not the entity colour); per-instance colour drives the look.
  • sim/sim.gdfx_bursts: Array[Vector2] replaced by fx_events: Array[Dictionary]. Event kinds: reaction (pos+element), death (pos+element), pickup (pos). Cleared at top of each tick; NOT included in snapshot_string()/state_checksum() (determinism-neutral).
  • fx/fx_manager.gd (FxManager): pooled POOL_SIZE=256 additive Sprite2D effects consuming sim.fx_events each frame. DEATH_CAP=8 per frame (surplus deaths skip the pop visually but still count in sim).
  • fx/screen_feedback.gd (ScreenFeedback): damage vignette shader + low-HP border + decaying camera shake. Driven by player HP changes in main.gd.
  • ui/theme/neon_theme.gd (NeonTheme): code-built Theme (not a .tres). Orbitron as default font, JetBrains Mono for HUD numbers, dark translucent fills, cyan border, corner_radius=12. Apply via NeonTheme.get_theme().
  • fonts/: Orbitron + JetBrains Mono variable .ttf files (OFL-licensed), committed with .import sidecars and OFL-*.txt licence files.
  • HUD/LevelUpPanel/ResultsPanel — all restyled with NeonTheme; “LEVEL UP” / “RUN OVER” titles in Orbitron.
  • render/arena_background.gd — now uses BLEND_MODE_ADD for additive grid glow.

Visual distinction pass (M2 cycle 10, DONE)

Section titled “Visual distinction pass (M2 cycle 10, DONE)”

Render/UI-only pass so every enemy type and weapon is identifiable on the field. Determinism-neutral (baseline checksums unchanged at the time: 1240757503/1997788103; superseded by cycle 11).

  • The “invisible entity” trap (the headline lesson): a sim entity pool with no renderer, OR an fx_event kind with no FxManager case, is silently invisible — no error, the sim still ticks and deals damage. Before this cycle, orbit shards (no projectile + no fx), the beam (emitted a beam fx_event that FxManager had no case for → dropped), and shooter enemy_proj (pool existed, no renderer) all rendered nothing while affecting gameplay. Rule: every entity pool needs a renderer in main.gd; every fx_events kind needs a matching match arm in FxManager.consume. Audit both when adding a pool or an fx kind.
  • Enemy per-type colour + size, both data-driven from bible.json: each enemy has a color hex in seed.js (re-export after editing). main.gd._build_enemy_type_colors() builds a render-side type→Color LUT keyed by EnemyPool.TYPE_*. _enemy_colors() shows the type colour when an enemy has no aura, and the element aura colour overrides it when hit (so reactions still read). Colours: swarmer #ff5a8c, tank #5b8cff, shooter #ff4d4d, splitter #1fe0b0 (teal — deliberately NOT green, which collides with the green XP gems), elite #c06aff.
  • Per-instance size: SwarmRenderer.sync(pool, colors, scales=null) gained an optional scales: PackedFloat32Array; when present it builds Transform2D(Vector2(s,0),Vector2(0,s),pos) so each instance draws at its true radius. main.gd._enemy_scales() = radius/ENEMY_MESH_RADIUS (base mesh radius 14). Tanks (r22) and elites (r26) are visibly bigger. Gems/projectiles pass no scales (uniform). The old sync ignored per-enemy radius entirely — all enemies drew at 14px regardless of collision size; the comment claiming “per-enemy sizing handled in sync()” was aspirational, now real.
  • Per-element projectile colour: _proj_colors() tints each projectile by projectiles.element_idx (pulse=lightning-yellow); elementless shots (turret, el −1) use PROJ_NEUTRAL near-white. Shooter bolts render via the new enemy_proj_renderer in threat-red (ENEMY_PROJ_COLOR).
  • Orbit shard render: WeaponOrbit.positions: Array[Vector2] is filled each update()render-read only, excluded from snapshot/checksum (recomputed every tick, so determinism-neutral). main.gd draws 3 additive cold glow Sprite2D at those positions.
  • Beam render: FxManager gained a _BeamNode pool (BEAM_POOL_SIZE=8) + a beam case drawing a fading additive line (soft outer + white core). weapon_beam.gd’s fx event now carries element for colour. beam_active_count() accessor added for tests.
  • WeaponPanel shows weapon glyphs, not cooldown arcs: _CooldownArc replaced by _WeaponIcon (procedural _draw per kind: bolt/rings/ring+shards/line/turret), drawn in the weapon’s element colour. Names + damage stat kept; the cooldown/frequency arc is gone (per design call). Kinds/names are static const arrays indexed by slot.
  • The public site is a matching legend. ~/Claude/bullet-heaven-site/index.html Arsenal+Enemies sections show the SAME glyphs (inline SVG) and glowing colour-squares (real colours + relative sizes). If you change an enemy colour, a weapon glyph, or an enemy size, update the site legend to match — it advertises itself as “exactly how it reads in-game.”

Performance telemetry (M2 cycle 15, DONE; tvOS-first expansion + adaptive-quality reporting)

Section titled “Performance telemetry (M2 cycle 15, DONE; tvOS-first expansion + adaptive-quality reporting)”

Auto-records perf from real devices (web + tvOS) so we tune from real sessions, no manual reports. Full ops in telemetry/README.md.

  • Game sender: net/telemetry.gd (Telemetry, render-side, NOT /sim) — now active on tvOS AND web (gate = not OS.has_feature("editor") and DisplayServer.get_name() != "headless"; was web-only — the ATV is the primary playtest platform, so perf MUST be captured there). Every 5s POSTs an anonymous sample (fps, frame/sim/render ms, draws, prims, enemy/zone/web counts, run_time, low_fx, quality = adaptive tier, dev = coarse device id, Sim_Const.BUILD, a session) as text/plain (CORS-simple, no preflight); errors ignored. net/telemetry_device.gd (TelemetryDevice.id()) gives the device id (“web” or e.g. “tvOS/AppleTV11,1”). main generates ONE per-run session id (_run_session) shared by BOTH senders so a slow run’s perf samples correlate with its gameplay summary.
  • Backend: CF Worker bullet-heaven-telemetry (telemetry/, account 06f…) + D1 bullet-heaven-telemetry (id 6e33b252-…). POST /sample ingests perf; GET / dashboard + /stats.json aggregate by build (avg/min fps, % <40fps, slowest sessions by device/UA). Deploy with $CF_LUMARA_DEPLOY_TOKEN + $CF_ACCOUNT_ID (wrangler from telemetry/; schema in telemetry/schema.sql, CREATE … IF NOT EXISTS so re-applying is idempotent). URL: bullet-heaven-telemetry.chris-allen-06f.workers.dev.
  • GAMEPLAY telemetry (build 46) — the balance data, from tvOS too. POST /gameplay ingests a per-run summary into the D1 runs table; the dashboard gained sections for runs by build/mode (avg run_time, level, kills, DPS, reactions, gold, weapons), deepest story region reached, and decoy type usage. Sender = net/gameplay_telemetry.gd (active on tvOS + web, persistent across runs). This is what lets us tune story balance (Hush/Warden HP, reaction damage) from real data instead of guessing — USE IT before blind-tuning.
  • DPS is EFFECTIVE damage, not overkill (fixed). Sim._damage_enemy caps the dmg_dealt_total credit at the enemy’s remaining HP (clampf(dealt,0,max(before,0))) — raw HP subtraction unchanged, so the overkill mechanic + determinism baseline are intact, but the dashboard DPS is now meaningful (was reading in the billions because every AoE/burst counted full magnitude on low/dead enemies). The old DPS numbers pre-fix are garbage.
  • Richer run summary + new dashboard sections (tvOS-first expansion): the gameplay summary now also sends death_cause (dominant contact enemy type / “ranged” / “boss” / hazard — latched at the fatal blow in _hurt_player(amount, source)), peak_enemies (per-run high-water load — a perf signal on EVERY run without the 5s sampler), damage_taken, weapons_list, build_picks (every upgrade taken), dev. The render-excluded sim counters death_cause/peak_enemies/damage_taken are NOT in the checksum; EnemyPool.type_name(id) maps a type id → short name for attribution. Dashboard added Perf by device, Deaths — what killed the player (by mode/cause), quality avg/max columns, peak/taken on the runs table. D1 columns added via one-time ALTER (Compatibility: CREATE … IF NOT EXISTS only covers fresh DBs; ALTER TABLE … ADD COLUMN is NOT idempotent — re-running errors, expected).
  • Security (caught by the commit security review, fixed): the dashboard renders the attacker-controlled user-agent → must HTML-esc() every interpolated value (stored-XSS). Viewer auth is fail-closed (401 if STATS_KEY unset/mismatch) with a constant-time compare + strict CSP. Key in ~/.secrets BULLET_HEAVEN_TELEMETRY_KEY.
  • Per-build tracking: each deploy-demo.sh bumps Sim_Const.BUILD so the dashboard compares perf across builds — this is WHY the build-number convention matters.

Adaptive graphics — QualityManager (DONE, builds 49-50)

Section titled “Adaptive graphics — QualityManager (DONE, builds 49-50)”

render/quality_manager.gd (render-side, NOT /sim) auto-scales render fidelity to hold a frame-rate floor so the Apple TV stays playable with no settings to touch. All render-side → determinism baseline untouched by construction (the determinism test builds Sim.new and never binds it).

  • 6 cumulative tiers (L0 full neon → L5 stripped). Cheapest-looking losses first: FX caps → additive halo layer (SwarmRenderer.set_halo_visible) → bloom (world_env.environment.glow_enabled) + arena grid (ArenaBackground.set_low) → damage numbers (DamageNumbers.enabled) + vignette/shake (ScreenFeedback.enabled) + outline-only zones (ZoneRenderer.low_detail) → all juice fx (FxManager.enabled/death_cap/reaction_cap). Each render system got a null-safe knob; the tier→knob policy lives only in QualityManager._apply().
  • Driven by main during active play (quality_manager.tick(1.0/dt, dt) after the game_over guard) — NOT a self-run _process (so the cheap menu/results screens don’t disturb the tier). Persists across runs (created in _ready, excluded from _new_run queue_free alongside gameplay_telemetry; bind(...) re-points it at each run’s fresh nodes + re-asserts the tier). F4 cycles a manual override (auto → fixed L0..L5 → auto); tvOS needs no input.
  • Upgrades are PROBE-based, not headroom-based — because Chris’s Living Room Apple TV (AppleTV11,1, A12) is a 50Hz panel. vsync pins fps at 50, so you can NEVER observe “fps > 57” to know there’s GPU room — the first attempt (build 49) got permanently stuck at the ugliest tier. The fix (build 50): while holding the floor (fps ≥ 49) it optimistically steps a tier UP every 6s; if that overshoots and frames drop, the downgrade path walks it back and arms a 20s backoff. Downgrade is fast (0.4s; double-step below 40fps). Refresh-agnostic. Decision core tick(fps,dt) is pure + unit-tested.
  • ⚠️ Renderer split (CORRECTED 2026-06-30 — the old “gl_compatibility on BOTH” note was WRONG): the web demo runs gl_compatibility (WebGL — no Vulkan/Metal in browsers), but the tvOS build deliberately runs the Mobile renderer on METAL. The MAIN repo’s project.godot is rendering_method.mobile="gl_compatibility"; the tvOS repo’s project.godot overrides to rendering_method.mobile="mobile" (a documented tvOS-only platform file — see the tvOS CLAUDE.md), so on the Apple TV the deployed app renders through Godot’s RenderingDevice Metal backend (the tvOS Godot is a 4.7-dev fork with the Metal driver + the unified “Apple Embedded” iOS/tvOS driver). Implications: (a) on tvOS, real HDR glow/bloom via WorldEnvironment IS available (it’s NOT gl_compat there) — more post-FX headroom than this note used to claim; (b) the game is 2D MultiMesh so it’s GPU-light — the ATV bottleneck is fill-rate/overdraw (additive halos + bloom) + the 50Hz vsync cap, NOT Metal API throughput, so “full use of Metal” isn’t the lever, overdrop reduction is; (c) any HDR pipeline still must stay faked on web (gl_compat). To confirm the live tvOS renderer string definitively, add RenderingServer.get_video_adapter_name()/driver to the telemetry payload (Godot’s boot log goes to Apple unified logging, NOT stdout, so devicectl --console shows nothing).
  • Telemetry feedback loop: the perf sample’s quality field shows what tier each device settles at; the ATV at 50Hz holds ~50fps but settles ~L1-L3 at normal load (since build 53 the ladder sheds the full-screen BLOOM first and KEEPS the cheap neon halos through L3 — see cycle 22; pre-build-53 it stripped halos at L2 and looked far flatter) and exhausts tiers only in extreme 300+ swarms (a case the cycle-21 survival rework’s SOFT_ENEMY_CAP 340→140 cut largely removes).
  • Next graphics/perf work is PLANNED, not built: docs/superpowers/plans/2026-06-25-graphics-perf-plan.md (viewport culling, platform-aware entity budget, player weapons-always-on — FxManager.enabled currently kills weapon bolts/beams at L5, ultra-neon palette/saturation, evolving player sprite). Gated on the other agent’s cycle-21 rework landing + a re-measure first.

Graphics & perf pass (M2 cycle 22, DONE) — build 52

Section titled “Graphics & perf pass (M2 cycle 22, DONE) — build 52”

Implemented the render-only, determinism-safe items of the graphics-perf plan (docs/superpowers/plans/2026-06-25-graphics-perf-plan.md). ALL render-side → survival determinism baseline 1432233777/2300319179 byte-identical by construction (the determinism test builds Sim.new directly and binds no render nodes). 5 chunks, each TDD’d + verified + shipped to the Apple TV @ BUILD 52.

  • Weapons always render (Item 1): FxManager.consume now SPLITS event kinds — essential weapon feedback (bolt/beam/slash/nova) ALWAYS spawns; disposable juice (death/reaction/chain/pickup/dmgnum/ghost_*/accum_warn) is gated by enabled (+ per-frame caps). The old single top-level if not enabled: return killed your pulse/beam/blade/nova at L5; now enabled=false (set by QualityManager at L5) only sheds juice. The lesson: a quality “off switch” must distinguish player-feedback fx from spectacle fx — gating them together makes the weapons disappear exactly when the player most needs to see them.
  • Halo overdraw + hot cores (Items 2a, 6): HALO_SCALE 3.0→2.0 in SwarmRenderer AND ArchetypeRenderer — the additive halo quad is 2D so fill cost scales with the SQUARE (9×→4× area, ~55% less halo overdraw) while keeping an obvious glow. Cores now lerp(WHITE, CORE_HEAT=0.62) (was 0.5) so the centre reads white-hot and the saturated-hue additive halo reads coloured — that contrast IS the bloom look on the gl_compatibility renderer (no HDR buffer needed).
  • Hit-flash (Item 6): FxManager consumes dmgnum events (previously ignored by it — only DamageNumbers read them) with a brief white-hot impact spark (gold-white + larger for “big” hits), HITFLASH_CAP/frame, gated as juice. Both consumers read the same sim.fx_events array independently.
  • Player visual evolution (Item 7): NEW render/player_renderer.gd (PlayerRenderer, Node2D) — extracted the inline player visual from main.gd. The craft accretes detail across 5 level-tier bands (tier_for(level) = clampi(level/5, 0, 4)): dart → finned interceptor → multi-hull → winged cruiser → ornate flagship, with growing glow + orbiting accent orbs + hotter core + thruster plume; crossing a tier pops an “ascension” flash. Architecture: player_node stays the transform root (position + facing rotation + camera child — the camera’s ignore_rotation default keeps the view upright); PlayerRenderer is a child that owns the evolving visual and rebuilds only on tier change. Accent orbs are quality-gated via QualityManager.player_visual.low_detail = level >= 3 (the hull always stays). Keys on player.level (read-only) → determinism-safe.
  • Viewport culling (Item 3): SwarmRenderer.sync gained an optional cull_rect — visible entities compact into slots 0..j-1 and visible_instance_count = j limits the draw (Godot built-in, no buffer realloc; colours stay indexed by ORIGINAL pool index). main._visible_world_rect() (camera-centred, padded MARGIN=110) is passed for gems/projectiles/enemy_proj; enemies are NOT culled (they chase the player, mostly on-screen, and use the different ArchetypeRenderer). Gems — which litter the whole ±2000 arena where enemies died — are the clear win.
  • Adaptive ladder re-order (build 53) — “graphics reduced more than they need to be” fix. The build-52 halo cut reduced the COST of halos but the QualityManager tier POLICY still killed them at L2 while keeping the EXPENSIVE full-screen bloom until L3. On the 50Hz/20ms ATV, bloom is the constant that doesn’t fit, so it dragged the device to L2-L3 and stripped the cheap, high-value neon halos as collateral — the “stripped” look. Re-ordered the ladder around real cost: bloom_on = l<=0 (the costly full-screen pass sheds FIRST), halos_on = l<=3 (cheap per-entity neon glow survives to L3, only goes at L4), with vignette/damage-numbers/grid/accent-orbs shedding in the middle (screen_fx_on/damage_numbers_on l<=2, low_terrain/player_low_detail l>=2/l>=3). So the ATV (settles ~L1-L3) now KEEPS its halos the whole way. The ladder is extracted into pure static predicates (QualityManager.bloom_on(l) etc.) — unit-tested + a monotonicity test (a feature can’t turn back ON at a worse tier). PROBE_DWELL 6→4s for faster recovery after a transient spike. Lesson: a quality ladder must be ordered by COST-PER-VISUAL-VALUE, not by a fixed “big GPU wins last” rule — when you make one effect cheaper (halos), re-check that the ladder still sheds the now-most-expensive thing (bloom) first, or the cheap effect gets stripped as collateral. Render-only; determinism unchanged.
  • DEFERRED: Item 4 (platform-aware entity budget) — sim-touching; gated on a fresh on-device ATV re-measure per the plan, and the cycle-21 SOFT_ENEMY_CAP 140 already designs away the failing 250–340 band. Item 6 full palette overhaul (OKLCH hue re-spacing of every element/enemy colour in bible.json) — subjective, wants Chris’s eye + coordination with content edits. Web demo R2 redeploy still deferred (the start menu changed the public demo’s first screen auto-attract→menu — needs Chris’s OK).
  • MEASUREMENT MODE (build 54-55, TEMPORARY) — main.gd MEASURE_FULL_QUALITY := true. Pins the QualityManager at L0 (every effect ON, no adaptive degrade) and shows a live on-screen FULL QUALITY (L0) fps NN lowest MM readout (smoothed EMA past a 2s warmup; min resets per run) so we can read the ATV’s true GPU ceiling at full fidelity before finalising the ladder. main.gd-only, not in any test. Flip back to false to restore adaptive quality once measured. The on-screen lowest uses the SMOOTHED fps (same 0.1 EMA the adaptive system sees) — a one-frame stall gives a meaningless raw min; the sustained floor is what matters.

⚠️ ADAPTIVE_QUALITY := false since 2026-06-28 — re-enable at the 0.1 release

Section titled “⚠️ ADAPTIVE_QUALITY := false since 2026-06-28 — re-enable at the 0.1 release”

main.gd’s const ADAPTIVE_QUALITY := false (distinct from MEASURE_FULL_QUALITY above) pins every run at L0 and skips QualityManager’s per-frame tick() entirely — a deliberate choice (commit 16ef059, 2026-06-28) so graphical changes could be evaluated without the ladder fighting the dev mid-test. Confirmed live via telemetry (2026-07-04): the Apple TV’s quality tier has been stuck at 0 across every build since, including sessions with 88-100% of frames under 40fps — the whole adaptive safety net this section describes has had zero effect on real play for over a week. Chris’s call: leave it off until the v0.1 release build, then flip it back on. Until then, treat every new full-screen/decorative effect as running at ALWAYS-FULL cost with no ladder to catch it — see the pattern below. When re-enabling: NebulaBg (next section) still has no _on(level) predicate or _apply() case, so it needs that wiring added at the same time or it’ll keep running at full cost even at L5.

Default pattern — cheap-by-construction backgrounds/atmospheric FX (established 2026-07-04)

Section titled “Default pattern — cheap-by-construction backgrounds/atmospheric FX (established 2026-07-04)”

Found while debugging a “cycling backgrounds tanks fps” report (render/nebula_bg.gd’s full-screen shader + ArenaBackground’s Nebula/Galaxy variants). Apply both of these to any NEW full-screen or large-overdraw background/atmospheric effect by default, not just when a perf complaint comes in:

  • A full-screen procedural shader is the worst case for the ATV’s fill-rate-bound A12 GPU (every pixel, every frame, regardless of scene content). Render it into an offscreen SubViewport at a FRACTION of the real display resolution (NebulaBg.RESOLUTION_SCALE = 0.5 → ~4x fewer shaded pixels), then show it via a bilinearly-upscaled TextureRect. Invisible for anything already soft/blurred (clouds, glow, distant stars); would visibly soften anything with hard edges. This is a plain 2D Godot technique — NOT Metal-specific. It works identically on gl_compatibility (web) since it’s just a smaller render target + a texture sample, no renderer-specific feature involved. render/nebula_bg.gd is the reference implementation (_viewport/_display/RESOLUTION_SCALE).
  • Additive-sprite overdraw cost scales with AREA (scale²), not count. When an effect needs fewer pixels shaded, shrink each sprite’s SCALE before cutting how many there are — halving scale cuts area (and cost) by ~4x while keeping the same visual density/count. Already the exact lesson behind HALO_SCALE 3.0→2.0 (cycle 22 above); reapplied 2026-07-04 to ArenaBackground.GALAXY_GLOW_SCALE (20.0→12.0) and the Nebula variant’s cloud sprite scale range (14-28→8-16).
  • The general (non-background-specific) perf technique catalog lives in docs/superpowers/plans/2026-06-25-graphics-perf-plan.md — weapon-fx-always-on, halo overdraw, viewport culling, platform-aware entity budget, palette/HDR-feel tricks, player evolution. Items 1/2a/3/6-free-tier/7 shipped at BUILD 52 (cycle 22 above); Item 4 (platform-aware entity budget) is the one still-open item, gated on a fresh on-device re-measure. That plan predates the SubViewport-downres technique above — it doesn’t mention resolution scaling at all, so don’t assume it’s already covered there.