@zakkster/lite-scratch-fx 1.3.2 → 1.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-08-23
4
+
5
+ Determinism, recipe tuning, and stage lane reclaim. One backward-compatible interface
6
+ addition -- a 5th `rng` argument to `recipe.init`; recipes that ignore it are unaffected.
7
+
8
+ ### Added
9
+ - **Seeded RNG passed to `recipe.init` (B-14).** `recipe.init(ctx, capacity, w, h, rng)` now
10
+ receives the controller's or stage's seeded `Random` as an additive 5th argument.
11
+ `dragonBreath`, `shineWave`, and `lightningCrawl` drew from `Math.random()` in `init`/`tick`,
12
+ breaking reproducibility; they now draw from `rng`, so two reveals at the same seed produce
13
+ byte-identical lanes. Recipes with a shorter `init` signature ignore the argument.
14
+ - **Stage sub-range reclaim (B-8).** `createScratchStage` keeps a free list of released lane
15
+ ranges; `controller.destroy()` returns its `[start, capacity)` range and `createController`
16
+ reuses an exact-capacity match before extending the pool, so a grid rebuilt after destroying
17
+ all controllers no longer throws `pool exhausted`. `remainingCapacity` counts the free list.
18
+ `cancel()` and completion do not reclaim -- the range stays reserved and the controller is
19
+ revealable. The per-frame tick path is byte-identical (the free list is touched only in
20
+ `createController`/`destroy`).
21
+
22
+ ### Changed
23
+ - **`burn` retuned to a 0.2 s flash (B-15).** Defaults `count` 150 -> 100, `duration` 1500 -> 200
24
+ ms; spawn `size` 2-5 -> 3-8, `decay` 0.01-0.03 -> 0.02-0.05, upward `vy` -1..-4 -> -7..-2. The
25
+ additive gravity term `vy += 0.1 * ds` is removed and the cover fade is power2.in (`1 - t*t`)
26
+ instead of the cubic `easeIn`. Matches the consuming renderer's burn.
27
+ - **`iceBreath` default `count` 300 -> 450 (B-16).**
28
+
29
+ ### Removed
30
+ - `Math.random()` from `dragonBreath`, `shineWave`, and `lightningCrawl` (replaced by the
31
+ seeded `rng`).
32
+
3
33
  ## [1.3.2] - 2026-08-23
4
34
 
5
35
  Recipe-fidelity pass against the consuming renderer. No API or signature change; the
package/README.md CHANGED
@@ -36,7 +36,7 @@ import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';
36
36
  const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });
37
37
 
