PURPOSE

Particle system for sparks, smoke, exhaust, and star confetti. Ported from the legacy 05-vfx.js Particles module. Owns the pooled Particle object, the public Particles singleton (singular spawn, omnidirectional burst, hex-color burst, muzzle flash, warp arrival poof, star burst, impact burst), the per-frame integrator, and the supporting overlay accumulators (Notifications, RewardFeed, DmgNumbers, XpAccum, ShieldDmgAccum, HpDmgAccum) that live alongside it in the same file.

OWNS

  • Particle interface — x, y, vx, vy, l, ml, sz, r, g, b, a, tp (tp is 'spark' | 'smoke' | 'exhaust' | 'star').
  • Module-local particle pool (_pool) and the POOL_MAX = 600 cap.
  • _acquire() / _release(p) — pool checkout/return; allocates a fresh particle only when the pool is empty.
  • Particles singleton: update(dt), add(x, y, vx, vy, l, sz, r, g, b, a=1, tp='spark'), burst(cx, cy, n, tp, col, sp=80), _hexToRgb(hex), burstHex(cx, cy, n, tp, hex, sp=80), muzzleFlash(cx, cy, fireAngle, n, hex1, hex2?), warpArrivalPoof(cx, cy, hex, radius), starBurst(wx, wy, count, lifetime), impactBurst(cx, cy, n, hex1, hex2?).
  • DMG_ARC_GRAVITY = 200 (px/s²) — gravity for damage-number arc physics.
  • Sibling overlays exported from the same file: Notifications, RewardFeed, DmgNumbers, XpAccum, ShieldDmgAccum, HpDmgAccum.

READS FROM

  • world.particles — the live particle array driven by update and gated by CFG.MAX_PARTICLES.
  • world.dmgNumbers — read and written by DmgNumbers.
  • CFG.MAX_PARTICLES from ../core — hard cap on world.particles.length enforced by every spawn path.
  • PERF_FLAGS from ../core/config — imported (currently used by adjacent VFX wiring; available to spawn paths).
  • swapRemove from ../core/utils — O(1) removal of dead particles and dead damage numbers.
  • globalThis.ship — read by XpAccum.update to emit aqua absorption sparks above the ship.
  • globalThis.AIControl — read by RewardFeed.add to suppress text when AI control is enabled.

PUSHES TO

  • world.particles — pushed by Particles.add after pool acquisition; popped/released via swapRemove in update when l <= 0.
  • world.dmgNumbers — pushed by DmgNumbers.add, DmgNumbers.addLabel, DmgNumbers.addText; removed via swapRemove when l <= 0.
  • The internal _pool array — receives released particles up to POOL_MAX.
  • Notifications.e, RewardFeed.e — internal text-event arrays drained by their own update methods.

DOES NOT

  • Does not render. No draw calls, no canvas/WebGL access — rendering lives in the VFX/render layer that reads world.particles and world.dmgNumbers.
  • Does not own time scaling. update accepts whatever dt the caller hands it (notes on starBurst confirm bridge.ts passes rawDt so star confetti animates through time freeze).
  • Does not despawn beyond lifetime expiry; there is no distance/visibility culling here.
  • Does not modify particle color or alpha over lifetime — colors are set at spawn; visual fade is the renderer’s responsibility using l / ml.
  • Does not apply drag to spark or star particles; drag (0.92^(dt*60)) is applied only to 'smoke' and 'exhaust'.
  • Does not allocate when the pool is warm — _acquire pops from _pool before constructing a new object.
  • Does not gate by PERF_FLAGS itself — PERF_FLAGS is imported but spawn caps come from CFG.MAX_PARTICLES and the soft-budget filter (see Pattern notes).

Signals

  • Hard cap: every spawn path checks world.particles.length < CFG.MAX_PARTICLES before pushing; over-cap requests are silently dropped.
  • Soft cap: Particles.add additionally drops 'smoke' and 'spark' requests once the array is at or above 80% of CFG.MAX_PARTICLES, preserving headroom for more important spawns (impact, muzzle, star, exhaust).
  • DmgNumbers.add enforces DMG_CAP = 15 for damage numbers (separate from particle cap); addLabel and addText use a higher 80-entry cap on world.dmgNumbers.
  • Notifications.add keeps at most 4 entries (shifts oldest); RewardFeed.add keeps at most 3 entries.
  • XpAccum / ShieldDmgAccum / HpDmgAccum are singletons with active flag, total, refreshes, timer, rise, age, and a maxLife (2.5s for XP, 1.2s for shield/HP).

