@zakkster/lite-snow
v2.0.0
Published
Zero-GC, SoA environmental snow engine with drift physics, Z-depth parallax, persistent-pack accumulation, dimension caching, and off-screen culling. Designed for cinematic snowfall with high-performance rendering and natural flake behavior.
Maintainers
Readme
@zakkster/lite-snow
Zero-GC SoA environmental snow engine. Drift physics, living-air forces (gust, turbulence, drag), Z-depth parallax, opt-in persistent-pack accumulation, bucketed rendering, four complete presets (flurry, heavy, blizzard, calm), and reduced-motion + spawn shaping. Zero dependencies.
The snow engine the ecosystem was missing
lite-snow is the environmental-weather end of the @zakkster real-time canvas stack. Confetti bursts, fireworks bloom, rain streaks -- all short-lived spawn-and-die effects. None of them models the thing an ambient winter scene actually needs: thousands of flakes drifting on parallax depth, settling into a persistent pile that decays over time, all on a flat structure-of-arrays pool that never allocates once it is built. lite-snow is that piece: a headless simulation you drive every frame and hand a 2D context, never touching the GC in the hot loop.
npm install @zakkster/lite-snowimport { SnowEngine } from '@zakkster/lite-snow';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const snow = new SnowEngine(10000);
let w = canvas.width, h = canvas.height;
let last = performance.now();
function loop(time) {
const dt = Math.min((time - last) / 1000, 0.1);
last = time;
snow.spawn(dt, w, h); // top up the pool
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, w, h); // your background -- the engine never clears
snow.updateAndDraw(ctx, dt, w, h); // physics + render, snow overlays on top
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);One import, one pool, zero allocations after construction. Live demo (./demo/demo.html in the repo).
Table of contents
- Why this exists
- What you get
- SnowEngine, phase by phase
- API reference
- Composability
- Splitting physics from pixels
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
Ambient snow has two requirements that no small library solves at once:
It runs forever. A menu backdrop, a game weather layer, or a holiday landing page keeps the loop alive for minutes or hours. A per-flake object churned every frame turns into GC jitter you can watch.
lite-snowholds every flake in preallocated typed-array columns and rebuilds its render bins in place, so a 10,000-flake field allocates nothing after the constructor returns -- proven under--expose-gcat zero bytes per frame and zero major collections.It has to look settled, not just falling. Real snow drifts on wind, sways on its own little sine, gets fainter with depth, and then piles up and slowly melts.
lite-snowmodels all of it: per-flake sinusoidal drift, Z-depth parallax scaling every physical parameter, a two-phase falling -> settled lifecycle, and an opt-inaccumulatemode that raises a persistent height-map pack flakes land on and that decays over time as an exact integer ledger.
Existing options are either heavyweight particle frameworks (tens of KB, allocating, config-heavy) or hand-rolled canvas loops (no depth, no accumulation, allocating a fresh array every frame). lite-snow is the purpose-built kernel for this one job.
What you get
new SnowEngine(maxParticles?, config?)-- one stateful pool that owns every buffer it will ever use. Build it once; drive it every frame withspawn()thenupdateAndDraw().spawn(dt, w, h)-- tops up the pool; spawn count auto-scales with canvas area x density x dt.updateAndDraw(ctx, dt, w, h)-- one call runs the physics pass, bins every live flake, and renders in a fixed handful of batched fills. Does not clear the canvas.clear()/destroy()-- full simulation reset, and full teardown that nulls every typed array.
- Three O(1) telemetry getters --
fallingCount,meltingCount,activeCount, maintained by the state transitions, never scanned. SNOW_PRESETS-- four frozen, complete scenes:flurry,heavy,blizzard,calm. Each names the same 21 scene knobs, so{ ...calm, ...heavy }equalsheavyexactly.- Accessibility built in --
reducedMotion: trueis a construction-time hard override that swaps the twelve motion knobs forcalm's values. The engine does no DOM access; your app readsprefers-reduced-motionand passes the flag. - Zero runtime dependencies -- the OKLCH-object-to-CSS-string conversion is inlined; RNG is injectable for determinism.
Full types ship in ./SnowEngine.d.ts. Every config key, method, getter, and internal field is documented.
SnowEngine, phase by phase
Phase 1: falling (state = 1)
Flakes spawn above the viewport with a random Z-depth in [0.2, 1.0]. Every parameter scales by that depth, precomputed once at spawn into dedicated columns (gz[], wz[], radius[], driftAmp[], bucket[]):
| Property | Formula | Effect |
|---|---|---|
| Fall speed | gravity x z | far flakes fall slower |
| Wind drift | wind x z | far flakes drift less |
| Flake radius | (baseRadius +/- jitter) x z | far flakes are smaller |
| Drift amplitude | driftAmplitude x z | far flakes sway less |
| Render alpha | z x 0.8 | far flakes are fainter |
Each flake also carries a unique sinusoidal drift -- a sine wave with per-flake random phase, frequency, and amplitude -- which produces the floating-leaf motion that makes snow read as real rather than as vertical rain.
Living air (opt-in). Three forces layer on top, all off by default and all byte-identical to v1.1.1 when off: gust (a global sinusoidal horizontal acceleration on the shared clock), turbulence (a per-flake rotating acceleration that reuses each flake's driftPhase/driftSpeed, adding no new RNG and no new column), and drag (terminal-velocity damping). The vx[]/vy[] columns carry the perturbation. See ./decisions/0002-living-air.md.
Phase 2: settled and melting (state = 2)
When a flake reaches the floor (y >= h, or floorY), it settles:
- its shape morphs from a circle to a flat ellipse (2.5x wider, 0.5x tall), as if flattening on the ground;
- its alpha fades from
zto 0 over a randommeltTimeMin..meltTimeMaxseconds, computed via one reciprocal per frame, not per flake.
State 2 is the landed state: a flake that has come to rest and is now aging out. There is no separate landedCount -- meltingCount is the settled count.
Accumulation pack (opt-in, construction-time)
With accumulate: true, settled flakes also raise a persistent pack: a Uint16Array height-map, one column per packResolution px, that flakes land on (settling at floorY - pack[column]) and that decays over time. It is an exact integer ledger -- packSum === landed - decayed - capped - truncated with no epsilon -- caps each column at maxPackHeight, skips out-of-range columns rather than clamping them into a false wall, and truncates in place on resize. Off by default the engine is byte-identical to v1.2.0 and pack === null. See ./decisions/0003-accumulation.md.
Bucketed rendering
Flakes are binned into three depth tiers at spawn:
| Bucket | Z range | Alpha | Radius scale | |---|---|---|---| | 0 (far) | 0.2-0.4 | 0.24 | ~0.3x | | 1 (mid) | 0.4-0.7 | 0.44 | ~0.55x | | 2 (near) | 0.7-1.0 | 0.72 | ~0.9x |
The physics pass bins every live flake into four preallocated Uint32 index lists in one sweep, so the render touches only live slots, never the whole pool. Each depth bucket renders in one batched ctx.fill(); melting flakes are quantized into at most 8 alpha bands, each a single fill(). A full frame is therefore 3 depth-bucket fills + [0..1] pack fill (only when accumulate: true and the pack is non-empty) + up to 8 melt-band fills -- 3 + 1 + 8 worst case, independent of how many flakes are on screen.
API reference
new SnowEngine(maxParticles?, config?)
| Parameter | Type | Default | Description |
|---|---|---|---|
| maxParticles | number | 10000 | Pool capacity, shared between falling and melting flakes. |
| config | SnowConfig | see below | All options. Live-mutable between frames. |
Both are validated at construction and throw a RangeError naming the value:
| Input | Rule |
|---|---|
| maxParticles | integer, 1 <= n <= 10000000 |
| baseRadius | finite, > 0 |
| packResolution (armed) | integer, 1 <= n <= 256 |
| maxPackWidth (armed) | integer, packResolution <= n <= 16384 |
| maxPackHeight (armed) | integer, 1 <= n <= 65535 |
A parameter that sizes an allocation throws; a parameter that only shapes behaviour coerces to its default. That is why the pack dimensions throw while spawnBand, spawnMargin, floorY, and the living-air knobs fail closed to their defaults. See ./decisions/0004-presets-reduced-motion.md.
Methods and telemetry
| Method | Signature | Description |
|---|---|---|
| .spawn | .spawn(dt, w, h) | Spawn new flakes. Auto-scales with area x density x dt. |
| .update | .update(dt, w, h) | Physics + binning only, no ctx, runnable headless in bare Node. Always advances on a valid frame. |
| .draw | .draw(ctx) | Render the last update()'s state. Mutates no simulation state; draw(null) no-ops; a draw() with no update() this frame redraws the last frame. |
| .updateAndDraw | .updateAndDraw(ctx, dt, w, h) | Physics + render in one call, the update() + draw() compat wrapper. Does not clear the canvas. A bad ctx freezes the whole frame (see the migration note below). |
| .clear | .clear() | Full simulation reset: kills all particles, zeroes the drift clock, resets the dimension cache and the telemetry counters. |
| .destroy | .destroy() | Runs clear(), then nulls the 14 SoA columns, the 4 render bins, the pack, and the config/color references. Idempotent. |
| Getter | Description |
|---|---|
| .fallingCount | Flakes in the falling phase (state === 1). |
| .meltingCount | Settled/melting flakes (state === 2) -- the landed count. |
| .activeCount | fallingCount + meltingCount -- pool occupancy. |
All three getters are O(1) integer reads maintained by the state transitions, not scans. activeCount / maxParticles measures how close the pool is to saturation; a pool pinned at capacity means density is outrunning the melt rate and new flakes are being silently dropped. Because the counts are maintained rather than counted, writing state[i] directly desynchronises them -- the 14 SoA columns are public to read, but a slot's state is the engine's to change (go through spawn(), clear(), or the melt lifecycle).
If a draw call throws -- a lost context, a canvas removed mid-frame -- the engine restores ctx.globalAlpha to 1.0 and rethrows. Physics has already settled by then, so the pool is intact and the next frame renders normally.
Presets and constants
| Preset | Density | Wind | Gravity | Drift | Feel |
|---|---|---|---|---|---|
| SNOW_PRESETS.flurry | 10 | 30 | 40 | 15 | gentle -- its values ARE the engine defaults |
| SNOW_PRESETS.heavy | 24 | 150 | 80 | 25 | dense, windy |
| SNOW_PRESETS.blizzard | 40 | 400 | 250 | 50 | extreme whiteout |
| SNOW_PRESETS.calm | 4 | 8 | 20 | 4 | minimal motion -- the reducedMotion target |
Each preset names all 21 scene knobs (defaults included via the same constants the engine uses), so { ...SNOW_PRESETS.calm, ...SNOW_PRESETS.heavy } equals heavy exactly. Presets never name rng, color, or reducedMotion -- injection, appearance, and accessibility are not scene. SNOW_PRESETS and every preset object are frozen all the way down; spread a preset to customise it ({ ...SNOW_PRESETS.heavy, wind: 40 }), because assigning into one would reconfigure every engine built from it afterwards.
| Constant | Value | Meaning |
|---|---|---|
| VERSION | package version string | Kept in three-place sync with package.json and llms.txt. |
| SNOW_PRESETS | frozen { flurry, heavy, blizzard, calm } | Four complete 21-key scenes. |
| maxParticles ceiling | 10000000 | A typo fails loudly instead of attempting a multi-gigabyte allocation. |
| radius floor | 0.01 | radius[] is clamped to this minimum after depth scaling. |
Config reference
Most values are read live every frame -- mutate snow.config.gravity, wind, density, driftAmplitude, packDecay, maxPackHeight, floorY, friction, and the other physics knobs between frames and the next frame honours them. Seven knobs are the exception, resolved once at construction and NOT read live: accumulate, packResolution, and maxPackWidth size the pack; reducedMotion resolves the motion override; spawnBand and spawnMargin precompute the spawn band; and color is pre-parsed to a CSS string. Mutating any of those seven on a live instance is unsupported and has no effect (for packResolution it would take effect only partially, since _packCols/_invPackRes are frozen at construction -- do not rely on it).
| Option | Type | Default | Description |
|---|---|---|---|
| gravity | number | 40 | Downward acceleration (px/s^2). Snow is light. |
| wind | number | 30 | Horizontal wind (px/s). Positive = right. |
| density | number | 10.0 | Spawn multiplier. Auto-scales with canvas area. |
| baseRadius | number | 2.5 | Base flake radius (px). Depth-scaled per flake. Throws if not finite > 0. |
| driftAmplitude | number | 15 | Horizontal drift sine amplitude (px). Depth-scaled. |
| driftFreq | number | 1.0 | Drift sine frequency (Hz). Per-flake jitter +/-0.25. |
| meltTimeMin | number | 2.0 | Minimum time before a settled flake fades (seconds). |
| meltTimeMax | number | 5.0 | Maximum melt time (seconds). |
| gust | number | 0 | Global sinusoidal horizontal acceleration (px/s^2). 0 = off. Fails closed to default. |
| gustFreq | number | ~2.094 (TAU/3) | Gust angular frequency (rad/s). 0 freezes the gust off. |
| turbulence | number | 0 | Per-flake rotating acceleration (px/s^2). 0 = off. Reuses drift phase/speed, no new RNG. |
| drag | number | 1 | Terminal-velocity damping in [0,1]. 1 = off (positional base untouched). |
| accumulate | boolean | false | Build the persistent pack. Construction-time only (arms on === true); a runtime flip is inert. |
| packResolution | number | 4 | Pack column width (px). Armed: integer [1,256] or throws. |
| maxPackWidth | number | 4096 | Px the pack spans; floor(maxPackWidth/packResolution) columns. Armed: integer [packResolution,16384] or throws. |
| maxPackHeight | number | 200 | Max height (packUnits) per column; landings past it are capped and booked. Armed: integer [1,65535] or throws. |
| packDecay | number | 2.0 | Pack decay rate (packUnits/s). Non-finite -> default; negative -> 0. |
| floorY | number | null | null | Settle floor (px). null tracks the per-frame h; a finite value is used raw. Non-finite -> null. |
| friction | number | 0 | Contact-frame vx damping applied once at settle. Clamped [0,1]; negative -> 0. |
| spawnBand | { min, max } | null | null | Spawn Y band, drawn as y = max - rng()*(max-min). Null/garbage fails closed to [-100,-50]. |
| spawnMargin | number | null | null | Fixed spawn inset (px). null derives the v1.3.0 wind offset (h/gravity)*abs(wind). Non-finite/negative -> null. |
| reducedMotion | boolean | false | Hard override: === true swaps the 12 motion keys for calm's. Resolved once; the flag wins over any knob set alongside it. |
| color | OklchColor | string | 'oklch(0.98 0.02 250)' | Flake color. Pre-parsed to a CSS string at construction. |
| rng | Function | Math.random | RNG () => number in [0,1). Inject for determinism. |
reducedMotion is a hard override, not a modifier. reducedMotion: true overwrites the twelve MOTION keys -- gravity, wind, density, baseRadius, driftAmplitude, driftFreq, gust, gustFreq, turbulence, drag, spawnBand, spawnMargin -- with calm's values. The flag wins over any explicit knob set alongside it, so { reducedMotion: true, gust: 500 } has gust 0 and { ...SNOW_PRESETS.blizzard, reducedMotion: true } equals calm. It resolves once at construction (a runtime flip is inert) and before baseRadius validation, so { baseRadius: 0, reducedMotion: true } constructs (baseRadius becomes calm's 2.5) while { baseRadius: 0 } throws. Non-motion keys (accumulate, pack*, floorY, friction, meltTime*) are not overridden -- accumulation is a pile, not motion. The engine performs no DOM access; read the media query in your app:
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const snow = new SnowEngine(2000, { ...SNOW_PRESETS.blizzard, reducedMotion: reduce });Fail-closed frames
spawn() and updateAndDraw() validate dt, w, and h once per call, before touching any state. A frame the engine cannot trust is a frame it does not run:
| Input | Behaviour |
|---|---|
| dt non-finite (NaN, Infinity) or negative | no-op frame |
| dt greater than 0.1 | clamped to 0.1 |
| dt of 0 | runs; no motion, still renders |
| w or h non-finite or <= 0 | no-op frame |
| ctx null, or missing arc / ellipse | no-op frame |
A no-op frame advances no clock, touches no particle, and draws nothing, so the canvas keeps whatever was last drawn -- a dropped frame reads as a held frame, not a blank one. Nothing is substituted for a rejected dt; a default value would fabricate motion you did not ask for. This matters most on the first requestAnimationFrame tick, after a backgrounded tab, or on a canvas not yet laid out -- all of which can hand you NaN or 0.
Composability
Every @zakkster weather and effect engine shares the same drive contract -- spawn(), then updateAndDraw(ctx, dt, w, h), no clearing of its own -- so a full scene is just a stack of engines sharing one context, painted back to front in one frame:
import { SnowEngine } from '@zakkster/lite-snow';
import { RainEngine } from '@zakkster/lite-rain';
import { FireworksEngine } from '@zakkster/lite-fireworks';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
let w = canvas.width, h = canvas.height, last = performance.now();
// One ctx, three engines, each owning its own preallocated pool.
const snow = new SnowEngine(8000, { accumulate: true });
const rain = new RainEngine(6000, { density: 3 });
const fireworks = new FireworksEngine(5000);
function loop(time) {
const dt = Math.min((time - last) / 1000, 0.1);
last = time;
snow.spawn(dt, w, h);
rain.spawn(dt, w, h);
ctx.fillStyle = '#05060f';
ctx.fillRect(0, 0, w, h); // the caller owns clearing
fireworks.updateAndDraw(ctx, dt, w, h); // bloom in the far background
snow.updateAndDraw(ctx, dt, w, h); // drifting + accumulating snow
rain.updateAndDraw(ctx, dt, w, h); // rain streaks on top
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);Each engine is deterministic under an injected rng, allocates nothing after construction, and hands the next stage the same flat 2D context -- no format translation, no allocation between layers.
Splitting physics from pixels
updateAndDraw() is the compat wrapper; update() and draw() are the two halves it fuses. Splitting them buys three things the fused call cannot express -- fixed-step physics, headless simulation, and off-thread rendering (decisions/0005-update-draw-split.md).
Fixed-step physics, variable-rate draw
Run physics on a fixed accumulator so behaviour is frame-rate independent, and render once per animation frame no matter how many steps ran. draw() reads the bins the last update() filled, so one draw() renders the state after all N steps:
const STEP = 1 / 120; // fixed physics step, seconds
let acc = 0;
let last = performance.now();
function loop(now) {
acc += (now - last) / 1000;
last = now;
// Catch physics up to wall-clock in fixed increments -- no ctx here.
while (acc >= STEP) {
snow.spawn(STEP, w, h);
snow.update(STEP, w, h);
acc -= STEP;
}
ctx.fillStyle = '#0b1020';
ctx.fillRect(0, 0, w, h);
snow.draw(ctx); // one render of the state after all the steps above
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);A draw() with no update() since the last one simply redraws the last committed frame -- that is also how you repaint on a resize or an expose event at zero physics cost. clear() un-commits that frame, so a clear(); draw() renders empty, never the ghosts of dead flakes.
Headless simulation and worker / OffscreenCanvas
update() touches no ctx and no DOM, so it runs in bare Node (tests, server-side determinism checks) or inside a Web Worker driving an OffscreenCanvas. Advance physics off the main thread, render from the same instance:
// worker.js -- no window, no document
import { SnowEngine } from '@zakkster/lite-snow';
let snow, ctx, w = 0, h = 0;
onmessage = (e) => {
const m = e.data;
if (m.type === 'init') {
const canvas = m.canvas; // a transferred OffscreenCanvas
ctx = canvas.getContext('2d');
w = canvas.width; h = canvas.height;
snow = new SnowEngine(4000, { accumulate: true });
} else if (m.type === 'tick') {
snow.spawn(m.dt, w, h);
snow.update(m.dt, w, h); // physics, off the main thread
ctx.clearRect(0, 0, w, h);
snow.draw(ctx); // render into the OffscreenCanvas
}
};// main thread
const canvas = document.querySelector('#snow').transferControlToOffscreen();
const worker = new Worker('./worker.js', { type: 'module' });
worker.postMessage({ type: 'init', canvas }, [canvas]);
// then post { type: 'tick', dt } each animation frameMigration note: bad-ctx behaviour differs deliberately
The split API and the fused wrapper differ on ONE point, by design (decisions/0005-update-draw-split.md (a)). updateAndDraw(ctx, ...) with a null or malformed ctx is a total no-op: physics included, the clock stays frozen. The split's update() has no ctx to check and always advances; only draw(null) no-ops. So moving from
snow.updateAndDraw(ctx, dt, w, h); // bad ctx -> whole frame frozento
snow.update(dt, w, h); snow.draw(ctx); // bad ctx -> physics still advanceschanges what a bad-ctx frame does. That is the separation the split exists to buy -- keep calling updateAndDraw if you want the old freeze-on-bad-ctx behaviour.
Zero-GC design notes
new SnowEngine(max) allocates every buffer it will ever use up front: 14 structure-of-arrays columns, 4 render-bin index lists, and one fixed melt-band histogram. updateAndDraw() afterward does nothing but arithmetic on those typed arrays and byte writes -- it rebuilds the bins in place, never reallocating. The torture harness (@zakkster/lite-leak + @zakkster/lite-gc-profiler) proves 0 B/op across the frame loop, 0 retained bytes over 4096 build/drive/destroy cycles, and 0 major collections, under --expose-gc.
Per-particle (scales with maxParticles):
| Group | Columns | Element | Bytes/particle |
|---|---|---|---|
| SoA floats | x, y, z, gz, wz, radius, driftPhase, driftSpeed, driftAmp, life, vx, vy (12) | Float32 | 48 |
| SoA bytes | bucket, state (2) | Uint8 | 2 |
| Render bins | _bin0, _bin1, _bin2, _binMelt (4) | Uint32 | 16 |
| Total | | | 66 |
So the default 10,000 slots cost 660 KB; the 10,000,000 ceiling is 660 MB. The vx/vy living-air columns add +8 bytes over v1.1.1's 58.
Fixed (does NOT scale with maxParticles):
| Buffer | Type | Bytes | When |
|---|---|---|---|
| _meltAlphaCount | Uint32Array(8) | 32 | always |
| pack | Uint16Array(maxPackWidth / packResolution) | 2048 at defaults (4096 / 4 = 1024 cells) | only when accumulate: true |
Unarmed, pack === null and costs nothing.
Bottom line: ZERO allocations after construction. No exceptions, no asterisk.
Size provenance. SnowEngine.js is 818 lines / 37,979 bytes raw / 11,022 bytes at gzip -9, measured 2026-08-22 on Node v26.3.1, darwin arm64 26.5.2. That figure is unminified -- do not compare it against a competitor's minified+gzipped number. The bundle-size badge above pulls the live minified+gzipped figure from bundlephobia.
Design decisions worth knowing
- Zero runtime dependencies, by design. The one thing the engine needs from outside -- turning an OKLCH color object into a CSS string -- is inlined, so there is no color-library dependency on the render path.
./decisions/0001-zero-deps.md. - Living air is a separate integration model.
drag < 1folds gravity and wind intovx/vyand damps toward a terminal fall speed; atdrag === 1the positional base is untouched and byte-identical to v1.1.1. Turbulence reuses each flake's existing drift phase/speed, so it adds no RNG draw and no column../decisions/0002-living-air.md. - Accumulation is an exact integer ledger, and out-of-range columns SKIP. The pack conserves
landed - decayed - capped - truncatedwith no epsilon, and the decay domain is a superset of the raise domain (the S6 lesson: when two guards cover one state, the write domain must be a subset of the cleanup domain). Overhang columns settle at the floor and raise nothing rather than clamping into a false edge wall../decisions/0003-accumulation.md. - Presets name 21 of 24 keys;
reducedMotionis a hard override. ExcludingreducedMotionfrom presets is load-bearing: if a preset namedreducedMotion: false, then{ reducedMotion: true, ...SNOW_PRESETS.blizzard }would silently cancel the accessibility flag. The subtractive spawn formmax - rng()*(max-min)is preserved verbatim because the mirror form is a different per-draw value and would break every determinism digest../decisions/0004-presets-reduced-motion.md.
Testing
The suite is node:test only -- no framework, no runtime dependency. Deterministic unit and boundary tests cover the config surface, the two-phase lifecycle, the accumulation ledger, the four presets, the reduced-motion override, and the fail-closed frame door. A docs-drift guard asserts every method, getter, and config key is documented in both README.md and llms.txt (both directions), every relative link in the shipped docs resolves inside the published tarball (not merely on disk, which is what lets it catch a link to a file files[] does not ship), and the allocation table's 66 B/particle matches the measured typed-array partition. The torture gate (13 tiers, T0-T12) proves leak-freedom and the zero-alloc frame loop.
npm test # node:test unit + boundary + docs-drift suites
npm run torture # @zakkster/lite-leak + lite-gc-profiler: 0 B/op, 0 retained, 0 major GC
npm run verify # test + torture, the publish gateEvery performance and quality claim is a committed digest or a gated number; no gate output is a FAIL. See ./CHANGELOG.md for the per-release record.
What this is not
- Not a physics engine. Flakes drift, settle, and melt on a fixed integration model; there is no rigid-body dynamics, no forces you compose, no constraint solver.
- Not a collision system. Flakes do not collide with each other or with scene geometry. The only surface is the settle floor (
horfloorY) and the accumulation pack height. - Not a renderer. The engine issues a fixed handful of batched fills into a 2D context you own and never calls
clearRect-- snow is an overlay. Your code clears and draws the background. - Not DOM-aware. No
window, nodocument, nomatchMedia. Reduced motion is a flag you pass; resize is aw/hyou pass. - Not a WebGL path. Canvas 2D only. There is no shader and no GPU buffer. Off-thread rendering IS supported: update() is headless, so it drives an OffscreenCanvas from a Web Worker (see Splitting physics from pixels).
Ecosystem
Part of the @zakkster zero-GC stack:
lite-rain-- environmental rain with the same drive contractlite-fireworks-- shell bursts and bloomslite-confetti-- spawn-and-die celebration particleslite-color-- OKLCH color pipelinelite-random-- seeded deterministic RNGlite-snow-- this package
License
MIT (c) Zahary Shinikchiev [email protected]
