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

v1.4.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.

Downloads

689

Readme

@zakkster/lite-snow

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

Zero-GC SoA environmental snow engine with drift physics, Z-depth parallax, opt-in persistent-pack accumulation, and bucketed rendering. Zero dependencies. 3 presets.

Live Demo

https://cdpn.io/pen/debug/yyapJqB

Why lite-snow?

| Feature | lite-snow | tsparticles | weatherJS | p5.js | |---|---|---|---|---| | Zero-GC hot path | Yes | No | No | No | | SoA flat arrays | 14 arrays | No | No | No | | Z-depth parallax | Yes (0.2-1.0) | No | No | Manual | | Sinusoidal drift | Per-flake | Partial | No | Manual | | Snow accumulation | Persistent pack + ellipse morph | No | No | No | | Bucketed rendering | 3 tiers | No | No | No | | Built-in presets | 3 | Config-heavy | No | No | | OKLCH color | Yes | No | No | No | | Bundle size | 2.0KB gzipped | ~40KB | ~10KB | ~800KB |

Installation

npm install @zakkster/lite-snow

Quick Start

import { 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);

    ctx.fillStyle = '#0a0a1a';
    ctx.fillRect(0, 0, w, h);         // dark sky background

    snow.updateAndDraw(ctx, dt, w, h); // snow overlays on top
    requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

One-Liner with Presets

import { SnowEngine, SNOW_PRESETS } from '@zakkster/lite-snow';

const blizzard = new SnowEngine(15000, SNOW_PRESETS.blizzard);

Important: updateAndDraw() does not clear the canvas. Snow is an overlay effect. Call ctx.clearRect() or draw your background before calling updateAndDraw().


Presets

| Preset | Density | Wind | Gravity | Drift | Radius | Feel | |---|---|---|---|---|---|---| | SNOW_PRESETS.flurry | 10 | 30 | 40 | 15 | 2.5 | Gentle, peaceful | | SNOW_PRESETS.heavy | 24 | 150 | 80 | 25 | 3.5 | Dense, windy | | SNOW_PRESETS.blizzard | 40 | 400 | 250 | 50 | 2.0 | Extreme whiteout |


The Snow Pipeline

Phase 1: Falling Flake (state = 1)

Snowflakes spawn above the viewport with random Z-depth (0.2-1.0). Every parameter scales by Z:

| Property | Formula | Effect | |---|---|---| | Fall speed | gravity × z | Far flakes fall slower | | Wind drift | wind × z | Far flakes drift less | | Flake radius | (baseRadius +/- jitter) × z | Far flakes are smaller | | Drift amplitude | driftAmplitude × z | Far flakes sway less | | Render alpha | z × 0.8 | Far flakes are faint |

Each flake has a unique sinusoidal drift -- a sine wave with per-flake random phase, frequency, and amplitude. This produces the natural floating-leaf motion that makes snow look real instead of just falling vertically.

All Z-dependent values are precomputed at spawn: gz[], wz[], radius[], driftAmp[], bucket[].

Phase 2: Melt (state = 2)

When a flake reaches the floor (y >= h), it transitions to a settled state:

  • Shape morphs from a circle to a flat ellipse (2.5× width, 0.5× height) -- simulating a flake flattening on the ground
  • Alpha fades from z to 0 over meltTimeMin to meltTimeMax seconds
  • Computed via invMeltMax (one division per frame, not per flake)

This creates a subtle accumulation layer at the bottom of the canvas -- flakes don't just disappear, they settle and melt.

With accumulate: true (opt-in, construction-time only), settled flakes also raise a persistent pack: a Uint16Array heightmap, one column per packResolution px, that flakes land ON and that decays over time. It is an exact integer ledger (packSum === landed - decayed - capped - truncated), caps each column at maxPackHeight, skips out-of-range columns rather than clamping them into a false wall, and truncates on resize. Off by default, the engine is byte-identical to v1.2.0 and pack === null.

Bucketed Rendering

Flakes are binned into 3 depth tiers at spawn:

