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
Particlespool — preallocated particle records ({ x, y, vx, vy, l, ml, sz, r, g, b, a, tp }), bounded byMAX_PARTICLES, with acquire/release recycling and steady-state zero-allocation; type tags drive drag (smoke/exhaustdecelerate, others don’t) and downstream rendering branches.Notifications/RewardFeed— small text feeds with their own entry arrays and lifetime tickers;RewardFeedself-suppresses when an externalAIControlglobal 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 eachcollect()and own a rise/age/timer state;XpAccumadditionally 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 combinedsx/syexposed 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 byMAX_FX, with per-type draw branches for streak, scar, ghost_arc, exhaust, flutter, muzzle_triangle, and impact_ring (wherex2/y2are repurposed as start/end radius for rings).ExplosionFX— multi-layer stamp explosion entity array bounded byMAX_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 byMAX_HALO_RINGS.AoeExplosion— entity array bounded byMAX_AOEplus 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-awareMAX; 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 byMAX_RINGS; supports full-circle pulses, world-space shockwaves, and partial-arc shockwaves (shield-shatter) via optionalarcStart/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 (
_statesmap), convex-hull outline cache keyed by shape key (_outlineCache), and a periodic cleanup counter that prunes states whose ids are no longer alive. boss-layerslayer registry — module-privatelayersarray bounded byMAX_LAYERSwith a soft warning atSOFT_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 eachdustMoteslayer.- Color-parsing helpers (
_hexToRgb/parseHex/hexToRgb) duplicated per-module — each VFX file owns its own;boss-layers.parseHexcrashes on malformed input by design.
READS FROM
engine/core—world(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/config—PERF_FLAGS(mobile / low-perf gating for ambient boss layers and smoke caps).engine/core/device-capabilities—isMobile()for the smoke-particle cap.engine/core/utils—swapRemovefor O(1) particle / damage-number eviction.engine/core/types—EnemyEntity,BossArena(host anchoring + arena geometry for boss layers).engine/rendering/camera—Camera.toS,Camera.toSx,Camera.zoomPulse(screen-space projection and camera-zoom pulse hand-off).engine/rendering/shapes—Shapes.getplusShapeDef.polys(vertex source for the enemy-orbit convex-hull cache).engine/audio/micro-sfx—MicroSfx.play(every Juice event triggers a sub-audible cue).engine/affixes/palette—getAffixVfxColor(affix-halo ring color on the enemy orbit pass).data/weapons/_types—RecoilProfiletype for per-weapon recoil overrides.- Global
AIControl— read byRewardFeed.addto suppress feed entries during AI runs.
PUSHES TO
engine/rendering/camera—Camera.zoomPulsefor bosscameraZoomPulsehand-off (the only outward call).engine/audio/micro-sfx—MicroSfx.play(event)once perJuice.fire.- Canvas 2D
CanvasRenderingContext2Dpassed in by the renderer — every VFX module writes pixels viactx.save/ draw /ctx.restoreand 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
progressandpulseStrengthfrom 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.updateis 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
updateanddraw(ordrawBottom/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
Particlesuses an acquire/release object pool. - Persist any state across runs.
reset/clearhelpers 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;addis the steady-state pool-backed primitive everything else routes through.Notifications.add/.updateandRewardFeed.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;updateis 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 combinedsx/syfor 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 legacydrawcalls 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 theVfxLayerKitfactory 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/resetfor run boundaries, and (for debug overlays) acountgetter. 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
hexToRgbhelper instead of sharing one — explicit copy-paste with the same shape acrossparticles,post-fx,explosion-fx,sonar-rings,player-glow, andboss-layers.boss-layers.parseHexis 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 viadrawImage. This was the response to mobilecreateRadialGradientcost 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.MAXis split viaisMobile(), ambient boss layers (dust motes, floor stripes, refraction sweep, per-swarm-member core pulses) gate onPERF_FLAGS.lowPerf, andExplosionFXimportsPERF_FLAGSbut only as a referenced symbol. - Screen-space versus world-space is decided per-draw-call:
DmgNumbers.addTextflags entries withscreen: true, boss screen-tint / edge-vignette / fullscreen-slice layers fill in raw screen coords during the additive pass, and everything else projects throughCamera.toS. - Removed-system tombstones are preserved as no-ops or pass-through fields (
Juice.updateis empty;ShipRecoil.chargeGlow/_fireFlashare retained for API compat;WeaponManagerlightning fields were removed but the field names live on;updateThrusterSmokekeeps 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 aLayerHandlefor later mutation / kill) or fire a one-shot burst that delegates toSonarRings/Particles. The layer registry is module-private; all instances of the kit share the same backing array. LayerHandle.setRotateHz/setPulseHzsnap-set the rate even when callers pass an easing / duration — the renderers compute rotation asage * rotateHz, so a tween would phase-jump the visual. The duration parameter is preserved as a forward-compat hook.boss-layers.pushLayercrashes on cap overflow rather than dropping silently — caller-bug semantics. A softconsole.warnfires once when the live count crossesSOFT_LAYER_WARNso 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-layersusessetTimeoutfor 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
_delayframe counter rather than a per-spawn timestamp — AoE bursts decrement the counter during update and only start ticking lifetime / physics once it reaches zero. SonarRingsexposes three spawn variants instead of taking an options object;spawnis the cheap weapon-fire pulse (fixed alpha and start-radius math),shockwaveis the dramatic full-circle, andshockwaveArcadds partial-arc fields for shield shatter.ExplosionFXkeeps 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 shareclearandcountbut otherwise don’t interact.AoeExplosionis intentionally a fourth explosion variant rather than a preset onExplosionFX: 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.SmokeFXcomposites through two offscreen canvases that resize on demand to match the main canvas; tinting happens via asource-atopfill 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.PlayerGlowhas a deliberately bifurcated draw: 99% of frames take the idle path (onedrawImageof a pre-baked blue stamp, no composite mode change), and only the ~0.3 s flash window takes the slow path (multiplycomposite + 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.
NotificationsandRewardFeedare tiny text feeds that share almost the same shape but are kept as distinct objects;RewardFeedadds anAIControl-gated early return thatNotificationsdoesn’t.- The
XpAccumparticle emission reaches outside its module by readingglobalThis.shipdirectly — a deliberate shortcut so the popup can anchor above the ship without taking it as a parameter.