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-scratch-fx

v1.7.0

Published

One-shot scratch-card reveal effects on canvas. A controller scans the still-covered pixels of a scratch layer, spawns particles from them, and delegates physics and rendering to a recipe. 21 themeable recipes (burn, shatter, dissolve, glitch, gold dust,

Readme

@zakkster/lite-scratch-fx

One-shot scratch-card reveal effects on canvas. A controller scans the remaining pixels of a scratch layer, spawns particles from them, and delegates physics + rendering to a recipe. 21 ready-made reveal recipes, themeable palettes. Zero GSAP, zero-GC hot path, deterministic seeded RNG for reproducible reveals.

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

The scratch-card reveal layer the ecosystem was missing

Scratch cards have two halves that nobody ships together. The interaction -- a pointer erasing a foil layer -- is a five-line canvas destination-out loop you already know how to write. The reveal -- the moment the card decides "won", clears the rest, and celebrates -- is where every project reinvents an ad-hoc particle burst, hardcodes a palette, and (because it runs a fresh burst every frame) drops frames on the GC. This library is that second half: a controller that reads the still-covered pixels of your scratch layer as spawn points, and 21 ready-made recipes that turn them into a burn, a shatter, a confetti blast, or your own. Deterministic from a seed, so an instant-win reveal is auditable and replayable. Zero GSAP.

npm install @zakkster/lite-scratch-fx
import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';

// scratchCanvas: the foil layer the user scratches off.
// fxCanvas: an overlay canvas the reveal renders particles onto.
const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });

revealButton.onclick = () => {
    fx.reveal(BurnRecipe(), () => {
        console.log('prize revealed');
    });
};

reveal(recipe, onDone?) samples spawn points from the still-covered pixels of the scratch layer, runs the recipe to completion, clears the overlay, then calls onDone. A second reveal while one is active is ignored. Same seed in, same reveal out -- every time.


Table of contents


Why this exists

The gap is interaction vs reveal. Erasing a foil layer with a pointer is trivial and every stack solves it inline. The reveal -- the payoff animation once the card is decided -- is what gets copy-pasted, hardcoded, and quietly leaked. Three things make it worth a library instead of a snippet:

  1. The spawn points come from the layer itself. A reveal should erupt from the part still covered, not from a fixed grid. The controller downsamples the scratch layer, collects the pixels whose alpha is still above threshold, and hands those to the recipe as normalized 0..1 spawn spots -- so a half-scratched card reveals only what is left.

  2. Instant-win reveals must be auditable. A prize-bearing reveal that cannot be reproduced is a support ticket waiting to happen. The RNG is seeded and deterministic: the same seed replays the exact same reveal, so "what did the player see" is a reproducible question, not a guess. seed(s) re-seeds between reveals.

  3. The reveal runs every frame -- so it cannot allocate. A burst that mints a fresh object per surviving pixel, or a new particle-view per frame, turns the celebration into GC jitter. The reveal-start scan and the per-frame tick are both zero-allocation by construction (see Zero-GC design notes).


What you get

  • createScratchController(scratch, fx, options?) -- one controller over a scratch layer and an effect overlay. reveal(recipe, onDone?) scans, spawns, runs to completion, clears, and calls back. The API every caller reaches for.
  • 21 reveal recipes across 4 families -- 10 particle (burn, dissolve, explode, dragonBreath, iceBreath, goldDust, confettiBlast, cosmicDust, matrixDecay, liquidMelt), 3 image (shatter, pixelShatter, glitchReveal), 5 beam (shine, shineWave, laserScan, lightningCrawl, neonPulse), and 3 css (peel, fade, implosion). Bring your own by matching the recipe interface.
  • 14 themeable recipes. Every recipe with a visible palette takes the same { colors, theme } pair, so a themed host threads one { light, mid, dark } triple through every effect. The 7 image / neutral recipes have no palette and ignore both.
  • Three driving modes, none able to double-drive the loop -- the default self-driven engine (one card), { driven: true } (one page clock drives N controllers), and createScratchStage (many boxes revealing at once over one shared pool).
  • An extensible registry. RECIPES (short name -> factory), RECIPE_NAMES, and a live RECIPE_META array so a picker groups and filters without hardcoding the list. registerRecipe adds or overrides a recipe and every existing picker sees it.
  • Full TypeScript declarations ship in index.d.ts -- the recipe interface, controller and stage options, Theme, and ThemeableRecipeOptions.