| Bucket | Z Range | Alpha | Radius Scale | |---|---|---|---| | 0 (far) | 0.2-0.4 | 0.24 | ~0.3× | | 1 (mid) | 0.4-0.7 | 0.44 | ~0.55× | | 2 (near) | 0.7-1.0 | 0.72 | ~0.9× |

Each depth bucket renders in one batched ctx.fill() call. Melting flakes are quantized into at most 8 alpha bands, each drawn in a single fill() -- so a full frame is 3 depth-bucket fills plus up to 8 melt-band fills (3 + 8 worst case), plus one closed-path pack fill when accumulate: true and the pack is non-empty (3 + 1 + 8 worst case), independent of how many flakes are on screen. The physics pass bins every live flake into preallocated index lists in one sweep, so the render touches only live slots, never the whole pool.


Full Config Reference

All config values are live-mutable between frames.

| Option | Type | Default | Description | |---|---|---|---| | gravity | number | 40 | Downward acceleration (px/s^2). Snow is very 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. | | 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 settled flake fades (seconds). | | meltTimeMax | number | 5.0 | Maximum melt time (seconds). | | color | OklchColor | string | 'oklch(0.98 0.02 250)' | Flake color. Pre-parsed at construction. | | rng | Function | Math.random | RNG function. Inject for determinism. |


Canvas Setup (No Built-in Resize)

import { SnowEngine } from '@zakkster/lite-snow';

const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const snow = new SnowEngine();

let w = 0, h = 0;
const dpr = window.devicePixelRatio || 1;

function updateSize() {
    w = canvas.clientWidth || window.innerWidth;
    h = canvas.clientHeight || window.innerHeight;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.scale(dpr, dpr);
}

let scheduled = false;
new ResizeObserver(() => {
    if (!scheduled) {
        scheduled = true;
        requestAnimationFrame(() => { scheduled = false; updateSize(); });
    }
}).observe(canvas.parentElement || document.body);

updateSize();

Seeded Random (Deterministic)

import { SnowEngine } from '@zakkster/lite-snow';
import { Random } from '@zakkster/lite-random';

const rng = new Random(42);
const snow = new SnowEngine(10000, { rng: () => rng.next() });

Recipes

import { SnowEngine, SNOW_PRESETS } from '@zakkster/lite-snow';

const snow = new SnowEngine(8000, {
    ...SNOW_PRESETS.flurry,
    color: { l: 0.95, c: 0.02, h: 240 },
});
const snow = new SnowEngine(12000, SNOW_PRESETS.heavy);
const snow = new SnowEngine(15000, SNOW_PRESETS.blizzard);

// Ramp wind over time
let windTarget = 400;
setInterval(() => {
    windTarget = 200 + Math.random() * 500 * (Math.random() > 0.5 ? 1 : -1);
}, 3000);

// In loop:
snow.config.wind += (windTarget - snow.config.wind) * dt * 1.5;
function gameLoop(dt) {
    snow.spawn(dt, w, h);

    ctx.clearRect(0, 0, w, h);
    drawBackground();
    drawCharacters();
    drawUI();

    snow.updateAndDraw(ctx, dt, w, h);
}
// Volcanic ash
const ash = new SnowEngine(6000, {
    color: { l: 0.3, c: 0.05, h: 30 },
    gravity: 60,
    driftAmplitude: 20,
});

// Cherry blossom petals
const petals = new SnowEngine(4000, {
    color: { l: 0.8, c: 0.15, h: 340 },
    gravity: 25,
    driftAmplitude: 30,
    baseRadius: 3.5,
});

// Floating embers
const embers = new SnowEngine(3000, {
    color: { l: 0.6, c: 0.25, h: 30 },
    gravity: -15,  // float upward!
    wind: 50,
    driftAmplitude: 10,
    baseRadius: 1.5,
});
import { SnowEngine } from '@zakkster/lite-snow';
import { RainEngine } from '@zakkster/lite-rain';
import { FireworksEngine } from '@zakkster/lite-fireworks';

const snow = new SnowEngine(8000);
const rain = new RainEngine(6000, { density: 3 });
const fireworks = new FireworksEngine(5000);

