@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.
Maintainers
Readme
@zakkster/lite-particles
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-free —
update()anddraw()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. Nonewin 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
maxParticlescap prevents runaway allocation. Pool full?emit()returnsnull. No crash, no stutter - A pinned contract, no silent wrong answers —
emit()throws on an unknown field (custom state goes ondata) and rejects an invalid lifecycle tonull;normalizedLifeis 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'sinnerRadius - Lifecycle hooks that don't allocate —
onDeathsub-emitters (sparks that burst into sparks, cascade-capped), easingcurvesbaked into lookup tables (noMathon the hot path), andfollow(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-particlesOne 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:
ringmeans the perimeter by default. That's what a shockwave is. AddinnerRadiusand it becomes a filled annulus — sampled uniformly by area, not by radius. (The naiveinnerRadius + u * (radius - innerRadius)is uniform in radius, which piles particles toward the centre and leaves the rim looking thin.radius: 10, innerRadius: 0puts 50% of particles inside r=7.07, not 71%.)- Config
x/ystill win. The zone provides the base; anything yourconfigFnreturns 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 = mouseXis the supported way to move an emitter. To change a dimension (radius,innerRadius,width,height, line endpoints), usesetZone(), which re-validates — a raw in-place dimension write skips validation (e.g.innerRadius > radiuswould sampleNaN). 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
rectthat lies outsideboundsare recycled on the firstupdate().
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-confetti — seed 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 replayParity comes from seed parity, not from sharing a
Randominstance.It is tempting to construct one
Randomand 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 andpackTowas added here instead — the GPU payoff on the faster core, with no breaking change. The evidence lives intest/bench-soa.mjs; the reasoning indecisions/0010–0011.
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