The controller and the recipe interface

A recipe is a plain object with five members. The controller calls init once, spawn for each particle, then tick every frame until it returns true, and finally destroy:

function MyRecipe({ count = 120, duration = 1000 } = {}) {
    let size;
    return {
        count,                                   // 0 = pure-canvas reveal, no particles
        init(ctx, capacity, w, h, rng) {         // allocate arrays; rng = seeded Random (same one passed to spawn), use instead of Math.random for reproducible reveals
            size = new Float32Array(capacity);
        },
        spawn(idx, rng, w, h, spot) {            // one particle; spot is 0..1 on the layer
            size[idx] = rng.range(2, 5);
            return { x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-4, -1), life: 1 };
        },
        tick(dt, elapsedMs, p, ctx, src, w, h) { // every frame; return true when done
            let alive = 0;
            for (let i = 0; i < p.max; i++) {
                if (p.life[i] <= 0) continue;
                const slot = p.data[i];          // maps back to the spawn index (see below)
                p.x[i] += p.vx[i] * dt * 60;
                p.y[i] += p.vy[i] * dt * 60;
                p.life[i] -= dt;
                if (p.life[i] <= 0) continue;
                alive++;
                ctx.globalAlpha = p.life[i];
                ctx.beginPath();
                ctx.arc(p.x[i], p.y[i], size[slot], 0, Math.PI * 2);
                ctx.fill();
            }
            return alive === 0 && elapsedMs >= duration;
        },
        destroy() { size = null; },
    };
}

spot is shared and mutable -- never retain it. The spot handed to every spawn call is one reused object. Read spot.x / spot.y synchronously; the next spawn overwrites it in place. This is what keeps reveal-start allocation-free: there is no { x, y } literal per surviving pixel.

p.data[i] maps a live particle back to its slot. Recipes keep their own parallel typed arrays (size above), keyed by spawn index. The controller passes each particle's spawn index as the engine's data flag, so inside tick you read p.data[i] to recover the index your spawn wrote to. A test locks this mapping so it stays correct across engine upgrades.

dt is in seconds; elapsedMs is milliseconds since the reveal began. The particle-view p (raw SoA lanes x, y, vx, vy, life, invLife, data, max) is reused across frames, so tick allocates nothing. To make a recipe themeable, take { colors, theme } in your factory and resolve a ramp once with resolvePalette.


API reference

createScratchController

createScratchController(sourceCanvas, effectCanvas, options?): ScratchController

| Option | Type | Default | Meaning | | -------------- | ----------------- | ------------- | --------------------------------------------------- | | maxParticles | number | 2000 | Sizes the owned pool; when engine is given the capacity is taken from engine.x.length and this is ignored | | seed | number | Date.now() | Deterministic RNG seed | | scanPrecision| number | 32 | Horizontal resolution of the spawn-point pixel scan | | driven | boolean | false | Host-driven mode: you call tick(dt) each frame | | engine | SoaParticleEngine | (own) | Share one caller-supplied engine (one lane pool) |

Returns a controller:

controller.reveal(recipe, onDone?): void   // run a reveal; ignored if one is active
controller.tick(dt): void                  // advance a frame in { driven: true } mode; dt in SECONDS
controller.seed(s): void                   // re-seed the RNG for reproducible reveals
controller.destroy(): void                 // stop, clean up the active recipe, release an owned engine
  • default -- the controller owns a @zakkster/lite-soa-particle-engine that self-drives its own requestAnimationFrame loop. Fine for a single card.
  • { driven: true } -- the controller never starts a RAF loop; the host calls tick(dt) every frame. tick is a no-op unless a reveal is active, so calling it on every box is cheap. One clock, N controllers.
  • { engine } -- share one engine (one lane pool for the whole page). A shared engine has a single render slot, so one reveal per shared engine at a time; a reveal on a busy shared engine is ignored, and destroy() never tears a shared engine down.

createScratchStage

For many boxes revealing simultaneously over a single pool, a stage owns one engine, hands each controller a fixed sub-range of the lanes, and drives every active reveal through one clock.

