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

v1.5.0

Published

A headless particle engine with GC-free physics, lifecycle management, bounds culling, declarative emission zones, and seeded deterministic replay.

Readme

@zakkster/lite-particles

npm version sponsor Tree-Shakeable npm bundle size npm downloads npm total downloads TypeScript License: MIT

A headless particle engine with GC-free physics, lifecycle management, and bounds culling.

Bring your own renderer. We handle the physics.

Why This Library?

Most particle libraries on npm ship with a Canvas or WebGL renderer baked in. The moment you need to render differently — DOM elements, Three.js sprites, PixiJS, SVG, or a custom WebGL shader — you're fighting the library instead of using it.

@zakkster/lite-particles is headless by design:

  • GC-freeupdate() and draw() allocate 0 bytes per call at every particle count (measured; enforced by a torture gate). Particles are preallocated in an inline dense free-list and recycled. No new in your game loop, no GC pauses at 60fps
  • Bring your own renderer — the draw() callback gives you the particle and a normalized life value. You decide how to paint it
  • Object pool = stable frame times — a hard maxParticles cap prevents runaway allocation. Pool full? emit() returns null. No crash, no stutter
  • A pinned contract, no silent wrong answersemit() throws on an unknown field (custom state goes on data) and rejects an invalid lifecycle to null; normalizedLife is always in [0,1], never NaN; a recycled particle never wears a dead one's colour; a seeded replay is stable even as you mutate a ring's innerRadius
  • Lifecycle hooks that don't allocateonDeath sub-emitters (sparks that burst into sparks, cascade-capped), easing curves baked into lookup tables (no Math on the hot path), and follow(target) to trail a moving object. All 0 B/call. See Lifecycle Hooks
  • Real physics — gravity, frame-independent drag, velocity integration. Not just "move dots randomly"
  • Bounds culling — particles that leave the screen are automatically recycled instead of computing invisible physics
  • Designed for real games, not demos — born in a production scratch card game with 500+ simultaneous particles

Installation

npm install @zakkster/lite-particles

One runtime dependency: @zakkster/lite-random (itself zero-dependency), which powers seeded determinism and is re-exposed in full through emitter.random.

Quick Start

import { Emitter } from '@zakkster/lite-particles';

const emitter = new Emitter({ maxParticles: 500 });

// Spawn a burst (allocation-free: write fields straight onto each particle)
emitter.emitEach(50, (p, i) => {
    p.x = 400; p.y = 300;
    p.vx = Math.cos(i * 0.5) * 200;
    p.vy = -Math.random() * 400;
    p.gravity = 600;
    p.drag = 0.98;
    p.life = 1.5;
    p.maxLife = 1.5;
    p.size = 4;
});

// Game loop
function frame(now) {
    const dt = (now - last) / 1000;  // IMPORTANT: dt in seconds
    last = now;

    emitter.update(dt);

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    emitter.draw(ctx, (ctx, p, life) => {
        ctx.globalAlpha = life;
        ctx.fillRect(p.x, p.y, p.size, p.size);
    });

    requestAnimationFrame(frame);
}

IMPORTANT: update(dt) expects dt in seconds, not milliseconds. If using requestAnimationFrame timestamps, divide by 1000.

Benchmarks & Comparison

Micro‑Benchmarks (Chrome M1, 2026)

| Operation | Ops/sec | |------------------|---------| | update() | ~8M | | draw() | ~10M | | emit() | ~50M | | emitBurst(50) | ~1.5M |

Comparison

| Feature | lite‑particles | pixi‑particles | three.js sprites | canvas libs | |---------|----------------|----------------|------------------|-------------| | Headless | ✔ | ✘ | ✘ | ✘ | | Zero GC | ✔ | ✘ | ✘ | ✘ | | Custom renderer | ✔ | ✘ | ✘ | ✘ | | Physics included | ✔ | ✘ | ✘ | ✘ | | Bounds culling | ✔ | ✘ | ✘ | ✘ | | <2KB | ✔ | ✘ | ✘ | ✘ |

API Reference

new Emitter(options?)

