engine/vfx

PURPOSE — Visual-effects runtime for everything that flashes, sparks, lingers, blooms, or shakes a sprite without affecting gameplay. Owns particle pools, smoke compositing, layered explosions, expanding rings, weapon post-FX, thruster fire, the under-ship player glow, the per-enemy orbit dot + affix halo, the composable boss-VFX layer kit, screen-space damage / XP / shield / HP popups, and the per-shot ship recoil + warmup anticipation. All effects are read-only with respect to combat — they consume world state and renderer context, never write to combat or entity fields.

OWNS

  • Particles pool — preallocated particle records ({ x, y, vx, vy, l, ml, sz, r, g, b, a, tp }), bounded by MAX_PARTICLES, with acquire/release recycling and steady-state zero-allocation; type tags drive drag (smoke / exhaust decelerate, others don’t) and downstream rendering branches.
  • Notifications / RewardFeed — small text feeds with their own entry arrays and lifetime tickers; RewardFeed self-suppresses when an external AIControl global is present and disabled.
  • DmgNumbers — world arc-physics damage numbers plus screen-space block-text player-damage popups; damage-tier color/size table, stagger-delay frame counter, gravity constant, and a hard cap on simultaneous numbers.
  • XpAccum, ShieldDmgAccum, HpDmgAccum — stacking float-text accumulators that refresh their lifetime each collect() and own a rise/age/timer state; XpAccum additionally drives an aqua particle emitter scaled to refresh count.
  • Juice — event → particle-count table plus a side-effect dispatcher that bursts particles at the ship and triggers micro-SFX; dilation/shake fields are retained as no-ops.
  • ShipRecoil — combined squash/stretch state for the ship sprite: per-archetype + per-weapon-id recoil profile registry, recoil tween timer + targets, per-frame anticipation accumulators reset before weapon updates, elastic ease-out, and final combined sx/sy exposed to the renderer.
  • setThrusterRarityColor, _rarityRGB — rarity → flame-tint mapping consumed by the cold-flame branch of the thruster draw.
  • Thruster fire sparks — a fixed-size ring buffer of FireSpark records plus a fractional spawn accumulator; sparks drift, fade, and respawn from oldest slot when full.
  • PostFx — cosmetic post-impact / post-fire FX entity array bounded by MAX_FX, with per-type draw branches for streak, scar, ghost_arc, exhaust, flutter, muzzle_triangle, and impact_ring (where x2/y2 are repurposed as start/end radius for rings).
  • ExplosionFX — multi-layer stamp explosion entity array bounded by MAX_EXPLOSIONS; layered timing specs per preset (DEATH_LAYERS / IMPACT_LAYERS / BIG_LAYERS); pre-baked stamp canvases (core, rays, ring, debris); shared tint scratch canvas reused per draw; plus a separate Halo-ring entity array bounded by MAX_HALO_RINGS.
  • AoeExplosion — entity array bounded by MAX_AOE plus the four-layer fireball palette (outermost slot recolored to weapon identity, inner three locked), sonar / boundary / fireball timing constants, and the eased per-layer expand-hold-shrink schedule.
  • SmokeFX — dual-layer smoke particle array bounded by mobile-aware MAX; pre-baked smoke / flash / glow stamp canvases; offscreen bottom + top compositing canvases that auto-resize to the main canvas; tint gradients per layer; flash-particle subtype rendered direct-to-main on the top pass.
  • SonarRings — ring entity array bounded by MAX_RINGS; supports full-circle pulses, world-space shockwaves, and partial-arc shockwaves (shield-shatter) via optional arcStart/arcEnd.
  • PlayerGlow — current flash RGB + decay timer; pre-baked default-blue stamp for the idle path and pre-baked white stamp for the flash multiply-tint path.
  • Enemy-orbit dot state — per-enemy orbit position keyed by enemy id (_states map), convex-hull outline cache keyed by shape key (_outlineCache), and a periodic cleanup counter that prunes states whose ids are no longer alive.
  • boss-layers layer registry — module-private layers array bounded by MAX_LAYERS with a soft warning at SOFT_LAYER_WARN; per-layer alpha/radius/color tweens; per-kind draw functions; per-pass ms accumulators (_passMs) for the gauntlet runner; dust-mote particle field lazy-seeded onto each dustMotes layer.
  • Color-parsing helpers (_hexToRgb / parseHex / hexToRgb) duplicated per-module — each VFX file owns its own; boss-layers.parseHex crashes on malformed input by design.

