@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,
Maintainers
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.
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-fximport { 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
- What you get
- The controller and the recipe interface
- API reference
- Composability
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
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:
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..1spawn spots -- so a half-scratched card reveals only what is left.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.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), andcreateScratchStage(many boxes revealing at once over one shared pool). - An extensible registry.
RECIPES(short name -> factory),RECIPE_NAMES, and a liveRECIPE_METAarray so a picker groups and filters without hardcoding the list.registerRecipeadds 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, andThemeableRecipeOptions.
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-enginethat self-drives its ownrequestAnimationFrameloop. Fine for a single card. { driven: true }-- the controller never starts a RAF loop; the host callstick(dt)every frame.tickis 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; arevealon a busy shared engine is ignored, anddestroy()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 >= CcreateController 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 inRECIPESandRECIPE_METAimmediately, so existing pickers pick it up with no code change. Omittedmetafields fall back to the prior entry, then a de-camelCased name,category: 'custom', andfalseflags. ThrowsTypeErroron a badidor a non-functionfactory.resolvePalette(colors, theme, fallback)-- resolve a colour ramp for a themeable recipe:colorswins, thentheme, then the recipe's ownfallback.
Several recipes take extra tuning knobs alongside count/duration; all default to the
current built-in look:
DissolveRecipe--fadeSpeed(default0.3): fraction ofdurationover which the source layer fades out.DragonBreathRecipe/IceBreathRecipe--spread(jet half-angle in radians, defaults0.3/0.225),speedMin/speedMax(spawn speed range, defaults12-25/15-28).BurnRecipe/DragonBreathRecipe--glowBudget(defaultInfinity= 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.0means zero halos (cores only);80matches the game'sGLOW_BUDGETparity. 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 }oncreateScratchControllerorstage.createController. Everyreveal(recipe)then ignores the recipe it is handed and runs a calmcount: 0opacity fade instead -- zero particles, no spray,onDonestill 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 trueToned-down variant:
reducedMotionRecipe(recipe, { scale = 0.25, duration? }). Wraps any recipe and keeps its look but spawnsround(count * scale)particles (a quarter, by default); an optionaldurationcaps 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'stick. For a full "motion off", use the blanket switch above.RECIPE_META[*].motionSafemarks the inherently calm built-ins so a picker can prefer them. It is strict: onlyfade(a pure opacity fade) ismotionSafe: true; every recipe that moves geometry -- particles, a peel wipe, an implosion -- isfalse.
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
scanPrecisionpx wide and collects pixels whose alpha is still above threshold as normalized0..1spawn 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: 0is a real value. A pure-canvas reveal (shine,implosion, the css recipes) declarescount: 0and spawns no particles. The controller usescount ?? maxParticles, so an explicit0is honoured -- an earliercount || maxbug turned0into 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 removedsharedTickeroption double-drove the loop, corruptingdtso life never decayed and reveals ran forever; driven mode replaces it correctly. spotandpare 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 };colorswins,thememaps 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, andglitchRevealdraw the scratch layer each frame viadrawImage(src)-- no snapshot -- so they work on cross-origin/tainted layers.needsUntaintedCanvasis retained inRECIPE_META(all built-insfalse) 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-outloop 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:
revealruns 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
spotorp. 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 throughlite-random-- the deterministic seeded RNG behind reproducible revealslite-lerp-- the interpolation helpers recipes use for motion and colourlite-ambient-fx-- ambient canvas backgrounds; itsregisterThemeis the model for this package'sregisterRecipelite-signal-- zero-GC reactive graph for hot pathslite-scratch-fx-- this package
License
MIT (c) Zahary Shinikchiev [email protected]