createScratchStage(options?): ScratchStage

| Option | Type | Default | Meaning | | -------------- | ------ | ------------ | ---------------------------------------------- | | maxParticles | number | 2000 | Total shared pool capacity, split across cards | | seed | number | Date.now() | Base seed; each controller derives base + index |

stage.createController(sourceCanvas, effectCanvas, options?): StageController
stage.tick(dt): void                  // advance every active reveal one frame; dt in SECONDS
stage.destroy(): void                 // stop everything and release the shared engine
stage.remainingCapacity              // slots still unreserved in the shared pool (read-only)
stage.remainingCapacityFor(capacity) // exact slots allocatable for a request of this size:
                                     // a size-C createController succeeds iff this returns >= C

createController options: capacity (slots reserved out of the pool, default 300 -- reserving past maxParticles throws), seed (default: the stage base seed plus the controller index), and scanPrecision (default 32). A stage-managed controller exposes reveal(recipe, onDone?), seed(s), and destroy() -- but no tick of its own, the stage drives it.

Registry and palette helpers

registerRecipe(id, factory, meta?): RecipeFactory
resolvePalette(colors, theme, fallback): string[]
  • registerRecipe(id, factory, meta?) -- add a recipe or override a built-in. It lands in RECIPES and RECIPE_META immediately, so existing pickers pick it up with no code change. Omitted meta fields fall back to the prior entry, then a de-camelCased name, category: 'custom', and false flags. Throws TypeError on a bad id or a non-function factory.
  • resolvePalette(colors, theme, fallback) -- resolve a colour ramp for a themeable recipe: colors wins, then theme, then the recipe's own fallback.

Several recipes take extra tuning knobs alongside count/duration; all default to the current built-in look:

  • DissolveRecipe -- fadeSpeed (default 0.3): fraction of duration over which the source layer fades out.
  • DragonBreathRecipe / IceBreathRecipe -- spread (jet half-angle in radians, defaults 0.3 / 0.225), speedMin/speedMax (spawn speed range, defaults 12-25 / 15-28).
  • BurnRecipe / DragonBreathRecipe -- glowBudget (default Infinity = unlimited): a per-frame cap on how many particles paint their outer glow halo. The brighter core arc, physics, and offscreen cull are never capped -- only the halo is skipped once the budget is spent that frame; the counter resets every tick. 0 means zero halos (cores only); 80 matches the game's GLOW_BUDGET parity. Default output is byte-identical to earlier versions.

Reduced motion

Reveals run in lottery / instant-win UIs, so honouring prefers-reduced-motion matters. The library does not read the media query itself -- the host owns it -- but it gives you two ways to respond, plus a flag for building a picker.

  • Blanket switch: { reducedMotion: true } on createScratchController or stage.createController. Every reveal(recipe) then ignores the recipe it is handed and runs a calm count: 0 opacity fade instead -- zero particles, no spray, onDone still fires once on completion. This is the honest "motion off" path.

    const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const ctrl = createScratchController(cover, fx, { reducedMotion: calm });
    ctrl.reveal(RECIPES.dragonBreath());   // draws a plain fade when calm is true
  • Toned-down variant: reducedMotionRecipe(recipe, { scale = 0.25, duration? }). Wraps any recipe and keeps its look but spawns round(count * scale) particles (a quarter, by default); an optional duration caps the reveal early. It reuses the inner recipe's arrays, so it adds no hot-path allocation. Note it can only reduce particle count -- it cannot suppress a recipe's own per-particle glow, which is drawn inside the recipe's tick. For a full "motion off", use the blanket switch above.

  • RECIPE_META[*].motionSafe marks the inherently calm built-ins so a picker can prefer them. It is strict: only fade (a pure opacity fade) is motionSafe: true; every recipe that moves geometry -- particles, a peel wipe, an implosion -- is false.

Constants

| Constant | Value | Meaning | | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- | | VERSION | '1.7.0' | Package version string (synced to package.json). | | maxParticles (default) | 2000 | Controller / stage pool capacity. | | scanPrecision (default) | 32 | Horizontal resolution of the spawn-point scan. | | stage capacity (default)| 300 | Slots a stage controller reserves from the pool. | | RECIPE_NAMES.length | 21 | Count of built-in recipes at load time. | | RECIPE_META[n] | { id, name, category, themeable, needsUntaintedCanvas } | Live per-recipe metadata for building pickers. | | categories | 'particle' | 'image' | 'beam' | 'css' | The four built-in recipe families. |