READS FROM

  • engine/coreworld (particle / dmgNumbers arrays), ship (anchor for Juice bursts and XP particle emission), camera (zoom + frustum), game.time (per-frame time for flicker / pulse / orbit phase), W / H (screen dimensions for cull tests and screen-space overlays), CFG.MAX_PARTICLES.
  • engine/core/configPERF_FLAGS (mobile / low-perf gating for ambient boss layers and smoke caps).
  • engine/core/device-capabilitiesisMobile() for the smoke-particle cap.
  • engine/core/utilsswapRemove for O(1) particle / damage-number eviction.
  • engine/core/typesEnemyEntity, BossArena (host anchoring + arena geometry for boss layers).
  • engine/rendering/cameraCamera.toS, Camera.toSx, Camera.zoomPulse (screen-space projection and camera-zoom pulse hand-off).
  • engine/rendering/shapesShapes.get plus ShapeDef.polys (vertex source for the enemy-orbit convex-hull cache).
  • engine/audio/micro-sfxMicroSfx.play (every Juice event triggers a sub-audible cue).
  • engine/affixes/palettegetAffixVfxColor (affix-halo ring color on the enemy orbit pass).
  • data/weapons/_typesRecoilProfile type for per-weapon recoil overrides.
  • Global AIControl — read by RewardFeed.add to suppress feed entries during AI runs.

PUSHES TO

  • engine/rendering/cameraCamera.zoomPulse for boss cameraZoomPulse hand-off (the only outward call).
  • engine/audio/micro-sfxMicroSfx.play(event) once per Juice.fire.
  • Canvas 2D CanvasRenderingContext2D passed in by the renderer — every VFX module writes pixels via ctx.save / draw / ctx.restore and never escapes that scope.

DOES NOT

  • Detect collisions, deal damage, knock back, freeze, or otherwise mutate entities. AoE rings and explosion stamps are decorative — the actual damage pass lives in engine/combat.
  • Spawn or despawn bullets, enemies, pickups, or any gameplay entity.
  • Read or write input, manage the run state machine, or drive any audio besides micro-SFX cues fired from Juice.
  • Schedule weapon fire, pick weapon targets, or compute weapon damage / fire-rate / spread. Anticipation receives a per-weapon warmup progress and pulseStrength from the weapon layer; the recoil profile lookup is data-only.
  • Apply or read time dilation — boss-layer decay explicitly uses wall-dt so cinematics keep playing through pause / slow-mo, and every other VFX runs on whatever dt is passed in (the bridge is responsible for choosing dilated vs raw).
  • Own camera shake or time dilation systems (those have been removed; Juice.update is intentionally a no-op).
  • Render HUD bars, weapon UI, score, the upgrade-pick screen, the chest screen, or anything outside the world / overlay draw pass.
  • Decide draw order. Each module exports its update and draw (or drawBottom / drawTop, or the under / additive pair for boss layers); the bridge sequences them around the sprite layer.
  • Pool damage numbers, sonar rings, post-FX, explosions, or AoE explosions — those use plain arrays bounded by per-module caps with early-return on overflow. Only Particles uses an acquire/release object pool.
  • Persist any state across runs. reset / clear helpers exist for run boundaries and the bridge calls them.

Signals fired / Signals watched — none. Juice.fire(event) is a direct string-keyed dispatch from gameplay, not a signal bus subscription; all other VFX entry points are direct function calls from the bridge or from weapons / combat. Boss VFX layers expose mutation through returned LayerHandle objects rather than topic subscriptions.

