@zakkster/lite-fireworks
v1.6.0
Published
Zero-GC, SoA fireworks engine supporting OKLCH colors, cinematic bloom trails, vector-comet mode, and multi-phase shell explosions. Designed for high-performance displays with no allocations in the hot path.
Maintainers
Readme
@zakkster/lite-fireworks
Zero-GC, SoA fireworks engine: physically-correct ballistic shells, a six-shape burst vocabulary, named presets, primitive-only event hooks, and a seeded playShow sequencer. Bloom trails on dark canvases, vector-comets on light. OKLCH colors. One dependency, zero allocation in the hot path.
The celebration engine the ecosystem was missing
The @zakkster suite draws, colors, and animates -- but nothing in it turned a moment of delight into the thing a game-over screen, a scratch-card reveal, or a New Year countdown actually needs: a real fireworks show. Ballistic shells that arc to a target and burst, stars that fall under gravity and fade, a timeline that fires the finale on a clock -- all with zero allocation per frame so a long-running display never stutters on a garbage-collection pause. lite-fireworks is that piece.
npm install @zakkster/lite-fireworksOne dependency: @zakkster/lite-color (OKLCH triplets -> CSS strings, resolved once at construction). Here is a full runnable loop -- get a 2D context, build an engine, launch a shell, and drive it from requestAnimationFrame:
import { FireworksEngine } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
const max = 5000;
const rng = Math.random;
const sky = new FireworksEngine(max, { rng });
// Launch a shell from the bottom edge; it bursts at 30% of canvas height.
const sx = canvas.width / 2, sy = canvas.height, targetY = canvas.height * 0.3;
const colorId = 2, shape = 0; // gold sphere
sky.launch(sx, sy, targetY, colorId, shape);
let last = 0;
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
sky.updateAndDraw(ctx, dt, canvas.width, canvas.height);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Table of contents
- Why this exists
- What you get
- Two render modes
- Particle physics
- API reference
- Composability
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
A fireworks display is the worst case for a naive particle system: hundreds to thousands of live stars, several simultaneous bursts, and a render loop that must never pause. The common libraries allocate an object per particle per frame, so a two-minute finale is a slideshow of GC hitches. lite-fireworks keeps every particle in flat Float32Array / Uint8Array pools (structure-of-arrays), pre-resolves every color once, batches the bloom draw to at most one fill per color, and never allocates while a frame is in flight. The shell velocity is real ballistics -- vy = -sqrt(2 * gravity * dy) -- so an arc reaches exactly the height you asked for, and changing gravity keeps the arcs physically correct with no magic tuning. It is a pure engine: you hand it (ctx, dt, w, h) and it renders. It owns no DOM, no resize logic, no timer.
What you get
The whole surface is three exports: FireworksEngine, FIREWORK_SHAPES, and FIREWORK_PRESETS.
launch(startX, startY, targetY, colorId, shape)-- fire a ballistic shell that burstsshapeat its apex.explode(originX, originY, colorId, shape)-- spawn a burst directly, no shell (auto-called at apex).updateAndDraw(ctx, dt, w, h)-- integrate physics and render one frame.preset(name)-- copy a namedFIREWORK_PRESETStuning into config;falseon an unknown name.playShow(script)-- arm a seeded timeline of(t, x, targetY, colorId, shape)5-tuples;falseif malformed.clear()-- kill every particle and disarm the show.destroy()-- null every pool. Idempotent.
Here is the surface in one runnable tour:
import { FireworksEngine, FIREWORK_SHAPES, FIREWORK_PRESETS } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
const sky = new FireworksEngine(4000, { rng: Math.random });
sky.preset('celebration'); // named tuning
sky.launch(canvas.width / 2, canvas.height, 200, 4, FIREWORK_SHAPES.WILLOW);
sky.explode(300, 240, 1, FIREWORK_SHAPES.RING); // burst with no shell
const shapeCount = Object.keys(FIREWORK_SHAPES).length; // 6
const presetNames = Object.keys(FIREWORK_PRESETS); // celebration/finale/elegant/arcade
for (let f = 0; f < 90; f++) sky.updateAndDraw(ctx, 1 / 60, canvas.width, canvas.height);
sky.clear(); // empty the sky
sky.destroy(); // release the poolsOne boolean, transparentBackground, chooses between two visually distinct renderers over the same physics.
Bloom mode (default, transparentBackground: false) -- dark canvases. Every frame the engine paints the WHOLE canvas with fadeColor via fillRect(0, 0, w, h), then composites particles with globalCompositeOperation = 'lighter'. The fade leaves a decaying ghost of the previous frame -- the classic cinematic bloom trail -- and the additive blend makes overlapping stars glow. Because the fade fills the entire canvas, bloom mode is destructive to anything else drawn on that canvas: it owns its surface.
import { FireworksEngine } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
const sky = new FireworksEngine(5000, {
transparentBackground: false, // bloom mode
fadeColor: 'rgba(10, 10, 10, 0.25)', // trail persistence -- lower alpha = longer trails
rng: Math.random,
});
sky.explode(canvas.width / 2, canvas.height / 2, 3);
sky.updateAndDraw(ctx, 1 / 60, canvas.width, canvas.height);Vector-comet mode (transparentBackground: true) -- light or shared canvases. The engine calls clearRect(0, 0, w, h) instead of the fade fill and draws each star as a velocity-direction stroke with lineCap = 'round'. Nothing is composited additively and nothing is painted over the background, so this is the overlay path: draw fireworks on top of a game, a photo, or a scratch-card surface without erasing it.
import { FireworksEngine } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
const sky = new FireworksEngine(3000, {
transparentBackground: true, // vector-comet mode -- clearRect, no destructive fill
rng: Math.random,
});
sky.explode(canvas.width / 2, canvas.height / 2, 5);
sky.updateAndDraw(ctx, 1 / 60, canvas.width, canvas.height);Canvas ownership. The verbatim default fade is 'rgba(10, 10, 10, 0.25)' (with spaces). In bloom mode that fill covers (0, 0, w, h) every frame, so the engine assumes it owns the whole canvas. When you need to composite over other content -- an overlay, an HUD, a scratch-card -- set transparentBackground: true and the fade is suppressed in favor of clearRect.
Particle physics
A shell launches from (startX, startY) toward targetY with velocity computed from real ballistics:
vy = -sqrt(2 * gravity * abs(startY - targetY))That guarantees the shell decelerates to near-zero vertical velocity exactly at targetY, where it frees its slot and enqueues an apex burst. Shells get a small horizontal wobble and, unlike stars, experience no drag -- they are powered projectiles. Friction is dt-independent: the per-frame retention is f = pow(friction, dt * 60), so damping is per-second, identical at 30, 60, or 144 fps.
A burst spawns stars in one of six shapes (pass the NUMBER from FIREWORK_SHAPES, not the object):
0 SPHERE-- random angle, speed 50-350, life ~1.0-1.2 (the founding burst).1 RING-- uniform angle by spawn index at a fixed speed; a clean, fully deterministic circle.2 PALM-- half the star budget, faster and longer-lived, drooping under the same gravity.3 WILLOW-- long life, low speed; the trailing droop is long life falling under gravity.4 CRACKLE-- short-life stars bornstate=3; each re-enqueues one sphere micro-burst at death (cascade depth 1).5 DOUBLE-- an immediate sphere plus one more sphere scheduled a few frames later.
Stars experience full physics -- gravity pulls them down, friction slows them -- and are culled when life reaches zero or they leave the viewport. Each particle carries a Uint8Array state: 0 free/idle, 1 shell, 2 star, 3 crackle star (bornState = shape === 4 ? 3 : 2). The lifecycle:
stateDiagram-v2
[*] --> idle0
idle0 --> shell1 : launch()
shell1 --> pendingDrain : apex (vy >= -20), slot freed + burst enqueued
pendingDrain --> star2 : drain fires explode()
pendingDrain --> crackle3 : drain fires explode(shape 4)
idle0 --> star2 : direct explode()
idle0 --> crackle3 : direct explode(shape 4)
star2 --> idle0 : life <= 0 or out of bounds
crackle3 --> pendingDrain : death enqueues one micro-burst
crackle3 --> idle0 : slot freedAPI reference
new FireworksEngine(maxParticles = 5000, config = {})
launch(startX, startY, targetY, colorId = null, shape = 0) -> undefined
explode(originX, originY, colorId, shape = 0) -> undefined
updateAndDraw(ctx, dt, w, h) -> undefined
preset(name) -> boolean
playShow(script) -> boolean
clear() -> undefined
destroy() -> undefinedlaunch picks a random color when colorId is null; a non-integer colorId is a no-op, an out-of-range integer clamps. explode has NO default colorId -- a non-integer colorId is a no-op return. A non-integer shape is a no-op; an out-of-range integer clamps into [0, 5].
Shape enum (FIREWORK_SHAPES, frozen -- pass the number):
| Name | Value |
|---|---|
| SPHERE | 0 |
| RING | 1 |
| PALM | 2 |
| WILLOW | 3 |
| CRACKLE | 4 |
| DOUBLE | 5 |
Presets (FIREWORK_PRESETS, applied by preset(name) -- copies primitives, retains no object):
| Name | gravity | friction | starCount |
|---|---|---|---|
| celebration | 250 | 0.96 | 60 |
| finale | 300 | 0.94 | 120 |
| elegant | 180 | 0.98 | 40 |
| arcade | 350 | 0.90 | 48 |
Config defaults (constructor config is merged over these; every value is live-mutable between frames):
| Option | Default | Notes |
|---|---|---|
| gravity | 250 | Downward acceleration, px/s^2. |
| friction | 0.96 | Per-second velocity retention; f = pow(friction, dt * 60). |
| starCount | 60 | Stars per burst (PALM uses half). |
| transparentBackground | false | false = bloom (fade fill + lighter); true = comet (clearRect). |
| fadeColor | 'rgba(10, 10, 10, 0.25)' | Bloom fade overlay -- WITH spaces, quoted exactly. |
| colors | 7 OKLCH {l,c,h} | Hues 30/330/60/270/150/210/0; resolved to CSS once at construction. |
| rng | Math.random | Inject a seeded () => number in [0, 1) for reproducible shows. |
| slotCursor | off unless set to literal true | Opt-in rotating free-slot cursor (see below). |
| onLaunch | unset | onLaunch(startX, startY, colorId), primitives only. |
| onBurst | unset | onBurst(originX, originY, colorId, shape), primitives only. |
slotCursor is off unless you set it to the literal true -- there is no false default key. It is a documented caller trade, not a default: the rotating free-slot cursor resumes each free-slot scan from where the last one stopped instead of a linear 0..max walk. That reorders which slots a busy pool fills, which reorders the batched draw loop's center emission and therefore MOVES the committed position hash. Off by default keeps every baseline reproducible byte-for-byte; turning it on is a measured trade you make on a saturated pool where the amortized scan matters.
The hooks pass primitives only (no object or array at the call site, so a no-op hook adds zero bytes per op). onLaunch fires at the end of a successful launch. onBurst fires at the end of explode, from BOTH the immediate call and the apex drain, with CLAMPED values. Every config value is live-mutable between frames -- wire sliders straight to config:
import { FireworksEngine } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
let launches = 0;
const sky = new FireworksEngine(4000, {
rng: Math.random,
onLaunch(x, y, colorId) { launches++; }, // primitives only -- zero allocation
});
// Live-mutate config between frames (this is what a slider oninput would do).
sky.config.gravity = 320;
sky.config.friction = 0.94;
sky.config.starCount = 90;
sky.config.transparentBackground = false;
sky.launch(canvas.width / 2, canvas.height, 200, 5, 2);
for (let f = 0; f < 60; f++) sky.updateAndDraw(ctx, 1 / 60, canvas.width, canvas.height);The playShow tuple is (t, x, targetY, colorId, shape) -- there is NO startY column; the walk passes h from updateAndDraw as the shell start edge. Entries must be ascending in t, the script length a multiple of 5, at most 1024 entries; anything malformed fails closed at build time and returns false.
Composability
A finale is a timeline. playShow parses a flat script of (t, x, targetY, colorId, shape) 5-tuples ONCE into a preallocated buffer, then walks it with a zero-allocation cursor, firing a launch as each entry's time arrives. Seed the rng and the whole show is reproducible:
import { FireworksEngine, FIREWORK_SHAPES } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
// A tiny seeded PRNG so the finale is bit-for-bit reproducible.
function mulberry32(seed) {
return function rng() {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const sky = new FireworksEngine(6000, { rng: mulberry32(42) });
const S = FIREWORK_SHAPES;
// (t seconds, x, targetY, colorId, shape) x N -- ascending in t, length a multiple of 5.
const armed = sky.playShow([
0.0, 200, 180, 0, S.SPHERE,
0.3, 640, 140, 2, S.RING,
0.6, 1080, 200, 4, S.CRACKLE,
1.0, 640, 120, 3, S.WILLOW,
1.4, 400, 160, 5, S.DOUBLE,
]);
for (let f = 0; f < 120; f++) sky.updateAndDraw(ctx, 1 / 60, canvas.width, canvas.height);The scratch-card win: an arcade preset over a transparent overlay, bursting a DOUBLE shape on reveal, with an optional audio boom wired through the primitive onBurst hook. The audio import is illustrative -- the block guards the call, so the fireworks run with or without @zakkster/lite-audio installed:
import { FireworksEngine, FIREWORK_SHAPES } from '@zakkster/lite-fireworks';
// import { createBoom } from '@zakkster/lite-audio'; // optional -- guarded below
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
// Optional audio: `typeof` on an undeclared name is safe, so this stays runnable
// whether or not lite-audio is present.
const boom = typeof createBoom === 'function'
? createBoom({ wave: 'sine', decay: 0.4 })
: null;
const sky = new FireworksEngine(2000, {
transparentBackground: true, // overlay the game canvas -- no destructive fade
rng: Math.random,
onBurst(x, y, colorId, shape) {
if (boom) boom(x, y); // primitive-only hook: zero allocation at the call site
},
});
sky.preset('arcade'); // 350 / 0.90 / 48 -- fast, saturated
// Player scratches to a winning cell -> celebrate at the reveal point.
function onReveal(x, y) {
const colorId = 2;
sky.explode(x, y, colorId, FIREWORK_SHAPES.DOUBLE);
}
onReveal(canvas.width / 2, canvas.height / 2);
for (let f = 0; f < 120; f++) sky.updateAndDraw(ctx, 1 / 60, canvas.width, canvas.height);Scope honesty: the cross-repo ../LiteScratchFx (Vikings) wiring is a separate LiteScratchFx session. This recipe is written and validated HERE; it does not claim the Vikings integration is shipped.
For a display that runs off the main thread, transfer an OffscreenCanvas to a worker and drive updateAndDraw there. The browser-only calls are guarded so the pattern stays portable; the engine driver itself runs anywhere a 2D context exists:
import { FireworksEngine } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('sky');
const ctx = canvas.getContext('2d');
// Main thread: hand the canvas to a worker. Guarded for portability.
function startWorker() {
if (typeof Worker === 'undefined' || !canvas.transferControlToOffscreen) return null;
const off = canvas.transferControlToOffscreen();
const worker = new Worker('./fireworks-worker.js', { type: 'module' });
worker.postMessage({ canvas: off, w: canvas.width, h: canvas.height }, [off]);
return worker;
}
// Worker body: give it a 2D context + size and it owns the frame loop. Portable.
function driveOffscreen(context, w, h) {
const sky = new FireworksEngine(4000, { rng: Math.random });
sky.launch(w / 2, h, h * 0.3, 2, 3);
let last = 0;
return function frame(now) {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
sky.updateAndDraw(context, dt, w, h);
};
}
const step = driveOffscreen(ctx, canvas.width, canvas.height);
step(16); step(32); step(48); // a few frames on this thread's context
startWorker();Every particle field is a preallocated typed array; there is no per-particle object, ever. The deferred-burst queue, the timed-drain lanes, the per-color bloom buckets, and the playShow timeline are all sized at construction and mutated in place, so nothing in launch, explode, or updateAndDraw allocates.
| Path | Per-frame allocation |
|---|---|
| updateAndDraw physics + bloom draw | 0 B |
| updateAndDraw vector-comet draw | 0 B |
| launch / explode (steady state) | 0 B |
| playShow playback (cursor walk fires launch) | 0 B |
| A no-op onLaunch / onBurst | 0 B/op |
The bloom pass batches: instead of one beginPath/arc/fillStyle/fill per particle, it buckets each surviving particle's slot index by color into a preallocated Uint32Array and emits one fill per non-empty color -- at most colors.length fills per frame regardless of particle count. Bench provenance (node --expose-gc test/bench.mjs), 16 simultaneous bursts / 3200 live stars: fill() calls per frame 3200 -> 7 (a 457x reduction), while the arc() centre count is unchanged at 3200 -- every particle still emits exactly one centre. The arrayBuffers growth over a preallocated full-frame loop is a hard-gated 0 B/op in both modes. Fingerprint: node v26.3.1 arm64 v8 14.6.202.34-node.20.
Design decisions worth knowing
- The engine owns no canvas. It takes
(ctx, dt, w, h)and renders. You control DPR scaling,ResizeObserverstrategy, and the canvas lifecycle -- which is exactly why the same engine composes as a full-screen finale or a transparent overlay. nullis not zero. A non-finite coordinate, a fractionalcolorId, or a badshapeis a no-op return, never a poisoned slot that hashes silently as0. A malformedplayShowscript fails closed at build time, never mid-play.- The shape and shows are additive. Every feature after the founding sphere is an overlay that moves no committed fingerprint: a plain launch stores shape 0, the apex drain enqueues 0, and the founding baseline is byte-exact.
slotCursorstays opt-in so the shipped scan is a byte-identical linear walk and every baseline reproduces; you turn the cursor on knowingly, on a saturated pool.
Testing
test/**/*.test.mjs holds 112 tests across six files: the founding physics + fingerprint suite, the boundary and QA suites, the shapes/presets/hooks/playShow suite, a README-example runner that executes every runnable js block in this file against the instrumented mock context, and a demo per-frame smoke gate. The torture harness (node --expose-gc test/torture.mjs) proves retention and zero-GC on the hot path with @zakkster/lite-leak and @zakkster/lite-gc-profiler.
npm test # node --test over test/**/*.test.mjs
npm run torture # node --expose-gc test/torture.mjs -- leak + zero-GC gate
npm run bench # node --expose-gc test/bench.mjs -- throughput + op-count numbers
npm run verify # test + tortureWhat this is not
- Not a canvas manager: no DOM, no resize handling, no timer. You own the surface and the loop.
- Not a compositor: in bloom mode the fade fill covers
(0, 0, w, h)every frame and is destructive to anything else on that canvas. UsetransparentBackground: trueto overlay. - Not a physics sandbox: shells and stars share one global gravity and friction; shapes are spawn-only parameterizations, not per-star force fields.
- Not a general emitter: it fires ballistic shells and radial bursts, not arbitrary particle graphs.
Ecosystem
Part of LiteLibrariesSuite -- zero-GC, deterministic, single-file ESM micro-libraries. lite-fireworks builds on @zakkster/lite-color for OKLCH and pairs naturally with @zakkster/lite-audio (booms via the onBurst hook) and any canvas surface such as @zakkster/lite-scratchfx.
License
MIT (c) Zahary Shinikchiev [email protected]