RECIPES is an extensible null-prototype registry keyed by short name; RECIPE_NAMES is its keys at load time; RECIPE_META is a live array (registerRecipe keeps it in sync). Image recipes draw the source canvas live each frame via drawImage(src), so they work on cross-origin/tainted layers; needsUntaintedCanvas is retained in RECIPE_META (all built-ins false) for a third-party recipe that reads the canvas back (getImageData/toDataURL) and needs to advertise a same-origin requirement to pickers.


Composability

A full grid of scratch cards: the manual erase you own, one shared pool via createScratchStage, a per-card controller, a RECIPE_META-driven picker, and a single requestAnimationFrame calling stage.tick(dt).

import { createScratchStage, RECIPES, RECIPE_META } from '@zakkster/lite-scratch-fx';

// One pool, one clock, a whole grid of cards revealing at once.
const stage = createScratchStage({ maxParticles: 1500, seed: 1 });

// A picker driven by RECIPE_META -- registering a recipe needs no change here.
const particle = RECIPE_META.filter((m) => m.category === 'particle');
const pick = () => particle[(Math.random() * particle.length) | 0].id;

for (const card of document.querySelectorAll('.card')) {
    const scratch = card.querySelector('.scratch');   // the foil the user erases
    const fx = card.querySelector('.fx');             // the particle overlay
    const ctrl = stage.createController(scratch, fx, { capacity: 200 });

    // Manual erase: pointer strokes punch holes in the foil layer (you own this).
    const sctx = scratch.getContext('2d');
    sctx.globalCompositeOperation = 'destination-out';
    scratch.addEventListener('pointermove', (e) => {
        if (e.buttons !== 1) return;
        sctx.beginPath();
        sctx.arc(e.offsetX, e.offsetY, 18, 0, Math.PI * 2);
        sctx.fill();
    });

    // "Reveal the rest" runs a recipe over the pixels still covered.
    card.querySelector('.reveal').addEventListener('click', () => {
        ctrl.reveal(RECIPES[pick()]());
    });
}