function loop(time) {
    const dt = /* ... */;

    snow.spawn(dt, w, h);
    rain.spawn(dt, w, h);

    fireworks.updateAndDraw(ctx, dt, w, h); // bloom background
    snow.updateAndDraw(ctx, dt, w, h);       // snow overlay
    rain.updateAndDraw(ctx, dt, w, h);       // rain on top
}
windSlider.oninput = () => snow.config.wind = +windSlider.value;
densitySlider.oninput = () => snow.config.density = +densitySlider.value;
gravitySlider.oninput = () => snow.config.gravity = +gravitySlider.value;
driftSlider.oninput = () => snow.config.driftAmplitude = +driftSlider.value;

API

new SnowEngine(maxParticles?, config?)

| Parameter | Type | Default | Description | |---|---|---|---| | maxParticles | number | 10000 | Pool capacity. Shared between flakes and melting. | | config | SnowConfig | see above | All options. Live-mutable. |

Both are validated at construction and throw a RangeError naming the value:

| Input | Rule | |---|---| | maxParticles | integer, 1 <= n <= 10000000 | | baseRadius | finite, > 0 |

Sizing. The pool costs 66 bytes per particle -- twelve Float32Array columns at 4 bytes plus two Uint8Array columns at 1 byte (50 bytes of SoA state), plus four Uint32Array render-bin index lists at 4 bytes each (16 bytes). The two velocity columns vx/vy (S5 living air) add +8 bytes over v1.1.1's 58. The default 10000 slots are 660 KB; the 10000000 ceiling is 660 MB. The ceiling exists so a typo fails loudly instead of attempting a multi-gigabyte allocation. The bin lists are allocated once at construction and rebuilt in place each frame, never reallocated.

The accumulation pack (S6) is a separate fixed cost, not per-particle: one Uint16Array of maxPackWidth / packResolution columns (default 4096 / 4 = 1024 cells = 2 KB), allocated once and only when accumulate: true, never reallocated (a resize truncates it in place). Unarmed, pack === null and costs nothing.

Methods

| Method | Description | |---|---| | .spawn(dt, w, h) | Spawn new flakes. Auto-scales with area × density. | | .updateAndDraw(ctx, dt, w, h) | Physics + render. Does not clear canvas. | | .clear() | Full simulation reset: kills all particles, zeroes the drift clock, and invalidates the dimension cache. | | .destroy() | Nulls the 14 typed arrays, the config and the render bins. Idempotent. |

Telemetry

| Getter | Description | |---|---| | .fallingCount | Flakes in the falling phase (state = 1). | | .meltingCount | Flakes in the melt phase (state = 2). | | .activeCount | Sum of the two -- pool occupancy. |

All three are O(1) integer reads maintained by the existing state transitions, not scans. Use activeCount / maxParticles to see 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 they are maintained rather than counted, writing state[i] directly desynchronises them. The fourteen SoA columns are public and you may read them freely, but a slot's state is the engine's to change: go through spawn(), clear() or the melt lifecycle. clear() resynchronises from scratch.

SNOW_PRESETS

| Preset | Description | |---|---| | .flurry | Gentle snowfall | | .heavy | Dense, windy | | .blizzard | Extreme whiteout |

The table and every preset object are frozen. Spread a preset to customise it ({ ...SNOW_PRESETS.heavy, wind: 40 }) -- assigning into one would otherwise reconfigure every engine built from it afterwards.

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 -- the canvas keeps whatever was last drawn, so a dropped frame reads as a held frame rather than a blank one. Nothing is substituted for a rejected dt: a default value would fabricate motion you did not ask for.

This matters most with requestAnimationFrame on the first tick, after a backgrounded tab, or on a canvas that has not been laid out yet -- all of which can hand you a NaN or 0. Before 1.0.2 those inputs corrupted the pool permanently.

If a draw call throws -- a lost context, a canvas that goes away mid-frame -- the engine restores ctx.globalAlpha to 1.0 and rethrows. It does not swallow the error, and it does not leave your subsequent drawing translucent at whatever depth alpha it happened to be using. Physics has already settled by then, so the engine is intact and the next frame renders normally.


License

MIT

Part of the @zakkster ecosystem

Zero-GC, deterministic, tree-shakeable micro-libraries for high-performance web presentation.