npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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.

npm version Zero-GC npm bundle size npm downloads TypeScript Dependencies License: MIT

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-fireworks

One 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

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 bursts shape at 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 named FIREWORK_PRESETS tuning into config; false on an unknown name.
  • playShow(script) -- arm a seeded timeline of (t, x, targetY, colorId, shape) 5-tuples; false if 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 pools

One 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 born state=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 freed

API 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() -> undefined

launch 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, ResizeObserver strategy, and the canvas lifecycle -- which is exactly why the same engine composes as a full-screen finale or a transparent overlay.
  • null is not zero. A non-finite coordinate, a fractional colorId, or a bad shape is a no-op return, never a poisoned slot that hashes silently as 0. A malformed playShow script 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.
  • slotCursor stays 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 + torture

What 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. Use transparentBackground: true to 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]