// One rAF drives every active reveal in the grid; dt is in SECONDS.
let last = performance.now();
function frame(now) {
    const dt = (now - last) / 1000; last = now;
    stage.tick(dt);                // advances every card mid-reveal, allocation-free
    requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

The erase is plain canvas destination-out -- this library never touches your input. The stage holds one lane pool for the whole grid, hands each card a fixed 200-slot sub-range, and the single stage.tick(dt) fans out to every card that is mid-reveal. Add a recipe with registerRecipe and the RECIPE_META picker above serves it with no edit. A recipe instance holds per-reveal state, so build a fresh one per reveal (as RECIPES[pick()]() does above) rather than sharing a single instance across cards or reveals.


Zero-GC design notes

Two hot paths matter: the reveal-start scan (runs once per reveal, over every covered pixel) and the per-frame tick (runs every frame, over every live particle). Both are allocation-free by construction.

| Operation | Steady-state allocations | | ------------------------------- | ------------------------------------------------- | | reveal-start scan canvas | 1 ever per controller (built once, reused) | | per-pixel spawn objects | 0 -- one reused, mutable spot object | | per-frame tick | 0 -- one reused particle-view (p) | | stage subarray views | once per controller, reused every frame |

The scan canvas is minted a single time at construction, not per reveal, and the surviving pixels fill preallocated Float32Arrays read back through one reused spot -- no { x, y } literal per pixel. The per-frame tick runs physics over the engine's typed-array lanes through a particle-view object that is reused across frames. A stage controller builds subarray views of its lane sub-range once and reuses them every frame, so even a grid of simultaneous reveals grows the heap by nothing.

The torture gate (@zakkster/lite-gc-profiler + @zakkster/lite-leak, under --expose-gc) proves this across tiers T0-T5 + T9: every one of the 21 recipes completes (T0), an empty scratch layer still completes via the centre fallback (T1), a seed replays byte-identical spawn work (T2), a controller mints exactly one scan canvas across 25 reveals (T3), the per-frame tick stays under the bytes/op ceiling (T4), a concurrent two-card stage tick stays under it too (T5), and the T9 controls prove each gate rejects its deliberately-broken variant.


Design decisions worth knowing

  • Spawn points come from the layer, not a grid. The controller downsamples the scratch layer to scanPrecision px wide and collects pixels whose alpha is still above threshold as normalized 0..1 spawn spots. A half-scratched card reveals only the part still covered. An empty layer falls back to the centre, so a reveal never hangs.
  • count: 0 is a real value. A pure-canvas reveal (shine, implosion, the css recipes) declares count: 0 and spawns no particles. The controller uses count ?? maxParticles, so an explicit 0 is honoured -- an earlier count || max bug turned 0 into a full 2000-particle pool.
  • One drive, never two. The engine self-drives its own RAF and computes its own dt. The three modes (default, { driven: true }, createScratchStage) are each a single drive. The removed sharedTicker option double-drove the loop, corrupting dt so life never decayed and reveals ran forever; driven mode replaces it correctly.
  • spot and p are shared and reused. The spawn spot and the particle-view are one reused object each -- read them synchronously, never retain. This is the whole zero-GC story, and it is a contract a recipe must honour.
  • Theming is one shape for every effect. The 14 palette recipes all take { colors, theme }; colors wins, theme maps a { light, mid, dark } triple onto the ramp, and omitting both keeps the recipe's own default. A skin threads one triple through the whole grid.
  • Image recipes draw the source canvas live. shatter, pixelShatter, and glitchReveal draw the scratch layer each frame via drawImage(src) -- no snapshot -- so they work on cross-origin/tainted layers. needsUntaintedCanvas is retained in RECIPE_META (all built-ins false) for a third-party recipe that reads the canvas back (getImageData/toDataURL) and must advertise a same-origin requirement to a picker.

Testing

62 deterministic node:test cases, all pass, plus a torture gate that proves both leak-freedom and the zero-allocation reveal path.

npm test          # 62 node:test cases: controller lifecycle, data[] indexing, per-recipe
                  # reveal-to-completion, theming, the registry, driven mode, and the stage
npm run torture   # node --expose-gc test/torture.mjs -> prints "ok" (T0-T5 + T9)

The suites cover the controller lifecycle, the load-bearing data[i] spawn-index mapping, a reveal-to-completion smoke test for every one of the 21 recipes, theming (every themeable recipe drives its palette from a theme; short-ramp safety; colors overrides theme), the registry and RECIPE_META bijection, host-driven mode, the concurrent stage, and a VERSION-matches-package.json pin. The torture harness gates completion, degenerate layers, RNG determinism, the one-scan-canvas-ever assertion, and the per-frame / concurrent-stage heap-growth ceilings, with controls proving each gate can fail. No gate output is a FAIL.


What this is not

  • Not a scratch-gesture library. It reads the covered pixels of your scratch layer; it does not draw the foil or handle the erase input. The pointer destination-out loop is five lines and yours -- see Composability.
  • Not GSAP or a general animation timeline. No tweens, no easing DSL, no timeline scrubbing. It is a one-shot reveal driver: reveal runs a recipe to completion and stops.
  • Not a physics engine. Each recipe owns its own physics over the engine's typed-array lanes. There is no collision, no constraint solver, no shared force field.
  • Not a UI framework. No components, no DOM layout, no canvas creation for your cards. Bring your own markup and wire two canvases to a controller.
  • Not for retaining spot or p. Both are reused, mutable objects. Retaining either reads garbage on the next frame -- copy the numbers out if you need them later.

Ecosystem

Part of the @zakkster zero-GC stack. This package builds on three of them:

  • lite-soa-particle-engine -- the struct-of-arrays particle pool the reveal renders through
  • lite-random -- the deterministic seeded RNG behind reproducible reveals
  • lite-lerp -- the interpolation helpers recipes use for motion and colour
  • lite-ambient-fx -- ambient canvas backgrounds; its registerTheme is the model for this package's registerRecipe
  • lite-signal -- zero-GC reactive graph for hot paths
  • lite-scratch-fx -- this package

License

MIT (c) Zahary Shinikchiev [email protected]