PURPOSE

Boss VFX layer kit — composable, additively-stacked visual layers for boss encounters. Provides persistent host/arena-anchored layers (auras, core pulses, under-shadows, orbital rings, rune circles, lattice/conduit lines, dust motes, floor glow stripes, hex tile shimmer, refraction sweeps), screen-space overlays (tint pulse, edge vignette, fullscreen slice), telegraph holds (cone/circle/line/bloom), and one-shot bursts (shockwaves, sparks, shards, beam charges, projectile trails, glass cracks, ground impacts) for boss data files to compose without re-implementing rendering primitives.

OWNS

  • Module-private layers: BossLayer[] array — every active persistent layer, transient telegraph, and screen-space overlay lives here until kill() or natural lifetime expiry.
  • MAX_LAYERS = 512 hard cap (crashes on overflow) and SOFT_LAYER_WARN = 256 soft threshold (one-shot console warn per session).
  • Per-pass ms accumulators _passMs for the boss-gauntlet telemetry sampler: bossVfxUnder, bossVfxAdditive, tickBossVfxLayers, tickAllAbilities.
  • Easing curve table (applyEase) supporting linear, easeOutCubic, easeOutQuart, easeOutBack, easeOutElastic, easeInExpo, easeInOutCubic, pulseSine.
  • Tween machinery for alpha, radius, and linear color interpolation on each layer.
  • Hex color parsing/format helpers (parseHex, rgba) that crash on malformed input — colors come from data tables, not user input.
  • The LayerHandle returned to callers for retuning alpha, color, radius, rotate-rate, pulse-rate, and killing layers (with optional fade-out window).
  • Lazy-seeded mote particle field on dustMotes layers (count derived from density, capped at 40).

READS FROM

  • engine/core — global camera, screen W, H.
  • engine/rendering/cameraCamera.toS, Camera.toSx, Camera.zoomPulse for world-to-screen projection and the genuine fire-and-forget camera zoom pulse.
  • engine/core/configPERF_FLAGS.lowPerf via isLowPerfMode().
  • engine/core/typesEnemyEntity and BossArena shapes for host/arena anchors.
  • engine/vfx/particlesParticles.add for spark/shard/beam-charge/projectile-trail emission.
  • engine/vfx/sonar-ringsSonarRings.shockwave and SonarRings.shockwaveArc for shockwave bands, shockwave stacks, telegraph blooms, and hit-glass-crack arcs.

PUSHES TO

  • The active Canvas 2D context via renderBossVfxLayersUnder (source-over pass for under-shadow / dust motes / floor glow stripes / refraction sweep / ground impact / telegraph circle) and renderBossVfxLayersAdditive (lighter pass for aura / core pulse / orbital ring / rune circle / lattice line / conduit line / hex tile shimmer / telegraph cone / telegraph line, followed by screen-space overlays using overlay, source-over, and lighter comp-ops per kind).
  • The Particles system (sparks, shards, beam-charge sparks, projectile trail dots).
  • The SonarRings system (single rings, staggered stacks, partial-arc cracks).
  • The Camera primitive (zoomPulse) for screen-shake-adjacent zoom kicks.

DOES NOT

  • Does not advance gameplay or AI — visual decay uses wall-dt (rawDt), cooldown/AI logic stays on game-dt elsewhere.
  • Does not own boss data, palette tables, or per-boss setupVfx orchestration — callers compose layers from their own data files and read isLowPerfMode() to decide which ambient layers to skip.
  • Does not draw the boss sprite itself — renderBossVfxLayersUnder and renderBossVfxLayersAdditive are wrapped around the sprite render by the caller.
  • Does not handle audio, damage, hit detection, telegraph→fire resolution, or any gameplay consequence — purely visual.
  • Does not silently truncate when the layer cap is hit — pushLayer throws to surface caller leaks loudly.
  • Does not provide smooth tween ramps for setRotateHz / setPulseHz — these snap-set the rate; easing/duration params are forward-compat hooks only.
  • Does not persist across encounters — clearBossVfxLayers is the teardown helper.
  • Does not auto-clean dead-host layers — latticeLine and conduitLine skip rendering when either host’s alive flag flips false but stay in the layer array until killed.

Signals

  • Throws boss-layers: layer cap exceeded (512) — caller leaked persistent layers when pushLayer would exceed the hard cap.
  • Throws boss-layers: malformed color '<hex>', expected '#RRGGBB' when parseHex rejects a color string.
  • One-shot console.warn: boss-layers: live layer count crossed 256 (cap 512) — investigate density / leaks the first time the layer count crosses the soft threshold in a session.

