blockyard 0.0.1 → 0.1.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,1141 @@
1
+ // AGENTS: the idle effects that are SOMETHING HAPPENING rather than a pattern.
2
+ //
3
+ // (operator, 2026-09-13: "no more per-tile stuff. Think more tron lightcycles stuff", then
4
+ // "Build all of them with amazing effects", and "we can even add flying ships or other
5
+ // pixel-graphic inspired art".)
6
+ //
7
+ // THE DISTINCTION THIS MODULE EXISTS FOR. Seventeen of the board's effects are FIELDS: pure
8
+ // functions of a tile's position and the effect's clock, evaluated everywhere at once
9
+ // (`fxAt` in blockscene3d.js). A field is wallpaper -- plasma, aurora, checker, ripple. Three
10
+ // were AGENTS: a thing with a position, a route it has already travelled, and a future. You can
11
+ // watch an agent and wonder what it will do, and that is the whole difference.
12
+ //
13
+ // Those three (`lightcycle`, `ball`, and the retired `packets`) were built as `if` branches inside
14
+ // details3d.js's startFx and fxNow, with their drawing hard-wired into paintFrame. Fifty of them
15
+ // cannot be fifty `if`s in a 2,400-line renderer, so this module holds the registry and every
16
+ // agent's three parts, and details3d.js keeps only the seams.
17
+ //
18
+ // THE CONTRACT. An agent is `{ ms, build, frame, draw }`:
19
+ //
20
+ // build(ctx) once, when the effect starts, with { st, seed, W, H, tops, tiles, rnd }.
21
+ // The board is STILL while an effect runs, so a route, a formation or a sprite's
22
+ // path is decided here and a frame only moves along it. Returns whatever the
23
+ // agent wants to keep; it lands on `fx.agent`.
24
+ // frame(a, u) every frame, with the built state and progress 0..1. Returns the per-frame
25
+ // shape: `{ heads, ... }`. `heads` is how an agent LIGHTS THE BOARD -- see below.
26
+ // draw(ctx, view, lw) the agent's own geometry over the cubes, reading `view.fx`.
27
+ //
28
+ // EVERY AGENT MUST PUBLISH `heads`. effects.test.js plays every registered kind through fxAt and
29
+ // demands it light something (`worst > 0.3`); only `pulse` is exempt, because it draws on the
30
+ // price line. That test is not an obstacle to work around -- it is what stops an effect being
31
+ // painted OVER the board rather than interacting with it. `heads` is
32
+ // `[{ x, y, color: [r,g,b], alpha }]` in grid units, and the cubes near one flash in its colour.
33
+ //
34
+ // DETERMINISM. Board-level choices come from `fxHash(seed)` or the seeded `rnd` handed to
35
+ // `build`, never Math.random, so an effect replays identically and a test can assert on it.
36
+ // Per-frame sparkle (a crackle re-rolled every frame) may use Math.random because it is never
37
+ // asserted frame to frame -- electrical precisely because it never draws twice.
38
+ //
39
+ // CANVAS RULES. No ctx.clip(), no globalAlpha, no composite modes, no shadowBlur
40
+ // (viewer-canvas-rules.test.js scans this file's siblings; every glow here is layered plain rgba).
41
+ import {
42
+ project, fxHash, cubeHeight,
43
+ cyclePath, ballPath, cycleCrashes, cellTops, pathHeights,
44
+ // the board's OWN gravity, for the tractor beam's release. A dropped cube must fall the way every
45
+ // other cube on this board falls -- a second hand-rolled parabola beside the real one would land
46
+ // differently from the transition happening next to it.
47
+ bounceDrop, bouncesUntil, landingMs, fallShare,
48
+ } from './blockscene3d.js';
49
+ // The tetris drop points the real rules at the board instead of a well: pure, and already
50
+ // tested in its own right, so the piece shapes and their rotations are not reinvented here.
51
+ import { PIECES, cellsOf } from './tetris.js';
52
+
53
+ // a cheap deterministic 0..1 from an integer. details3d.js keeps its own copy for the price-line
54
+ // pulse; duplicating six lines is better than widening that module's public surface for a helper.
55
+ export const hash01 = (n) => { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); };
56
+
57
+ /** A seeded stream, so a build() makes the same board every replay. */
58
+ export function rng(seed) {
59
+ let s = (seed >>> 0) || 1;
60
+ return () => ((s = (Math.imul(s, 1103515245) + 12345) >>> 0) / 4294967296);
61
+ }
62
+
63
+ // ---------------------------------------------------------------- helpers shared by agents
64
+
65
+ /** Point `d` units along a polyline of grid points, and the index it sits in. */
66
+ export function alongPath(pts, d) {
67
+ const end = pts.length - 1;
68
+ const k = Math.max(0, Math.min(end - 1, Math.floor(d)));
69
+ const f = Math.max(0, Math.min(1, d - k));
70
+ const a = pts[k], b = pts[k + 1] ?? a;
71
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, k };
72
+ }
73
+
74
+ /**
75
+ * The tallest resting cube on the board, and its centre. What a data-aware agent aims at.
76
+ *
77
+ * The board is transactions: height is vbytes and `rate` is sat/vB. An agent that heads for the
78
+ * tallest cube is eating the biggest transaction in the block, which is worth more than an agent
79
+ * that wanders -- the picture it draws is then a reading of the data, the way `cascade` is.
80
+ */
81
+ export function tallestTile(tiles) {
82
+ let best = null, bh = -1;
83
+ for (const t of tiles ?? []) {
84
+ const h = (t.floor ?? 0) + cubeHeight(t);
85
+ if (h > bh) { bh = h; best = t; }
86
+ }
87
+ return best ? { x: best.x + best.s / 2, y: best.y + best.s / 2, h: bh, tile: best } : null;
88
+ }
89
+
90
+ /** The richest resting cube (highest feerate), for agents that read the fee structure. */
91
+ export function richestTile(tiles) {
92
+ let best = null, br = -Infinity;
93
+ for (const t of tiles ?? []) {
94
+ const r = Number(t.rate);
95
+ if (Number.isFinite(r) && r > br) { br = r; best = t; }
96
+ }
97
+ return best ? { x: best.x + best.s / 2, y: best.y + best.s / 2, rate: br, tile: best } : null;
98
+ }
99
+
100
+ // ---------------------------------------------------------------- the registry
101
+
102
+ /**
103
+ * Every agent, by kind. details3d.js reads this in three places and nowhere else:
104
+ * startFx (build), fxNow (frame) and paintFrame (draw).
105
+ *
106
+ * `lightcycle` and `ball` are migrated here first, unchanged in behaviour, so the contract is
107
+ * proven against the tests that already exist (lightcycle-crash.test.js calls drawCycles with a
108
+ * hand-built view; details3d.test.js asserts triggerIdle accepts both and still REFUSES
109
+ * `packets`, the retired one). New agents are added as data below them.
110
+ */
111
+ export const AGENTS = Object.create(null);
112
+
113
+ /** Register one agent. Kinds are added to FX_MS, settings and the panel separately, in order. */
114
+ export function defineAgent(kind, spec) {
115
+ AGENTS[kind] = spec;
116
+ return spec;
117
+ }
118
+
119
+ /** Is this kind an agent (rather than a per-tile field)? */
120
+ export const isAgent = (kind) => Object.prototype.hasOwnProperty.call(AGENTS, kind);
121
+
122
+ // ---------------------------------------------------------------- light cycles
123
+ //
124
+ // (operator, 2026-09-11: "add an energy pulse that travels along from one side of the board to
125
+ // another via block outlines. Be creative and varied", then 2026-09-12 "the lightcycles shouldn't
126
+ // have the electricity effect" -- a rider whose point is a clean light wall is not a storm.)
127
+ //
128
+ // Two riders from opposite edges, each laying a wall along the grid LINES, until one crosses the
129
+ // other's wall and de-reses. The routes and the height of every stretch are fixed at build time
130
+ // because the board is still; a frame only advances the heads.
131
+ defineAgent('lightcycle', {
132
+ draw(ctx, view, lw, api) { api.drawCycles(ctx, view, lw); },
133
+ build({ st, seed, W, H, tiles, rnd }) {
134
+ const tops = cellTops(tiles, W, H);
135
+ // on the price board the riders come in from the left and the right, never from the front
136
+ // (operator, 2026-09-14: "it all needs to be left/right or right/left movement")
137
+ const priceBoard = st?.axes?.line?.length > 1;
138
+ const sides = priceBoard || rnd() < 0.5 ? ['left', 'right'] : ['bottom', 'top'];
139
+ // ...and their walls run the candles' rows (axes.y is the middle of the candle row), so they
140
+ // ride the candle tops rather than the empty floor in front
141
+ // (axes.y 3.7 and a body 1.4 deep: grid lines 3 to 5, whose stretches all ride a candle cell)
142
+ const lanes = priceBoard && Number.isFinite(st.axes.y) ? [Math.floor(st.axes.y - 0.7), Math.ceil(st.axes.y + 0.7)] : null;
143
+ const paths = sides.map((side, i) => {
144
+ const pts = cyclePath(seed + i * 7919, W, H, side, lanes);
145
+ return { pts, hs: pathHeights(pts, tops, W, H), color: i ? [255, 150, 40] : [80, 220, 255], lag: i * 0.06 };
146
+ });
147
+ return { paths, crashes: cycleCrashes(paths) };
148
+ },
149
+ frame(a, u, { ms, derezMs }) {
150
+ const cycles = a.paths.map((p, i) => {
151
+ const len = p.pts.length - 1;
152
+ const c = a.crashes?.[i];
153
+ if (c && u >= c.u) {
154
+ // DE-RES: stopped dead where it hit; the draw shatters its wall and fades it
155
+ return { ...p, d: c.d, from: 0, alpha: 0, trail: Infinity, derez: Math.min(1, ((u - c.u) * ms) / derezMs), crash: c };
156
+ }
157
+ const v = Math.max(0, (u - p.lag) / (1 - p.lag));
158
+ const run = Math.min(1, v / 0.8), after = Math.max(0, (v - 0.8) / 0.2);
159
+ // the whole wall, from the start of the route: it is what the cycle has drawn on the board,
160
+ // and it stands until the effect is over (alpha fades it out at the very end)
161
+ return { ...p, d: run * len, from: 0, alpha: 1 - 0.75 * after, trail: Infinity };
162
+ });
163
+ const heads = cycles
164
+ .filter((c) => c.alpha > 0.05 && c.d > 0 && c.d < c.pts.length - 1)
165
+ .map((c) => { const p = alongPath(c.pts, c.d); return { x: p.x, y: p.y, color: c.color, alpha: c.alpha }; });
166
+ // the crash flashes white and lights the cubes round it
167
+ for (const c of cycles) {
168
+ if (c.derez != null && c.derez < 0.6) heads.push({ x: c.crash.at.x, y: c.crash.at.y, color: [255, 255, 255], alpha: 1 - c.derez / 0.6 });
169
+ }
170
+ return { cycles, heads };
171
+ },
172
+ });
173
+
174
+ // ---------------------------------------------------------------- the lightning ball
175
+ //
176
+ // (operator, 2026-09-11, of the data packets that were here: "It comes off more as wandering
177
+ // lights. I was hoping for something that comes from off-screen along a grid line, and then starts
178
+ // tracing through the grid to the opposite side. Some high-effects version of a lightning ball
179
+ // moving along the grid, illuminating everything it comes near".)
180
+ defineAgent('ball', {
181
+ draw(ctx, view, lw, api) { api.drawBall(ctx, view, lw); },
182
+ build({ st, W, H, seed, tiles, rnd }) {
183
+ const tops = cellTops(tiles, W, H);
184
+ const line = st?.axes?.line;
185
+ if (line?.length > 1) {
186
+ // ON THE PRICE BOARD IT TRAVELS THE PRICE LINE (operator, 2026-09-14: "The plasma ball needs
187
+ // to move left to right on the markets. not back to front (or right to left). It needs to
188
+ // move across the entire chart randomly", then "make the lightning ball travel along the
189
+ // price line"). A route picked from any of four sides ran the depth axis half the time,
190
+ // which on eight units of depth is a ball crossing the board's thickness. Now: in from off
191
+ // the left edge, out past the right, along the grid line under the candles, and its height
192
+ // at every step is the line's own -- the ball rides the price, and its burn runs the line.
193
+ const y = Math.max(1, Math.min(H - 1, Math.round(Number.isFinite(st.axes.y) ? st.axes.y : H / 2)));
194
+ const E = 6;
195
+ const pts = [];
196
+ for (let x = -E; x <= W + E; x++) pts.push({ x, y });
197
+ const zAt = (x) => {
198
+ if (x <= line[0].x) return line[0].z;
199
+ for (let i = 1; i < line.length; i++) {
200
+ if (x <= line[i].x) { const a = line[i - 1], b = line[i]; return a.z + (b.z - a.z) * ((x - a.x) / Math.max(1e-9, b.x - a.x)); }
201
+ }
202
+ return line[line.length - 1].z;
203
+ };
204
+ const hs = [];
205
+ for (let k = 0; k + 1 < pts.length; k++) hs.push(zAt((pts[k].x + pts[k + 1].x) / 2));
206
+ return { paths: [{ pts, hs, color: [150, 215, 255] }] };
207
+ }
208
+ const side = ['left', 'right', 'bottom', 'top'][(rnd() * 4) | 0];
209
+ const pts = ballPath(seed, W, H, side, Math.ceil(Math.max(W, H) * 0.35) + 8);
210
+ return { paths: [{ pts, hs: pathHeights(pts, tops, W, H), color: [150, 215, 255] }] };
211
+ },
212
+ frame(a, u) {
213
+ // the ball runs the whole route at one speed; it carries its own light, so no sweep envelope
214
+ const p = a.paths[0], len = p.pts.length - 1;
215
+ const d = Math.max(0, Math.min(1, (u - 0.02) / 0.96)) * len;
216
+ const at = alongPath(p.pts, d);
217
+ const ball = { x: at.x, y: at.y, z: (p.hs[at.k] ?? 0) + 0.9, d, pts: p.pts, hs: p.hs };
218
+ return { ball, amp: 1, heads: [{ x: at.x, y: at.y, color: p.color, alpha: 1 }] };
219
+ },
220
+ });
221
+
222
+ export { project, fxHash };
223
+
224
+ // ================================================================= BATCH ONE
225
+ //
226
+ // Six agents, chosen to cover five different motion vocabularies rather than six variations on
227
+ // one: a strider, a ricochet, a seeker, a hopper, a formation, and an explosion shaped by the
228
+ // board. Three of them READ THE DATA -- the board is transactions, and an effect that answers
229
+ // "which is biggest" or "how dense is this block" is worth more than one that ignores what it
230
+ // crosses (docs/EFFECTS-AGENTS.md).
231
+
232
+ // --- shared drawing -------------------------------------------------------
233
+ // Plain rgba fills and strokes, layered. No clip, no globalAlpha, no composite modes, no
234
+ // shadowBlur -- same rules as the renderer, and viewer-canvas-rules.test.js now scans this file.
235
+
236
+ /** A filled polygon in screen space. */
237
+ function poly(ctx, pts, fill) {
238
+ if (pts.length < 3) return;
239
+ ctx.fillStyle = fill;
240
+ ctx.beginPath();
241
+ ctx.moveTo(pts[0].x, pts[0].y);
242
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
243
+ ctx.closePath();
244
+ ctx.fill();
245
+ }
246
+
247
+ /** A polyline in screen space. */
248
+ function line(ctx, pts, col, w) {
249
+ if (pts.length < 2) return;
250
+ ctx.strokeStyle = col; ctx.lineWidth = w;
251
+ ctx.beginPath();
252
+ ctx.moveTo(pts[0].x, pts[0].y);
253
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
254
+ ctx.stroke();
255
+ }
256
+
257
+ /**
258
+ * Nested discs: this renderer's only way to make something glow.
259
+ *
260
+ * `r` is in PIXELS -- callers size it from view.unit, never from lw. lw is about one device pixel,
261
+ * so a sprite measured in line-widths comes out the same tiny size on every board; measured in
262
+ * grid units it is the same size relative to the BOARD, which is what reads.
263
+ */
264
+ function bloom(ctx, x, y, r, rgb, a = 1) {
265
+ for (const [k, m] of [[1, 0.10], [0.62, 0.22], [0.34, 0.55], [0.16, 0.95]]) {
266
+ ctx.fillStyle = `rgba(${rgb.join(',')},${(m * a).toFixed(3)})`;
267
+ ctx.beginPath(); ctx.arc(x, y, r * k, 0, Math.PI * 2); ctx.fill();
268
+ }
269
+ }
270
+
271
+ /**
272
+ * THE SAUCER, drawn at a point on screen -- shared, because two effects fly the same craft
273
+ * (operator, 2026-09-15: "The top-down view in block space coming from a sphere does not look
274
+ * good. Can we re-purpose the UFO"). The scan beam used to hang from a glowing ball, which from
275
+ * directly above is just a bright blob; a craft reads as a source even top-down, because its rim
276
+ * and its dome give it an orientation a sphere does not have.
277
+ *
278
+ * `r` is in PIXELS: callers size it from view.unit, never from lw (see bloom).
279
+ */
280
+ /**
281
+ * THE SAUCER SEEN FROM ABOVE (operator, 2026-09-15: "We need a top-down rendering of the UFO for
282
+ * this scene"). The side-on craft below is right where the camera looks along the board; on the
283
+ * block board it looks down, and a dome-and-rim drawn edge-on there is just a blue smear -- which
284
+ * is what the screenshot showed.
285
+ *
286
+ * From above a saucer is concentric: hull, inner ring, canopy, and running lights spaced around
287
+ * the rim. `squash` is the camera's depth foreshortening (oblique.dy), so the circles come out as
288
+ * the same ellipses the board itself is drawn in.
289
+ */
290
+ export function saucerAbove(ctx, cx, cy, r, a = 1, squash = 1, phase = 0) {
291
+ const ell = (rr, fill) => {
292
+ ctx.fillStyle = fill;
293
+ ctx.beginPath();
294
+ for (let i = 0; i <= 28; i++) {
295
+ const th = (i / 28) * Math.PI * 2;
296
+ const x = cx + Math.cos(th) * rr, y = cy + Math.sin(th) * rr * squash;
297
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
298
+ }
299
+ ctx.closePath(); ctx.fill();
300
+ };
301
+ ell(r, `rgba(70,110,170,${(0.85 * a).toFixed(3)})`); // the hull, dark against the glow
302
+ ell(r * 0.74, `rgba(120,180,255,${(0.9 * a).toFixed(3)})`); // the lit ring
303
+ ell(r * 0.40, `rgba(205,235,255,${(0.95 * a).toFixed(3)})`); // the canopy
304
+ ell(r * 0.18, `rgba(255,255,255,${(0.95 * a).toFixed(3)})`); // its highlight
305
+ // running lights: spaced round the rim, turning slowly so it reads as a craft and not a target
306
+ for (let i = 0; i < 8; i++) {
307
+ const th = phase + (i / 8) * Math.PI * 2;
308
+ const x = cx + Math.cos(th) * r * 0.88, y = cy + Math.sin(th) * r * 0.88 * squash;
309
+ const tw = 0.55 + 0.45 * Math.sin(phase * 3 + i * 1.9);
310
+ ctx.fillStyle = `rgba(255,255,255,${(0.9 * tw * a).toFixed(3)})`;
311
+ ctx.beginPath(); ctx.arc(x, y, Math.max(0.6, r * 0.075), 0, Math.PI * 2); ctx.fill();
312
+ }
313
+ }
314
+
315
+ export function saucer(ctx, cx, cy, r, a = 1) {
316
+ const dome = [];
317
+ for (let i = 0; i <= 20; i++) { const ang = Math.PI + (i / 20) * Math.PI; dome.push({ x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r * 0.42 }); }
318
+ poly(ctx, dome, `rgba(120,180,255,${(0.9 * a).toFixed(3)})`);
319
+ poly(ctx, [
320
+ { x: cx - r, y: cy }, { x: cx + r, y: cy },
321
+ { x: cx + r * 0.7, y: cy + r * 0.25 }, { x: cx - r * 0.7, y: cy + r * 0.25 },
322
+ ], `rgba(200,230,255,${(0.95 * a).toFixed(3)})`);
323
+ bloom(ctx, cx, cy + r * 0.2, r * 0.8, [180, 230, 255], 0.5 * a);
324
+ }
325
+
326
+ /**
327
+ * A lens flare at a bright point: an anamorphic streak in the colour, a white core, four thin
328
+ * rays turning slowly, and a run of faint ghosts along the line toward the picture's middle.
329
+ * `f` is 1 at full and 0 gone. Gradients only, no composite modes (the canvas rules).
330
+ */
331
+ export function lensFlare(ctx, x, y, L, rgb, f, view, lw) {
332
+ if (f <= 0) return;
333
+ const c = rgb.join(',');
334
+ const lin = (x0, y0, x1, y1, stops) => {
335
+ const g = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(x0, y0, x1, y1) : null;
336
+ if (!g || typeof g.addColorStop !== 'function') return stops[Math.floor(stops.length / 2)][1];
337
+ for (const [o, col] of stops) g.addColorStop(o, col);
338
+ return g;
339
+ };
340
+ const stroke = (x0, y0, x1, y1, fill, w) => { ctx.strokeStyle = fill; ctx.lineWidth = Math.max(lw * 0.8, w); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); };
341
+ stroke(x - L * 0.7, y, x + L * 0.7, y, lin(x - L * 0.7, y, x + L * 0.7, y, [[0, `rgba(${c},0)`], [0.5, `rgba(${c},${(0.25 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]), L * 0.06);
342
+ stroke(x - L, y, x + L, y, lin(x - L, y, x + L, y, [[0, `rgba(${c},0)`], [0.35, `rgba(${c},${(0.35 * f).toFixed(3)})`], [0.5, `rgba(255,255,255,${(0.9 * f).toFixed(3)})`], [0.65, `rgba(${c},${(0.35 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]), L * 0.018);
343
+ const spin = (view.now ?? 0) * 0.0004;
344
+ for (let k = 0; k < 4; k++) {
345
+ const ang = spin + (k / 4) * Math.PI, rl = L * 0.45;
346
+ const x0 = x - Math.cos(ang) * rl, y0 = y - Math.sin(ang) * rl, x1 = x + Math.cos(ang) * rl, y1 = y + Math.sin(ang) * rl;
347
+ stroke(x0, y0, x1, y1, lin(x0, y0, x1, y1, [[0, 'rgba(255,255,255,0)'], [0.5, `rgba(255,255,255,${(0.5 * f).toFixed(3)})`], [1, 'rgba(255,255,255,0)']]), L * 0.008);
348
+ }
349
+ const cx0 = (view.boardW ?? 0) / 2, cy0 = -(view.boardH ?? 0) / 4;
350
+ for (let k = 1; k <= 4; k++) {
351
+ const t = k * 0.3, gx = x + (cx0 - x) * t, gy = y + (cy0 - y) * t, gr = L * 0.05 * (0.6 + t), ga = 0.14 * f * (1 - k / 6);
352
+ ctx.fillStyle = `rgba(${c},${ga.toFixed(3)})`; ctx.beginPath(); ctx.arc(gx, gy, Math.max(1, gr), 0, Math.PI * 2); ctx.fill();
353
+ }
354
+ }
355
+
356
+ /** A square standing at grid (x, y) at height z, as screen points. */
357
+ const quad = (view, x, y, z, r) => [
358
+ project(x - r, y - r, z, view), project(x + r, y - r, z, view),
359
+ project(x + r, y + r, z, view), project(x - r, y + r, z, view),
360
+ ];
361
+
362
+
363
+
364
+
365
+
366
+
367
+
368
+
369
+
370
+
371
+
372
+
373
+
374
+ // ================================================================= BATCH TWO
375
+ //
376
+ // Six more, from the same catalogue: a splitter, four personalities, a formation that breaks up,
377
+ // a thief, two populations that fight, and drifting debris. Between them they add the three things
378
+ // batch one had no example of -- an agent that DIVIDES, agents with different rules from each
379
+ // other, and two populations that interact.
380
+
381
+ /** A closed wireframe outline in screen space -- Asteroids' whole visual language. */
382
+ function wire(ctx, pts, col, w) {
383
+ if (pts.length < 3) return;
384
+ ctx.strokeStyle = col; ctx.lineWidth = w;
385
+ ctx.beginPath();
386
+ ctx.moveTo(pts[0].x, pts[0].y);
387
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
388
+ ctx.closePath();
389
+ ctx.stroke();
390
+ }
391
+
392
+ /** An expanding ring, for a detonation. Cheap: one stroke, no fill. */
393
+ function ring(ctx, x, y, r, col, w) {
394
+ ctx.strokeStyle = col; ctx.lineWidth = w;
395
+ ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.stroke();
396
+ }
397
+
398
+ // --- 7. CENTIPEDE ---------------------------------------------------------
399
+ //
400
+ // A column of segments weaving down the board, dropping a row each time it reaches an edge, and
401
+ // SPLITTING in two partway through -- the split is the thing everyone remembers about Centipede,
402
+ // and no other agent here divides. Our board is a grid of obstacles, which is the mushroom field
403
+ // the original needs.
404
+ defineAgent('centipede', {
405
+ build({ W, H, rnd }) {
406
+ // a boustrophedon: across, drop, back across -- the route the whole body follows in file
407
+ const pts = [];
408
+ const rows = 6;
409
+ const dropEvery = Math.max(2, Math.floor(H / (rows + 1)));
410
+ let dir = rnd() < 0.5 ? 1 : -1;
411
+ let x = dir > 0 ? 1 : W - 1;
412
+ let y = H - 2;
413
+ pts.push({ x, y });
414
+ for (let r = 0; r < rows; r++) {
415
+ const end = dir > 0 ? W - 1 : 1;
416
+ const steps = Math.max(1, Math.abs(end - x));
417
+ for (let i = 0; i < steps; i++) { x += dir; pts.push({ x, y }); }
418
+ for (let d = 0; d < dropEvery && y > 1; d++) { y -= 1; pts.push({ x, y }); }
419
+ dir = -dir;
420
+ }
421
+ return { pts, len: 11, splitAt: 0.45 + rnd() * 0.2, splitSeg: 4 + Math.floor(rnd() * 3) };
422
+ },
423
+ frame(a, u) {
424
+ const total = a.pts.length - 1;
425
+ const lead = Math.max(0, Math.min(1, u)) * total;
426
+ const split = u >= a.splitAt;
427
+ const heads = [];
428
+ const body = [];
429
+ for (let i = 0; i < a.len; i++) {
430
+ // after the split the tail half drops back and travels on its own, a little behind
431
+ const lag = i * 1.6 + (split && i >= a.splitSeg ? 6 + (u - a.splitAt) * 26 : 0);
432
+ const d = lead - lag;
433
+ if (d < 0) continue;
434
+ const p = alongPath(a.pts, d);
435
+ const isHead = i === 0 || (split && i === a.splitSeg);
436
+ body.push({ x: p.x, y: p.y, i, head: isHead });
437
+ heads.push({
438
+ x: p.x, y: p.y, r: isHead ? 1.9 : 1.4,
439
+ color: isHead ? [255, 230, 120] : [120, 235, 140],
440
+ alpha: isHead ? 1 : 0.85,
441
+ });
442
+ }
443
+ return { centipede: { body, split }, heads };
444
+ },
445
+ draw(ctx, view, lw) {
446
+ const c = view.fx?.centipede;
447
+ if (!c) return;
448
+ const U = view.unit ?? 8;
449
+ // BIG ENOUGH TO BE A BODY, not a row of lit cubes. The first cut drew each segment at about
450
+ // one cube wide in the board's own green, so on a field of 3,700 green cubes it read as
451
+ // highlighting rather than as a creature crawling over them -- verified on the live board.
452
+ // Now: segments half again as large, a SPINE joining them so the body is one thing, and a
453
+ // magenta-red that nothing else on this board wears.
454
+ const pts = c.body.map((b) => project(b.x, b.y, 1.2, view));
455
+ for (let i = 1; i < pts.length; i++) {
456
+ // the spine only joins segments that are actually adjacent -- after the split the two halves
457
+ // are far apart, and a line between them would draw the creature back together
458
+ const gap = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
459
+ if (gap > U * 3.5) continue;
460
+ line(ctx, [pts[i - 1], pts[i]], 'rgba(180,60,120,0.55)', U * 1.15);
461
+ }
462
+ for (let i = c.body.length - 1; i >= 0; i--) {
463
+ const b = c.body[i];
464
+ const p = pts[i];
465
+ const r = U * (b.head ? 1.75 : 1.25);
466
+ bloom(ctx, p.x, p.y, r, b.head ? [255, 210, 90] : [235, 90, 150], b.head ? 1 : 0.9);
467
+ // a hard rim, so a segment has an edge against a bright cube instead of dissolving into it
468
+ ring(ctx, p.x, p.y, r * 0.55, b.head ? 'rgba(255,245,210,0.9)' : 'rgba(255,190,225,0.75)', lw * 2);
469
+ if (b.head) {
470
+ for (const o of [-0.34, 0.34]) {
471
+ ctx.fillStyle = 'rgba(25,10,20,0.95)';
472
+ ctx.beginPath(); ctx.arc(p.x + o * U * 0.62, p.y - U * 0.26, U * 0.22, 0, Math.PI * 2); ctx.fill();
473
+ }
474
+ }
475
+ }
476
+ },
477
+ });
478
+
479
+
480
+
481
+
482
+
483
+ // --- 10. TRACTOR BEAM -----------------------------------------------------
484
+ //
485
+ // DATA-AWARE, and the only agent that uses `lift`: a ship stops over the TALLEST cube on the
486
+ // board, opens a beam, and draws it up. The cube rises, thins and vanishes -- then comes back when
487
+ // the effect ends, because nothing here mutates the board (see the hide/scale note in
488
+ // blockscene3d.js).
489
+ defineAgent('tractor', {
490
+ build({ tiles, W, H, rnd }) {
491
+ const target = tallestTile(tiles) ?? { x: W / 2, y: H / 2, h: 1, tile: null };
492
+ // where it leaves for, picked now so the exit replays identically (operator, 2026-09-14: "After
493
+ // it drops them, have it fly up and away in a random direction")
494
+ const away = rnd() * Math.PI * 2;
495
+ return { target, from: { x: rnd() < 0.5 ? -4 : W + 4, y: target.y }, away: { dx: Math.cos(away), dy: Math.sin(away) }, W, H };
496
+ },
497
+ // IT PUTS THE BLOCK DOWN AND LEAVES (operator, 2026-09-13: "rather than just abruptly finish,
498
+ // have the UFO drop the blocks causing them to slightly bounce, and then quickly flies off
499
+ // screen"). Before this the beam simply stopped: the cube was still four and a half units in the
500
+ // air, the saucer still parked over it, and the whole thing blinked out mid-abduction.
501
+ //
502
+ // HIGHER STILL, AND A REAL FALL (operator, 2026-09-14: "have it flying even higher off the board.
503
+ // and lift the blocks higher. When it lets go of the blocks, allow gravity to make them fall, and
504
+ // bounce before coming to rest"). The drop used to be squeezed into a fixed share of the effect,
505
+ // so its speed came from the effect's length, not from gravity. It is now timed by the board's
506
+ // own GRAVITY -- the constant every refresh landing falls under -- so a 24-unit fall takes
507
+ // fallMs(24) = 849 ms whatever the effect's duration, and the cube bounces with a coefficient of
508
+ // restitution of 0.5: hops of 6, 1.5 and 0.4 units, each shorter by sqrt(0.25), before it rests.
509
+ // Livelier than a refresh landing (e 0.15-0.36, hops of a few percent) on purpose: those barely
510
+ // leave the floor, and the ask was for a bounce you can see.
511
+ //
512
+ // IN 0.00-0.22 the saucer flies in from one side
513
+ // HOLD 0.22-0.50 the beam comes on and draws the cube up, easing to a stop at the top
514
+ // RELEASE 0.50 the beam cuts; the cube falls from rest under gravity and bounces
515
+ // AWAY first impact -> 1.00 the saucer climbs off along its own heading while it bounces
516
+ //
517
+ // Still grounded well before the end: `lift` is a per-frame OVERRIDE on a copy of the tile, so a
518
+ // cube still in the air when the effect stops would snap to the floor instead of landing on it.
519
+ // If the effect is ever made too short to hold the whole landing, the landing is compressed to
520
+ // fit rather than cut -- an honest fall that ends in a snap is worse than a quicker one.
521
+ frame(a, u, ctx = {}) {
522
+ const ms = ctx.ms ?? 8000;
523
+ const IN = 0.22, HOLD = 0.5;
524
+ const LIFT = 24;
525
+ // three times the old 5-over-the-top altitude, and never less than 6 over the lifted cube
526
+ const alt = Math.max(3 * (a.target.h + 5), a.target.h + LIFT + 6);
527
+ const E2 = 0.25; // restitution 0.5, squared: each hop's share of the last
528
+ const hops = bouncesUntil(E2, E2);
529
+ const landU = Math.min(0.9 - HOLD, landingMs(LIFT, E2, hops, E2) / ms);
530
+ const impactU = HOLD + landU * fallShare(E2, hops, E2);
531
+ const dir = a.away ?? { dx: a.from.x < a.target.x ? -1 : 1, dy: 0 };
532
+ const reach = Math.max(a.W, a.H) + 8;
533
+
534
+ // the saucer: in, parked over the cube, then away from the moment the cube first hits the floor
535
+ let ship, leaving = false, shipAlpha = 0.9;
536
+ if (u < IN) {
537
+ const v = u / IN;
538
+ ship = { x: a.from.x + (a.target.x - a.from.x) * v, y: a.target.y, z: alt };
539
+ } else if (u < impactU) {
540
+ ship = { x: a.target.x, y: a.target.y, z: alt };
541
+ } else {
542
+ // up and off along its own heading, t^2 so it pulls away rather than drifting
543
+ const v = Math.min(1, (u - impactU) / Math.max(1e-6, 1 - impactU));
544
+ ship = { x: a.target.x + dir.dx * reach * v * v, y: a.target.y + dir.dy * reach * v * v, z: alt + 30 * v * v };
545
+ leaving = true;
546
+ shipAlpha = Math.max(0, 1 - v);
547
+ }
548
+
549
+ // the cube
550
+ let lift = 0, beam = 0, dropped = false;
551
+ if (u >= IN && u < HOLD) {
552
+ const v = (u - IN) / (HOLD - IN);
553
+ beam = Math.sin(Math.min(1, v * 1.2) * Math.PI) * 0.6 + 0.4;
554
+ lift = LIFT * (1 - Math.cos(Math.PI * v)) / 2; // eases to a stop at the top, so the fall starts from rest
555
+ } else if (u >= HOLD && u < HOLD + landU) {
556
+ // RELEASED. The beam is off the instant it lets go -- a beam still drawn over a falling cube
557
+ // reads as the ship dropping something it is still holding.
558
+ lift = LIFT * bounceDrop((u - HOLD) / landU, E2, hops, E2);
559
+ dropped = true;
560
+ }
561
+
562
+ const heads = [{ x: ship.x, y: ship.y, color: [180, 220, 255], alpha: shipAlpha, r: 1.6 }];
563
+ if (u >= IN && u < HOLD + landU) {
564
+ heads.push({ x: a.target.x, y: a.target.y, color: [190, 235, 255], alpha: 1, r: 2.4, lift });
565
+ if (beam > 0) heads.push({ x: a.target.x, y: a.target.y, color: [140, 200, 255], alpha: 0.5 * beam, r: 4 });
566
+ // a flash of dust on each impact, and only then
567
+ if (dropped && lift < 0.05) heads.push({ x: a.target.x, y: a.target.y, color: [255, 240, 200], alpha: 0.7, r: 3 });
568
+ }
569
+ return {
570
+ tractor: { ship, beam, lift, ...(u >= IN && !leaving ? { target: a.target } : {}), ...(dropped ? { dropped: true } : {}), ...(leaving ? { leaving: true } : {}) },
571
+ heads,
572
+ };
573
+ },
574
+ draw(ctx, view, lw) {
575
+ const t = view.fx?.tractor;
576
+ if (!t) return;
577
+ const U = view.unit ?? 8;
578
+ const c = project(t.ship.x, t.ship.y, t.ship.z, view);
579
+ if (t.beam > 0.02) {
580
+ // the cone, as stacked translucent quads: no gradients needed, and none allowed on a fill
581
+ const base = project(t.ship.x, t.ship.y, 0, view);
582
+ const w0 = U * 0.5, w1 = U * 2.2 * t.beam;
583
+ poly(ctx, [
584
+ { x: c.x - w0, y: c.y }, { x: c.x + w0, y: c.y },
585
+ { x: base.x + w1, y: base.y }, { x: base.x - w1, y: base.y },
586
+ ], `rgba(150,210,255,${(0.18 * t.beam).toFixed(3)})`);
587
+ poly(ctx, [
588
+ { x: c.x - w0 * 0.4, y: c.y }, { x: c.x + w0 * 0.4, y: c.y },
589
+ { x: base.x + w1 * 0.45, y: base.y }, { x: base.x - w1 * 0.45, y: base.y },
590
+ ], `rgba(220,245,255,${(0.3 * t.beam).toFixed(3)})`);
591
+ }
592
+ // the saucer, shared with the scan beam's source (saucer() above)
593
+ saucer(ctx, c.x, c.y, U * 1.6);
594
+ void lw;
595
+ },
596
+ });
597
+
598
+ // --- 11. MISSILE COMMAND --------------------------------------------------
599
+ //
600
+ // TWO POPULATIONS THAT INTERACT, which nothing else here does: arcs rain toward the board while
601
+ // interceptors rise to meet them, and each interception is an expanding ring that stops what it
602
+ // catches.
603
+ defineAgent('missile', {
604
+ build({ W, H, rnd }) {
605
+ const n = 5;
606
+ const incoming = [];
607
+ for (let i = 0; i < n; i++) {
608
+ const from = { x: rnd() * W, y: H + 3 };
609
+ const to = { x: rnd() * W, y: 1 + rnd() * (H * 0.4) };
610
+ const t0 = rnd() * 0.35;
611
+ // the interceptor leaves later and meets it partway: worked out now, so a frame only moves
612
+ const meet = 0.45 + rnd() * 0.3;
613
+ incoming.push({ from, to, t0, meet, up: { x: to.x + (rnd() - 0.5) * 8, y: 0 } });
614
+ }
615
+ return { incoming, W, H };
616
+ },
617
+ frame(a, u) {
618
+ const heads = [];
619
+ const shots = [];
620
+ const bursts = [];
621
+ for (const m of a.incoming) {
622
+ const v = (u - m.t0) / 0.7;
623
+ if (v <= 0) continue;
624
+ const caught = v >= m.meet;
625
+ const k = Math.min(caught ? m.meet : v, 1);
626
+ const x = m.from.x + (m.to.x - m.from.x) * k;
627
+ const y = m.from.y + (m.to.y - m.from.y) * k;
628
+ if (!caught) {
629
+ shots.push({ x, y, from: m.from, k, kind: 'down' });
630
+ heads.push({ x, y, color: [255, 120, 90], alpha: 1, r: 1.2 });
631
+ } else {
632
+ const age = (v - m.meet) / 0.35;
633
+ if (age < 1) {
634
+ bursts.push({ x, y, age });
635
+ heads.push({ x, y, color: [255, 240, 180], alpha: 1 - age, r: 1.5 + age * 5 });
636
+ }
637
+ }
638
+ // the interceptor climbing to the meeting point
639
+ const iv = Math.min(1, Math.max(0, (v - m.meet * 0.45) / (m.meet * 0.55)));
640
+ if (iv > 0 && !caught) {
641
+ const ix = m.up.x + (x - m.up.x) * iv, iy = m.up.y + (y - m.up.y) * iv;
642
+ shots.push({ x: ix, y: iy, from: m.up, k: iv, kind: 'up' });
643
+ heads.push({ x: ix, y: iy, color: [140, 255, 200], alpha: 1, r: 1 });
644
+ }
645
+ }
646
+ return { missile: { shots, bursts }, heads };
647
+ },
648
+ draw(ctx, view, lw) {
649
+ const m = view.fx?.missile;
650
+ if (!m) return;
651
+ const U = view.unit ?? 8;
652
+ for (const s of m.shots) {
653
+ const a0 = project(s.from.x, s.from.y, 0.8, view);
654
+ const a1 = project(s.x, s.y, 0.8, view);
655
+ const col = s.kind === 'down' ? '255,120,90' : '140,255,200';
656
+ line(ctx, [a0, a1], `rgba(${col},0.35)`, lw * 3);
657
+ line(ctx, [a0, a1], `rgba(${col},0.9)`, lw * 1.2);
658
+ bloom(ctx, a1.x, a1.y, U * 0.45, s.kind === 'down' ? [255, 150, 110] : [170, 255, 210], 0.9);
659
+ }
660
+ for (const b of m.bursts) {
661
+ const c = project(b.x, b.y, 0.9, view);
662
+ const r = U * (0.5 + b.age * 3.4);
663
+ const fade = 1 - b.age;
664
+ ring(ctx, c.x, c.y, r, `rgba(255,220,150,${(0.8 * fade).toFixed(3)})`, lw * 3);
665
+ ring(ctx, c.x, c.y, r * 0.65, `rgba(255,255,240,${(0.9 * fade).toFixed(3)})`, lw * 1.6);
666
+ bloom(ctx, c.x, c.y, r * 0.5, [255, 235, 190], fade * 0.7);
667
+ }
668
+ },
669
+ });
670
+
671
+
672
+
673
+ // ================================================================ BATCH THREE
674
+ //
675
+ // Six that change the board rather than crossing it, and two that ADD INFORMATION -- which is the
676
+ // rarest thing an effect here can do. Every one is still a per-frame override: nothing below
677
+ // mutates a tile (see hide/scale in blockscene3d.js).
678
+
679
+ /** Flood fill over the grid from a seed, stopping at cells a predicate refuses. Pure. */
680
+ export function floodFrom(x0, y0, W, H, blocked, limit = 900) {
681
+ const seen = new Set();
682
+ const order = [];
683
+ const key = (x, y) => y * W + x;
684
+ const q = [[x0 | 0, y0 | 0]];
685
+ seen.add(key(x0 | 0, y0 | 0));
686
+ while (q.length && order.length < limit) {
687
+ const [x, y] = q.shift();
688
+ order.push({ x, y, d: order.length });
689
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
690
+ const nx = x + dx, ny = y + dy;
691
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
692
+ const k = key(nx, ny);
693
+ if (seen.has(k)) continue;
694
+ seen.add(k);
695
+ if (blocked(nx, ny)) continue; // seen, so it is never revisited, but not entered
696
+ q.push([nx, ny]);
697
+ }
698
+ }
699
+ return order;
700
+ }
701
+
702
+
703
+
704
+
705
+
706
+
707
+
708
+
709
+
710
+
711
+
712
+
713
+
714
+ // ================================================================= BATCH FOUR
715
+ //
716
+ // The family that could not be built until heads carried `hide` and `scale` (see the commit that
717
+ // wired them through). Three of these ALTER THE BOARD -- absorbing, collapsing, digging -- which
718
+ // the operator allowed on the condition that it snaps back: "Allow it, it snaps back". It does,
719
+ // by construction, because none of them touches a tile. They publish an override per frame and
720
+ // the renderer applies it to a copy; when the effect ends, or a transition takes the stage, or
721
+ // the tab is hidden, the override simply stops being computed.
722
+
723
+
724
+
725
+ // --- 20. BOULDER DASH -----------------------------------------------------
726
+ //
727
+ // A column gives way and the cubes above it COLLAPSE, each shrinking to nothing in turn, the
728
+ // cascade spreading outward from where it started.
729
+ defineAgent('boulderdash', {
730
+ build({ W, H, rnd, tiles }) {
731
+ const at = { x: Math.floor(rnd() * W), y: Math.floor(rnd() * H) };
732
+ void tiles;
733
+ return { at, W, H, reach: 7 + Math.floor(rnd() * 5) };
734
+ },
735
+ frame(a, u) {
736
+ // the cascade front, spreading out from the collapse
737
+ const front = u * a.reach * 1.5;
738
+ const heads = [];
739
+ for (let r = 0; r <= Math.min(a.reach, Math.ceil(front)); r++) {
740
+ const age = (front - r) / 2.2;
741
+ if (age < 0 || age > 1.6) continue;
742
+ const shrink = Math.max(0, 1 - age); // full height at the front, gone behind it
743
+ const n = Math.max(1, r * 4);
744
+ for (let i = 0; i < n; i++) {
745
+ const ang = (i / n) * Math.PI * 2;
746
+ heads.push({
747
+ x: a.at.x + Math.cos(ang) * r, y: a.at.y + Math.sin(ang) * r,
748
+ // THE RING MUST STILL BE VISIBLE WHERE IT IS WIDEST. alpha was 0.8 * shrink, and shrink
749
+ // goes to zero as a cube finishes collapsing -- so at u=0.9, with the front at its
750
+ // furthest, every head was alpha 0.00 and the collapse happened in the dark. Measured.
751
+ // A floor keeps the rim lit; `scale` carries the collapse, and it is gated on reach now
752
+ // rather than on alpha, so the two are properly separate.
753
+ color: [255, 190, 110], alpha: 0.35 + 0.55 * shrink, r: 1.1,
754
+ scale: 0.12 + 0.88 * shrink,
755
+ });
756
+ }
757
+ }
758
+ return { boulderdash: { at: a.at, front }, heads };
759
+ },
760
+ draw(ctx, view, lw) {
761
+ const b = view.fx?.boulderdash;
762
+ if (!b) return;
763
+ const U = view.unit ?? 8;
764
+ // dust where the front is passing, so a collapse has debris
765
+ for (let i = 0; i < 28; i++) {
766
+ const ang = hash01(i * 13 + 5) * Math.PI * 2;
767
+ const rad = b.front * (0.6 + 0.5 * hash01(i * 7 + 11));
768
+ const c = project(b.at.x + Math.cos(ang) * rad, b.at.y + Math.sin(ang) * rad, 0.5 + hash01(i) * 1.5, view);
769
+ const r = U * (0.18 + 0.3 * hash01(i * 3 + 19));
770
+ ctx.fillStyle = `rgba(210,190,150,${(0.28 * (1 - Math.min(1, b.front / 11))).toFixed(3)})`;
771
+ ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.fill();
772
+ }
773
+ void lw;
774
+ },
775
+ });
776
+
777
+
778
+
779
+ // --- 24. BALL LIGHTNING ---------------------------------------------------
780
+ //
781
+ // (operator, 2026-09-14: "Remove the Portal effect, and replace it with a bright electric blue neon
782
+ // sphere of slow moving ball lightning that crackles with energy, and random arcs of energy burst out
783
+ // from the ball lightning and electrifies the blocks the arcs touch. Have it move from one end of the
784
+ // view space to the other disappearing off-screen.")
785
+ //
786
+ // The route and every arc are decided at build, from the seeded rnd -- the board is still while an
787
+ // effect runs -- so a frame only asks where the ball is and which arcs are alive. An arc is aimed at a
788
+ // real block near the ball, never at empty floor: the ask is that arcs electrify what they touch.
789
+ // The crackle on the sphere itself is re-rolled every frame (Math.random, as this file allows for
790
+ // sparkle that is never asserted frame to frame): electrical precisely because it never repeats.
791
+ defineAgent('stormball', {
792
+ build({ st, W, H, tiles, tops, rnd }) {
793
+ tops ??= cellTops(tiles ?? [], W, H);
794
+ const top = (cx, cy) => (cx >= 0 && cx < W && cy >= 0 && cy < H ? tops[cy * W + cx] : 0);
795
+ let highest = 0;
796
+ for (let i = 0; i < W * H; i++) highest = Math.max(highest, tops[i] ?? 0);
797
+ // ON THE PRICE BOARD IT FLIES THROUGH THE CHART, not over it: a height inside the line's own
798
+ // range, so the candles it passes are beside it and the arcs strike what it comes near
799
+ // (operator, 2026-09-14: "electrifying any elements it comes near")
800
+ const line = st?.axes?.line;
801
+ let alt = highest + 3.5, zPath = null;
802
+ if (line?.length > 1) {
803
+ let hi = -Infinity;
804
+ for (const p of line) hi = Math.max(hi, p.z);
805
+ // UP AND DOWN THE WHOLE CHART (operator, 2026-09-15: "it's not moving up and down enough
806
+ // during transit. It should try to cover a lot of space"): the height is two sines over the
807
+ // run, the slow one swinging across most of the range, the quick one on top. The range is
808
+ // the ground to the line's high -- not the line's own low, which sits above the volume band
809
+ // (operator, later: "The lightning ball really stays in the top 2/3's of the screen. It
810
+ // needs to seek the ground level more than it presently does before rising again")
811
+ const lo = 1;
812
+ alt = (lo + hi) / 2;
813
+ zPath = { lo, hi, f: [1.2 + rnd() * 1.3, 3 + rnd() * 3], ph: [rnd() * Math.PI * 2, rnd() * Math.PI * 2] };
814
+ }
815
+ // across the board the long way round, far enough past both edges to start and end off-screen
816
+ const leftToRight = rnd() < 0.5;
817
+ // the block board's view is far wider than the board, so the run starts well past its edges;
818
+ // the price board's view is fitted to its width (obliqueFit), and the same margin there kept
819
+ // the ball off-screen for the first third of its run (a third of a 7-day board is 190 units)
820
+ // ...but far enough that the whole thing has LEFT before the effect ends (operator, 2026-09-14:
821
+ // "have it fully moved off the display before you remove it"): the corona reaches 12 units
822
+ // from the centre and the trail seven behind, and at 8 the run ended with the glow still
823
+ // on the chart and cut off dead
824
+ const margin = st?.axes?.line?.length > 1 ? 20 : Math.max(W, H) * 0.55 + 6;
825
+ const from = { x: leftToRight ? -margin : W + margin, y: H * (0.22 + 0.56 * rnd()) };
826
+ const to = { x: leftToRight ? W + margin : -margin, y: H * (0.22 + 0.56 * rnd()) };
827
+ const weave = { amp: 1.5 + 2 * rnd(), cycles: 1 + rnd() * 1.5, phase: rnd() * Math.PI * 2 };
828
+ // bursts of arcs: a few a second, one to three at a time, each alive for a moment -- half as
829
+ // often on the price board (operator, 2026-09-15: "Tone down chance of emitting lightning by
830
+ // 50%"), where the run is also slower, so per second it is quieter still
831
+ const arcs = [];
832
+ // ...a fifth quieter again ("Reduce ball lighting electricity chance by 20%"), then 15% more
833
+ // ("increase the ball lightning strike chance by +15%"): 2.5 / 1.15
834
+ const every = line?.length > 1 ? 2.17 : 1;
835
+ for (let u = 0.03; u < 0.97; u += (0.016 + 0.035 * rnd()) * every) {
836
+ const n = 1 + Math.floor(rnd() * 4);
837
+ for (let k = 0; k < n; k++) {
838
+ const arc = { u0: u + rnd() * 0.01, life: 0.02 + 0.035 * rnd(), ang: rnd() * Math.PI * 2, reach: 3.5 + 8 * rnd(), seed: Math.floor(rnd() * 1e9) };
839
+ // one strike in five flares: a lens flare blooms where it lands (operator, 2026-09-15:
840
+ // "Consider adding the lense flare effect on occasional lightning strikes")
841
+ if (rnd() < 0.2) arc.flare = true;
842
+ // A SECOND ARC OFF THE STRUCK BLOCK, AND SOMETIMES A THIRD (operator, 2026-09-14: "a small
843
+ // chance for a second arc to spawn from the block and arc to a different block", then "Make
844
+ // the secondary arcing more frequent, and have a 50% for an additional third arc"). Half
845
+ // the arcs carry a chain -- its own direction and reach from the block the first lands on,
846
+ // to a different block -- and half of those chain once more from the second block to a
847
+ // third. Decided here, so a replay throws the same chains.
848
+ // ...then a quarter fewer of each (2026-09-15: "decrease secondary and third arcing by 25%")
849
+ if (rnd() < 0.375) {
850
+ arc.chain = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
851
+ if (rnd() < 0.375) arc.chain.next = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
852
+ }
853
+ arcs.push(arc);
854
+ }
855
+ }
856
+ return { from, to, weave, alt, zPath, arcs, W, H, top };
857
+ },
858
+ frame(a, u) {
859
+ const at = (uu) => {
860
+ const x = a.from.x + (a.to.x - a.from.x) * uu;
861
+ const y = a.from.y + (a.to.y - a.from.y) * uu + a.weave.amp * Math.sin(uu * Math.PI * 2 * a.weave.cycles + a.weave.phase);
862
+ if (a.zPath) {
863
+ const { lo, hi, f, ph } = a.zPath;
864
+ const w = 0.5 + 0.44 * Math.sin(uu * Math.PI * 2 * f[0] + ph[0]) + 0.14 * Math.sin(uu * Math.PI * 2 * f[1] + ph[1]);
865
+ return { x, y, z: lo + (hi - lo) * Math.max(0, Math.min(1, w)) };
866
+ }
867
+ return { x, y, z: a.alt + 0.6 * Math.sin(uu * 17) };
868
+ };
869
+ const ball = at(u);
870
+ // where it just was: the nebula it leaves behind is drawn there, fading
871
+ const trail = [0.01, 0.022, 0.036, 0.052, 0.07, 0.09].map((d) => (u - d >= 0 ? at(u - d) : null)).filter(Boolean);
872
+ // the block an arc thrown at (aimX, aimY) strikes: the tallest cell near there, never `not`
873
+ // (the block a chain leaves), and none if the floor is bare
874
+ const strike = (aimX, aimY, not = []) => {
875
+ let hit = null;
876
+ const consider = (cx, cy) => {
877
+ const h = a.top(cx, cy);
878
+ // `not`: a chain must reach a different block -- two units clear of every block already
879
+ // struck in its chain, which on the candle board is the next candle along
880
+ if (not.some((n) => Math.max(Math.abs(cx + 0.5 - n.x), Math.abs(cy + 0.5 - n.y)) < 2)) return;
881
+ if (h > 0 && (!hit || h > hit.z)) hit = { x: cx + 0.5, y: cy + 0.5, z: h };
882
+ };
883
+ for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) consider(Math.floor(aimX) + dx, Math.floor(aimY) + dy);
884
+ // A THIN BOARD (the price board: eight deep, hundreds wide): an arc thrown at a random angle
885
+ // lands off the front or the back more often than not, so the strike falls back to the
886
+ // tallest thing in the three columns it was thrown toward, at any depth -- a candle
887
+ // (operator, 2026-09-14: "the ball lightning was not arcing out to any of the candles")
888
+ if (!hit && a.H <= 12) for (let dx = -1; dx <= 1; dx++) for (let cy = 0; cy < a.H; cy++) consider(Math.floor(aimX) + dx, cy);
889
+ return hit;
890
+ };
891
+ const live = [];
892
+ // LIGHT EMITTERS (operator, 2026-09-15: "The lightning ball needs to illuminate anything it
893
+ // comes near with an electric hazy white effect. Need the lightning strikes to emit lights").
894
+ // `heads` is how an agent lights the board (fxAt): the ball is a wide hazy white source now,
895
+ // not a blue tint, and every strike is a source of its own where it lands
896
+ const heads = [{ x: ball.x, y: ball.y, color: [225, 240, 255], alpha: 0.95, r: 6.5 }];
897
+ for (const arc of a.arcs) {
898
+ const age = (u - arc.u0) / arc.life;
899
+ if (age < 0 || age > 2.2) continue; // alive, then an afterglow on the block
900
+ const p = at(arc.u0);
901
+ // NO ARCS FROM OFF THE BOARD (2026-09-15: "make the lightning ball completely disappear off
902
+ // the viewport before killing it. Seeing it flash out at the end"): a ball past the edge
903
+ // could still reach a candle at the edge with an arc, so the last thing seen was a bolt
904
+ // from nowhere; while it is off the board nothing is drawn from it -- not even an arc it
905
+ // threw a moment before the edge -- and the run ends with it long gone
906
+ if (a.zPath && (ball.x < -1 || ball.x > a.W + 1)) continue;
907
+ // the block the arc strikes: the tallest cell near where it is thrown, and none if the floor is bare
908
+ const hit = strike(p.x + Math.cos(arc.ang) * arc.reach, p.y + Math.sin(arc.ang) * arc.reach);
909
+ if (!hit) continue;
910
+ if (age <= 1) live.push({ to: hit, seed: arc.seed, strength: 1 - age * 0.6, age, flare: !!arc.flare });
911
+ // THE BLOCK IT TOUCHES IS ELECTRIFIED: a hard blue flare that flickers while the arc lives and
912
+ // dies away after it -- lit through fxAt's heads, so only the cubes actually struck light up
913
+ const flicker = 0.7 + 0.3 * Math.sin(u * 900 + arc.seed);
914
+ const glow = age <= 1 ? flicker : Math.max(0, 1 - (age - 1) / 1.2) * 0.7;
915
+ heads.push({ x: hit.x, y: hit.y, color: [230, 245, 255], alpha: Math.min(1, glow * 1.25), r: 2.4 });
916
+ // ...and the chain, if this arc carries one: from the struck block to another. ITS OWN
917
+ // EVENT, not a bend in the first (2026-09-14: "I'm not seeing secondary arcs" -- they were
918
+ // there in a third of the arc frames, drawn in the first arc's colour from the first arc's
919
+ // end while it still lived, and read as one longer arc). It leaps half a beat after the
920
+ // first lands and outlives it, in violet against the first's blue, and never to the block it
921
+ // left or one beside it -- a chain a unit long is a chain nobody sees.
922
+ // Violet was the first colour, and it vanished: the nebula the ball drags is violet and
923
+ // magenta. Electric green is on nothing else here -- not the blue arcs, not the nebula, not
924
+ // the price line's yellow -- and a chain lives 1.2 arc-lives, so it is on screen longer
925
+ // than the arc that threw it. NO DELAY (operator: "get rid of the delay on the secondary
926
+ // arcing"): the chain leaps the instant the first arc lands, and the third with it.
927
+ let from = hit, link = arc.chain, hop = 1;
928
+ const struck = [hit];
929
+ while (link) {
930
+ const age2 = age / 1.2;
931
+ const to = strike(from.x + Math.cos(link.ang) * link.reach, from.y + Math.sin(link.ang) * link.reach, struck);
932
+ if (!to) break;
933
+ if (age2 <= 1) live.push({ from, to, seed: link.seed, strength: Math.min(1, 1.2 * (1 - age2 * 0.5)), chain: hop, age: age2 });
934
+ const glow2 = age2 <= 1 ? 0.7 + 0.3 * Math.sin(u * 900 + link.seed) : Math.max(0, 1 - (age2 - 1) / 1.2) * 0.7;
935
+ heads.push({ x: to.x, y: to.y, color: [200, 255, 225], alpha: Math.min(1, glow2 * 1.25), r: 2.2 });
936
+ struck.push(to); from = to; link = link.next; hop++;
937
+ }
938
+ }
939
+ return { stormball: { at: ball, arcs: live, trail, t: u }, heads };
940
+ },
941
+ draw(ctx, view, lw) {
942
+ const s = view.fx?.stormball;
943
+ if (!s) return;
944
+ const U = view.unit ?? 8;
945
+ const c = project(s.at.x, s.at.y, s.at.z, view);
946
+ // sized in grid units, so it reads as the same bright object on every board (bloom's note above)
947
+ // bigger than the first cut on the block board: it is the one thing there (operator: "visually
948
+ // stunning"); half that on the price board (operator, 2026-09-14: "make the ball lightning half
949
+ // the size it is now"), where it shares the view with the line
950
+ // ...then half again (2026-09-15: "the lightning ball is still too large. shrink it up by half again")
951
+ const R = U * (view.axes?.line?.length > 1 ? 0.85 : 3.4);
952
+ // LIGHTNING THAT LOOKS LIKE LIGHTNING (operator, 2026-09-15: "the lightning ball tendrils on
953
+ // the markets page look too big and jagged. Is there a way to thin them up a bit, or make
954
+ // substantial visual improvements to the lightning arcs?"). The first bolt was nine points
955
+ // thrown 18% of the length to either side, re-rolled every frame, in strokes sized to the grid
956
+ // unit -- on the price board, whose unit is three times the block board's, that was a fat
957
+ // zigzag flailing at 60 Hz. Three changes:
958
+ // * MIDPOINT DISPLACEMENT: the channel is split in half, the middle thrown to one side, and
959
+ // each half split again four times over with the throw halving each level -- the shape of
960
+ // a discharge, which wanders on the large scale and crackles on the small, never a zigzag;
961
+ // * BRANCHES: one or two forks leave the channel partway, thinner and shorter, each a
962
+ // smaller discharge of its own, dying out before they reach anything;
963
+ // * HELD FOR A BEAT: the shape is rolled from the arc's seed and the clock in 60 ms steps, so
964
+ // it holds for a few frames and then jumps -- a flicker, not a shiver -- and the strokes
965
+ // are sized to the ball's own radius, so the price board's half-size ball throws half-width
966
+ // bolts.
967
+ const beat = Math.floor((view.now ?? 0) / 60);
968
+ const rollFrom = (seed) => { let x = (seed ^ (beat * 2654435761)) >>> 0 || 1; return () => ((x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296); };
969
+ const displace = (pts, i, j, amp, depth, rnd) => {
970
+ if (depth === 0 || j - i < 2) return;
971
+ const m = (i + j) >> 1, a = pts[i], b = pts[j];
972
+ const nx = -(b.y - a.y), ny = b.x - a.x, L = Math.hypot(nx, ny) || 1;
973
+ const off = (rnd() - 0.5) * 2 * amp * L;
974
+ pts[m] = { x: (a.x + b.x) / 2 + (nx / L) * off, y: (a.y + b.y) / 2 + (ny / L) * off };
975
+ displace(pts, i, m, amp * 0.55, depth - 1, rnd); displace(pts, m, j, amp * 0.55, depth - 1, rnd);
976
+ };
977
+ const bolt = (p, q, amp, rnd, levels = 4) => {
978
+ const n = 1 << levels, pts = new Array(n + 1); pts[0] = p; pts[n] = q;
979
+ displace(pts, 0, n, amp, levels, rnd);
980
+ return pts;
981
+ };
982
+ // THE HAZE: a wide, soft, white light round the ball, over the board, so what it passes reads
983
+ // as lit by it and not only tinted -- and the same, smaller, wherever an arc lands
984
+ {
985
+ const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, R * 5) : null;
986
+ if (g && typeof g.addColorStop === 'function') { g.addColorStop(0, 'rgba(235,245,255,0.30)'); g.addColorStop(0.35, 'rgba(225,240,255,0.14)'); g.addColorStop(1, 'rgba(220,235,255,0)'); ctx.fillStyle = g; }
987
+ else ctx.fillStyle = 'rgba(235,245,255,0.08)';
988
+ ctx.beginPath(); ctx.arc(c.x, c.y, R * 5, 0, Math.PI * 2); ctx.fill();
989
+ }
990
+ // the arcs first, so the sphere sits over the roots of its own lightning
991
+ for (const arc of s.arcs) {
992
+ const end = project(arc.to.x, arc.to.y, arc.to.z, view);
993
+ // a chained arc leaves the block the first one struck, not the ball
994
+ const from = arc.from ? project(arc.from.x, arc.from.y, arc.from.z, view) : c;
995
+ const rnd = rollFrom(arc.seed);
996
+ const main = bolt(from, end, 0.11, rnd);
997
+ // a chain (hop 1 or 2 from the block) is electric green, so it reads as the block discharging, not the ball
998
+ const [halo, body, edge] = arc.chain ? ['20,200,110', '110,255,170', '215,255,235'] : ['30,120,255', '60,170,255', '140,220,255'];
999
+ const sz = (arc.chain ? 0.85 : 1) * R;
1000
+ const stroke = (pts, col, w, a) => line(ctx, pts, `rgba(${col},${a.toFixed(3)})`, Math.max(lw, w));
1001
+ // THE FLASH (2026-09-15: "the lightning bolts still need much more visual flash and effects
1002
+ // to them"): the first fifth of an arc's life is the strike -- the halo blazes at three
1003
+ // times its weight and a white flash blooms where it lands -- then a bead of light runs the
1004
+ // channel from root to tip, sparks spray from the strike point and fall away, and the whole
1005
+ // thing settles to the steady glow. `age` is 0 at the strike and 1 at the end.
1006
+ const age = arc.age ?? (1 - arc.strength) / 0.6;
1007
+ const flash = Math.max(0, 1 - age / 0.2);
1008
+ stroke(main, halo, sz * (0.30 + 0.25 * flash), (0.28 + 0.5 * flash) * arc.strength);
1009
+ stroke(main, body, sz * 0.12, 0.6 * arc.strength);
1010
+ stroke(main, edge, sz * 0.055, 0.95 * arc.strength);
1011
+ stroke(main, '245,252,255', sz * (0.022 + 0.02 * flash), arc.strength);
1012
+ if (flash > 0) bloom(ctx, end.x, end.y, R * 1.6 * (1.2 - flash * 0.5), [255, 255, 255], 0.9 * flash);
1013
+ // the landing lights its surroundings: a hazy white pool that outlives the flash
1014
+ if (age < 0.8) bloom(ctx, end.x, end.y, R * 2.6, [230, 242, 255], 0.35 * (1 - age / 0.8) * arc.strength);
1015
+ // the occasional strike flares: an anamorphic streak through the landing, four turning
1016
+ // rays, and a run of ghosts off toward the picture's middle, over the first third of the arc
1017
+ if (arc.flare && age < 0.35) lensFlare(ctx, end.x, end.y, R * 2.2, arc.chain ? [110, 255, 170] : [140, 220, 255], Math.pow(1 - age / 0.35, 1.5), view, lw);
1018
+ // the bead: a bright knot of light running the channel in the first third of the arc's life
1019
+ const run = Math.min(1, age / 0.35), bi = Math.min(main.length - 1, Math.floor(run * (main.length - 1)));
1020
+ if (age < 0.5) bloom(ctx, main[bi].x, main[bi].y, sz * 0.22, [235, 250, 255], 0.9 * (1 - age));
1021
+ // the sparks: a dozen short lines flung from the strike point, longer and fainter as they go,
1022
+ // drifting down, gone by half the arc's life
1023
+ if (age < 0.55) {
1024
+ const srnd = rollFrom(arc.seed ^ 0x9e3779b9);
1025
+ const life = age / 0.55;
1026
+ for (let k = 0; k < 12; k++) {
1027
+ const ang = srnd() * Math.PI * 2, spd = 0.6 + srnd() * 1.4, len = sz * (0.25 + 0.2 * srnd());
1028
+ const dist = sz * 1.3 * spd * life, drop = sz * 1.2 * life * life;
1029
+ const x1 = end.x + Math.cos(ang) * dist, y1 = end.y + Math.sin(ang) * dist + drop;
1030
+ const x0 = x1 - Math.cos(ang) * len * (1 - life), y0 = y1 - Math.sin(ang) * len * (1 - life) - drop * 0.15;
1031
+ line(ctx, [{ x: x0, y: y0 }, { x: x1, y: y1 }], `rgba(${edge},${(0.9 * (1 - life) * arc.strength).toFixed(3)})`, Math.max(lw, sz * 0.02));
1032
+ }
1033
+ }
1034
+ // the branches: two or three leave the channel a third to two thirds along, at 20-45
1035
+ // degrees, a quarter to a half of the remaining length, and fade toward their tips
1036
+ const forks = 2 + (rnd() < 0.5 ? 1 : 0);
1037
+ for (let f = 0; f < forks; f++) {
1038
+ const k = Math.floor(main.length * (0.3 + 0.4 * rnd()));
1039
+ const at = main[k], dx = end.x - at.x, dy = end.y - at.y;
1040
+ const ang = (rnd() < 0.5 ? 1 : -1) * (0.35 + 0.45 * rnd()), len = 0.25 + 0.25 * rnd();
1041
+ const tip = { x: at.x + (dx * Math.cos(ang) - dy * Math.sin(ang)) * len, y: at.y + (dx * Math.sin(ang) + dy * Math.cos(ang)) * len };
1042
+ const br = bolt(at, tip, 0.14, rnd, 3);
1043
+ stroke(br, body, sz * 0.06, 0.35 * arc.strength);
1044
+ stroke(br, edge, sz * 0.028, 0.7 * arc.strength);
1045
+ stroke(br, '245,252,255', sz * 0.012, 0.8 * arc.strength);
1046
+ }
1047
+ // ...and a ring bursting from the block a chain lands on, the moment it lands
1048
+ if (arc.chain && arc.age < 0.6) {
1049
+ const f = arc.age / 0.6;
1050
+ ring(ctx, end.x, end.y, R * (0.12 + 0.65 * f), `rgba(150,255,190,${(0.9 * (1 - f)).toFixed(3)})`, Math.max(lw * 2, R * 0.035) * (1 - 0.5 * f));
1051
+ ring(ctx, end.x, end.y, R * (0.06 + 0.4 * f), `rgba(235,255,245,${(0.8 * (1 - f)).toFixed(3)})`, Math.max(lw, R * 0.018));
1052
+ }
1053
+ bloom(ctx, end.x, end.y, R * 0.65, arc.chain ? [120, 255, 180] : [90, 200, 255], arc.strength);
1054
+ }
1055
+ // THE SPHERE IS ONE SMOOTH THING (operator, 2026-09-14: "needs a smooth gradient, not different
1056
+ // discs around it. It needs to be visually stunning. Consider adding nebula effects"). Radial
1057
+ // gradients, which the canvas rules never forbade -- clip, globalAlpha, composite modes and
1058
+ // shadowBlur are the calls software rasterisers drop; a gradient fill is a fill. A harness
1059
+ // without them gets the old shells.
1060
+ // a gradient fill, or the first stop's colour where the canvas cannot make one (a test harness)
1061
+ const gradientOf = (x, y, r, stops) => {
1062
+ const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
1063
+ if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
1064
+ for (const [o, col] of stops) g.addColorStop(o, col);
1065
+ return g;
1066
+ };
1067
+ const radial = (x, y, r, stops) => {
1068
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
1069
+ ctx.fillStyle = gradientOf(x, y, r, stops); ctx.fill();
1070
+ };
1071
+ const t = (s.t ?? 0) * 11; // seconds into the run, for the drift
1072
+ // THE NEBULA: three slow wisps of plasma around the ball, each a blob whose edge wanders with
1073
+ // a few sines, turning at its own rate, in violet, magenta and cyan -- faint, wide, and drawn
1074
+ // first so the sphere burns through them
1075
+ const wisps = [[170, 70, 255, 0.42, 1.0], [255, 60, 190, 0.38, -0.7], [40, 220, 255, 0.28, 0.5], [255, 130, 80, 0.22, -1.3]];
1076
+ wisps.forEach(([r, g, b, a, spin], k) => {
1077
+ const cxk = c.x + Math.cos(t * spin * 0.6 + k * 2.1) * R * 0.5, cyk = c.y + Math.sin(t * spin * 0.45 + k * 1.3) * R * 0.4;
1078
+ const base = R * (2.1 + 0.45 * k);
1079
+ ctx.beginPath();
1080
+ for (let i = 0; i <= 40; i++) {
1081
+ const th = (i / 40) * Math.PI * 2;
1082
+ const wobble = 1 + 0.28 * Math.sin(th * 3 + t * spin + k) + 0.17 * Math.sin(th * 5 - t * 0.8 * spin + k * 3) + 0.09 * Math.sin(th * 8 + t * 1.7);
1083
+ const rr = base * wobble;
1084
+ const x = cxk + Math.cos(th) * rr, y = cyk + Math.sin(th) * rr;
1085
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
1086
+ }
1087
+ ctx.closePath();
1088
+ ctx.fillStyle = gradientOf(cxk, cyk, base * 1.4, [[0, `rgba(${r},${g},${b},${a})`], [0.35, `rgba(${r},${g},${b},${(a * 0.7).toFixed(3)})`], [0.7, `rgba(${r},${g},${b},${(a * 0.25).toFixed(3)})`], [1, `rgba(${r},${g},${b},0)`]]);
1089
+ ctx.fill();
1090
+ });
1091
+ // the nebula it leaves behind: where the ball just was, each older and fainter
1092
+ (s.trail ?? []).forEach((p, i) => {
1093
+ const q = project(p.x, p.y, p.z, view);
1094
+ const f = 1 - (i + 1) / ((s.trail?.length ?? 1) + 1);
1095
+ radial(q.x, q.y, R * (1.8 + i * 0.6), [[0, `rgba(140,150,255,${(0.28 * f).toFixed(3)})`], [0.5, `rgba(120,60,230,${(0.16 * f).toFixed(3)})`], [1, 'rgba(60,20,160,0)']]);
1096
+ });
1097
+ // the corona: wide, electric, breathing
1098
+ const breath = 1 + 0.06 * Math.sin(t * 5.3) + 0.04 * Math.sin(t * 8.9);
1099
+ radial(c.x, c.y, R * 3.6 * breath, [[0, 'rgba(60,160,255,0.5)'], [0.3, 'rgba(40,120,255,0.22)'], [0.6, 'rgba(90,50,230,0.08)'], [1, 'rgba(90,50,230,0)']]);
1100
+ // sparks in orbit: motes of light circling at their own speeds and radii, each a tiny gradient
1101
+ for (let i = 0; i < 26; i++) {
1102
+ const sp = 0.6 + (i % 7) * 0.23, ph = i * 2.399, rr = R * (1.5 + ((i * 37) % 11) / 11 * 2.2);
1103
+ const ang = t * sp + ph, wob = 1 + 0.18 * Math.sin(t * 3.1 + i);
1104
+ const x = c.x + Math.cos(ang) * rr * wob, y = c.y + Math.sin(ang) * rr * 0.55 * wob;
1105
+ const tone = i % 3 === 0 ? '255,120,230' : i % 3 === 1 ? '120,200,255' : '200,160,255';
1106
+ radial(x, y, Math.max(1.5, U * 0.22), [[0, `rgba(${tone},0.95)`], [0.4, `rgba(${tone},0.45)`], [1, `rgba(${tone},0)`]]);
1107
+ }
1108
+ // the ball: white-hot centre through cyan to a violet rim, one gradient
1109
+ radial(c.x, c.y, R * 1.35 * breath, [
1110
+ [0, 'rgba(255,255,255,1)'], [0.18, 'rgba(235,250,255,1)'], [0.4, 'rgba(150,225,255,0.95)'],
1111
+ [0.62, 'rgba(60,170,255,0.8)'], [0.82, 'rgba(90,80,255,0.45)'], [1, 'rgba(120,60,255,0)'],
1112
+ ]);
1113
+ // THE CRACKLE: short filaments skittering over and just off the surface, new every frame
1114
+ for (let i = 0; i < 14; i++) {
1115
+ const ang = Math.random() * Math.PI * 2, r0 = R * (0.35 + 0.5 * Math.random()), r1 = R * (1.05 + 0.55 * Math.random());
1116
+ const p = { x: c.x + Math.cos(ang) * r0, y: c.y + Math.sin(ang) * r0 };
1117
+ const q = { x: c.x + Math.cos(ang + (Math.random() - 0.5) * 0.9) * r1, y: c.y + Math.sin(ang + (Math.random() - 0.5) * 0.9) * r1 };
1118
+ const pts = bolt(p, q, 0.16, rollFrom(i * 7919 + 17), 3);
1119
+ line(ctx, pts, 'rgba(90,190,255,0.55)', Math.max(lw * 1.6, R * 0.035));
1120
+ line(ctx, pts, 'rgba(230,248,255,0.95)', Math.max(lw, R * 0.012));
1121
+ }
1122
+ },
1123
+ });
1124
+
1125
+ // ================================================================= BATCH FIVE
1126
+ //
1127
+ // The last six, and with them every effect in the catalogue that was worth building. Two use the
1128
+ // skyline as TERRAIN rather than as an obstacle, one is deliberately rare, and one de-reses the
1129
+ // board and puts it back.
1130
+
1131
+
1132
+
1133
+
1134
+
1135
+
1136
+
1137
+
1138
+
1139
+
1140
+
1141
+