| Option | Type | Default | Description | |--------|------|---------|-------------| | maxParticles | number | 1000 | Hard memory limit. Pool does not expand. | | onUpdate | Function | null | Custom per-particle hook (particle, dt) | | onDeath | Function | null | Sub-emitter hook (particle) fired on life expiry (v1.4.0). See Lifecycle Hooks. | | maxCascadeDepth | number | 8 | onDeath cascade cap — an emit() past it throws (v1.4.0). | | bounds | {x,y,width,height} | null | Off-screen culling rectangle | | zone | EmissionZone | null | Emission shape, sampled at emit time. See Emission Zones. | | curves | {name: (t)=>number} | null | Easing curves baked into Float32Array LUTs (v1.4.0). See Lifecycle Hooks. | | curveSegments | number | 256 | LUT resolution per curve (v1.4.0). | | seed | number | Date.now() | RNG seed. Same seed → same emission sequence. | | random | PRNG | null | Bring your own PRNG (anything with .next() → [0,1)). Wins over seed. |

Methods

| Method | Description | |--------|-------------| | .emit(config) | Spawn one particle. Returns it, or null if the pool is full or the lifecycle is invalid (see below). An unknown config key throws — custom state goes on data. | | .emitEach(count, initFn) | Spawn many, allocation-free. initFn(particle, index) writes fields onto the particle directly. Capacity is checked before initFn, so a full pool burns no rng draw. Raw path: writes a sealed particle (stray key throws) and you own the lifecycle. Stops at pool limit. Returns how many actually spawned. | | .emitBurst(count, configFn) | Deprecated (v1.2.0, removed in v2.0.0) — use .emitEach. configFn(index) returns a config object per particle (one allocation each). Stops at pool limit. Returns how many actually spawned. | | .update(dt) | Physics tick. dt in seconds. Phase order (pinned): follow → decrement life → early death (+ onDeath) → integrate → bounds cull → onUpdate hook. | | .draw(ctx, callback) | Iterate for rendering (Canvas2D). Callback: (ctx, particle, normalizedLife), where normalizedLife is always in [0,1] (clamped, never NaN). | | .packTo(out, offset?) | GPU handoff (v1.5.0). Pack active particles into a Float32Array as lite-gl LAYOUT.POINT instances — (x, y, size, r, g, b, a, _pad), 8 floats each, screen pixels. Zero alloc. Returns the count. A too-small out throws RangeError; a non-Float32Array throws TypeError. See GPU Handoff. | | .follow(target) | Track a moving target world-space (v1.4.0): each update() moves the zone origin to target.x/target.y. null stops. Throws if no zone is set. | | .curve(name) | The baked sampler for a configured curve (v1.4.0): curve('size')(t) returns the eased value with no easing math on the hot path. Hoist it out of your loop. | | .curveTable(name) | The raw Float32Array LUT behind a curve (v1.4.0), for a bare index. | | .clear() | Kill all particles instantly. Great for scene resets. Does not fire onDeath. | | .destroy() | Destroy emitter and pool. Idempotent. Does not fire onDeath. | | .activeCount | Number of alive particles (getter). | | .recycledThisFrame | Particles the last update() returned to the pool — life expiry + bounds culls (getter). | | .random | The PRNG driving zone sampling (getter). Share it with your configFn. | | .seed(s) | Re-seed and replay. Parity with lite-confetti's .seed(s). | | .setZone(zone) | Swap the emission zone at runtime. null restores raw config x/y. |

Particle Properties

| Property | Default | Description | |----------|---------|-------------| | x, y | 0 | Position (pixels) | | vx, vy | 0 | Velocity (pixels/second) | | gravity | 0 | Downward acceleration (pixels/s²) | | drag | 1 | Velocity damping per frame (1 = none, 0.9 = 10% loss) | | life | 0 | Remaining life in seconds. emit() requires life > 0 — a missing or non-positive life returns null rather than a dead-on-arrival particle. | | maxLife | 1 | Life at birth. Defaults to life when only life is given, so normalizedLife starts at exactly 1.0. | | size | 1 | For use in your render callback | | r, g, b, a | 1 | Colour in [0,1] (v1.5.0), default opaque white. First-class fields so packTo can stream them to the GPU. Reset to white on recycle. | | userData | 0 | A numeric handle (v1.5.0) — an integer id into your own sprite/entity registry. The typed sibling of data. | | data | null | The object escape hatch for arbitrary custom state — sprites, closures, metadata. emit() throws on any other top-level key, and particles are sealed, so this (or userData, if numeric) is where it goes. |