Entry points

  • isLowPerfMode(): boolean — read by per-boss setupVfx to skip ambient layers (dust motes, floor glow stripes, refraction sweep) and per-swarm-member core pulses on low-perf devices.
  • tickBossVfxLayers(rawDt: number): void — advances every layer’s age, resolves in-flight tweens, expires transient layers, and splices dead entries. Wall-dt only.
  • renderBossVfxLayersUnder(ctx: CanvasRenderingContext2D): void — source-over pass painted under the boss sprite.
  • renderBossVfxLayersAdditive(ctx: CanvasRenderingContext2D): void — lighter pass painted over the boss sprite, plus screen-space overlay tail.
  • createVfxLayerKit(): VfxLayerKit — returns the public factory bag of methods every boss data file uses to compose layers (aura, corePulse, underShadow, orbitalRing, runeCircle, latticeLine, conduitLine, dustMotes, floorGlowStripes, hexTileShimmer, refractionSweep, telegraphBloom, telegraphCone, telegraphCircle, telegraphLine, shockwaveBand, shockwaveStack, additiveSparkBurst, spiralSparkBurst, shardsBurst, beamCharge, projectileTrail, hitGlassCrack, groundImpactCircle, screenTintPulse, edgeVignette, fullscreenSlice, cameraZoomPulse).
  • clearBossVfxLayers(): void — encounter-teardown helper that empties the layer array.
  • bossVfxLayerCount(): number — live layer count for debug overlays and perf checks.
  • getBossVfxPassMs() — latest per-pass ms breakdown for the gauntlet runner’s telemetry sampler.
  • accumulateAbilityTickMs(deltaMs: number): void — bridge hook for the tick loop to feed tickAllAbilities ms into the per-pass breakdown.
  • Exported types: BossPalette, Easing, LayerHandle, VfxLayerKit.

Pattern notes

  • Two-pass render: renderBossVfxLayersUnder (source-over) for floor effects, renderBossVfxLayersAdditive (lighter) for glow stacks, with screen-space overlays appended at the tail of the additive pass using per-kind comp-ops (overlay for screen tint, source-over for edge vignette, lighter for fullscreen slice).
  • Wall-dt decay is invariant — every visual ages off rawDt so death cinematics still play through pause/slow-mo while cooldown/AI logic continues on game-dt elsewhere.
  • Persistent layers attach to an EnemyEntity host, a second hostB (lattice/conduit endpoints), a BossArena, or a static world position; render-time anchorX/anchorY falls through host → arena center → x/y → 0.
  • Transient layers (telegraphs, screen overlays, ground impacts) set lifetime at creation and auto-expire in tickBossVfxLayers; kill(fadeOutDuration) retro-fits a lifetime by tweening alpha to 0 over the window.
  • LayerHandle is the only contract callers hold onto a layer — screen-space layers (screenTintPulse, edgeVignette, fullscreenSlice) return handles specifically so phase-transition code can .kill() long-duration overlays (e.g. edgeVignette at duration=9999s) early and avoid stack leaks; cameraZoomPulse is the only fire-and-forget call (routes through Camera.zoomPulse).
  • setRotateHz and setPulseHz snap-set the underlying rate channels and mirror across paired fields (rotateHz/sweepHz, pulseHz/freqHz) so the call is uniform regardless of layer kind; easing/duration params are deliberately unused — implementing a smooth ramp would phase-jump rotation since the renderers compute age * rate directly.
  • dustMotes lazily seeds its particle field on first draw (count = min(40, max(4, density * 60))), uses a fixed 0.016s sub-step (frame-time spikes lose a tiny amount of motion), and wraps motes that drift past the arena radius with a –0.5x velocity reflection.
  • refractionSweep paints four bands (primary/secondary/warning/cool) across the arena along a rotating sweep axis, clipped to the arena footprint.
  • floorGlowStripes clips to the arena footprint and scrolls perpendicular to a fixed axis at scrollHz cycles per stripe period.
  • Telegraph renderers (drawTelegraphCone, drawTelegraphCircle, drawTelegraphLine) all share the same alpha curve: easeInExpo ramp-in for the full lifetime, plus a 4Hz alpha pulse multiplier during the final 30% as a fire-soon strobe.
  • fullscreenSlice fades in over the first 10% of lifetime, holds, then 8Hz alpha-pulses during the final 30%.
  • corePulse falls back to a default radius of 8 world units when the caller passes 0/undefined; per-boss data files override (Digbot Swarm uses radius=4 to keep dots tight on small swarm bodies).
  • Color tweens interpolate linearly on parsed RGB channels and snap to the target color when t >= 1.
  • Tween records mutate layer state inside resolveTween only when the tween completes — in-flight resolution is a pure read.
  • MAX_LAYERS = 512 was raised from 256 after Digbot Swarm’s worst case (~25 sampled core pulses + 4 Foreman markers + 2 arena layers + transient bursts during a phase-transition cinematic) broke the original cap; SOFT_LAYER_WARN = 256 preserves the old cap as a leak/density warning.
  • _perfNow uses performance.now() when available, falls back to Date.now()performance.mark() was rejected because it would build an unbounded entry buffer the gauntlet runner would have to clear every sample.
  • latticeLine’s additive parameter is preserved in the API but ignored — lattice lines always render in the additive pass per spec; the flag is forward-compat only.
  • parseHex and pushLayer crash loudly on bad data — no silent fallbacks, in line with the codebase rule that internal config errors must surface immediately.