blockyard 0.0.1 → 0.0.9

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 (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,964 @@
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
+ /** A square standing at grid (x, y) at height z, as screen points. */
272
+ const quad = (view, x, y, z, r) => [
273
+ project(x - r, y - r, z, view), project(x + r, y - r, z, view),
274
+ project(x + r, y + r, z, view), project(x - r, y + r, z, view),
275
+ ];
276
+
277
+
278
+
279
+
280
+
281
+
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+ // ================================================================= BATCH TWO
290
+ //
291
+ // Six more, from the same catalogue: a splitter, four personalities, a formation that breaks up,
292
+ // a thief, two populations that fight, and drifting debris. Between them they add the three things
293
+ // batch one had no example of -- an agent that DIVIDES, agents with different rules from each
294
+ // other, and two populations that interact.
295
+
296
+ /** A closed wireframe outline in screen space -- Asteroids' whole visual language. */
297
+ function wire(ctx, pts, col, w) {
298
+ if (pts.length < 3) return;
299
+ ctx.strokeStyle = col; ctx.lineWidth = w;
300
+ ctx.beginPath();
301
+ ctx.moveTo(pts[0].x, pts[0].y);
302
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
303
+ ctx.closePath();
304
+ ctx.stroke();
305
+ }
306
+
307
+ /** An expanding ring, for a detonation. Cheap: one stroke, no fill. */
308
+ function ring(ctx, x, y, r, col, w) {
309
+ ctx.strokeStyle = col; ctx.lineWidth = w;
310
+ ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.stroke();
311
+ }
312
+
313
+ // --- 7. CENTIPEDE ---------------------------------------------------------
314
+ //
315
+ // A column of segments weaving down the board, dropping a row each time it reaches an edge, and
316
+ // SPLITTING in two partway through -- the split is the thing everyone remembers about Centipede,
317
+ // and no other agent here divides. Our board is a grid of obstacles, which is the mushroom field
318
+ // the original needs.
319
+ defineAgent('centipede', {
320
+ build({ W, H, rnd }) {
321
+ // a boustrophedon: across, drop, back across -- the route the whole body follows in file
322
+ const pts = [];
323
+ const rows = 6;
324
+ const dropEvery = Math.max(2, Math.floor(H / (rows + 1)));
325
+ let dir = rnd() < 0.5 ? 1 : -1;
326
+ let x = dir > 0 ? 1 : W - 1;
327
+ let y = H - 2;
328
+ pts.push({ x, y });
329
+ for (let r = 0; r < rows; r++) {
330
+ const end = dir > 0 ? W - 1 : 1;
331
+ const steps = Math.max(1, Math.abs(end - x));
332
+ for (let i = 0; i < steps; i++) { x += dir; pts.push({ x, y }); }
333
+ for (let d = 0; d < dropEvery && y > 1; d++) { y -= 1; pts.push({ x, y }); }
334
+ dir = -dir;
335
+ }
336
+ return { pts, len: 11, splitAt: 0.45 + rnd() * 0.2, splitSeg: 4 + Math.floor(rnd() * 3) };
337
+ },
338
+ frame(a, u) {
339
+ const total = a.pts.length - 1;
340
+ const lead = Math.max(0, Math.min(1, u)) * total;
341
+ const split = u >= a.splitAt;
342
+ const heads = [];
343
+ const body = [];
344
+ for (let i = 0; i < a.len; i++) {
345
+ // after the split the tail half drops back and travels on its own, a little behind
346
+ const lag = i * 1.6 + (split && i >= a.splitSeg ? 6 + (u - a.splitAt) * 26 : 0);
347
+ const d = lead - lag;
348
+ if (d < 0) continue;
349
+ const p = alongPath(a.pts, d);
350
+ const isHead = i === 0 || (split && i === a.splitSeg);
351
+ body.push({ x: p.x, y: p.y, i, head: isHead });
352
+ heads.push({
353
+ x: p.x, y: p.y, r: isHead ? 1.9 : 1.4,
354
+ color: isHead ? [255, 230, 120] : [120, 235, 140],
355
+ alpha: isHead ? 1 : 0.85,
356
+ });
357
+ }
358
+ return { centipede: { body, split }, heads };
359
+ },
360
+ draw(ctx, view, lw) {
361
+ const c = view.fx?.centipede;
362
+ if (!c) return;
363
+ const U = view.unit ?? 8;
364
+ // BIG ENOUGH TO BE A BODY, not a row of lit cubes. The first cut drew each segment at about
365
+ // one cube wide in the board's own green, so on a field of 3,700 green cubes it read as
366
+ // highlighting rather than as a creature crawling over them -- verified on the live board.
367
+ // Now: segments half again as large, a SPINE joining them so the body is one thing, and a
368
+ // magenta-red that nothing else on this board wears.
369
+ const pts = c.body.map((b) => project(b.x, b.y, 1.2, view));
370
+ for (let i = 1; i < pts.length; i++) {
371
+ // the spine only joins segments that are actually adjacent -- after the split the two halves
372
+ // are far apart, and a line between them would draw the creature back together
373
+ const gap = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
374
+ if (gap > U * 3.5) continue;
375
+ line(ctx, [pts[i - 1], pts[i]], 'rgba(180,60,120,0.55)', U * 1.15);
376
+ }
377
+ for (let i = c.body.length - 1; i >= 0; i--) {
378
+ const b = c.body[i];
379
+ const p = pts[i];
380
+ const r = U * (b.head ? 1.75 : 1.25);
381
+ bloom(ctx, p.x, p.y, r, b.head ? [255, 210, 90] : [235, 90, 150], b.head ? 1 : 0.9);
382
+ // a hard rim, so a segment has an edge against a bright cube instead of dissolving into it
383
+ 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);
384
+ if (b.head) {
385
+ for (const o of [-0.34, 0.34]) {
386
+ ctx.fillStyle = 'rgba(25,10,20,0.95)';
387
+ ctx.beginPath(); ctx.arc(p.x + o * U * 0.62, p.y - U * 0.26, U * 0.22, 0, Math.PI * 2); ctx.fill();
388
+ }
389
+ }
390
+ }
391
+ },
392
+ });
393
+
394
+
395
+
396
+
397
+
398
+ // --- 10. TRACTOR BEAM -----------------------------------------------------
399
+ //
400
+ // DATA-AWARE, and the only agent that uses `lift`: a ship stops over the TALLEST cube on the
401
+ // board, opens a beam, and draws it up. The cube rises, thins and vanishes -- then comes back when
402
+ // the effect ends, because nothing here mutates the board (see the hide/scale note in
403
+ // blockscene3d.js).
404
+ defineAgent('tractor', {
405
+ build({ tiles, W, H, rnd }) {
406
+ const target = tallestTile(tiles) ?? { x: W / 2, y: H / 2, h: 1, tile: null };
407
+ // where it leaves for, picked now so the exit replays identically (operator, 2026-09-14: "After
408
+ // it drops them, have it fly up and away in a random direction")
409
+ const away = rnd() * Math.PI * 2;
410
+ return { target, from: { x: rnd() < 0.5 ? -4 : W + 4, y: target.y }, away: { dx: Math.cos(away), dy: Math.sin(away) }, W, H };
411
+ },
412
+ // IT PUTS THE BLOCK DOWN AND LEAVES (operator, 2026-09-13: "rather than just abruptly finish,
413
+ // have the UFO drop the blocks causing them to slightly bounce, and then quickly flies off
414
+ // screen"). Before this the beam simply stopped: the cube was still four and a half units in the
415
+ // air, the saucer still parked over it, and the whole thing blinked out mid-abduction.
416
+ //
417
+ // HIGHER STILL, AND A REAL FALL (operator, 2026-09-14: "have it flying even higher off the board.
418
+ // and lift the blocks higher. When it lets go of the blocks, allow gravity to make them fall, and
419
+ // bounce before coming to rest"). The drop used to be squeezed into a fixed share of the effect,
420
+ // so its speed came from the effect's length, not from gravity. It is now timed by the board's
421
+ // own GRAVITY -- the constant every refresh landing falls under -- so a 24-unit fall takes
422
+ // fallMs(24) = 849 ms whatever the effect's duration, and the cube bounces with a coefficient of
423
+ // restitution of 0.5: hops of 6, 1.5 and 0.4 units, each shorter by sqrt(0.25), before it rests.
424
+ // Livelier than a refresh landing (e 0.15-0.36, hops of a few percent) on purpose: those barely
425
+ // leave the floor, and the ask was for a bounce you can see.
426
+ //
427
+ // IN 0.00-0.22 the saucer flies in from one side
428
+ // HOLD 0.22-0.50 the beam comes on and draws the cube up, easing to a stop at the top
429
+ // RELEASE 0.50 the beam cuts; the cube falls from rest under gravity and bounces
430
+ // AWAY first impact -> 1.00 the saucer climbs off along its own heading while it bounces
431
+ //
432
+ // Still grounded well before the end: `lift` is a per-frame OVERRIDE on a copy of the tile, so a
433
+ // cube still in the air when the effect stops would snap to the floor instead of landing on it.
434
+ // If the effect is ever made too short to hold the whole landing, the landing is compressed to
435
+ // fit rather than cut -- an honest fall that ends in a snap is worse than a quicker one.
436
+ frame(a, u, ctx = {}) {
437
+ const ms = ctx.ms ?? 8000;
438
+ const IN = 0.22, HOLD = 0.5;
439
+ const LIFT = 24;
440
+ // three times the old 5-over-the-top altitude, and never less than 6 over the lifted cube
441
+ const alt = Math.max(3 * (a.target.h + 5), a.target.h + LIFT + 6);
442
+ const E2 = 0.25; // restitution 0.5, squared: each hop's share of the last
443
+ const hops = bouncesUntil(E2, E2);
444
+ const landU = Math.min(0.9 - HOLD, landingMs(LIFT, E2, hops, E2) / ms);
445
+ const impactU = HOLD + landU * fallShare(E2, hops, E2);
446
+ const dir = a.away ?? { dx: a.from.x < a.target.x ? -1 : 1, dy: 0 };
447
+ const reach = Math.max(a.W, a.H) + 8;
448
+
449
+ // the saucer: in, parked over the cube, then away from the moment the cube first hits the floor
450
+ let ship, leaving = false, shipAlpha = 0.9;
451
+ if (u < IN) {
452
+ const v = u / IN;
453
+ ship = { x: a.from.x + (a.target.x - a.from.x) * v, y: a.target.y, z: alt };
454
+ } else if (u < impactU) {
455
+ ship = { x: a.target.x, y: a.target.y, z: alt };
456
+ } else {
457
+ // up and off along its own heading, t^2 so it pulls away rather than drifting
458
+ const v = Math.min(1, (u - impactU) / Math.max(1e-6, 1 - impactU));
459
+ ship = { x: a.target.x + dir.dx * reach * v * v, y: a.target.y + dir.dy * reach * v * v, z: alt + 30 * v * v };
460
+ leaving = true;
461
+ shipAlpha = Math.max(0, 1 - v);
462
+ }
463
+
464
+ // the cube
465
+ let lift = 0, beam = 0, dropped = false;
466
+ if (u >= IN && u < HOLD) {
467
+ const v = (u - IN) / (HOLD - IN);
468
+ beam = Math.sin(Math.min(1, v * 1.2) * Math.PI) * 0.6 + 0.4;
469
+ lift = LIFT * (1 - Math.cos(Math.PI * v)) / 2; // eases to a stop at the top, so the fall starts from rest
470
+ } else if (u >= HOLD && u < HOLD + landU) {
471
+ // RELEASED. The beam is off the instant it lets go -- a beam still drawn over a falling cube
472
+ // reads as the ship dropping something it is still holding.
473
+ lift = LIFT * bounceDrop((u - HOLD) / landU, E2, hops, E2);
474
+ dropped = true;
475
+ }
476
+
477
+ const heads = [{ x: ship.x, y: ship.y, color: [180, 220, 255], alpha: shipAlpha, r: 1.6 }];
478
+ if (u >= IN && u < HOLD + landU) {
479
+ heads.push({ x: a.target.x, y: a.target.y, color: [190, 235, 255], alpha: 1, r: 2.4, lift });
480
+ if (beam > 0) heads.push({ x: a.target.x, y: a.target.y, color: [140, 200, 255], alpha: 0.5 * beam, r: 4 });
481
+ // a flash of dust on each impact, and only then
482
+ if (dropped && lift < 0.05) heads.push({ x: a.target.x, y: a.target.y, color: [255, 240, 200], alpha: 0.7, r: 3 });
483
+ }
484
+ return {
485
+ tractor: { ship, beam, lift, ...(u >= IN && !leaving ? { target: a.target } : {}), ...(dropped ? { dropped: true } : {}), ...(leaving ? { leaving: true } : {}) },
486
+ heads,
487
+ };
488
+ },
489
+ draw(ctx, view, lw) {
490
+ const t = view.fx?.tractor;
491
+ if (!t) return;
492
+ const U = view.unit ?? 8;
493
+ const c = project(t.ship.x, t.ship.y, t.ship.z, view);
494
+ if (t.beam > 0.02) {
495
+ // the cone, as stacked translucent quads: no gradients needed, and none allowed on a fill
496
+ const base = project(t.ship.x, t.ship.y, 0, view);
497
+ const w0 = U * 0.5, w1 = U * 2.2 * t.beam;
498
+ poly(ctx, [
499
+ { x: c.x - w0, y: c.y }, { x: c.x + w0, y: c.y },
500
+ { x: base.x + w1, y: base.y }, { x: base.x - w1, y: base.y },
501
+ ], `rgba(150,210,255,${(0.18 * t.beam).toFixed(3)})`);
502
+ poly(ctx, [
503
+ { x: c.x - w0 * 0.4, y: c.y }, { x: c.x + w0 * 0.4, y: c.y },
504
+ { x: base.x + w1 * 0.45, y: base.y }, { x: base.x - w1 * 0.45, y: base.y },
505
+ ], `rgba(220,245,255,${(0.3 * t.beam).toFixed(3)})`);
506
+ }
507
+ // the saucer: a flattened dome with a lit rim
508
+ const r = U * 1.6;
509
+ const dome = [];
510
+ for (let i = 0; i <= 20; i++) { const ang = Math.PI + (i / 20) * Math.PI; dome.push({ x: c.x + Math.cos(ang) * r, y: c.y + Math.sin(ang) * r * 0.42 }); }
511
+ poly(ctx, dome, 'rgba(120,180,255,0.9)');
512
+ poly(ctx, [
513
+ { x: c.x - r, y: c.y }, { x: c.x + r, y: c.y },
514
+ { x: c.x + r * 0.7, y: c.y + r * 0.25 }, { x: c.x - r * 0.7, y: c.y + r * 0.25 },
515
+ ], 'rgba(200,230,255,0.95)');
516
+ bloom(ctx, c.x, c.y + r * 0.2, r * 0.8, [180, 230, 255], 0.5);
517
+ void lw;
518
+ },
519
+ });
520
+
521
+ // --- 11. MISSILE COMMAND --------------------------------------------------
522
+ //
523
+ // TWO POPULATIONS THAT INTERACT, which nothing else here does: arcs rain toward the board while
524
+ // interceptors rise to meet them, and each interception is an expanding ring that stops what it
525
+ // catches.
526
+ defineAgent('missile', {
527
+ build({ W, H, rnd }) {
528
+ const n = 5;
529
+ const incoming = [];
530
+ for (let i = 0; i < n; i++) {
531
+ const from = { x: rnd() * W, y: H + 3 };
532
+ const to = { x: rnd() * W, y: 1 + rnd() * (H * 0.4) };
533
+ const t0 = rnd() * 0.35;
534
+ // the interceptor leaves later and meets it partway: worked out now, so a frame only moves
535
+ const meet = 0.45 + rnd() * 0.3;
536
+ incoming.push({ from, to, t0, meet, up: { x: to.x + (rnd() - 0.5) * 8, y: 0 } });
537
+ }
538
+ return { incoming, W, H };
539
+ },
540
+ frame(a, u) {
541
+ const heads = [];
542
+ const shots = [];
543
+ const bursts = [];
544
+ for (const m of a.incoming) {
545
+ const v = (u - m.t0) / 0.7;
546
+ if (v <= 0) continue;
547
+ const caught = v >= m.meet;
548
+ const k = Math.min(caught ? m.meet : v, 1);
549
+ const x = m.from.x + (m.to.x - m.from.x) * k;
550
+ const y = m.from.y + (m.to.y - m.from.y) * k;
551
+ if (!caught) {
552
+ shots.push({ x, y, from: m.from, k, kind: 'down' });
553
+ heads.push({ x, y, color: [255, 120, 90], alpha: 1, r: 1.2 });
554
+ } else {
555
+ const age = (v - m.meet) / 0.35;
556
+ if (age < 1) {
557
+ bursts.push({ x, y, age });
558
+ heads.push({ x, y, color: [255, 240, 180], alpha: 1 - age, r: 1.5 + age * 5 });
559
+ }
560
+ }
561
+ // the interceptor climbing to the meeting point
562
+ const iv = Math.min(1, Math.max(0, (v - m.meet * 0.45) / (m.meet * 0.55)));
563
+ if (iv > 0 && !caught) {
564
+ const ix = m.up.x + (x - m.up.x) * iv, iy = m.up.y + (y - m.up.y) * iv;
565
+ shots.push({ x: ix, y: iy, from: m.up, k: iv, kind: 'up' });
566
+ heads.push({ x: ix, y: iy, color: [140, 255, 200], alpha: 1, r: 1 });
567
+ }
568
+ }
569
+ return { missile: { shots, bursts }, heads };
570
+ },
571
+ draw(ctx, view, lw) {
572
+ const m = view.fx?.missile;
573
+ if (!m) return;
574
+ const U = view.unit ?? 8;
575
+ for (const s of m.shots) {
576
+ const a0 = project(s.from.x, s.from.y, 0.8, view);
577
+ const a1 = project(s.x, s.y, 0.8, view);
578
+ const col = s.kind === 'down' ? '255,120,90' : '140,255,200';
579
+ line(ctx, [a0, a1], `rgba(${col},0.35)`, lw * 3);
580
+ line(ctx, [a0, a1], `rgba(${col},0.9)`, lw * 1.2);
581
+ bloom(ctx, a1.x, a1.y, U * 0.45, s.kind === 'down' ? [255, 150, 110] : [170, 255, 210], 0.9);
582
+ }
583
+ for (const b of m.bursts) {
584
+ const c = project(b.x, b.y, 0.9, view);
585
+ const r = U * (0.5 + b.age * 3.4);
586
+ const fade = 1 - b.age;
587
+ ring(ctx, c.x, c.y, r, `rgba(255,220,150,${(0.8 * fade).toFixed(3)})`, lw * 3);
588
+ ring(ctx, c.x, c.y, r * 0.65, `rgba(255,255,240,${(0.9 * fade).toFixed(3)})`, lw * 1.6);
589
+ bloom(ctx, c.x, c.y, r * 0.5, [255, 235, 190], fade * 0.7);
590
+ }
591
+ },
592
+ });
593
+
594
+
595
+
596
+ // ================================================================ BATCH THREE
597
+ //
598
+ // Six that change the board rather than crossing it, and two that ADD INFORMATION -- which is the
599
+ // rarest thing an effect here can do. Every one is still a per-frame override: nothing below
600
+ // mutates a tile (see hide/scale in blockscene3d.js).
601
+
602
+ /** Flood fill over the grid from a seed, stopping at cells a predicate refuses. Pure. */
603
+ export function floodFrom(x0, y0, W, H, blocked, limit = 900) {
604
+ const seen = new Set();
605
+ const order = [];
606
+ const key = (x, y) => y * W + x;
607
+ const q = [[x0 | 0, y0 | 0]];
608
+ seen.add(key(x0 | 0, y0 | 0));
609
+ while (q.length && order.length < limit) {
610
+ const [x, y] = q.shift();
611
+ order.push({ x, y, d: order.length });
612
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
613
+ const nx = x + dx, ny = y + dy;
614
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
615
+ const k = key(nx, ny);
616
+ if (seen.has(k)) continue;
617
+ seen.add(k);
618
+ if (blocked(nx, ny)) continue; // seen, so it is never revisited, but not entered
619
+ q.push([nx, ny]);
620
+ }
621
+ }
622
+ return order;
623
+ }
624
+
625
+
626
+
627
+
628
+
629
+
630
+
631
+
632
+
633
+
634
+
635
+
636
+
637
+ // ================================================================= BATCH FOUR
638
+ //
639
+ // The family that could not be built until heads carried `hide` and `scale` (see the commit that
640
+ // wired them through). Three of these ALTER THE BOARD -- absorbing, collapsing, digging -- which
641
+ // the operator allowed on the condition that it snaps back: "Allow it, it snaps back". It does,
642
+ // by construction, because none of them touches a tile. They publish an override per frame and
643
+ // the renderer applies it to a copy; when the effect ends, or a transition takes the stage, or
644
+ // the tab is hidden, the override simply stops being computed.
645
+
646
+
647
+
648
+ // --- 20. BOULDER DASH -----------------------------------------------------
649
+ //
650
+ // A column gives way and the cubes above it COLLAPSE, each shrinking to nothing in turn, the
651
+ // cascade spreading outward from where it started.
652
+ defineAgent('boulderdash', {
653
+ build({ W, H, rnd, tiles }) {
654
+ const at = { x: Math.floor(rnd() * W), y: Math.floor(rnd() * H) };
655
+ void tiles;
656
+ return { at, W, H, reach: 7 + Math.floor(rnd() * 5) };
657
+ },
658
+ frame(a, u) {
659
+ // the cascade front, spreading out from the collapse
660
+ const front = u * a.reach * 1.5;
661
+ const heads = [];
662
+ for (let r = 0; r <= Math.min(a.reach, Math.ceil(front)); r++) {
663
+ const age = (front - r) / 2.2;
664
+ if (age < 0 || age > 1.6) continue;
665
+ const shrink = Math.max(0, 1 - age); // full height at the front, gone behind it
666
+ const n = Math.max(1, r * 4);
667
+ for (let i = 0; i < n; i++) {
668
+ const ang = (i / n) * Math.PI * 2;
669
+ heads.push({
670
+ x: a.at.x + Math.cos(ang) * r, y: a.at.y + Math.sin(ang) * r,
671
+ // THE RING MUST STILL BE VISIBLE WHERE IT IS WIDEST. alpha was 0.8 * shrink, and shrink
672
+ // goes to zero as a cube finishes collapsing -- so at u=0.9, with the front at its
673
+ // furthest, every head was alpha 0.00 and the collapse happened in the dark. Measured.
674
+ // A floor keeps the rim lit; `scale` carries the collapse, and it is gated on reach now
675
+ // rather than on alpha, so the two are properly separate.
676
+ color: [255, 190, 110], alpha: 0.35 + 0.55 * shrink, r: 1.1,
677
+ scale: 0.12 + 0.88 * shrink,
678
+ });
679
+ }
680
+ }
681
+ return { boulderdash: { at: a.at, front }, heads };
682
+ },
683
+ draw(ctx, view, lw) {
684
+ const b = view.fx?.boulderdash;
685
+ if (!b) return;
686
+ const U = view.unit ?? 8;
687
+ // dust where the front is passing, so a collapse has debris
688
+ for (let i = 0; i < 28; i++) {
689
+ const ang = hash01(i * 13 + 5) * Math.PI * 2;
690
+ const rad = b.front * (0.6 + 0.5 * hash01(i * 7 + 11));
691
+ const c = project(b.at.x + Math.cos(ang) * rad, b.at.y + Math.sin(ang) * rad, 0.5 + hash01(i) * 1.5, view);
692
+ const r = U * (0.18 + 0.3 * hash01(i * 3 + 19));
693
+ ctx.fillStyle = `rgba(210,190,150,${(0.28 * (1 - Math.min(1, b.front / 11))).toFixed(3)})`;
694
+ ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.fill();
695
+ }
696
+ void lw;
697
+ },
698
+ });
699
+
700
+
701
+
702
+ // --- 24. BALL LIGHTNING ---------------------------------------------------
703
+ //
704
+ // (operator, 2026-09-14: "Remove the Portal effect, and replace it with a bright electric blue neon
705
+ // sphere of slow moving ball lightning that crackles with energy, and random arcs of energy burst out
706
+ // from the ball lightning and electrifies the blocks the arcs touch. Have it move from one end of the
707
+ // view space to the other disappearing off-screen.")
708
+ //
709
+ // The route and every arc are decided at build, from the seeded rnd -- the board is still while an
710
+ // effect runs -- so a frame only asks where the ball is and which arcs are alive. An arc is aimed at a
711
+ // real block near the ball, never at empty floor: the ask is that arcs electrify what they touch.
712
+ // The crackle on the sphere itself is re-rolled every frame (Math.random, as this file allows for
713
+ // sparkle that is never asserted frame to frame): electrical precisely because it never repeats.
714
+ defineAgent('stormball', {
715
+ build({ st, W, H, tiles, tops, rnd }) {
716
+ tops ??= cellTops(tiles ?? [], W, H);
717
+ const top = (cx, cy) => (cx >= 0 && cx < W && cy >= 0 && cy < H ? tops[cy * W + cx] : 0);
718
+ let highest = 0;
719
+ for (let i = 0; i < W * H; i++) highest = Math.max(highest, tops[i] ?? 0);
720
+ // ON THE PRICE BOARD IT FLIES THROUGH THE CHART, not over it: a height inside the line's own
721
+ // range, so the candles it passes are beside it and the arcs strike what it comes near
722
+ // (operator, 2026-09-14: "electrifying any elements it comes near")
723
+ const line = st?.axes?.line;
724
+ let alt = highest + 3.5;
725
+ if (line?.length > 1) {
726
+ let lo = Infinity, hi = -Infinity;
727
+ for (const p of line) { lo = Math.min(lo, p.z); hi = Math.max(hi, p.z); }
728
+ alt = lo + (hi - lo) * (0.35 + 0.5 * rnd());
729
+ }
730
+ // across the board the long way round, far enough past both edges to start and end off-screen
731
+ const leftToRight = rnd() < 0.5;
732
+ // the block board's view is far wider than the board, so the run starts well past its edges;
733
+ // the price board's view is fitted to its width (obliqueFit), and the same margin there kept
734
+ // the ball off-screen for the first third of its run (a third of a 7-day board is 190 units)
735
+ // ...but far enough that the whole thing has LEFT before the effect ends (operator, 2026-09-14:
736
+ // "have it fully moved off the display before you remove it"): the corona reaches 12 units
737
+ // from the centre and the trail seven behind, and at 8 the run ended with the glow still
738
+ // on the chart and cut off dead
739
+ const margin = st?.axes?.line?.length > 1 ? 16 : Math.max(W, H) * 0.55 + 6;
740
+ const from = { x: leftToRight ? -margin : W + margin, y: H * (0.22 + 0.56 * rnd()) };
741
+ const to = { x: leftToRight ? W + margin : -margin, y: H * (0.22 + 0.56 * rnd()) };
742
+ const weave = { amp: 1.5 + 2 * rnd(), cycles: 1 + rnd() * 1.5, phase: rnd() * Math.PI * 2 };
743
+ // bursts of arcs: a few a second, one to three at a time, each alive for a moment
744
+ const arcs = [];
745
+ for (let u = 0.03; u < 0.97; u += 0.016 + 0.035 * rnd()) {
746
+ const n = 1 + Math.floor(rnd() * 4);
747
+ for (let k = 0; k < n; k++) {
748
+ 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) };
749
+ // A SECOND ARC OFF THE STRUCK BLOCK, AND SOMETIMES A THIRD (operator, 2026-09-14: "a small
750
+ // chance for a second arc to spawn from the block and arc to a different block", then "Make
751
+ // the secondary arcing more frequent, and have a 50% for an additional third arc"). Half
752
+ // the arcs carry a chain -- its own direction and reach from the block the first lands on,
753
+ // to a different block -- and half of those chain once more from the second block to a
754
+ // third. Decided here, so a replay throws the same chains.
755
+ if (rnd() < 0.5) {
756
+ arc.chain = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
757
+ if (rnd() < 0.5) arc.chain.next = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
758
+ }
759
+ arcs.push(arc);
760
+ }
761
+ }
762
+ return { from, to, weave, alt, arcs, W, H, top };
763
+ },
764
+ frame(a, u) {
765
+ const at = (uu) => {
766
+ const x = a.from.x + (a.to.x - a.from.x) * uu;
767
+ 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);
768
+ return { x, y, z: a.alt + 0.6 * Math.sin(uu * 17) };
769
+ };
770
+ const ball = at(u);
771
+ // where it just was: the nebula it leaves behind is drawn there, fading
772
+ 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);
773
+ // the block an arc thrown at (aimX, aimY) strikes: the tallest cell near there, never `not`
774
+ // (the block a chain leaves), and none if the floor is bare
775
+ const strike = (aimX, aimY, not = []) => {
776
+ let hit = null;
777
+ const consider = (cx, cy) => {
778
+ const h = a.top(cx, cy);
779
+ // `not`: a chain must reach a different block -- two units clear of every block already
780
+ // struck in its chain, which on the candle board is the next candle along
781
+ if (not.some((n) => Math.max(Math.abs(cx + 0.5 - n.x), Math.abs(cy + 0.5 - n.y)) < 2)) return;
782
+ if (h > 0 && (!hit || h > hit.z)) hit = { x: cx + 0.5, y: cy + 0.5, z: h };
783
+ };
784
+ for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) consider(Math.floor(aimX) + dx, Math.floor(aimY) + dy);
785
+ // A THIN BOARD (the price board: eight deep, hundreds wide): an arc thrown at a random angle
786
+ // lands off the front or the back more often than not, so the strike falls back to the
787
+ // tallest thing in the three columns it was thrown toward, at any depth -- a candle
788
+ // (operator, 2026-09-14: "the ball lightning was not arcing out to any of the candles")
789
+ 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);
790
+ return hit;
791
+ };
792
+ const live = [];
793
+ const heads = [{ x: ball.x, y: ball.y, color: [70, 180, 255], alpha: 0.7, r: 4.5 }];
794
+ for (const arc of a.arcs) {
795
+ const age = (u - arc.u0) / arc.life;
796
+ if (age < 0 || age > 2.2) continue; // alive, then an afterglow on the block
797
+ const p = at(arc.u0);
798
+ // the block the arc strikes: the tallest cell near where it is thrown, and none if the floor is bare
799
+ const hit = strike(p.x + Math.cos(arc.ang) * arc.reach, p.y + Math.sin(arc.ang) * arc.reach);
800
+ if (!hit) continue;
801
+ if (age <= 1) live.push({ to: hit, seed: arc.seed, strength: 1 - age * 0.6 });
802
+ // THE BLOCK IT TOUCHES IS ELECTRIFIED: a hard blue flare that flickers while the arc lives and
803
+ // dies away after it -- lit through fxAt's heads, so only the cubes actually struck light up
804
+ const flicker = 0.7 + 0.3 * Math.sin(u * 900 + arc.seed);
805
+ const glow = age <= 1 ? flicker : Math.max(0, 1 - (age - 1) / 1.2) * 0.7;
806
+ heads.push({ x: hit.x, y: hit.y, color: [60, 190, 255], alpha: Math.min(1, glow * 1.25), r: 1.2 });
807
+ // ...and the chain, if this arc carries one: from the struck block to another. ITS OWN
808
+ // EVENT, not a bend in the first (2026-09-14: "I'm not seeing secondary arcs" -- they were
809
+ // there in a third of the arc frames, drawn in the first arc's colour from the first arc's
810
+ // end while it still lived, and read as one longer arc). It leaps half a beat after the
811
+ // first lands and outlives it, in violet against the first's blue, and never to the block it
812
+ // left or one beside it -- a chain a unit long is a chain nobody sees.
813
+ // Violet was the first colour, and it vanished: the nebula the ball drags is violet and
814
+ // magenta. Electric green is on nothing else here -- not the blue arcs, not the nebula, not
815
+ // the price line's yellow -- and a chain lives 1.2 arc-lives, so it is on screen longer
816
+ // than the arc that threw it. NO DELAY (operator: "get rid of the delay on the secondary
817
+ // arcing"): the chain leaps the instant the first arc lands, and the third with it.
818
+ let from = hit, link = arc.chain, hop = 1;
819
+ const struck = [hit];
820
+ while (link) {
821
+ const age2 = age / 1.2;
822
+ const to = strike(from.x + Math.cos(link.ang) * link.reach, from.y + Math.sin(link.ang) * link.reach, struck);
823
+ if (!to) break;
824
+ if (age2 <= 1) live.push({ from, to, seed: link.seed, strength: Math.min(1, 1.2 * (1 - age2 * 0.5)), chain: hop, age: age2 });
825
+ const glow2 = age2 <= 1 ? 0.7 + 0.3 * Math.sin(u * 900 + link.seed) : Math.max(0, 1 - (age2 - 1) / 1.2) * 0.7;
826
+ heads.push({ x: to.x, y: to.y, color: [120, 255, 170], alpha: Math.min(1, glow2 * 1.25), r: 1.2 });
827
+ struck.push(to); from = to; link = link.next; hop++;
828
+ }
829
+ }
830
+ return { stormball: { at: ball, arcs: live, trail, t: u }, heads };
831
+ },
832
+ draw(ctx, view, lw) {
833
+ const s = view.fx?.stormball;
834
+ if (!s) return;
835
+ const U = view.unit ?? 8;
836
+ const c = project(s.at.x, s.at.y, s.at.z, view);
837
+ // sized in grid units, so it reads as the same bright object on every board (bloom's note above)
838
+ // bigger than the first cut on the block board: it is the one thing there (operator: "visually
839
+ // stunning"); half that on the price board (operator, 2026-09-14: "make the ball lightning half
840
+ // the size it is now"), where it shares the view with the line
841
+ const R = U * (view.axes?.line?.length > 1 ? 1.7 : 3.4);
842
+ // a jagged bolt between two screen points, `kink` pixels of wander, re-rolled each frame
843
+ const bolt = (p, q, kink, steps) => {
844
+ const pts = [p];
845
+ for (let i = 1; i < steps; i++) {
846
+ const f = i / steps, nx = -(q.y - p.y), ny = q.x - p.x, L = Math.hypot(nx, ny) || 1;
847
+ const off = (Math.random() - 0.5) * 2 * kink * Math.sin(Math.PI * f);
848
+ pts.push({ x: p.x + (q.x - p.x) * f + (nx / L) * off, y: p.y + (q.y - p.y) * f + (ny / L) * off });
849
+ }
850
+ pts.push(q);
851
+ return pts;
852
+ };
853
+ // the arcs first, so the sphere sits over the roots of its own lightning
854
+ for (const arc of s.arcs) {
855
+ const end = project(arc.to.x, arc.to.y, arc.to.z, view);
856
+ // a chained arc leaves the block the first one struck, not the ball
857
+ const from = arc.from ? project(arc.from.x, arc.from.y, arc.from.z, view) : c;
858
+ const main = bolt(from, end, Math.hypot(end.x - from.x, end.y - from.y) * 0.18, 9);
859
+ // a chain (hop 1 or 2 from the block) is electric green, so it reads as the block discharging, not the ball
860
+ const [halo, body, edge] = arc.chain ? ['20,200,110', '110,255,170', '215,255,235'] : ['30,120,255', '60,170,255', '140,220,255'];
861
+ const sz = arc.chain ? 0.85 : 1;
862
+ line(ctx, main, `rgba(${halo},${(0.3 * arc.strength).toFixed(3)})`, Math.max(lw * 8, U * 1.1) * sz);
863
+ line(ctx, main, `rgba(${body},${(0.6 * arc.strength).toFixed(3)})`, Math.max(lw * 4, U * 0.45) * sz);
864
+ line(ctx, main, `rgba(${edge},${(0.95 * arc.strength).toFixed(3)})`, Math.max(lw * 2, U * 0.2) * sz);
865
+ line(ctx, main, `rgba(245,252,255,${arc.strength.toFixed(3)})`, Math.max(lw, U * 0.08) * sz);
866
+ // ...and a ring bursting from the block it lands on, the moment it lands
867
+ if (arc.chain && arc.age < 0.6) {
868
+ const f = arc.age / 0.6;
869
+ ring(ctx, end.x, end.y, U * (0.4 + 2.2 * f), `rgba(150,255,190,${(0.9 * (1 - f)).toFixed(3)})`, Math.max(lw * 2, U * 0.12) * (1 - 0.5 * f));
870
+ ring(ctx, end.x, end.y, U * (0.2 + 1.4 * f), `rgba(235,255,245,${(0.8 * (1 - f)).toFixed(3)})`, Math.max(lw, U * 0.06));
871
+ }
872
+ // a fork off the main channel, and a spark where it lands
873
+ const k = 3 + Math.floor(Math.random() * 4);
874
+ const fork = main[k], forkEnd = { x: fork.x + (Math.random() - 0.5) * U * 3, y: fork.y + (Math.random() - 0.5) * U * 3 };
875
+ line(ctx, bolt(fork, forkEnd, U * 0.5, 4), `rgba(130,215,255,${(0.6 * arc.strength).toFixed(3)})`, Math.max(lw, U * 0.08));
876
+ bloom(ctx, end.x, end.y, U * 2.2, [90, 200, 255], arc.strength);
877
+ }
878
+ // THE SPHERE IS ONE SMOOTH THING (operator, 2026-09-14: "needs a smooth gradient, not different
879
+ // discs around it. It needs to be visually stunning. Consider adding nebula effects"). Radial
880
+ // gradients, which the canvas rules never forbade -- clip, globalAlpha, composite modes and
881
+ // shadowBlur are the calls software rasterisers drop; a gradient fill is a fill. A harness
882
+ // without them gets the old shells.
883
+ // a gradient fill, or the first stop's colour where the canvas cannot make one (a test harness)
884
+ const gradientOf = (x, y, r, stops) => {
885
+ const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
886
+ if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
887
+ for (const [o, col] of stops) g.addColorStop(o, col);
888
+ return g;
889
+ };
890
+ const radial = (x, y, r, stops) => {
891
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
892
+ ctx.fillStyle = gradientOf(x, y, r, stops); ctx.fill();
893
+ };
894
+ const t = (s.t ?? 0) * 11; // seconds into the run, for the drift
895
+ // THE NEBULA: three slow wisps of plasma around the ball, each a blob whose edge wanders with
896
+ // a few sines, turning at its own rate, in violet, magenta and cyan -- faint, wide, and drawn
897
+ // first so the sphere burns through them
898
+ 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]];
899
+ wisps.forEach(([r, g, b, a, spin], k) => {
900
+ 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;
901
+ const base = R * (2.1 + 0.45 * k);
902
+ ctx.beginPath();
903
+ for (let i = 0; i <= 40; i++) {
904
+ const th = (i / 40) * Math.PI * 2;
905
+ 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);
906
+ const rr = base * wobble;
907
+ const x = cxk + Math.cos(th) * rr, y = cyk + Math.sin(th) * rr;
908
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
909
+ }
910
+ ctx.closePath();
911
+ 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)`]]);
912
+ ctx.fill();
913
+ });
914
+ // the nebula it leaves behind: where the ball just was, each older and fainter
915
+ (s.trail ?? []).forEach((p, i) => {
916
+ const q = project(p.x, p.y, p.z, view);
917
+ const f = 1 - (i + 1) / ((s.trail?.length ?? 1) + 1);
918
+ 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)']]);
919
+ });
920
+ // the corona: wide, electric, breathing
921
+ const breath = 1 + 0.06 * Math.sin(t * 5.3) + 0.04 * Math.sin(t * 8.9);
922
+ 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)']]);
923
+ // sparks in orbit: motes of light circling at their own speeds and radii, each a tiny gradient
924
+ for (let i = 0; i < 26; i++) {
925
+ const sp = 0.6 + (i % 7) * 0.23, ph = i * 2.399, rr = R * (1.5 + ((i * 37) % 11) / 11 * 2.2);
926
+ const ang = t * sp + ph, wob = 1 + 0.18 * Math.sin(t * 3.1 + i);
927
+ const x = c.x + Math.cos(ang) * rr * wob, y = c.y + Math.sin(ang) * rr * 0.55 * wob;
928
+ const tone = i % 3 === 0 ? '255,120,230' : i % 3 === 1 ? '120,200,255' : '200,160,255';
929
+ 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)`]]);
930
+ }
931
+ // the ball: white-hot centre through cyan to a violet rim, one gradient
932
+ radial(c.x, c.y, R * 1.35 * breath, [
933
+ [0, 'rgba(255,255,255,1)'], [0.18, 'rgba(235,250,255,1)'], [0.4, 'rgba(150,225,255,0.95)'],
934
+ [0.62, 'rgba(60,170,255,0.8)'], [0.82, 'rgba(90,80,255,0.45)'], [1, 'rgba(120,60,255,0)'],
935
+ ]);
936
+ // THE CRACKLE: short filaments skittering over and just off the surface, new every frame
937
+ for (let i = 0; i < 14; i++) {
938
+ const ang = Math.random() * Math.PI * 2, r0 = R * (0.35 + 0.5 * Math.random()), r1 = R * (1.05 + 0.55 * Math.random());
939
+ const p = { x: c.x + Math.cos(ang) * r0, y: c.y + Math.sin(ang) * r0 };
940
+ 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 };
941
+ const pts = bolt(p, q, R * 0.22, 4);
942
+ line(ctx, pts, 'rgba(90,190,255,0.55)', Math.max(lw * 2.2, U * 0.12));
943
+ line(ctx, pts, 'rgba(230,248,255,0.95)', Math.max(lw, U * 0.04));
944
+ }
945
+ },
946
+ });
947
+
948
+ // ================================================================= BATCH FIVE
949
+ //
950
+ // The last six, and with them every effect in the catalogue that was worth building. Two use the
951
+ // skyline as TERRAIN rather than as an obstacle, one is deliberately rare, and one de-reses the
952
+ // board and puts it back.
953
+
954
+
955
+
956
+
957
+
958
+
959
+
960
+
961
+
962
+
963
+
964
+