Emission Zones (v1.1.0)

Declare the shape once; the position is sampled at emit time. Your configFn stops doing position math and goes back to describing motion.

const emitter = new Emitter({
  maxParticles: 800,
  zone: { type: 'ring', x: 400, y: 300, radius: 80 },
  seed: 12345,
});

emitter.emitBurst(30, (i) => ({
  vx: Math.cos(i) * 180,   // x/y come from the zone
  vy: -220,
  gravity: 400,
  life: 1.2,
  maxLife: 1.2,
}));

| Zone | Shape | rng draws | |------|-------|-----------| | { type: 'point', x, y } | Single origin | 0 | | { type: 'line', x1, y1, x2, y2 } | Along a segment — rain, waterfalls, top-of-screen spawns | 1 | | { type: 'ring', x, y, radius } | On the perimeter — shockwaves, explosions | 2 | | { type: 'ring', x, y, radius, innerRadius } | Filled annulus, uniform by area | 2 | | { type: 'rect', x, y, width, height } | Area — ground smoke, magic circles, crowds | 2 |

The draw count is the determinism contract, exported as ZONE_DRAWS — a seeded stream advances by exactly ZONE_DRAWS[zone.type] per emitted particle. A ring always draws 2 (perimeter and annulus alike; the perimeter draws the radius sample and discards it), so its rng footprint is constant and mutating innerRadius can never desync a replay.

