@zakkster/lite-scratch-fx 1.0.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,75 +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
- | Recipe | Effect |
78
- |---|---|
79
- | `burn` | Embers rise and fade while the layer burns away |
80
- | `shatter` | The layer breaks into gravity-driven glass pieces |
81
- | `dissolve` | Pixels detach and drift off |
82
- | `explode` | A hard outward blast of debris |
83
- | `dragonBreath` | A sweep of fire particles |
84
- | `iceBreath` | A sweep of frost particles |
85
- | `shineWave` | A moving light beam with sparkle particles |
86
- | `lightningCrawl` | Procedural lightning bolts crawl across |
87
- | `shine` | A single pass of light (no particles) |
88
- | `peel` | The layer curls and peels away |
89
- | `fade` | A plain opacity fade |
90
- | `implosion` | The layer collapses to the center with speed lines |
91
- | `glitchReveal` | Digital slice-and-shift glitch |
92
- | `matrixDecay` | Falling code-rain decay |
93
- | `goldDust` | Drifting gold sparkle |
94
- | `pixelShatter` | The layer shatters into pixel blocks |
95
- | `laserScan` | A scanning laser line |
96
- | `confettiBlast` | A confetti burst |
97
- | `liquidMelt` | The layer melts and runs |
98
- | `neonPulse` | A neon pulse ring |
99
- | `cosmicDust` | Slow drifting star dust |
100
-
101
- Several recipes (`shatter`, `glitchReveal`, `pixelShatter`) snapshot the scratch layer
102
- with `sourceCanvas.toDataURL()` and an `Image`, so the layer must be same-origin
103
- (untainted) for those effects.
104
-
105
- ## Writing a recipe
106
-
107
- 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`:
108
128
 
109
129
  ```js
110
130
  function MyRecipe({ count = 120, duration = 1000 } = {}) {
@@ -122,7 +142,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
122
142
  let alive = 0;
123
143
  for (let i = 0; i < p.max; i++) {
124
144
  if (p.life[i] <= 0) continue;
125
- 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)
126
146
  p.x[i] += p.vx[i] * dt * 60;
127
147
  p.y[i] += p.vy[i] * dt * 60;
128
148
  p.life[i] -= dt;
@@ -140,50 +160,291 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
140
160
  }
141
161
  ```
142
162
 
143
- **Per-particle state and `p.data[i]`.** Recipes keep their own parallel arrays (`size`
144
- above), keyed by the particle's slot. The controller passes each particle's spawn ring
145
- index as the engine's data flag, so inside `tick` you read `p.data[i]` to get the index
146
- 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
147
172
  engine upgrades.
148
173
 
149
- **`spot` is shared and mutable.** The `spot` handed to every `spawn` call is one reused
150
- object read `spot.x` / `spot.y` synchronously and never retain the reference; the next
151
- spawn overwrites it. This is what keeps the reveal-start frame allocation-free (no
152
- `{x, y}` literal per surviving pixel). To make a recipe themeable, take `{ colors, theme }`
153
- 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`.
178
+
179
+ </details>
180
+
181
+ ---
182
+
183
+ ## API reference
184
+
185
+ ### createScratchController
186
+
187
+ ```ts
188
+ createScratchController(sourceCanvas, effectCanvas, options?): ScratchController
189
+ ```
190
+
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
+ ```
207
+
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.
216
+
217
+ ### createScratchStage
154
218
 
155
- The per-frame `tick` runs allocation-free by design: the particle-view object is reused
156
- across frames, and physics runs over the engine's typed-array lanes. `npm run torture`
157
- gates both the reveal-start scan and the per-frame tick against heap growth.
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.
222
+
223
+ ```ts
224
+ createScratchStage(options?): ScratchStage
225
+ ```
226
+
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[]
250
+ ```
251
+
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
261
+
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. |
271
+
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.
277
+
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)`.
285
+
286
+ ```js
287
+ import { createScratchStage, RECIPES, RECIPE_META } from '@zakkster/lite-scratch-fx';
288
+
289
+ // One pool, one clock, a whole grid of cards revealing at once.
290
+ const stage = createScratchStage({ maxParticles: 1500, seed: 1 });
291
+
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
+ }
316
+
317
+ // One rAF drives every active reveal in the grid; dt is in SECONDS.
318
+ let last = performance.now();
319
+ function frame(now) {
320
+ const dt = (now - last) / 1000; last = now;
321
+ stage.tick(dt); // advances every card mid-reveal, allocation-free
322
+ requestAnimationFrame(frame);
323
+ }
324
+ requestAnimationFrame(frame);
325
+ ```
326
+
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
+ ```
158
406
 
159
- ## API
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.
160
415
 
161
- ### `createScratchController(sourceCanvas, effectCanvas, options?)`
416
+ ---
162
417
 
163
- | Option | Type | Default | |
164
- |---|---|---|---|
165
- | `maxParticles` | number | `2000` | Particle pool capacity |
166
- | `seed` | number | `Date.now()` | Deterministic RNG seed |
167
- | `scanPrecision` | number | `32` | Horizontal resolution of the spawn-point pixel scan |
418
+ ## What this is not
168
419
 
169
- Returns `{ reveal(recipe, onDone?), seed(s), destroy() }`.
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.
170
432
 
171
- - **`reveal(recipe, onDone?)`** — run a recipe; ignored if one is active.
172
- - **`seed(s)`** — re-seed the RNG for reproducible reveals.
173
- - **`destroy()`** — stop, clean up the active recipe, release the engine.
433
+ ---
174
434
 
175
- ## Notes on the engine
435
+ ## Ecosystem
176
436
 
177
- The particle engine (`@zakkster/lite-soa-particle-engine`) owns its own animation-frame
178
- loop and computes its own delta time; the controller does not drive it from an external
179
- ticker. An earlier draft accepted a `sharedTicker` and drove the engine loop manually —
180
- that double-drove the loop and corrupted its `dt`, breaking life decay and completion.
181
- It has been removed.
437
+ Part of the **@zakkster** zero-GC stack. This package builds on three of them:
182
438
 
183
- ## 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
184
445
 
185
- `@zakkster/lite-soa-particle-engine`, `@zakkster/lite-random`, `@zakkster/lite-lerp`.
446
+ ---
186
447
 
187
448
  ## License
188
449
 
189
- MIT © Zahary Shinikchiev
450
+ MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>