38
38
  revealButton.onclick = () => {
39
- fx.reveal(BurnRecipe({ duration: 1500 }), () => {
39
+ fx.reveal(BurnRecipe(), () => {
40
40
  console.log('prize revealed');
41
41
  });
42
42
  };
@@ -131,7 +131,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
131
131
  let size;
132
132
  return {
133
133
  count, // 0 = pure-canvas reveal, no particles
134
- init(ctx, capacity, w, h) { // allocate parallel per-particle arrays
134
+ init(ctx, capacity, w, h, rng) { // allocate arrays; rng = seeded Random (same one passed to spawn), use instead of Math.random for reproducible reveals
135
135
  size = new Float32Array(capacity);
136
136
  },
137
137
  spawn(idx, rng, w, h, spot) { // one particle; spot is 0..1 on the layer
@@ -261,7 +261,7 @@ resolvePalette(colors, theme, fallback): string[]
261
261
 
262
262
  | Constant | Value | Meaning |
263
263
  | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
264
- | `VERSION` | `'1.3.2'` | Package version string (synced to package.json). |
264
+ | `VERSION` | `'1.4.0'` | Package version string (synced to package.json). |
265
265
  | `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
266
266
  | `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
267
267
  | stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
package/index.d.ts CHANGED
@@ -69,7 +69,8 @@ export interface RecipeRng {
69
69
  export interface Recipe {
70
70
  /** Particles to spawn. `0` means a pure-canvas reveal with no particles. */
71
71
  count: number;
72
- init(ctx: CanvasRenderingContext2D, capacity: number, w: number, h: number): void;
72
+ /** `rng` is the controller/stage's seeded Random instance (same one passed to spawn); use it instead of Math.random for reproducible reveals. */
73
+ init(ctx: CanvasRenderingContext2D, capacity: number, w: number, h: number, rng: RecipeRng): void;
73
74
  spawn(idx: number, rng: RecipeRng, w: number, h: number, spot: SpawnSpot): SpawnState;
74
75
  /** Return `true` when the effect is complete. `elapsedMs` is milliseconds since reveal. */
75
76
  tick(
@@ -183,7 +184,10 @@ export interface StageController {
183
184
  * A no-op when idle or destroyed. Does not touch other controllers' reveals.
184
185
  */
185
186
  cancel(): void;
186
- /** Remove this controller from the stage (its sub-range is not reclaimed). */
187
+ /**
188
+ * Remove this controller from the stage. Its sub-range is reclaimed into the stage
189
+ * free-list for exact-capacity reuse by a later createController.
190
+ */
187
191
  destroy(): void;
188
192
  }
189
193
 
package/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  // Three-place version sync: this constant, package.json "version", and the top
15
15
  // CHANGELOG.md heading must always match. /release keeps them locked.
16
- export const VERSION = '1.3.2';
16
+ export const VERSION = '1.4.0';
17
17
 
18
18
  export { createScratchController } from './src/ScratchController.js';
19
19
  export { default as ScratchController } from './src/ScratchController.js';
package/llms.txt CHANGED
@@ -32,7 +32,7 @@ A second reveal while one is active is ignored.
32
32
 
33
33
  {
34
34
  count, // particles to spawn; 0 = pure-canvas reveal
35
- init(ctx, capacity, w, h), // allocate parallel per-particle arrays
35
+ init(ctx, capacity, w, h, rng), // allocate arrays; rng = seeded Random (same as spawn), use instead of Math.random for reproducible reveals
36
36
  spawn(idx, rng, w, h, spot), // -> { x, y, vx, vy, life }; spot is {x,y} in 0..1
37
37
  tick(dt, elapsedMs, p, ctx, src, w, h), // -> true when complete
38
38
  destroy(),
@@ -116,6 +116,8 @@ start+capacity) of the lanes. reserving past maxParticles throws. Each controlle
116
116
  into its slots (data[start+i]=i), renders through subarray views of its range (built once per
117
117
  controller, reused every frame -> zero per-frame alloc), draws to its own ctx, keeps its own RNG.
118
118
  stage.tick(dt) advances the single engine once; the one onTick fans out to every active reveal.
119
+ controller.destroy() reclaims its sub-range into a stage free-list for exact-capacity reuse by a
120
+ later createController; cancel/completion hold the range (the controller stays revealable).
119
121
  Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
120
122
  lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
121
123
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-scratch-fx",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "1.3.2",
4
+ "version": "1.4.0",
5
5
  "description": "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, cosmic dust, and more), a registry with a register hook, zero GSAP, a zero-GC hot path, deterministic seeded RNG, and a stage that runs a grid of simultaneous reveals over one shared pool.",
6
6
  "type": "module",
7
7
  "main": "./index.js",
@@ -204,7 +204,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
204
204
  const spots = scanner.scan(scanPrecision);
205
205
 
206
206
  // Init recipe with LOGICAL size (recipes stay in CSS px).
207
- recipe.init(ctx, cap, logicalW, logicalH);
207
+ recipe.init(ctx, cap, logicalW, logicalH, rng);
208
208
 
209
209
  // Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
210
210
  // like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
@@ -5,7 +5,7 @@
5
5
  * ScratchController.reveal(recipe, onDone). Grouped by family below.
6
6
  *
7
7
  * Recipe interface:
8
- * { count, init(ctx, capacity, w, h),
8
+ * { count, init(ctx, capacity, w, h, rng),
9
9
  * spawn(idx, rng, w, h, spot),
10
10
  * tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) -> boolean,
11
11
  * destroy() }
@@ -28,7 +28,7 @@ import {resolvePalette} from './Palette.js';
28
28
  // Spawn particles from the scratch layer's still-covered pixels and run their own physics.
29
29
  // ===========================================================================
30
30
 
31
- export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
31
+ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
32
32
  let pSize, pDecay, pColorIdx;
33
33
  const palette = resolvePalette(colors, theme, ['#ff6b00', '#ff8c00', '#ffaa00', '#ff4500', '#8B0000']);
34
34
 
@@ -40,22 +40,21 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
40
40
  pColorIdx = new Uint8Array(capacity);
41
41
  },
42
42
  spawn(idx, rng, w, h, spot) {
43
- pSize[idx] = rng.range(2, 5);
44
- pDecay[idx] = rng.range(0.01, 0.03);
43
+ pSize[idx] = rng.range(3, 8);
44
+ pDecay[idx] = rng.range(0.02, 0.05);
45
45
  pColorIdx[idx] = rng.int(0, palette.length - 1);
46
- return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-1, -4), life: 1.0};
46
+ return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-7, -2), life: 1.0};
47
47
  },
48
48
  tick(dt, elapsed, engine, ctx, src, w, h) {
49
49
  const {x, y, vx, vy, life, data, max} = engine;
50
50
  const ds = dt * 60;
51
51
  let alive = 0;
52
52
  const progress = clamp(elapsed / duration, 0, 1);
53
- src.style.opacity = 1 - easeIn(progress);
53
+ src.style.opacity = 1 - progress * progress;
54
54
 
55
55
  for (let i = 0; i < max; i++) {
56
56
  if (life[i] <= 0) continue;
57
57
  const p = data[i];
58
- vy[i] += 0.1 * ds;
59
58
  x[i] += vx[i] * ds;
60
59
  y[i] += vy[i] * ds;
61
60
  life[i] -= pDecay[p] * ds;
@@ -173,12 +172,13 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
173
172
 
174
173
 
175
174
  export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
176
- let pSize, pDecay, pColorIdx;
175
+ let pSize, pDecay, pColorIdx, r;
177
176
  const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
178
177
 
179
178
  return {
180
179
  count,
181
- init(ctx, capacity) {
180
+ init(ctx, capacity, w, h, rng) {
181
+ r = rng;
182
182
  pSize = new Float32Array(capacity);
183
183
  pDecay = new Float32Array(capacity);
184
184
  pColorIdx = new Uint8Array(capacity);
@@ -210,7 +210,7 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
210
210
  const p = data[i];
211
211
  x[i] += vx[i] * ds;
212
212
  y[i] += vy[i] * ds;
213
- vx[i] += (Math.random() - 0.5) * 0.5 * ds;
213
+ vx[i] += (r.next() - 0.5) * 0.5 * ds;
214
214
  vy[i] *= dvy;
215
215
  pSize[p] *= dsz;
216
216
  life[i] -= pDecay[p] * ds;
@@ -233,13 +233,13 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
233
233
  return alive === 0 && elapsed >= duration;
234
234
  },
235
235
  destroy() {
236
- pSize = pDecay = pColorIdx = null;
236
+ pSize = pDecay = pColorIdx = r = null;
237
237
  },
238
238
  };
239
239
  }
240
240
 
241
241
 
242
- export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} = {}) {
242
+ export function IceBreathRecipe({count = 450, duration = 1500, colors, theme} = {}) {
243
243
  let pSize, pDecay, pRot, pRotSpd, pColorIdx;
244
244
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
245
245
 
@@ -871,22 +871,23 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
871
871
 
872
872
  export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
873
873
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
874
- let py, pvy, pox, psz, pcol;
874
+ let py, pvy, pox, psz, pcol, r;
875
875
 
876
876
  return {
877
877
  count: 0, // No SoA particles -- managed internally
878
- init(ctx, capacity, w, h) {
878
+ init(ctx, capacity, w, h, rng) {
879
+ r = rng;
879
880
  py = new Float32Array(particleCount);
880
881
  pvy = new Float32Array(particleCount);
881
882
  pox = new Float32Array(particleCount);
882
883
  psz = new Float32Array(particleCount);
883
884
  pcol = new Uint8Array(particleCount);
884
885
  for (let i = 0; i < particleCount; i++) {
885
- py[i] = Math.random() * h;
886
- pvy[i] = (Math.random() - 0.5) * 2;
887
- pox[i] = (Math.random() - 0.5) * beamWidth * 0.6;
888
- psz[i] = Math.random() * 4 + 2;
889
- pcol[i] = (Math.random() * palette.length) | 0;
886
+ py[i] = r.next() * h;
887
+ pvy[i] = (r.next() - 0.5) * 2;
888
+ pox[i] = (r.next() - 0.5) * beamWidth * 0.6;
889
+ psz[i] = r.next() * 4 + 2;
890
+ pcol[i] = (r.next() * palette.length) | 0;
890
891
  }
891
892
  },
892
893
  spawn() {
@@ -916,10 +917,10 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
916
917
  for (let i = 0; i < particleCount; i++) {
917
918
  const px2 = bx + pox[i];
918
919
  py[i] += pvy[i] * ds;
919
- pvy[i] += (Math.random() - 0.5) * 0.3 * ds;
920
+ pvy[i] += (r.next() - 0.5) * 0.3 * ds;
920
921
  if (py[i] < -10) py[i] = h + 10;
921
922
  if (py[i] > h + 10) py[i] = -10;
922
- psz[i] = clamp(psz[i] + (Math.random() - 0.5) * 0.3 * ds, 1, 8);
923
+ psz[i] = clamp(psz[i] + (r.next() - 0.5) * 0.3 * ds, 1, 8);
923
924
  ctx.globalAlpha = 0.7;
924
925
  ctx.fillStyle = palette[pcol[i]];
925
926
  ctx.save();
@@ -932,7 +933,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
932
933
  return raw >= 1;
933
934
  },
934
935
  destroy() {
935
- py = pvy = pox = psz = pcol = null;
936
+ py = pvy = pox = psz = pcol = r = null;
936
937
  },
937
938
  };
938
939
  }
@@ -1013,6 +1014,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1013
1014
  const cMid = palette[(palette.length - 1) >> 1];
1014
1015
  const cOuter = palette[palette.length - 1];
1015
1016
  let bolts = [];
1017
+ let r;
1016
1018
 
1017
1019
  function genPts(x1, y1, x2, y2, d) {
1018
1020
  let p = [{x: x1, y: y1}, {x: x2, y: y2}];
@@ -1022,7 +1024,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1022
1024
  const a = p[j], b = p[j + 1];
1023
1025
  const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
1024
1026
  const dist = Math.hypot(b.x - a.x, b.y - a.y);
1025
- const off = (Math.random() - 0.5) * dist * 0.4;
1027
+ const off = (r.next() - 0.5) * dist * 0.4;
1026
1028
  const nx = -(b.y - a.y) / (dist || 1), ny = (b.x - a.x) / (dist || 1);
1027
1029
  np.push({x: mx + nx * off, y: my + ny * off});
1028
1030
  np.push(b);
@@ -1045,39 +1047,40 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1045
1047
  ctx.stroke();
1046
1048
  });
1047
1049
  if (d > 2) for (let i = 2; i < pts.length - 1; i += 3) {
1048
- if (Math.random() > 0.5) continue;
1049
- const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + Math.random() * 0.2);
1050
- const ba = Math.atan2(y2 - y1, x2 - x1) + (Math.random() - 0.5) * 1.5;
1050
+ if (r.next() > 0.5) continue;
1051
+ const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + r.next() * 0.2);
1052
+ const ba = Math.atan2(y2 - y1, x2 - x1) + (r.next() - 0.5) * 1.5;
1051
1053
  drawBolt(ctx, pts[i].x, pts[i].y, pts[i].x + Math.cos(ba) * bl, pts[i].y + Math.sin(ba) * bl, th * 0.5, d - 2);
1052
1054
  }
1053
1055
  }
1054
1056
 
1055
1057
  return {
1056
1058
  count: 0,
1057
- init(ctx, capacity, w, h) {
1059
+ init(ctx, capacity, w, h, rng) {
1060
+ r = rng;
1058
1061
  bolts = [];
1059
1062
  for (let b = 0; b < boltCount; b++) {
1060
- const e = (Math.random() * 4) | 0;
1063
+ const e = (r.next() * 4) | 0;
1061
1064
  let sx, sy;
1062
1065
  if (e === 0) {
1063
1066
  sx = 0;
1064
- sy = Math.random() * h;
1067
+ sy = r.next() * h;
1065
1068
  } else if (e === 1) {
1066
1069
  sx = w;
1067
- sy = Math.random() * h;
1070
+ sy = r.next() * h;
1068
1071
  } else if (e === 2) {
1069
- sx = Math.random() * w;
1072
+ sx = r.next() * w;
1070
1073
  sy = 0;
1071
1074
  } else {
1072
- sx = Math.random() * w;
1075
+ sx = r.next() * w;
1073
1076
  sy = h;
1074
1077
  }
1075
1078
  bolts.push({
1076
1079
  sx,
1077
1080
  sy,
1078
- ex: w * (0.3 + Math.random() * 0.4),
1079
- ey: h * (0.3 + Math.random() * 0.4),
1080
- delay: Math.random() * 0.3
1081
+ ex: w * (0.3 + r.next() * 0.4),
1082
+ ey: h * (0.3 + r.next() * 0.4),
1083
+ delay: r.next() * 0.3
1081
1084
  });
1082
1085
  }
1083
1086
  },
@@ -1109,6 +1112,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1109
1112
  },
1110
1113
  destroy() {
1111
1114
  bolts = [];
1115
+ r = null;
1112
1116
  },
1113
1117
  };
1114
1118
  }
@@ -46,6 +46,12 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
46
46
  const pending = new Set();
47
47
  let dispatching = false;
48
48
  let nextStart = 0;
49
+ // Free-list of reclaimed [start, capacity] ranges from destroyed controllers, in
50
+ // parallel arrays. Exact-match reuse only: a new controller reclaims a range solely
51
+ // when its capacity equals a freed one. Cold path (createController/destroy) -- never
52
+ // touched per frame.
53
+ const freeStart = [], freeCap = [];
54
+ let reclaimedCapacity = 0;
49
55
  let controllerCount = 0;
50
56
  let destroyed = false;
51
57
 
@@ -89,13 +95,32 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
89
95
  if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
90
96
  throw new TypeError('createController: dpr must be a positive finite number');
91
97
  }