A few things worth knowing:

  • ring means the perimeter by default. That's what a shockwave is. Add innerRadius and it becomes a filled annulus — sampled uniformly by area, not by radius. (The naive innerRadius + u * (radius - innerRadius) is uniform in radius, which piles particles toward the centre and leaves the rim looking thin. radius: 10, innerRadius: 0 puts 50% of particles inside r=7.07, not 71%.)
  • Config x/y still win. The zone provides the base; anything your configFn returns is applied on top. Use it to offset, or to override entirely.
  • A malformed zone throws. A typo'd zone that quietly emits everything at (0, 0) is the worst failure mode a visual library can have.
  • Zones are mutable — position live, dimensions via setZone(). emitter.zone.x = mouseX is the supported way to move an emitter. To change a dimension (radius, innerRadius, width, height, line endpoints), use setZone(), which re-validates — a raw in-place dimension write skips validation (e.g. innerRadius > radius would sample NaN). It no longer breaks replay (a ring's draw count is constant now), but it can still emit a malformed shape.
  • Bounds culling still applies. Particles born inside a rect that lies outside bounds are recycled on the first update().

Deterministic Replay (v1.1.0)

Pass a seed and the emission sequence is reproducible: same seed → identical positions, every run. This is the same model as @zakkster/lite-confettiseed in, own Random inside — so seeding both gives you a reproducible multi-effect scene.

const emitter = new Emitter({ seed: 12345, zone: { type: 'ring', x: 400, y: 300, radius: 80 } });
const confetti = createConfetti(canvas, { seed: 12345 });

emitter.seed(12345);   // rewind and replay

Parity comes from seed parity, not from sharing a Random instance.

It is tempting to construct one Random and hand it to both libraries. Don't — it does the opposite of what you want. Two consumers drawing from one stream are coupled: the sequence each one sees depends on how their calls interleave, which depends on frame timing. Fire the confetti one frame earlier and every subsequent particle position shifts. Two independent generators from the same seed are immune to that, which is exactly why both libraries own their RNG internally.

For full determinism, your configFn must be deterministic too. The emitter only owns zone sampling — Math.random() in your velocity math will still diverge. Share the emitter's stream:

const emitter = new Emitter({ seed: 12345, zone: { type: 'ring', x: 400, y: 300, radius: 80 } });
const rng = emitter.random;          // the same seeded stream

emitter.emitBurst(30, () => ({
  vx: rng.range(-200, 200),          // deterministic
  vy: rng.range(-300, -100),
  life: rng.range(0.8, 1.6),
  maxLife: 1.6,
  gravity: 400,
}));

One caveat: pin your @zakkster/lite-random version. A different PRNG algorithm produces a different stream from the same seed, and replays recorded on the old one will not reproduce.


Pool Observability (v1.1.0)

recycledThisFrame reports how many particles the last update() returned to the pool — life expiry plus bounds culls. It resets every frame. clear() and destroy() don't touch it: a scene reset is not churn, and counting it would spike your graphs every time the player restarts.

import { Profiler } from '@zakkster/lite-profiler';
const profiler = new Profiler();

function frame(dt) {
  emitter.update(dt);
  profiler.count('particles.active', emitter.activeCount);
  profiler.count('particles.recycled', emitter.recycledThisFrame);
}

Read it as churn: high recycled against low active means particles are dying almost as fast as you spawn them — either lifetimes are too short, or bounds is culling them the instant they're born. Both are cheap to fix once you can see them.


Lifecycle Hooks (v1.4.0)

Three allocation-free features for effects that react to a particle's life.

onDeath — sub-emitters (sparks that burst into sparks)

onDeath(p) fires when a particle dies of life expiry — not when it's culled off-screen, and not on clear()/destroy() (a scene reset is not death). The particle still holds its death position, so the hook can spawn more from it:

const emitter = new Emitter({
  maxParticles: 2000,
  onDeath: (p) => {
    // a dying rocket bursts into a ring of embers
    emitter.emitEach(12, (e, i) => {
      const a = (i / 12) * Math.PI * 2;
      e.x = p.x; e.y = p.y;
      e.vx = Math.cos(a) * 80; e.vy = Math.sin(a) * 80;
      e.life = e.maxLife = 0.6;
    });
  },
});

A particle emitted by the hook is integrated on the next frame, never the frame it was born. Cascades (embers that spawn embers) are bounded by maxCascadeDepth (default 8): an emit() that would go deeper throws a RangeError — an unbounded self-emitting effect is a bug, and it fails loud rather than silently melting your frame budget. Bound your own generations to stay under the cap.

curves — easing without the math on the hot path

Pre-bake easing curves into Float32Array lookup tables at construction, then read them per frame with no Math.sin/pow:

import { easeOutCubic, easeInQuad } from '@zakkster/lite-ease'; // devDependency, or any (t)=>number

const emitter = new Emitter({
  maxParticles: 500,
  curves: { size: easeOutCubic, alpha: easeInQuad },
});

// hoist the samplers ONCE, outside the render loop:
const sizeCurve = emitter.curve('size');
const alphaCurve = emitter.curve('alpha');

emitter.draw(ctx, (ctx, p, t) => {
  ctx.globalAlpha = alphaCurve(t);        // table read + lerp, no easing math
  const s = p.size * sizeCurve(t);
  ctx.fillRect(p.x, p.y, s, s);
});

The runtime reads a table and depends on neither @zakkster/lite-ease nor @zakkster/lite-lerp — they're devDependencies used to build and cross-check the LUTs. curveTable(name) exposes the raw Float32Array if you'd rather index it yourself.

follow — track a moving emitter

follow(target) makes the emission zone track any object with x/y, world-space: each update() moves the zone origin to the target, and particles already emitted stay where they were born — a trail, not a rigid attachment.

const emitter = new Emitter({ maxParticles: 300, zone: { type: 'point', x: 0, y: 0 } });
emitter.follow(player);            // player = { x, y }, e.g. your sprite

function frame(dt) {
  emitter.emit({ vx: rand(-20, 20), vy: rand(-20, 20), life: 0.8 });
  emitter.update(dt);              // zone origin snaps to player.x/player.y
}

follow(null) stops. It costs two property reads per frame, never per-particle. A null or non-finite target is a per-frame no-op (the zone holds its last position — no NaN).


GPU Handoff (v1.5.0)

draw() is the Canvas2D path — one JS callback per particle, which tops out in the tens of thousands. For hundreds of thousands, skip the per-particle call entirely: packTo() streams the particles into a @zakkster/lite-gl LAYOUT.POINT buffer, and the GPU draws them all in one instanced call.

Particles carry first-class r, g, b, a colour ([0,1], default opaque white) so the packed instance is complete:

import { Emitter, POINT_STRIDE } from '@zakkster/lite-particles';
import { createPointSink } from '@zakkster/lite-gl/backend';

const emitter = new Emitter({ maxParticles: 100_000 });
const sink = createPointSink(gl, { capacity: 100_000 });
const buf = new Float32Array(emitter.pool.size * POINT_STRIDE); // allocate ONCE

emitter.emitEach(100_000, (p) => {
  p.life = 2; p.maxLife = 2;
  p.vx = rand(-50, 50); p.vy = rand(-50, 50);
  p.r = 1; p.g = 0.6; p.b = 0.1; p.a = 1;   // warm spark
});

function frame(dt) {
  emitter.update(dt);
  const n = emitter.packTo(buf);            // 0 alloc, no math — just a copy
  sink.upload(buf, 0, n * POINT_STRIDE, 0, POINT_STRIDE);
  sink.draw(n);                             // one instanced draw for all n
}

Each packed instance is 8 floats: (x, y, size, r, g, b, a, _pad) — the exported POINT_STRIDE / POINT_OFFSETS / LAYOUT_VERSION document the contract. LAYOUT.POINT is screen pixels, so project world→screen before packing if your particles hold world coordinates. packTo allocates nothing and reflects the live count; a too-small out throws RangeError (fail closed).

Note — why the object core, not SoA? P4 prototyped a Structure-of-Arrays column store to feed the GPU. Measured against the object core it regressed update() 25–40% at every size (a physics update touches most per-particle fields — the pattern that favours arrays-of-structs), while its one edge, packTo, merely tied. So the column store was shelved and packTo was added here instead — the GPU payoff on the faster core, with no breaking change. The evidence lives in test/bench-soa.mjs; the reasoning in decisions/00100011.


Recipes

Fireworks

Evenly-spaced angles with randomized speed creates a classic radial burst:

emitter.emitBurst(80, (i) => {
    const angle = (i / 80) * Math.PI * 2;
    return {
        x: burstX, y: burstY,
        vx: Math.cos(angle) * rng.range(100, 300),
        vy: Math.sin(angle) * rng.range(100, 300),
        gravity: 200,
        drag: 0.97,
        life: 1.2,
        maxLife: 1.2,
        size: 3,
    };
});

Rising Smoke

Negative gravity pushes particles upward. High drag makes them decelerate and drift:

// Call every few frames for continuous smoke
emitter.emitBurst(3, () => ({
    x: fireX + rng.range(-10, 10),
    y: fireY,
    vx: rng.range(-20, 20),
    vy: rng.range(-10, -50),
    gravity: -20,
    drag: 0.92,
    life: rng.range(1, 2.5),
    maxLife: 2.5,
    size: rng.range(8, 20),
}));

Snowfall

Spawn along the top edge. No gravity — slow constant drift with slight horizontal wobble:

// Call once per frame during snow
if (rng.chance(0.3)) {
    emitter.emit({
        x: rng.range(0, canvasWidth),
        y: -10,
        vx: rng.range(-10, 10),
        vy: rng.range(20, 40),
        gravity: 0,
        drag: 0.99,
        life: 10,
        maxLife: 10,
        size: rng.range(2, 5),
    });
}

Sparks on Impact

Short-lived, fast, with heavy gravity pulling them down immediately:

emitter.emitBurst(15, () => ({
    x: impactX, y: impactY,
    vx: rng.range(-150, 150),
    vy: rng.range(-300, -50),
    gravity: 800,
    drag: 0.95,
    life: rng.range(0.2, 0.5),
    maxLife: 0.5,
    size: 2,
}));

Confetti Celebration

Wide spread, slow gravity, long life. Use the data field to store per-particle color:

const confettiColors = [
    { l: 0.7, c: 0.25, h: 30 },   // orange
    { l: 0.6, c: 0.3, h: 330 },    // pink
    { l: 0.7, c: 0.2, h: 60 },     // yellow
    { l: 0.5, c: 0.25, h: 260 },   // purple
];

emitter.emitBurst(100, () => ({
    x: rng.range(0, canvasWidth),
    y: -20,
    vx: rng.range(-80, 80),
    vy: rng.range(50, 200),
    gravity: 100,
    drag: 0.98,
    life: 3,
    maxLife: 3,
    size: rng.range(4, 8),
    data: { color: rng.pick(confettiColors) },
}));

Color Over Life

The normalizedLife parameter (1.0 at birth, 0.0 at death) works directly with lite-color:

import { lerpOklch, toCssOklch } from '@zakkster/lite-color';

const birth = { l: 0.95, c: 0.05, h: 60 };   // bright white-yellow
const death = { l: 0.4, c: 0.25, h: 15 };     // deep ember

emitter.draw(ctx, (ctx, p, life) => {
    const color = lerpOklch(death, birth, life);
    ctx.fillStyle = toCssOklch(color);
    ctx.globalAlpha = life * life;  // ease-in fade
    ctx.fillRect(p.x - p.size/2, p.y - p.size/2, p.size, p.size);
});

Size Over Life

Particles that grow as they age, or shrink as they die:

emitter.draw(ctx, (ctx, p, life) => {
    const radius = p.size * (1 + (1 - life) * 2);  // grows 3x by death
    ctx.beginPath();
    ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
    ctx.globalAlpha = life;
    ctx.fill();
});

Custom Physics Hook

The onUpdate callback runs after built-in physics. Add sine-wave wobble, magnetic attraction, or wind:

const emitter = new Emitter({
    maxParticles: 200,
    onUpdate: (p, dt) => {
        // Sine wave horizontal wobble
        p.x += Math.sin(p.y * 0.02) * 30 * dt;

        // Or: attract toward a point
        const dx = attractorX - p.x;
        const dy = attractorY - p.y;
        p.vx += dx * 0.5 * dt;
        p.vy += dy * 0.5 * dt;
    },
});

Bounds Culling

Particles outside the rectangle are automatically recycled. Add margin for particles that should disappear just off-screen:

const emitter = new Emitter({
    maxParticles: 500,
    bounds: { x: -50, y: -50, width: 900, height: 700 },  // 50px margin
});

Trail Effect

Spawn a particle every frame at the moving object's position with zero velocity:

// In your game loop, every frame:
emitter.emit({
    x: missile.x, y: missile.y,
    vx: 0, vy: 0,
    gravity: 0,
    life: 0.3,
    maxLife: 0.3,
    size: 6,
});

// Render with fade
emitter.draw(ctx, (ctx, p, life) => {
    ctx.globalAlpha = life;
    const r = p.size * life;  // shrinks to nothing
    ctx.beginPath();
    ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
    ctx.fill();
});

Pool Exhaustion Handling

When the pool is full, emit() returns null. Use this to gracefully degrade:

const p = emitter.emit(config);
if (!p) {
    // Pool exhausted — skip low-priority particles
    // High-priority effects can use a separate emitter with reserved capacity
}

The @zakkster Ecosystem

lite-particles is designed to work with the rest of the suite:

import { Emitter } from '@zakkster/lite-particles';
import { Random } from '@zakkster/lite-random';
import { lerpOklch, toCssOklch, createGradient } from '@zakkster/lite-color';
import { easeOut } from '@zakkster/lite-lerp';

const rng = new Random(42);
const gradient = createGradient([white, gold, ember], easeOut);
const emitter = new Emitter({ maxParticles: 300 });

emitter.emitBurst(50, () => ({
    x: centerX + rng.gaussian(0, 20),
    y: centerY + rng.gaussian(0, 20),
    vx: rng.gaussian(0, 100),
    vy: rng.gaussian(-200, 50),
    gravity: 400,
    life: rng.range(0.5, 1.5),
    maxLife: 1.5,
    size: rng.range(2, 6),
}));

emitter.draw(ctx, (ctx, p, life) => {
    ctx.fillStyle = toCssOklch(gradient(1 - life));
    ctx.globalAlpha = life;
    ctx.fillRect(p.x, p.y, p.size, p.size);
});

TypeScript

Full type definitions with the Particle interface exported:

import { Emitter, type Particle, type EmitterOptions } from '@zakkster/lite-particles';

const emitter = new Emitter({ maxParticles: 500 });
const p: Particle | null = emitter.emit({ x: 100, y: 200, life: 1 });

License

MIT