Entry points

  • Particles.update / .add / .burst / .burstHex / .muzzleFlash / .warpArrivalPoof / .starBurst / .impactBurst — per-frame tick plus all spawn paths; add is the steady-state pool-backed primitive everything else routes through.
  • Notifications.add / .update and RewardFeed.add / .update — top-left and bottom-left text feeds.
  • DmgNumbers.update / .add / .addLabel / .addText — world arc-physics number, world arbitrary label, and screen-space block-text player damage.
  • XpAccum.collect / .update, ShieldDmgAccum.collect / .update, HpDmgAccum.collect / .update — stacking popup accumulators above the ship.
  • Juice.fire / .update — event-keyed particle burst + micro-SFX dispatcher; update is a no-op kept for the bridge.
  • ShipRecoil.resetAnticipation / .anticipate / .fire / .update — per-frame anticipation reset before weapons tick, per-weapon anticipation contribution, per-fire recoil kick, and the final tween update producing combined sx/sy for the renderer.
  • setThrusterRarityColor — once-per-run flame-tint setter.
  • updateThrusterSmoke — per-frame fire-spark tick (the name is historical).
  • drawThruster — main draw + spark spawn for the player ship’s engine jet; takes ship pose, heat, thrust state, time, dt, velocity, and shield radius.
  • resetThruster — clears live sparks for a new run.
  • PostFx.spawn / .update / .draw / .clear / .count — cosmetic streak / scar / ghost-arc / exhaust / flutter / muzzle-triangle / impact-ring.
  • ExplosionFX.death / .impact / .big / .haloRing / .update / .draw / .clear / .count — stamp-based layered explosions with three intensity presets plus the standalone halo damage ring.
  • AoeExplosion.spawn / .update / .draw / .clear / .count — vector-shape sonar-pulse + boundary-ring + four-layer fireball for AoE weapons.
  • SmokeFX.smoke / .smokeCloud / .explosion / .update / .drawBottom / .drawTop / .draw — dual-layer composited smoke plus flash particles; the legacy draw calls both passes in sequence.
  • SonarRings.spawn / .shockwave / .shockwaveArc / .update / .draw / .clear / .count — full-circle weapon pulses, world-space shockwaves, and shield-shatter arc fragments.
  • PlayerGlow.flash / .update / .draw / .reset — under-ship glow with a fast-path idle blue stamp and slow-path color-multiply during the flash decay.
  • updateEnemyOrbits / drawEnemyOrbits / resetEnemyOrbits — per-enemy orbit dot, trail, glow, and affix halo plus the convex-hull / cumulative-edge-length cache that backs them.
  • createVfxLayerKit — returns the VfxLayerKit factory bound to a single shared layer registry; boss data files capture the kit and emit persistent layers and one-shot bursts through it.
  • tickBossVfxLayers — per-frame wall-dt advance for every layer plus tween resolution and lifetime eviction.
  • renderBossVfxLayersUnder / renderBossVfxLayersAdditive — two-pass draw around the boss sprite (alpha-blended under-pass + lighter additive pass plus screen-space overlays).
  • getBossVfxPassMs / accumulateAbilityTickMs — per-pass timing readouts for the gauntlet runner.
  • isLowPerfMode — read by boss data files to skip ambient layers.
  • clearBossVfxLayers / bossVfxLayerCount — teardown helper and debug count.