Entry points

  • Particles.update(dt) — called once per frame; integrates position, decays lifetime, applies drag to smoke/exhaust, releases dead particles back to the pool.
  • Particles.add(...) — low-level singular spawn used by burst, muzzleFlash, warpArrivalPoof, starBurst, impactBurst, and external callers (e.g. XpAccum).
  • Particles.burst(cx, cy, n, tp, col, sp=80) — radial RGB burst; per-particle speed 40 + rand*sp, lifetime 0.1 + rand*0.25, size 2 + rand*2 for smoke or 1 + rand*1.5 otherwise, alpha 0.9.
  • Particles.burstHex(cx, cy, n, tp, hex, sp=80) — hex-color wrapper over burst.
  • Particles.muzzleFlash(cx, cy, fireAngle, n, hex1, hex2?) — 70% of particles in a ±0.75 rad forward cone, 30% omnidirectional; speed 60 + rand*120, lifetime 0.08 + rand*0.15, alpha 0.85, type 'spark', color picked 60/40 between hex1 and hex2.
  • Particles.warpArrivalPoof(cx, cy, hex, radius) — sparks (min(8, 4 + floor(radius*0.2))) plus smoke (min(4, 2 + floor(radius*0.1))); offset from center by ±radius*0.6 (sparks) / ±radius*0.4 (smoke).
  • Particles.starBurst(wx, wy, count, lifetime) — golden five-pointed-star confetti at RGB (255, 200, 50), alpha 0.95, type 'star'; used for weapon chest pickup, drives through time freeze via rawDt.
  • Particles.impactBurst(cx, cy, n, hex1, hex2?) — bullet-impact starburst; speed 30 + rand*90, lifetime 0.1 + rand*0.2, alpha 0.9, type 'spark'.
  • Notifications.add/update, RewardFeed.add/update, DmgNumbers.add/addLabel/addText/update, XpAccum.collect/update, ShieldDmgAccum.collect/update, HpDmgAccum.collect/update — sibling overlay APIs called from gameplay code and ticked from the main loop.

Pattern notes

  • Pool-then-cap. _acquire reuses pooled objects to avoid GC pressure; the POOL_MAX = 600 ceiling is the recycle bin size, separate from the live cap CFG.MAX_PARTICLES = 400. Released particles past the bin size are dropped to GC.
  • Soft-priority filter in add. The 80% threshold blocks 'smoke' and 'spark' spawns first, leaving budget for 'exhaust' / 'star' / 'impact' spawns that bypass the soft filter (those types call add only after their own cap check).
  • Backwards iteration with swapRemove. Both Particles.update and DmgNumbers.update iterate i = length-1 down to 0 and remove via swapRemove for O(1) deletes without disturbing earlier indices.
  • Drag is frame-rate-independent. Math.pow(0.92, dt * 60) keeps the smoke/exhaust deceleration constant in real time regardless of frame rate.
  • Damage-number staging. DmgNumbers.add stamps a random 1-3 frame _delay; update decrements _delay before ticking lifetime, spreading AoE bursts across frames so numbers don’t all appear in one tick.
  • Damage-number arc-then-freeze. Enemy damage numbers integrate vx and gravity-affected vy for the first 0.25s of life, then lock position and fade out in place — punchier read than continuous motion. Player block-text damage (block: true) uses the older pause-then-rise behavior instead.
  • Auto-color by magnitude. DmgNumbers.add picks color and base size from damage value when no explicit col is provided: white <100, yellow >=100, orange >=400, red >=1000; crits force #ffcc44 at 1.3x size; final size is scaled *1.4 for visibility through dark backgrounds.
  • Accumulator pattern. XpAccum, ShieldDmgAccum, HpDmgAccum collapse rapid pickup/hit events into a single stacking popup keyed off active + timer; XpAccum also emits aqua absorption sparks from the ship at a rate that scales with refreshes.
  • AIControl suppression. RewardFeed.add is the only path that consults globalThis.AIControl to skip text emission when AI control is engaged.
  • Globals via globalThis. ship and AIControl are accessed through globalThis as any rather than imported, reflecting the legacy bridge-time wiring inherited from the JS port.