@zakkster/lite-scratch-fx 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,32 +1,38 @@
1
1
  # @zakkster/lite-scratch-fx
2
2
 
3
+ > 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.
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-scratch-fx.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
4
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Hot%20Path-00C853?style=for-the-badge&logo=leaf&logoColor=white)
5
8
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-scratch-fx?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-scratch-fx)
6
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-scratch-fx?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
7
10
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-scratch-fx?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
8
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
+ ![Dependencies](https://img.shields.io/badge/dependencies-3%20%40zakkster-blue)
9
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
10
14
 
11
- One-shot **scratch-card reveal effects** on canvas. A controller scans the remaining
12
- pixels of a scratch layer, spawns particles from those points, and delegates physics
13
- and rendering to a **recipe**. Ships **21 ready-made reveal recipes**; bring your own by
14
- matching the recipe interface.
15
+ ## The scratch-card reveal layer the ecosystem was missing
15
16
 
16
- Zero GSAP. Deterministic RNG. The particle engine owns its own `requestAnimationFrame`
17
- loop and auto-cleans up on completion.
17
+ Scratch cards have two halves that nobody ships together. The **interaction** -- a
18
+ pointer erasing a foil layer -- is a five-line canvas `destination-out` loop you already
19
+ know how to write. The **reveal** -- the moment the card decides "won", clears the rest,
20
+ and celebrates -- is where every project reinvents an ad-hoc particle burst, hardcodes a
21
+ palette, and (because it runs a fresh burst every frame) drops frames on the GC. This
22
+ library is that second half: a controller that reads the *still-covered* pixels of your
23
+ scratch layer as spawn points, and 21 ready-made **recipes** that turn them into a burn,
24
+ a shatter, a confetti blast, or your own. Deterministic from a seed, so an instant-win
25
+ reveal is auditable and replayable. Zero GSAP.
18
26
 
19
- ```
27
+ ```bash
20
28
  npm install @zakkster/lite-scratch-fx
21
29
  ```
22
30
 
23
- ## Quick start
24
-
25
31
  ```js
26
32
  import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';
27
33
 
28
- // scratchCanvas: the layer the user scratches off.
29
- // fxCanvas: an overlay canvas the effect renders particles onto.
34
+ // scratchCanvas: the foil layer the user scratches off.
35
+ // fxCanvas: an overlay canvas the reveal renders particles onto.
30
36
  const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });
31
37
 
