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.mdon 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. SeeCLAUDE.md§ “Subsystem architecture — read on demand”.
Neon visual overhaul (M2 cycle 6, DONE)
Section titled “Neon visual overhaul (M2 cycle 6, DONE)”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 frombible.jsonelements; falls back toNEUTRAL(magenta) for -1 or missing. Source of truth for all entity tinting.render/glow_texture.gd(GlowTexture): generates a shared 64×64 soft-radialImageTextureonce at load; used by halo layers and FxManager.render/swarm_renderer.gd— gained a halo layer: secondMultiMeshInstance2Dchild,HALO_SCALE=3.0,CanvasItemMaterialwithBLEND_MODE_ADD, syncs same transforms + element-tinted colours each frame. Core layer configure withColor.WHITE(not the entity colour); per-instance colour drives the look.sim/sim.gd—fx_bursts: Array[Vector2]replaced byfx_events: Array[Dictionary]. Event kinds:reaction(pos+element),death(pos+element),pickup(pos). Cleared at top of each tick; NOT included insnapshot_string()/state_checksum()(determinism-neutral).fx/fx_manager.gd(FxManager): pooledPOOL_SIZE=256additiveSprite2Deffects consumingsim.fx_eventseach frame.DEATH_CAP=8per 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 inmain.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 viaNeonTheme.get_theme().fonts/: Orbitron + JetBrains Mono variable.ttffiles (OFL-licensed), committed with.importsidecars andOFL-*.txtlicence files.- HUD/LevelUpPanel/ResultsPanel — all restyled with NeonTheme; “LEVEL UP” / “RUN OVER” titles in Orbitron.
render/arena_background.gd— now usesBLEND_MODE_ADDfor 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_eventkind with noFxManagercase, is silently invisible — no error, the sim still ticks and deals damage. Before this cycle, orbit shards (no projectile + no fx), the beam (emitted abeamfx_event thatFxManagerhad no case for → dropped), and shooterenemy_proj(pool existed, no renderer) all rendered nothing while affecting gameplay. Rule: every entity pool needs a renderer inmain.gd; everyfx_eventskind needs a matchingmatcharm inFxManager.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 acolorhex inseed.js(re-export after editing).main.gd._build_enemy_type_colors()builds a render-side type→Color LUT keyed byEnemyPool.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 optionalscales: PackedFloat32Array; when present it buildsTransform2D(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 oldsyncignored 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 byprojectiles.element_idx(pulse=lightning-yellow); elementless shots (turret, el −1) usePROJ_NEUTRALnear-white. Shooter bolts render via the newenemy_proj_rendererin threat-red (ENEMY_PROJ_COLOR). - Orbit shard render:
WeaponOrbit.positions: Array[Vector2]is filled eachupdate()— render-read only, excluded from snapshot/checksum (recomputed every tick, so determinism-neutral).main.gddraws 3 additive cold glowSprite2Dat those positions. - Beam render:
FxManagergained a_BeamNodepool (BEAM_POOL_SIZE=8) + abeamcase drawing a fading additive line (soft outer + white core).weapon_beam.gd’s fx event now carrieselementfor colour.beam_active_count()accessor added for tests. - WeaponPanel shows weapon glyphs, not cooldown arcs:
_CooldownArcreplaced by_WeaponIcon(procedural_drawperkind: 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 staticconstarrays indexed by slot. - The public site is a matching legend.
~/Claude/bullet-heaven-site/index.htmlArsenal+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”).maingenerates 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…) + D1bullet-heaven-telemetry(id6e33b252-…).POST /sampleingests perf;GET /dashboard +/stats.jsonaggregate by build (avg/min fps, % <40fps, slowest sessions by device/UA). Deploy with$CF_LUMARA_DEPLOY_TOKEN+$CF_ACCOUNT_ID(wrangler fromtelemetry/; schema intelemetry/schema.sql,CREATE … IF NOT EXISTSso re-applying is idempotent). URL:bullet-heaven-telemetry.chris-allen-06f.workers.dev. - GAMEPLAY telemetry (build 46) — the balance data, from tvOS too.
POST /gameplayingests a per-run summary into the D1runstable; 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_enemycaps thedmg_dealt_totalcredit 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 countersdeath_cause/peak_enemies/damage_takenare 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-timeALTER(Compatibility:CREATE … IF NOT EXISTSonly covers fresh DBs;ALTER TABLE … ADD COLUMNis 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 ifSTATS_KEYunset/mismatch) with a constant-time compare + strict CSP. Key in~/.secretsBULLET_HEAVEN_TELEMETRY_KEY. - Per-build tracking: each
deploy-demo.shbumpsSim_Const.BUILDso 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 inQualityManager._apply(). - Driven by
mainduring 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_runqueue_free alongsidegameplay_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 coretick(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’sproject.godotisrendering_method.mobile="gl_compatibility"; the tvOS repo’sproject.godotoverrides torendering_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, addRenderingServer.get_video_adapter_name()/driver to the telemetry payload (Godot’s boot log goes to Apple unified logging, NOT stdout, sodevicectl --consoleshows nothing). - Telemetry feedback loop: the perf sample’s
qualityfield 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’sSOFT_ENEMY_CAP340→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.consumenow SPLITS event kinds — essential weapon feedback (bolt/beam/slash/nova) ALWAYS spawns; disposable juice (death/reaction/chain/pickup/dmgnum/ghost_*/accum_warn) is gated byenabled(+ per-frame caps). The old single top-levelif not enabled: returnkilled your pulse/beam/blade/nova at L5; nowenabled=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_SCALE3.0→2.0 inSwarmRendererANDArchetypeRenderer— 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 nowlerp(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 thegl_compatibilityrenderer (no HDR buffer needed). - Hit-flash (Item 6):
FxManagerconsumesdmgnumevents (previously ignored by it — onlyDamageNumbersread them) with a brief white-hot impact spark (gold-white + larger for “big” hits),HITFLASH_CAP/frame, gated as juice. Both consumers read the samesim.fx_eventsarray independently. - Player visual evolution (Item 7): NEW
render/player_renderer.gd(PlayerRenderer, Node2D) — extracted the inline player visual frommain.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_nodestays the transform root (position + facing rotation + camera child — the camera’signore_rotationdefault keeps the view upright);PlayerRendereris a child that owns the evolving visual and rebuilds only on tier change. Accent orbs are quality-gated viaQualityManager.player_visual.low_detail = level >= 3(the hull always stays). Keys onplayer.level(read-only) → determinism-safe. - Viewport culling (Item 3):
SwarmRenderer.syncgained an optionalcull_rect— visible entities compact into slots0..j-1andvisible_instance_count = jlimits the draw (Godot built-in, no buffer realloc; colours stay indexed by ORIGINAL pool index).main._visible_world_rect()(camera-centred, paddedMARGIN=110) is passed for gems/projectiles/enemy_proj; enemies are NOT culled (they chase the player, mostly on-screen, and use the differentArchetypeRenderer). 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_onl<=2,low_terrain/player_low_detaill>=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_DWELL6→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_CAP140 already designs away the failing 250–340 band. Item 6 full palette overhaul (OKLCH hue re-spacing of every element/enemy colour inbible.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.gdMEASURE_FULL_QUALITY := true. Pins the QualityManager at L0 (every effect ON, no adaptive degrade) and shows a live on-screenFULL QUALITY (L0) fps NN lowest MMreadout (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 tofalseto restore adaptive quality once measured. The on-screenlowestuses 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
SubViewportat a FRACTION of the real display resolution (NebulaBg.RESOLUTION_SCALE = 0.5→ ~4x fewer shaded pixels), then show it via a bilinearly-upscaledTextureRect. 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 ongl_compatibility(web) since it’s just a smaller render target + a texture sample, no renderer-specific feature involved.render/nebula_bg.gdis 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_SCALE3.0→2.0 (cycle 22 above); reapplied 2026-07-04 toArenaBackground.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.