92
- if (nextStart + capacity > maxParticles) {
93
- throw new RangeError(
94
- `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
95
- );
98
+ // Reclaim an exact-capacity range from a destroyed controller before growing the
99
+ // pool. On a hit we skip the nextStart bump and the exhaustion throw entirely.
100
+ let start;
101
+ let reused = -1;
102
+ for (let i = 0; i < freeCap.length; i++) {
103
+ if (freeCap[i] === capacity) { reused = i; break; }
96
104
  }
97
- const start = nextStart;
98
- nextStart += capacity;
105
+ if (reused >= 0) {
106
+ start = freeStart[reused];
107
+ // Swap-remove: order is irrelevant for exact-match reuse.
108
+ const last = freeCap.length - 1;
109
+ freeStart[reused] = freeStart[last];
110
+ freeCap[reused] = freeCap[last];
111
+ freeStart.pop();
112
+ freeCap.pop();
113
+ reclaimedCapacity -= capacity;
114
+ } else {
115
+ if (nextStart + capacity > maxParticles) {
116
+ throw new RangeError(
117
+ `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
118
+ );
119
+ }
120
+ start = nextStart;
121
+ nextStart += capacity;
122
+ }
123
+ // A reused range is a NEW controller, so the index always advances -> new default seed.
99
124
  const index = controllerCount++;