Pattern notes

  • Each module is a self-contained world-renderable: it owns its update tick, its bounded entity store, its draw call, a clear/reset for run boundaries, and (for debug overlays) a count getter. The bridge sequences them; modules don’t know about each other.
  • The barrel file re-exports only the foundational trio (particles, smoke, juice). All other systems are imported directly from their files rather than through the index.
  • Every module duplicates a small hexToRgb helper instead of sharing one — explicit copy-paste with the same shape across particles, post-fx, explosion-fx, sonar-rings, player-glow, and boss-layers. boss-layers.parseHex is the only variant that crashes on malformed input (palette colors come from data tables, not user input).
  • Pre-baked offscreen-canvas stamps are the dominant performance pattern: smoke / flash / glow / explosion-core / rays / ring / debris / default-blue glow / white glow are all rendered once into cached HTMLCanvasElements and composited via drawImage. This was the response to mobile createRadialGradient cost in the per-particle path.
  • Particles use a true acquire/release pool. Sonar rings, post-FX, explosions, AoE explosions, halo rings, and damage numbers use plain arrays with early-return on cap overflow — no pooling.
  • Mobile gating is per-module: SmokeFX.MAX is split via isMobile(), ambient boss layers (dust motes, floor stripes, refraction sweep, per-swarm-member core pulses) gate on PERF_FLAGS.lowPerf, and ExplosionFX imports PERF_FLAGS but only as a referenced symbol.
  • Screen-space versus world-space is decided per-draw-call: DmgNumbers.addText flags entries with screen: true, boss screen-tint / edge-vignette / fullscreen-slice layers fill in raw screen coords during the additive pass, and everything else projects through Camera.toS.
  • Removed-system tombstones are preserved as no-ops or pass-through fields (Juice.update is empty; ShipRecoil.chargeGlow / _fireFlash are retained for API compat; WeaponManager lightning fields were removed but the field names live on; updateThrusterSmoke keeps its historical name despite no longer running smoke). These are intentional — call sites import the names.
  • The boss VFX layer kit is a separate sub-architecture: a single factory (createVfxLayerKit) returns a kit object whose methods either push a persistent layer (which returns a LayerHandle for later mutation / kill) or fire a one-shot burst that delegates to SonarRings / Particles. The layer registry is module-private; all instances of the kit share the same backing array.
  • LayerHandle.setRotateHz / setPulseHz snap-set the rate even when callers pass an easing / duration — the renderers compute rotation as age * rotateHz, so a tween would phase-jump the visual. The duration parameter is preserved as a forward-compat hook.
  • boss-layers.pushLayer crashes on cap overflow rather than dropping silently — caller-bug semantics. A soft console.warn fires once when the live count crosses SOFT_LAYER_WARN so density spikes are visible before they hit the hard cap.
  • Boss-layer decay runs on wall-dt while gameplay updates run on dilated dt; this is an explicit invariant so phase-transition cinematics keep playing through pause / slow-mo.
  • boss-layers uses setTimeout for staggered shockwave / spark stacks (emitTelegraphBloom, emitShockwaveStack, emitBeamCharge); the schedule is wall-clock, not game-clock, so a paused game still ticks those callbacks.
  • Damage-number stagger uses a per-entry _delay frame counter rather than a per-spawn timestamp — AoE bursts decrement the counter during update and only start ticking lifetime / physics once it reaches zero.
  • SonarRings exposes three spawn variants instead of taking an options object; spawn is the cheap weapon-fire pulse (fixed alpha and start-radius math), shockwave is the dramatic full-circle, and shockwaveArc adds partial-arc fields for shield shatter.
  • ExplosionFX keeps two separate sub-systems in one module: the stamp-based explosion entities (death / impact / big presets) and an independent halo-ring array drawn after the stamp pass. Both share clear and count but otherwise don’t interact.
  • AoeExplosion is intentionally a fourth explosion variant rather than a preset on ExplosionFX: it draws three vector layers (sonar pulse, boundary ring, four-layer fireball) instead of stamp blits, and its first fireball layer recolors to weapon identity while the inner three are locked to the orange / yellow / white-hot palette.
  • SmokeFX composites through two offscreen canvases that resize on demand to match the main canvas; tinting happens via a source-atop fill of a linear gradient applied to the merged additive layer, and the merged result is then drawn back to the main canvas at a per-pass opacity.
  • PlayerGlow has a deliberately bifurcated draw: 99% of frames take the idle path (one drawImage of a pre-baked blue stamp, no composite mode change), and only the ~0.3 s flash window takes the slow path (multiply composite + arc fill to color-multiply a white stamp).
  • The enemy-orbit dot caches a convex hull per shape key and stores cumulative edge lengths so any orbit fraction maps to an outline sample in O(log n). The cache never invalidates within a run.
  • Notifications and RewardFeed are tiny text feeds that share almost the same shape but are kept as distinct objects; RewardFeed adds an AIControl-gated early return that Notifications doesn’t.
  • The XpAccum particle emission reaches outside its module by reading globalThis.ship directly — a deliberate shortcut so the popup can anchor above the ship without taking it as a parameter.