32
38
  revealButton.onclick = () => {
@@ -36,100 +42,89 @@ revealButton.onclick = () => {
36
42
  };
37
43
  ```
38
44
 
39
- `reveal(recipe, onDone)` samples spawn points from the still-covered pixels of the
40
- scratch layer, runs the recipe to completion, then clears the overlay and calls
41
- `onDone`. A second `reveal` while one is active is ignored.
42
-
43
- ## Theming
44
-
45
- Every recipe with a visible palette (14 of the 21) takes the same two options, so a
46
- themed host can drive them all through one shape. Pass a `{ light, mid, dark }`
47
- `theme` for the common case, or an explicit `colors` ramp to override it. Either
48
- defaults to the recipe's own palette when omitted, so untyped calls look exactly as
49
- before.
50
-
51
- ```js
52
- // A skin threads one triple through every effect:
53
- const vikings = { light: '#e8d5a0', mid: '#b8860b', dark: '#3a2a10' };
54
- fx.reveal(RECIPES.burn({ theme: vikings }));
55
- fx.reveal(RECIPES.dissolve({ theme: vikings })); // single-colour recipes use theme.light
56
-
57
- // Or hand a recipe an exact ramp:
58
- fx.reveal(RECIPES.confettiBlast({ colors: ['#ff0055', '#00ffcc', '#ffcc00'] }));
59
- ```
60
-
61
- `colors` wins when both are given. The seven image-based (`shatter`, `pixelShatter`,
62
- `glitchReveal`) and neutral (`shine`, `peel`, `fade`, `implosion`) recipes have no
63
- palette to theme and safely ignore both options.
64
-
65
- ## The 21 recipes
66
-
67
- Import them by name, or pick from the `RECIPES` registry:
68
-
69
- ```js
70
- import { RECIPES, RECIPE_NAMES, createScratchController } from '@zakkster/lite-scratch-fx';
71
-
72
- const recipe = RECIPES.cosmicDust({ count: 300 });
73
- fx.reveal(recipe);
74
- // RECIPE_NAMES is every key: 'burn', 'shatter', 'dissolve',
75
- ```
76
-
77
- ### Building a picker with `RECIPE_META`
78
-
79
- `RECIPE_META` is a live array of `{ id, name, category, themeable, needsUntaintedCanvas }`
80
- for every recipe, so a UI can group and filter without hardcoding the list:
81
-
82
- ```js
83
- import { RECIPE_META } from '@zakkster/lite-scratch-fx';
84
-
85
- // Only the recipes a themed skin can drive:
86
- const themeable = RECIPE_META.filter((m) => m.themeable);
87
- // Group a dropdown by category: 'particle' | 'image' | 'beam' | 'css'
88
- ```
89
-
90
- ### Registering your own recipe
91
-
92
- `registerRecipe(id, factory, meta?)` adds a recipe (or overrides a built-in). It lands in
93
- `RECIPES` and `RECIPE_META` immediately, so existing pickers pick it up with no code change:
94
-
95
- ```js
96
- import { registerRecipe, createScratchController } from '@zakkster/lite-scratch-fx';
97
-
98
- registerRecipe('sparkle', SparkleRecipe, { category: 'particle', themeable: true });
99
- fx.reveal(RECIPES.sparkle({ theme: mySkin }));
100
- ```
101
-
102
- | Recipe | Effect |
103
- |---|---|
104
- | `burn` | Embers rise and fade while the layer burns away |
105
- | `shatter` | The layer breaks into gravity-driven glass pieces |
106
- | `dissolve` | Pixels detach and drift off |
107
- | `explode` | A hard outward blast of debris |
108
- | `dragonBreath` | A sweep of fire particles |
109
- | `iceBreath` | A sweep of frost particles |
110
- | `shineWave` | A moving light beam with sparkle particles |
111
- | `lightningCrawl` | Procedural lightning bolts crawl across |
112
- | `shine` | A single pass of light (no particles) |
113
- | `peel` | The layer curls and peels away |
114
- | `fade` | A plain opacity fade |
115
- | `implosion` | The layer collapses to the center with speed lines |
116
- | `glitchReveal` | Digital slice-and-shift glitch |
117
- | `matrixDecay` | Falling code-rain decay |
118
- | `goldDust` | Drifting gold sparkle |
119
- | `pixelShatter` | The layer shatters into pixel blocks |
120
- | `laserScan` | A scanning laser line |
121
- | `confettiBlast` | A confetti burst |
122
- | `liquidMelt` | The layer melts and runs |
123
- | `neonPulse` | A neon pulse ring |
124
- | `cosmicDust` | Slow drifting star dust |
125
-
126
- Several recipes (`shatter`, `glitchReveal`, `pixelShatter`) snapshot the scratch layer
127
- with `sourceCanvas.toDataURL()` and an `Image`, so the layer must be same-origin
128
- (untainted) for those effects.
129
-
130
- ## Writing a recipe
131
-
132
- A recipe is a plain object. The controller drives it:
45
+ `reveal(recipe, onDone?)` samples spawn points from the still-covered pixels of the
46
+ scratch layer, runs the recipe to completion, clears the overlay, then calls `onDone`. A
47
+ second `reveal` while one is active is ignored. Same seed in, same reveal out -- every
48
+ time.
49
+
50
+ ---
51
+
52
+ ## Table of contents
53
+
54
+ - [Why this exists](#why-this-exists)
55
+ - [What you get](#what-you-get)
56
+ - [The controller and the recipe interface](#the-controller-and-the-recipe-interface)
57
+ - [API reference](#api-reference)
58
+ - [createScratchController](#createscratchcontroller)
59
+ - [createScratchStage](#createscratchstage)
60
+ - [Registry and palette helpers](#registry-and-palette-helpers)
61
+ - [Constants](#constants)
62
+ - [Composability](#composability)
63
+ - [Zero-GC design notes](#zero-gc-design-notes)
64
+ - [Design decisions worth knowing](#design-decisions-worth-knowing)
65
+ - [Testing](#testing)
66
+ - [What this is not](#what-this-is-not)
67
+ - [Ecosystem](#ecosystem)
68
+ - [License](#license)
69
+
70
+ ---
71
+
72
+ ## Why this exists
73
+
74
+ The gap is **interaction vs reveal**. Erasing a foil layer with a pointer is trivial and
75
+ every stack solves it inline. The reveal -- the payoff animation once the card is decided
76
+ -- is what gets copy-pasted, hardcoded, and quietly leaked. Three things make it worth a
77
+ library instead of a snippet:
78
+
79
+ 1. **The spawn points come from the layer itself.** A reveal should erupt from the part
80
+ still covered, not from a fixed grid. The controller downsamples the scratch layer,
81
+ collects the pixels whose alpha is still above threshold, and hands those to the recipe
82
+ as normalized `0..1` spawn spots -- so a half-scratched card reveals only what is left.
83
+
84
+ 2. **Instant-win reveals must be auditable.** A prize-bearing reveal that cannot be
85
+ reproduced is a support ticket waiting to happen. The RNG is seeded and deterministic:
86
+ the same seed replays the exact same reveal, so "what did the player see" is a
87
+ reproducible question, not a guess. `seed(s)` re-seeds between reveals.
88
+
89
+ 3. **The reveal runs every frame -- so it cannot allocate.** A burst that mints a fresh
90
+ object per surviving pixel, or a new particle-view per frame, turns the celebration
91
+ into GC jitter. The reveal-start scan and the per-frame tick are both zero-allocation
92
+ by construction (see [Zero-GC design notes](#zero-gc-design-notes)).
93
+
94
+ ---
95
+
96
+ ## What you get
97
+
98
+ - **`createScratchController(scratch, fx, options?)`** -- one controller over a scratch
99
+ layer and an effect overlay. `reveal(recipe, onDone?)` scans, spawns, runs to
100
+ completion, clears, and calls back. The API every caller reaches for.
101
+ - **21 reveal recipes across 4 families** -- 10 **particle** (`burn`, `dissolve`,
102
+ `explode`, `dragonBreath`, `iceBreath`, `goldDust`, `confettiBlast`, `cosmicDust`,
103
+ `matrixDecay`, `liquidMelt`), 3 **image** (`shatter`, `pixelShatter`, `glitchReveal`),
104
+ 5 **beam** (`shine`, `shineWave`, `laserScan`, `lightningCrawl`, `neonPulse`), and 3
105
+ **css** (`peel`, `fade`, `implosion`). Bring your own by matching the recipe interface.
106
+ - **14 themeable recipes.** Every recipe with a visible palette takes the same
107
+ `{ colors, theme }` pair, so a themed host threads one `{ light, mid, dark }` triple
108
+ through every effect. The 7 image / neutral recipes have no palette and ignore both.
109
+ - **Three driving modes, none able to double-drive the loop** -- the default self-driven
110
+ engine (one card), `{ driven: true }` (one page clock drives N controllers), and
111
+ `createScratchStage` (many boxes revealing at once over one shared pool).
112
+ - **An extensible registry.** `RECIPES` (short name -> factory), `RECIPE_NAMES`, and a
113
+ live `RECIPE_META` array so a picker groups and filters without hardcoding the list.
114
+ `registerRecipe` adds or overrides a recipe and every existing picker sees it.
115
+ - **Full TypeScript declarations** ship in [`index.d.ts`](./index.d.ts) -- the recipe
116
+ interface, controller and stage options, `Theme`, and `ThemeableRecipeOptions`.
117
+
118
+ ---
119
+
120
+ ## The controller and the recipe interface
121
+
122
+ <details>
123
+ <summary>How the controller drives a recipe, and the two contracts that keep it zero-GC.</summary>
124
+
125
+ A recipe is a plain object with five members. The controller calls `init` once, `spawn`
126
+ for each particle, then `tick` every frame until it returns `true`, and finally
127
+ `destroy`:
133
128
 
134
129
  ```js
135
130
  function MyRecipe({ count = 120, duration = 1000 } = {}) {
@@ -147,7 +142,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
147
142
  let alive = 0;
148
143
  for (let i = 0; i < p.max; i++) {
149
144
  if (p.life[i] <= 0) continue;
150
- const slot = p.data[i]; // maps back to the array index (see below)
145
+ const slot = p.data[i]; // maps back to the spawn index (see below)
151
146
  p.x[i] += p.vx[i] * dt * 60;
152
147
  p.y[i] += p.vy[i] * dt * 60;
153
148
  p.life[i] -= dt;
@@ -165,127 +160,291 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
165
160
  }
166
161
  ```
167
162
 
168
- **Per-particle state and `p.data[i]`.** Recipes keep their own parallel arrays (`size`
169
- above), keyed by the particle's slot. The controller passes each particle's spawn ring
170
- index as the engine's data flag, so inside `tick` you read `p.data[i]` to get the index
171
- your `spawn` wrote to. This mapping is covered by a test so it stays correct across
163
+ **`spot` is shared and mutable -- never retain it.** The `spot` handed to every `spawn`
164
+ call is one reused object. Read `spot.x` / `spot.y` synchronously; the next spawn
165
+ overwrites it in place. This is what keeps reveal-start allocation-free: there is no
166
+ `{ x, y }` literal per surviving pixel.
167
+
168
+ **`p.data[i]` maps a live particle back to its slot.** Recipes keep their own parallel
169
+ typed arrays (`size` above), keyed by spawn index. The controller passes each particle's
170
+ spawn index as the engine's data flag, so inside `tick` you read `p.data[i]` to recover
171
+ the index your `spawn` wrote to. A test locks this mapping so it stays correct across
172
172
  engine upgrades.
173
173
 
174
- **`spot` is shared and mutable.** The `spot` handed to every `spawn` call is one reused
175
- object read `spot.x` / `spot.y` synchronously and never retain the reference; the next
176
- spawn overwrites it. This is what keeps the reveal-start frame allocation-free (no
177
- `{x, y}` literal per surviving pixel). To make a recipe themeable, take `{ colors, theme }`
178
- in your factory and resolve a ramp once with `resolvePalette` from `./src/Palette.js`.
174
+ `dt` is in **seconds**; `elapsedMs` is milliseconds since the reveal began. The
175
+ particle-view `p` (raw SoA lanes `x, y, vx, vy, life, invLife, data, max`) is reused
176
+ across frames, so `tick` allocates nothing. To make a recipe themeable, take
177
+ `{ colors, theme }` in your factory and resolve a ramp once with `resolvePalette`.
179
178
 
180
- The per-frame `tick` runs allocation-free by design: the particle-view object is reused
181
- across frames, and physics runs over the engine's typed-array lanes. `npm run torture`
182
- gates both the reveal-start scan and the per-frame tick against heap growth.
179
+ </details>
183
180
 
184
- ## API
181
+ ---
185
182
 
186
- ### `createScratchController(sourceCanvas, effectCanvas, options?)`
183
+ ## API reference
187
184
 
188
- | Option | Type | Default | |
189
- |---|---|---|---|
190
- | `maxParticles` | number | `2000` | Particle pool capacity |
191
- | `seed` | number | `Date.now()` | Deterministic RNG seed |
192
- | `scanPrecision` | number | `32` | Horizontal resolution of the spawn-point pixel scan |
185
+ ### createScratchController
193
186
 
194
- Returns `{ reveal(recipe, onDone?), seed(s), destroy() }`.
187
+ ```ts
188
+ createScratchController(sourceCanvas, effectCanvas, options?): ScratchController
189
+ ```
195
190
 
196
- - **`reveal(recipe, onDone?)`** run a recipe; ignored if one is active.
197
- - **`seed(s)`** re-seed the RNG for reproducible reveals.
198
- - **`destroy()`** stop, clean up the active recipe, release the engine.
191
+ | Option | Type | Default | Meaning |
192
+ | -------------- | ----------------- | ------------- | --------------------------------------------------- |
193
+ | `maxParticles` | number | `2000` | Particle pool capacity (ignored when `engine` given) |
194
+ | `seed` | number | `Date.now()` | Deterministic RNG seed |
195
+ | `scanPrecision`| number | `32` | Horizontal resolution of the spawn-point pixel scan |
196
+ | `driven` | boolean | `false` | Host-driven mode: you call `tick(dt)` each frame |
197
+ | `engine` | SoaParticleEngine | (own) | Share one caller-supplied engine (one lane pool) |
198
+
199
+ Returns a controller:
200
+
201
+ ```ts
202
+ controller.reveal(recipe, onDone?): void // run a reveal; ignored if one is active
203
+ controller.tick(dt): void // advance a frame in { driven: true } mode; dt in SECONDS
204
+ controller.seed(s): void // re-seed the RNG for reproducible reveals
205
+ controller.destroy(): void // stop, clean up the active recipe, release an owned engine
206
+ ```
199
207
 
200
- ## Scaling to a grid of scratch boxes
208
+ - **default** -- the controller owns a `@zakkster/lite-soa-particle-engine` that
209
+ self-drives its own `requestAnimationFrame` loop. Fine for a single card.
210
+ - **`{ driven: true }`** -- the controller never starts a RAF loop; the host calls
211
+ `tick(dt)` every frame. `tick` is a no-op unless a reveal is active, so calling it on
212
+ every box is cheap. One clock, N controllers.
213
+ - **`{ engine }`** -- share one engine (one lane pool for the whole page). A shared engine
214
+ has a single render slot, so **one reveal per shared engine at a time**; a `reveal` on a
215
+ busy shared engine is ignored, and `destroy()` never tears a shared engine down.
201
216
 
202
- By default each controller owns a particle engine that self-drives its own
203
- `requestAnimationFrame` loop — fine for one card. For a page full of scratch boxes,
204
- two options let one clock and one lane pool serve the whole grid:
217
+ ### createScratchStage
205
218
 
206
- ```js
207
- import { createScratchController } from '@zakkster/lite-scratch-fx';
208
- import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
219
+ For **many boxes revealing simultaneously** over a single pool, a stage owns one engine,
220
+ hands each controller a fixed sub-range of the lanes, and drives every active reveal
221
+ through one clock.
209
222
 
210
- // One shared engine (one lane pool for the whole page) + host-driven controllers.
211
- const engine = new SoaParticleEngine(1500);
212
- const boxes = cards.map((c) => createScratchController(c.scratch, c.fx, { engine, driven: true }));
223
+ ```ts
224
+ createScratchStage(options?): ScratchStage
225
+ ```
213
226
 
214
- // One page loop drives them all; dt is in SECONDS.
215
- let last = performance.now();
216
- function frame(now) {
217
- const dt = (now - last) / 1000; last = now;
218
- for (const b of boxes) b.tick(dt); // only the box mid-reveal does work
219
- requestAnimationFrame(frame);
220
- }
221
- requestAnimationFrame(frame);
227
+ | Option | Type | Default | Meaning |
228
+ | -------------- | ------ | ------------ | ---------------------------------------------- |
229
+ | `maxParticles` | number | `2000` | Total shared pool capacity, split across cards |
230
+ | `seed` | number | `Date.now()` | Base seed; each controller derives `base + index` |
231
+
232
+ ```ts
233
+ stage.createController(sourceCanvas, effectCanvas, options?): StageController
234
+ stage.tick(dt): void // advance every active reveal one frame; dt in SECONDS
235
+ stage.destroy(): void // stop everything and release the shared engine
236
+ stage.remainingCapacity // slots still unreserved in the shared pool (read-only)
237
+ ```
238
+
239
+ `createController` options: `capacity` (slots reserved out of the pool, default `300` --
240
+ reserving past `maxParticles` throws), `seed` (default: the stage base seed plus the
241
+ controller index), and `scanPrecision` (default `32`). A stage-managed controller exposes
242
+ `reveal(recipe, onDone?)`, `seed(s)`, and `destroy()` -- but **no `tick` of its own**, the
243
+ stage drives it.
244
+
245
+ ### Registry and palette helpers
246
+
247
+ ```ts
248
+ registerRecipe(id, factory, meta?): RecipeFactory
249
+ resolvePalette(colors, theme, fallback): string[]
222
250
  ```
223
251
 
224
- - **`{ driven: true }`** the controller never starts its own RAF; you call `tick(dt)`
225
- each frame. `tick` is a no-op unless a reveal is active, so calling it on every box is
226
- cheap. One clock, N controllers.
227
- - **`{ engine }`** share one caller-supplied engine, so the page holds *one* lane pool
228
- instead of one per box. A shared engine has a single render slot and one particle pool,
229
- so **one reveal per shared engine at a time**: a `reveal` on a busy shared engine is
230
- ignored (a scratch-card grid reveals one card at a time anyway). `destroy()` never tears
231
- down a shared engine — its lifecycle is yours.
252
+ - **`registerRecipe(id, factory, meta?)`** -- add a recipe or override a built-in. It
253
+ lands in `RECIPES` and `RECIPE_META` immediately, so existing pickers pick it up with no
254
+ code change. Omitted `meta` fields fall back to the prior entry, then a de-camelCased
255
+ name, `category: 'custom'`, and `false` flags. Throws `TypeError` on a bad `id` or a
256
+ non-function `factory`.
257
+ - **`resolvePalette(colors, theme, fallback)`** -- resolve a colour ramp for a themeable
258
+ recipe: `colors` wins, then `theme`, then the recipe's own `fallback`.
259
+
260
+ ### Constants
232
261
 
233
- The two compose (shared + driven, above) or stand alone. The default (own engine,
234
- self-driven) is unchanged.
262
+ | Constant | Value | Meaning |
263
+ | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
264
+ | `VERSION` | `'1.3.0'` | Package version string (synced to package.json). |
265
+ | `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
266
+ | `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
267
+ | stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
268
+ | `RECIPE_NAMES.length` | `21` | Count of built-in recipes at load time. |
269
+ | `RECIPE_META[n]` | `{ id, name, category, themeable, needsUntaintedCanvas }` | Live per-recipe metadata for building pickers. |
270
+ | categories | `'particle'` &#124; `'image'` &#124; `'beam'` &#124; `'css'` | The four built-in recipe families. |
235
271
 
236
- ### Concurrent reveals over one pool: `createScratchStage`
272
+ `RECIPES` is an extensible null-prototype registry keyed by short name;
273
+ `RECIPE_NAMES` is its keys at load time; `RECIPE_META` is a live array
274
+ (`registerRecipe` keeps it in sync). `needsUntaintedCanvas` is `true` only for the 3
275
+ image recipes, which snapshot the layer via `toDataURL()` and therefore need a same-origin
276
+ canvas.
237
277
 
238
- `{ engine }` shares a pool but runs **one reveal at a time**. When you need **many boxes
239
- revealing simultaneously** while still holding a single pool, use a stage: it owns one
240
- engine, hands each controller a fixed sub-range of the lanes, and dispatches every active
241
- reveal through one clock.
278
+ ---
279
+
280
+ ## Composability
281
+
282
+ A full grid of scratch cards: the manual erase you own, one shared pool via
283
+ `createScratchStage`, a per-card controller, a `RECIPE_META`-driven picker, and a single
284
+ `requestAnimationFrame` calling `stage.tick(dt)`.
242
285
 
243
286
  ```js
244
- import { createScratchStage } from '@zakkster/lite-scratch-fx';
287
+ import { createScratchStage, RECIPES, RECIPE_META } from '@zakkster/lite-scratch-fx';
245
288
 
289
+ // One pool, one clock, a whole grid of cards revealing at once.
246
290
  const stage = createScratchStage({ maxParticles: 1500, seed: 1 });
247
- const boxes = cards.map((c) =>
248
- stage.createController(c.scratch, c.fx, { capacity: 200 })); // reserves 200 slots each
249
291
 
250
- // Any number of boxes can reveal at once; one pool, one loop:
251
- boxes[3].reveal(RECIPES.burn({ theme: skin }));
252
- boxes[7].reveal(RECIPES.confettiBlast());
292
+ // A picker driven by RECIPE_META -- registering a recipe needs no change here.
293
+ const particle = RECIPE_META.filter((m) => m.category === 'particle');
294
+ const pick = () => particle[(Math.random() * particle.length) | 0].id;
295
+
296
+ for (const card of document.querySelectorAll('.card')) {
297
+ const scratch = card.querySelector('.scratch'); // the foil the user erases
298
+ const fx = card.querySelector('.fx'); // the particle overlay
299
+ const ctrl = stage.createController(scratch, fx, { capacity: 200 });
300
+
301
+ // Manual erase: pointer strokes punch holes in the foil layer (you own this).
302
+ const sctx = scratch.getContext('2d');
303
+ sctx.globalCompositeOperation = 'destination-out';
304
+ scratch.addEventListener('pointermove', (e) => {
305
+ if (e.buttons !== 1) return;
306
+ sctx.beginPath();
307
+ sctx.arc(e.offsetX, e.offsetY, 18, 0, Math.PI * 2);
308
+ sctx.fill();
309
+ });
310
+
311
+ // "Reveal the rest" runs a recipe over the pixels still covered.
312
+ card.querySelector('.reveal').addEventListener('click', () => {
313
+ ctrl.reveal(RECIPES[pick()]());
314
+ });
315
+ }
253
316
 
317
+ // One rAF drives every active reveal in the grid; dt is in SECONDS.
254
318
  let last = performance.now();
255
319
  function frame(now) {
256
320
  const dt = (now - last) / 1000; last = now;
257
- stage.tick(dt); // advances every active reveal
321
+ stage.tick(dt); // advances every card mid-reveal, allocation-free
258
322
  requestAnimationFrame(frame);
259
323
  }
260
324
  requestAnimationFrame(frame);
261
325
  ```
262
326
 
263
- Each controller reserves `capacity` slots from `maxParticles` (reserving past the pool
264
- throws), renders only its own particles, and keeps its own deterministic RNG. Stage-managed
265
- controllers have no `tick` of their own the stage drives them. The per-frame `stage.tick`
266
- is allocation-free even with many reveals in flight (torture-gated). Use `createScratchStage`
267
- for a live grid of simultaneous reveals; use `{ engine }` when reveals are one-at-a-time and
268
- you just want the memory saving; use the default per-controller engine for a single card.
327
+ The erase is plain canvas `destination-out` -- this library never touches your input. The
328
+ stage holds **one** lane pool for the whole grid, hands each card a fixed 200-slot
329
+ sub-range, and the single `stage.tick(dt)` fans out to every card that is mid-reveal. Add
330
+ a recipe with `registerRecipe` and the `RECIPE_META` picker above serves it with no edit.
331
+
332
+ ---
333
+
334
+ ## Zero-GC design notes
335
+
336
+ <details>
337
+ <summary>What the reveal path allocates (nothing, after warm-up) and how it stays that way.</summary>
338
+
339
+ Two hot paths matter: the **reveal-start scan** (runs once per reveal, over every covered
340
+ pixel) and the **per-frame tick** (runs every frame, over every live particle). Both are
341
+ allocation-free by construction.
342
+
343
+ | Operation | Steady-state allocations |
344
+ | ------------------------------- | ------------------------------------------------- |
345
+ | reveal-start scan canvas | **1 ever** per controller (built once, reused) |
346
+ | per-pixel spawn objects | **0** -- one reused, mutable `spot` object |
347
+ | per-frame `tick` | **0** -- one reused particle-view (`p`) |
348
+ | stage subarray views | once per controller, reused every frame |
349
+
350
+ The scan canvas is minted a single time at construction, not per reveal, and the surviving
351
+ pixels fill preallocated `Float32Array`s read back through one reused `spot` -- no
352
+ `{ x, y }` literal per pixel. The per-frame `tick` runs physics over the engine's
353
+ typed-array lanes through a particle-view object that is reused across frames. A stage
354
+ controller builds `subarray` views of its lane sub-range once and reuses them every frame,
355
+ so even a grid of simultaneous reveals grows the heap by nothing.
356
+
357
+ The torture gate (`@zakkster/lite-gc-profiler` + `@zakkster/lite-leak`, under
358
+ `--expose-gc`) proves this across tiers **T0-T5 + T9**: every one of the 21 recipes
359
+ completes (T0), an empty scratch layer still completes via the centre fallback (T1), a
360
+ seed replays byte-identical spawn work (T2), a controller mints exactly one scan canvas
361
+ across 25 reveals (T3), the per-frame tick stays under the bytes/op ceiling (T4), a
362
+ concurrent two-card stage tick stays under it too (T5), and the T9 controls prove each
363
+ gate rejects its deliberately-broken variant.
364
+
365
+ </details>
366
+
367
+ ---
368
+
369
+ ## Design decisions worth knowing
370
+
371
+ - **Spawn points come from the layer, not a grid.** The controller downsamples the scratch
372
+ layer to `scanPrecision` px wide and collects pixels whose alpha is still above
373
+ threshold as normalized `0..1` spawn spots. A half-scratched card reveals only the part
374
+ still covered. An empty layer falls back to the centre, so a reveal never hangs.
375
+ - **`count: 0` is a real value.** A pure-canvas reveal (`shine`, `implosion`, the css
376
+ recipes) declares `count: 0` and spawns no particles. The controller uses
377
+ `count ?? maxParticles`, so an explicit `0` is honoured -- an earlier `count || max` bug
378
+ turned `0` into a full 2000-particle pool.
379
+ - **One drive, never two.** The engine self-drives its own RAF and computes its own `dt`.
380
+ The three modes (default, `{ driven: true }`, `createScratchStage`) are each a *single*
381
+ drive. The removed `sharedTicker` option double-drove the loop, corrupting `dt` so life
382
+ never decayed and reveals ran forever; driven mode replaces it correctly.
383
+ - **`spot` and `p` are shared and reused.** The spawn spot and the particle-view are one
384
+ reused object each -- read them synchronously, never retain. This is the whole zero-GC
385
+ story, and it is a contract a recipe must honour.
386
+ - **Theming is one shape for every effect.** The 14 palette recipes all take
387
+ `{ colors, theme }`; `colors` wins, `theme` maps a `{ light, mid, dark }` triple onto the
388
+ ramp, and omitting both keeps the recipe's own default. A skin threads one triple through
389
+ the whole grid.
390
+ - **Image recipes need an untainted canvas.** `shatter`, `pixelShatter`, and
391
+ `glitchReveal` snapshot the layer via `toDataURL()` and an `Image`, so the scratch layer
392
+ must be same-origin. `RECIPE_META[n].needsUntaintedCanvas` flags them for a picker.
393
+
394
+ ---
395
+
396
+ ## Testing
397
+
398
+ **62 deterministic `node:test` cases, all pass**, plus a torture gate that proves both
399
+ leak-freedom and the zero-allocation reveal path.
400
+
401
+ ```bash
402
+ npm test # 62 node:test cases: controller lifecycle, data[] indexing, per-recipe
403
+ # reveal-to-completion, theming, the registry, driven mode, and the stage
404
+ npm run torture # node --expose-gc test/torture.mjs -> prints "ok" (T0-T5 + T9)
405
+ ```
406
+
407
+ The suites cover the controller lifecycle, the load-bearing `data[i]` spawn-index mapping,
408
+ a reveal-to-completion smoke test for every one of the 21 recipes, theming (every themeable
409
+ recipe drives its palette from a theme; short-ramp safety; `colors` overrides `theme`), the
410
+ registry and `RECIPE_META` bijection, host-driven mode, the concurrent stage, and a
411
+ `VERSION`-matches-`package.json` pin. The torture harness gates completion, degenerate
412
+ layers, RNG determinism, the one-scan-canvas-ever assertion, and the per-frame /
413
+ concurrent-stage heap-growth ceilings, with controls proving each gate can fail. No gate
414
+ output is a FAIL.
415
+
416
+ ---
417
+
418
+ ## What this is not
269
419
 
270
- ## Notes on the engine
420
+ - **Not a scratch-gesture library.** It reads the covered pixels of your scratch layer;
421
+ it does not draw the foil or handle the erase input. The pointer `destination-out` loop
422
+ is five lines and yours -- see [Composability](#composability).
423
+ - **Not GSAP or a general animation timeline.** No tweens, no easing DSL, no timeline
424
+ scrubbing. It is a one-shot reveal driver: `reveal` runs a recipe to completion and
425
+ stops.
426
+ - **Not a physics engine.** Each recipe owns its own physics over the engine's typed-array
427
+ lanes. There is no collision, no constraint solver, no shared force field.
428
+ - **Not a UI framework.** No components, no DOM layout, no canvas creation for your cards.
429
+ Bring your own markup and wire two canvases to a controller.
430
+ - **Not for retaining `spot` or `p`.** Both are reused, mutable objects. Retaining either
431
+ reads garbage on the next frame -- copy the numbers out if you need them later.
271
432
 
272
- By default the particle engine (`@zakkster/lite-soa-particle-engine`) self-drives its own
273
- animation-frame loop and computes its own delta time. There are exactly three single-drive
274
- modes, none of which can double-drive the loop:
433
+ ---
275
434
 
276
- - **default** — the engine self-drives (`start()`/`stop()`).
277
- - **`{ driven: true }`** — the host drives via `controller.tick(dt)` *instead of* the engine's
278
- RAF (see [Scaling to a grid](#scaling-to-a-grid-of-scratch-boxes)).
279
- - **`createScratchStage`** — the stage drives all its controllers via `stage.tick(dt)`.
435
+ ## Ecosystem
280
436
 
281
- The removed `sharedTicker` option was a different thing: it drove the engine loop *in addition*
282
- to the engine's own RAF, double-driving it and corrupting `dt` (breaking life decay and
283
- completion). Driven mode replaces it correctly — one clock, one drive.
437
+ Part of the **@zakkster** zero-GC stack. This package builds on three of them:
284
438
 
285
- ## Dependencies
439
+ - [`lite-soa-particle-engine`](https://www.npmjs.com/package/@zakkster/lite-soa-particle-engine) -- the struct-of-arrays particle pool the reveal renders through
440
+ - [`lite-random`](https://www.npmjs.com/package/@zakkster/lite-random) -- the deterministic seeded RNG behind reproducible reveals
441
+ - [`lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp) -- the interpolation helpers recipes use for motion and colour
442
+ - [`lite-ambient-fx`](https://www.npmjs.com/package/@zakkster/lite-ambient-fx) -- ambient canvas backgrounds; its `registerTheme` is the model for this package's `registerRecipe`
443
+ - [`lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal) -- zero-GC reactive graph for hot paths
444
+ - **`lite-scratch-fx`** -- this package
286
445
 
287
- `@zakkster/lite-soa-particle-engine`, `@zakkster/lite-random`, `@zakkster/lite-lerp`.
446
+ ---
288
447
 
289
448
  ## License
290
449
 
291
- MIT © Zahary Shinikchiev
450
+ MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>