100
125
 
101
126
  const ctx = effectCanvas.getContext('2d');
@@ -133,7 +158,8 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
133
158
  // The single terminator for this controller's reveal. Completion (fireDone=true),
134
159
  // cancel() (false), and destroy() (false) all route here so the recipe teardown,
135
160
  // host-style restore, canvas clear, and active-set removal happen in one place.
136
- // Cold path: once per reveal, never per frame. Sub-range stays reserved (B-8).
161
+ // Cold path: once per reveal, never per frame. The sub-range is held across every
162
+ // completion and cancel, and reclaimed into the stage free-list only on destroy() (B-8).
137
163
  function endReveal(fireDone) {
138
164
  if (!activeRecipe) return;
139
165
  if (activeRecipe.destroy) activeRecipe.destroy();
@@ -171,7 +197,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
171
197
  hostSnapshot = captureHostStyle(sourceCanvas);
172
198
 
173
199
  const spots = scanner.scan(scanPrecision);
174
- recipe.init(ctx, capacity, cw, ch);
200
+ recipe.init(ctx, capacity, cw, ch, rng);
175
201
 
176
202
  const requested = recipe.count ?? capacity;
177
203
  const count = Math.min(requested, capacity);
@@ -224,7 +250,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
224
250
  /** Re-seed this controller's RNG. */
225
251
  seed(s) { rng.reset(s); },
226
252
 
227
- /** Remove this controller from the stage. Its sub-range is not reclaimed (v1). */
253
+ /**
254
+ * Remove this controller from the stage. Its sub-range is reclaimed into the
255
+ * stage free-list for exact-capacity reuse by a later createController.
256
+ */
228
257
  destroy() {
229
258
  if (ctlDestroyed) return;
230
259
  ctlDestroyed = true;
@@ -232,6 +261,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
232
261
  // host-style restore, active-set removal, no onDone) before scanner teardown.
233
262
  endReveal(false);
234
263
  scanner.destroy();
264
+ // Reclaim the sub-range. The early-return above guarantees one push per
265
+ // controller, so a double-destroy cannot double-free.
266
+ freeStart.push(start); freeCap.push(capacity); reclaimedCapacity += capacity;
235
267
  },
236
268
  };
237
269
  return controller;
@@ -256,7 +288,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
256
288
  },
257
289
 
258
290
  /** @internal How many slots are still unreserved (for tests / diagnostics). */
259
- get remainingCapacity() { return maxParticles - nextStart; },
291
+ get remainingCapacity() { return maxParticles - nextStart + reclaimedCapacity; },
260
292
  };
261
293
  }
262
294