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,2678 @@
1
+ // blockscene3d.js -- the block as a field of uniform slabs on a plane, and a
2
+ // Tetris-inspired, collision-free choreography that moves them between layouts.
3
+ //
4
+ // THE DESIGN, as specified by the operator 2026-09-10:
5
+ // * Every tile on the plane has the SAME height. Feerate is carried by
6
+ // COLOUR alone (the feerate bands of feepalette.js). An earlier cut varied height
7
+ // by feerate, which made the resting plane a skyline; that is not this.
8
+ // Height is reserved entirely for motion.
9
+ // * On a refresh a tile lifts OFF the plane, travels, and descends into its
10
+ // new slot, never passing through another tile on the way.
11
+ // * The movement is TETRIS: lateral travel happens in whole grid steps, and
12
+ // the arrival is a gravity drop that accelerates and locks. Nothing
13
+ // drifts smoothly on a diagonal, because that is not how a piece moves.
14
+ // * Slow enough to read: >= 5 s against a 10 s refresh.
15
+ //
16
+ // HOW THE NO-COLLISION GUARANTEE WORKS. Every mover shares the same three
17
+ // phase boundaries, and that is the whole trick:
18
+ //
19
+ // phase 1 RISE each tile lifts straight up out of its OWN slot. The
20
+ // packer guarantees resting slots never overlap, so two
21
+ // tiles cannot meet while going straight up.
22
+ // phase 2 TRAVEL each tile shifts at its OWN ALTITUDE, in grid steps.
23
+ // Altitudes are distinct and separated by more than a
24
+ // slab is thick, so no two tiles can share space whatever
25
+ // routes they take -- no path planning, no pairwise
26
+ // checks.
27
+ // phase 3 DROP each tile falls straight down into its NEW slot. New
28
+ // slots are non-overlapping too, so again no meeting.
29
+ //
30
+ // The alternative -- staggered departures with tiles routing around each
31
+ // other -- needs pairwise path checks and still promises little. Phase
32
+ // separation is provable in three sentences, and is tested by sampling the
33
+ // whole flight and intersecting every pair of boxes.
34
+ //
35
+ // CONSTRAINTS (test/never-clip.test.js and the incident behind it): no
36
+ // ctx.clip(), no ctx.globalAlpha, zero dependencies. Pure geometry and time,
37
+ // no canvas, so all of it is testable headless.
38
+
39
+ // EVERY TILE IS THE SAME HEIGHT (operator, 2026-09-10): a block's volume must
40
+ // track its transaction's vbytes, and vbytes is an AREA on this grid. Briefly
41
+ // these were true cubes with height = side; that cubed the volume, so a tile
42
+ // twice as wide looked eight times the transaction instead of four, and the
43
+ // small ones vanished. Uniform height restores the proportion: footprint
44
+ // carries the size, and the height is a constant that only gives the tile
45
+ // enough body to catch the light.
46
+ export const TILE_H = 1.0; // the unit slab: the clearance lanes keep, and a fallback height
47
+ // ACTUAL CUBES (operator, 2026-09-11: "They need to be actual cubes"). This
48
+ // reverses the 2026-09-10 decision above on the operator's call. The honest
49
+ // part is unchanged -- the FOOTPRINT is the vbytes, exactly as on
50
+ // mempool.space -- but a block now stands as tall as it is wide, so a large
51
+ // transaction is a tower and a small one a pebble, and seen through the camera
52
+ // a cube shows the sides that face it. Everything that assumed one slab height
53
+ // now asks cubeHeight(): the lanes (so flight stays collision-free), the paint
54
+ // order, the growth policing and the edge hold.
55
+ // A tile laid out by a caller (board3d -- the market towers) may stand taller than its
56
+ // footprint: `tall` is its height. Transactions never set it and stay cubes.
57
+ export function cubeHeight(tile) {
58
+ if (tile && Number.isFinite(tile.tall)) return tile.tall;
59
+ return tile && Number.isFinite(tile.s) ? tile.s : TILE_H;
60
+ }
61
+ export const SLAB_H = TILE_H;
62
+
63
+ // --- projection --------------------------------------------------------
64
+ // A SQUARE GRID, straight on. Not isometric (operator, 2026-09-10: "Don't
65
+ // make it a diamond any more. Make it a square grid up and down. we should
66
+ // get rid of rotation if it helps the animation").
67
+ //
68
+ // Screen x is the grid column, screen y is the grid row MINUS the height, so
69
+ // a block that rises moves straight up the screen and nothing skews. The grid
70
+ // is therefore a true square, every cell is a square, and a tile's footprint
71
+ // lands exactly on cell boundaries -- which is what "grid aligned" can
72
+ // actually mean once the diamond is gone.
73
+ //
74
+ // Rotation is gone with it. Orbiting a square grid only turns it back into a
75
+ // diamond, and dropping it removes a whole axis from the depth sort, the face
76
+ // selection and the hit testing.
77
+ // flipY puts row 0 at the BOTTOM of the screen. The packer lays the richest
78
+ // transactions down first, at row 0, so without this the expensive end sits
79
+ // at the top; the operator wants it bottom-left, where a miner's "what fits"
80
+ // line reads naturally upward.
81
+ // THE SPHERE (operator, 2026-09-11: "A dome is the right analog for this. In
82
+ // fact, I want the entire sphere to be textured as it is, and I want our grid
83
+ // centered within the view-space" / "grid surface fills the panel" / "sphere
84
+ // continues rendering off the screen, textured ... viewport only obviously").
85
+ // The board is a patch of a real sphere: its centre raised by o.dome, its four
86
+ // corners on the plane, and the same sphere carrying on past the board to
87
+ // every edge of the panel. The radius follows from those two facts. No dome
88
+ // (or no grid), no sphere: the plane.
89
+ export function sphereOf(o = {}) {
90
+ const { dome = 0, gridW = 0, gridH = 0 } = o;
91
+ if (!(dome > 0) || !gridW || !gridH) return null;
92
+ const R = ((gridW / 2) ** 2 + (gridH / 2) ** 2 + dome * dome) / (2 * dome);
93
+ return { cx: gridW / 2, cy: gridH / 2, R, sink: R - dome };
94
+ }
95
+
96
+ // the sphere's height above the plane at a grid point
97
+ export function capZ(gx, gy, o = {}) {
98
+ const s = sphereOf(o);
99
+ if (!s) return 0;
100
+ const dx = gx - s.cx, dy = gy - s.cy;
101
+ return Math.sqrt(Math.max(0, s.R * s.R - dx * dx - dy * dy)) - s.sink;
102
+ }
103
+
104
+ // "straight up relative to their position on the sphere" (operator,
105
+ // 2026-09-11): the outward normal there, in grid units -- radial from the
106
+ // sphere's centre, so blocks leaving the board fan outward from its middle.
107
+ // The direction a block travels, as drawn. Under the oblique camera project() adds ox per unit
108
+ // of height, which is a constant rightward push on every block whatever its place on the board;
109
+ // taking it out of the path leaves motion that is symmetric about the middle -- what the sphere's
110
+ // normal was meant to give. Off the oblique camera there is no such term and this is the normal.
111
+ export function flightDir(gx, gy, o = {}) {
112
+ // The oblique lean is radial now (obliqueLean), so the camera's own push already fans from the
113
+ // middle: the flight is the sphere's normal again, and subtracting the lean here as well would
114
+ // count that fan twice and tip every flight to the left.
115
+ return surfaceNormal(gx, gy, o);
116
+ }
117
+
118
+ // HOW A CUBE LEAVES AND ARRIVES: 'normal' | 'arcing'.
119
+ //
120
+ // MEASURED FIRST, because the obvious story was wrong. The complaint (operator, 2026-09-13: "all
121
+ // the left and right side blocks are arcing towards/away from the sides instead of just traveling
122
+ // straight up") was that the path CURVES, and the settle loop below does bend it -- but sampling
123
+ // the drawn path up a flight showed the bend is about 2%: dx/dy runs 0.7335 -> 0.7481 at the left
124
+ // edge over a whole climb. The path was already very nearly straight. What it was not is VERTICAL:
125
+ // that slope means an edge cube moves three pixels sideways for every four up, while the middle
126
+ // column sits at -0.017. So what reads as arcing is mostly a steep straight FAN, not a curve, and
127
+ // removing the curve alone would have looked like nothing happened.
128
+ //
129
+ // normal the lean is held at its resting value: a straight line along the sphere's normal
130
+ // arcing the lean is re-settled as the cube climbs, so the fan bends slightly
131
+ //
132
+ // A third mode, `vertical`, removed the fan entirely -- no sideways travel at all. It was built,
133
+ // measured (exactly 0.00px drift at both edges) and shown side by side with these two, and the
134
+ // operator cut it: "Get rid of straight up. Make Along the board's curve the default." The fan is
135
+ // the board's curve being honest about itself, and a cube rising dead vertical off a domed board
136
+ // reads as flatter than the board is. Recorded so it is not proposed again as an obvious fix.
137
+ //
138
+ // The default here is `arcing` so that every caller that has never heard of this -- and every test
139
+ // written before it -- draws exactly what it always drew. settings.js passes a mode explicitly.
140
+ export const departMode = (o = {}) => o.departures ?? 'arcing';
141
+
142
+ /**
143
+ * Where a flying cube really is, and how it leans, settled once per cube.
144
+ *
145
+ * THE ONE PLACE BOTH CALLERS AGREE. liftProjector draws the cube here and obliqueOrder sorts it,
146
+ * and both used to re-derive this with the same six lines copied. The comment on the second copy
147
+ * warned that the order and the geometry must be measured at the same point -- so with a mode to
148
+ * honour, the copies become one function rather than two that can drift apart. Paint order that
149
+ * disagrees with geometry by even a little is the flicker class this board has had before.
150
+ */
151
+ export function flightGeom(tile, zv, o = {}) {
152
+ const cxRest = tile.x + tile.s / 2, cyRest = tile.y + tile.s / 2;
153
+ const mode = departMode(o);
154
+ const drift = flightOffset(cxRest, cyRest, zv, o).x; // along the sphere's normal, once clear
155
+ // THE LEAN IS ALSO THE CUBE'S SHAPE. Height enters the projection as `x += z * lean * unit`, so
156
+ // the same term that pushes a flight sideways is what makes a cube's side faces lean. A mode
157
+ // cannot simply zero it to change a path: that would flatten an airborne cube and then SNAP it at
158
+ // touchdown, because a resting cube leans by its column. So both modes keep a real lean and
159
+ // differ only in WHEN it is settled -- which is also why the removed `vertical` mode had to
160
+ // subtract the flight's share of it rather than drop the term.
161
+ // THE SETTLED LEAN at a given flight height. A cube's lean is a fixed point: it leans by where it
162
+ // is, and leaning moves where it is, so this iterates twice -- the shipped rule, unchanged.
163
+ const settleAt = (h) => {
164
+ const d = flightOffset(cxRest, cyRest, h, o).x;
165
+ const height = h + capZ(cxRest + d, cyRest, o) + (tile.floor ?? 0) + cubeHeight(tile) / 2;
166
+ let lean = obliqueLean(cxRest + d, o);
167
+ for (let i = 0; i < 2; i++) lean = obliqueLean(cxRest + d + height * lean, o);
168
+ return lean;
169
+ };
170
+ // A CUBE AT REST IS DRAWN THE SAME IN EVERY MODE, AND EACH MODE AGREES WITH ITSELF AS IT LANDS.
171
+ //
172
+ // Both halves of that were got wrong in turn, and only measurement caught either. First cut: the
173
+ // mode applied at every height, so a RESTING rim cube was drawn at 23.945640 under arcing and
174
+ // 23.976709 under the others -- a flight setting changing how the standing board looks. Second
175
+ // cut guarded on `zv > 0`, which fixed rest and broke the boundary instead: flight used the plain
176
+ // resting lean while rest used the settled one, so each mode now disagreed with ITSELF across
177
+ // touchdown -- a 0.031px snap on the last step down, and a `vertical` that still drifted 0.03px.
178
+ //
179
+ // The constant that satisfies both is the settled lean evaluated AT ZERO HEIGHT: it is exactly
180
+ // what a resting cube uses (so rest is identical in both modes and identical to shipped),
181
+ // and it does not change as the cube climbs (so the path stays perfectly straight and nothing
182
+ // moves at touchdown). `arcing` keeps re-settling as it climbs, which is the curve it is named
183
+ // for. Every number in this comment came from a probe, not from reading the code.
184
+ // THE SHAPE IS RE-SETTLED EVERY FRAME; ONLY THE PATH IS PINNED (operator, 2026-09-13: "that looks
185
+ // terrible when the blocks fall into place (non-changing perspective looks strange visually) ...
186
+ // I'm not seeing the bottom or the front face changing at all during movement. We are
187
+ // regressing"). Correct, and it was my regression: before this setting existed the renderer always
188
+ // re-settled the lean with height -- what `arcing` still does -- and making `normal` the default
189
+ // froze it for the whole flight. Measured falling from z=40 at gx=6: arcing sweeps -0.1291 ->
190
+ // -0.0885 and converges on the resting lean as it lands; `normal` held -0.0885 at EVERY height.
191
+ // Frozen exactly where the perspective should move most.
192
+ //
193
+ // So the two jobs are separated rather than traded. `lean` is the cube's SHAPE and is re-settled
194
+ // at the cube's real height in both modes, so a falling cube's faces change frame by frame as
195
+ // they always did. `leanPath` is what the departure mode decides -- pinned for `normal`, giving
196
+ // the straight line along the sphere's normal that was chosen, and re-settled for `arcing`, which
197
+ // is the curve it is named for. liftProjector draws with `lean` and then shifts the cube rigidly
198
+ // so its centre sits on `leanPath`; at rest the two are equal, so the standing board and the
199
+ // touchdown are untouched.
200
+ const lean = settleAt(zv);
201
+ return { drift, lean, leanPath: mode === 'arcing' ? lean : settleAt(0), cx: cxRest + drift };
202
+ }
203
+
204
+ // WHERE A FLIGHT HAS GOT TO at drawn height zv: straight up until it clears its neighbours, then out
205
+ // along the sphere's normal (operator, 2026-09-14: "The small blocks that remain on the board always
206
+ // sort over the cubes flying above it"). Flights used to fan from the floor, so a cube a unit up had
207
+ // already moved a fraction of a unit sideways -- into the space of the cube beside it. Two boxes that
208
+ // overlap on every axis have no correct paint order, and every rule for them flips as the cube
209
+ // bounces: measured on a live refresh, ordering those pairs by drawn height took the resting-over-
210
+ // flyer errors from 115 to 5 and the pops from 0 to 518; by the drifted footprint, 1 error and 1,100
211
+ // pops. The path was the fault, not the order. Rising straight up through FLIGHT_CLEAR first means
212
+ // nothing moves sideways until it is above the cubes around it, so a low cube never shares space with
213
+ // a neighbour; above that it fans exactly as before, and at rest nothing has moved at all. The
214
+ // projector, the lean and the paint order all read this one function.
215
+ export const FLIGHT_CLEAR = 3;
216
+ export function flightOffset(gx, gy, zv, o = {}) {
217
+ const c = o.oblique?.clear ?? FLIGHT_CLEAR;
218
+ if (!(zv > c)) return { x: 0, y: 0, z: Math.max(0, zv) };
219
+ const n = flightDir(gx, gy, o), t = zv - c;
220
+ return { x: n.x * t, y: n.y * t, z: c + n.z * t };
221
+ }
222
+
223
+ export function surfaceNormal(gx, gy, o = {}) {
224
+ const s = sphereOf(o);
225
+ if (!s) return { x: 0, y: 0, z: 1 };
226
+ const dx = gx - s.cx, dy = gy - s.cy;
227
+ return { x: dx / s.R, y: dy / s.R, z: Math.sqrt(Math.max(0, s.R * s.R - dx * dx - dy * dy)) / s.R };
228
+ }
229
+
230
+ // How far a unit of height pushes a cube sideways, at gx. Zero over the middle of the board and
231
+ // +/- oblique.ox at its edges: the fan of a camera standing over the centre. Without a board width
232
+ // (a bare projection in a test) it falls back to the old constant, so nothing that does not know
233
+ // about a board changes shape.
234
+ export function obliqueLean(gx, o = {}) {
235
+ const ox = o.oblique?.ox ?? 0.15;
236
+ const W = o.gridW || 0;
237
+ if (!(W > 0)) return ox;
238
+ const half = W / 2;
239
+ return ox * Math.max(-1, Math.min(1, (gx - half) / half));
240
+ }
241
+
242
+ export function project(gx, gy, gz, o = {}) {
243
+ const {
244
+ unit = 12, zUnit = 10, originX = 0, originY = 0, flipY = true,
245
+ persp = 0.55, vanishX = 0, vanishY = 0, risePerUnit = 0.075,
246
+ } = o;
247
+ // gz is HEIGHT TOWARD THE VIEWER (operator, 2026-09-10: "receding is not
248
+ // working well. Make them rise toward the viewer at different heights").
249
+ //
250
+ // A rising block grows and moves OUTWARD from the vanishing point, which is
251
+ // the opposite of the receding version and reads as coming at you. It can
252
+ // therefore push past the board's edge, so the renderer reserves a constant
253
+ // margin for the tallest lane -- constant, so the zoom still does not
254
+ // breathe during a transition.
255
+ //
256
+ // It remains a real spatial axis, so the no-collision proof is unchanged:
257
+ // two blocks at different heights cannot occupy the same space.
258
+ const x0 = originX + gx * unit;
259
+ // A LOWER CAMERA (operator, 2026-09-11: "Adjust the view even lower than currently, so that we
260
+ // see more of the side view, than from above"): oblique.dy draws the board's depth shorter than
261
+ // its width while height keeps its full oy, so the rows recede and the front faces of the cubes
262
+ // take the screen. 1 (the default) is the straight-down board the block viewer uses.
263
+ const dS = o.oblique?.dy ?? 1;
264
+ const y0 = originY + (flipY ? -gy : gy) * unit * dS;
265
+ // THE CURVED BOARD (operator, 2026-09-11: "adjusting the grid surface to be
266
+ // a partial spherical segment would give us an entirely new dimension to
267
+ // work with"): a patch of a real sphere, capZ above. It was a "pillow",
268
+ // pinned to zero on every edge, while the board had to sit flush with its
269
+ // frame; the board is centred on a sphere that fills the panel now, so the
270
+ // true cap is back. Off (flat) without o.dome.
271
+ const z = (gz || 0) + capZ(gx, gy, o);
272
+ if (!z) return { x: x0, y: y0 };
273
+ // THE OBLIQUE CAMERA (2026-09-11). Height is a FIXED screen offset -- up
274
+ // and a little right, a camera off the lower-left of the screen -- instead
275
+ // of the overhead pinhole's swell. Operator, in order: "offset something
276
+ // outside the screen bounds so things aren't dropping directly down from
277
+ // the viewer's perspective"; "Are they even proper cubes like I asked for?"
278
+ // (overhead, a two-unit cube showed a sliver of side and read as a tile);
279
+ // "Blocks bouncing around during movements and z-fighting in a weird way"
280
+ // (the pinhole grew blocks with height and a growth rule re-limited them
281
+ // every frame). Here every cube shows its top and its west and south faces,
282
+ // a fall is visible motion up and down the screen, and nothing ever changes
283
+ // size -- so there is no growth to police and nothing to flicker.
284
+ if (o.oblique) {
285
+ const px = x0 + z * (o.leanFixed ?? obliqueLean(gx, o)) * unit;
286
+ const py = y0 - (flipY ? 1 : -1) * z * o.oblique.oy * unit;
287
+ // HEIGHT WITH A PERSPECTIVE, NOT JUST AN OFFSET (operator, 2026-09-13: "I'm not seeing the
288
+ // bottom or the front face changing at all during movement ... fix camera stuff").
289
+ //
290
+ // The two lines above are the whole oblique camera: height moves a point up the screen and a
291
+ // little sideways, and NOTHING scales. Measured, that is why a cube's front face is 11.0px tall
292
+ // at z=0 and 11.0px at z=80 -- there is no term that could foreshorten it, so no lean policy
293
+ // could ever make faces change with altitude. It is an oblique projection in the strict sense:
294
+ // parallel, sizeless, and flat in depth.
295
+ //
296
+ // `rise` adds the missing term: a point is scaled about the vanishing point by 1 + gz*rise, so
297
+ // its distance from the camera finally does something. A resting cube's top is then slightly
298
+ // larger than its base (a real cube seen from above, rather than a parallelogram), and a cube
299
+ // in flight visibly grows as it comes toward the viewer.
300
+ //
301
+ // IT SCALES BY gz, NOT BY z, and the difference is the whole board. `z` above is
302
+ // `gz + capZ(...)`: on a domed board EVERY floor point carries the dome's own height, so
303
+ // scaling by `z` moved the floor too -- measured, the grid point (10,10,0) slid 97.490 ->
304
+ // 95.756 -> 93.155 as rise went 0 -> 0.004 -> 0.01, and a first version of this comment claimed
305
+ // the floor was untouched. It was not. Height ABOVE the board is what should foreshorten; the
306
+ // board's own curvature is the board. With gz the factor at a footprint is exactly 1 and the
307
+ // grid, the deck and every resting footprint are genuinely unchanged.
308
+ //
309
+ // Default 0, so nothing changes until it is switched on. That is deliberate: size changing with
310
+ // height is precisely what the oblique camera was chosen to avoid ("nothing ever changes size
311
+ // -- so there is no growth to police and nothing to flicker"), and three attempts at this area
312
+ // have already regressed the paint order. It ships as a control to be compared, and the flicker
313
+ // test is the gate on making it the default.
314
+ const rise = o.oblique.rise ?? 0;
315
+ if (!rise) return { x: px, y: py };
316
+ const k = 1 + Math.max(0, gz || 0) * rise;
317
+ const vx = o.vanishX ?? 0, vy = o.vanishY ?? 0;
318
+ return { x: vx + (px - vx) * k, y: vy + (py - vy) * k };
319
+ }
320
+ const k = 1 + z * risePerUnit * persp; // > 1: nearer, and larger
321
+ return { x: vanishX + (x0 - vanishX) * k, y: vanishY + (y0 - vanishY) * k };
322
+ }
323
+
324
+ // OUTWARD, THROUGH A REAL CAMERA (operator, 2026-09-11, the latest of several
325
+ // rounds: "I think we need to have blocks moving outwards instead of directly
326
+ // up. Having issues really selling the 3D-ness of it"). An airborne block is
327
+ // projected by a true pinhole about the vanishing point: position AND size
328
+ // scale together by liftBoost(z), so a rising block travels out along the ray
329
+ // from the board's centre as it grows, and falls back in along that ray onto
330
+ // its slot. Two blocks at the same height scale APART together, so they cannot
331
+ // run into each other (which the own-centre growth below the next heading did,
332
+ // and which settleGrowth had to police). The swell is 7% per unit, capped at
333
+ // 2x, so the outward travel mostly stays inside the board; the edge hold in
334
+ // airTop catches the rest. History of the rounds, oldest first:
335
+ //
336
+ // STRAIGHT UP, TOWARD THE VIEWER. The asks, in order:
337
+ // "consider having the 3d blocks moving more towards the center, not away
338
+ // from the center"; then "rise to and come from a centerpoint"; then, of the
339
+ // centre pull that produced, "I don't mean for shit to get pulled directly to
340
+ // the center, or spawn from the center. I mean just pull or spawn it towards
341
+ // the viewer directly up until it fades out."
342
+ //
343
+ // What that means geometrically: the pinhole above scales every point about
344
+ // the vanishing point, so a risen block both GROWS and SLIDES OUTWARD -- the
345
+ // slide is what carried rim blocks off the panel and read as "away from the
346
+ // center". An airborne block is therefore drawn about its OWN centre instead:
347
+ // the centre stays exactly where a resting block's would be, and only the
348
+ // size changes with height.
349
+ //
350
+ // And the size has to change VISIBLY. The pinhole alone swells a block by
351
+ // r * persp per unit -- about 1.5% for a one-unit bounce -- so a bounce did
352
+ // not come toward the viewer at all, while its shadow spread into a dark ring
353
+ // round it, and that read as sinking into a hole (operator: "It looks like the
354
+ // cubes are bouncing from below, not from above. They need to obviously bounce
355
+ // towards the viewer"). liftBoost swells it by 12% per unit of height, capped
356
+ // at 3x. A first cut saturated at 30% and the operator still saw "they always
357
+ // stay the same size despite height off the grid" -- most hops are under a
358
+ // unit, so a saturating swell was worth 1-2% where it mattered.
359
+ //
360
+ // At z -> 0 both terms go to 1 and this is exactly the resting projection, so
361
+ // nothing jumps at touchdown. A centre-gather pull was built and removed the
362
+ // same day; recorded so it is not re-added. Pure rendering: world positions --
363
+ // and so the no-collision proof -- are untouched.
364
+ // capped at 1.5x (was 2x): a doubled block covered four of its neighbours.
365
+ // No swell at all below the height settleGrowth counts as airborne (0.02): a
366
+ // block in the last instant of its landing is policed as RESTING, so it must
367
+ // also be DRAWN at resting size -- measured, one at z = 0.006 overlapped its
368
+ // neighbour by a hundredth of a pixel because the two disagreed.
369
+ export function liftBoost(z) { return z > 0.02 ? Math.min(1.5, 1 + 0.07 * z) : 1; }
370
+
371
+ // HELD INSIDE THE BOARD (operator: "I want the grid bounds flush with the
372
+ // viewport"). A swollen block near the rim would cross the edge, so its whole
373
+ // drawing is nudged inward by exactly as much as its swollen top would
374
+ // overhang, and no more: an interior block still goes straight up over its
375
+ // own slot. The board spans x in [0, 2 vanishX] and y between 0 and 2 vanishY
376
+ // (the vanishing point is its centre); with no vanishing point given there is
377
+ // no board to hold it in.
378
+ // The screen box of an airborne block's top face at growth g, after the edge
379
+ // hold: centre and half-size. The hold's bounds are the board WIDENED to
380
+ // wherever the block's resting top already reaches, so the hold only ever
381
+ // answers for the growth -- at g = 1 nothing moves, and nothing jumps at
382
+ // touchdown.
383
+ export function airTop(tile, g, o = {}) {
384
+ const { risePerUnit = 0.075, persp = 0.55, unit = 12, vanishX = 0, vanishY = 0 } = o;
385
+ const cTop = project(tile.x + tile.s / 2, tile.y + tile.s / 2, cubeHeight(tile), o);
386
+ // true perspective: the centre goes out along the ray from the vanishing point
387
+ const px = vanishX + (cTop.x - vanishX) * g, py = vanishY + (cTop.y - vanishY) * g;
388
+ const rest = (tile.s * unit / 2) * (1 + cubeHeight(tile) * risePerUnit * persp);
389
+ const half = rest * g;
390
+ const hold = (v, c, a, b) => {
391
+ const lo = Math.min(a, c - rest), hi = Math.max(b, c + rest);
392
+ return hi - lo < 2 * half ? (lo + hi) / 2 : Math.max(lo + half, Math.min(hi - half, v));
393
+ };
394
+ // The board's own bounds, given explicitly now that the vanishing point can
395
+ // sit off the board (camera options, 2026-09-11); without them the board is
396
+ // assumed centred on the vanishing point, as it always was.
397
+ const W = o.boardW ?? Math.abs(2 * vanishX);
398
+ const H = o.boardH ?? Math.abs(2 * vanishY);
399
+ const yLo = o.flipY === false ? 0 : -H, yHi = o.flipY === false ? H : 0;
400
+ const x = W ? hold(px, cTop.x, 0, W) : px;
401
+ const y = H ? hold(py, cTop.y, yLo, yHi) : py;
402
+ return { x, y, half, dx: x - px, dy: y - py };
403
+ }
404
+
405
+ // With the oblique camera a flight altitude is drawn COMPRESSED into the
406
+ // headroom above the board, so however high the lanes stack (cube lanes are
407
+ // intervals and can climb) a block in the air never leaves the frame; the
408
+ // cube's own height is drawn true. Near the plane the compression is the
409
+ // identity, so touchdown is seamless.
410
+ //
411
+ // The cube's own height counts against that headroom (2026-09-11). Only the
412
+ // BASE used to be compressed, into 8 units, and a 12-unit cube drawn true on
413
+ // top of it reached 20 units into a 13-unit strip -- the big ones flew off
414
+ // the top of the canvas. Each cube now gets what is left above its own
415
+ // height (headroom + dome - side), so its top never leaves the frame.
416
+ export function altitudeView(z, o = {}, h = 0) {
417
+ const H = Math.max(1.5, (o.oblique?.headroom ?? 10) + (o.dome || 0) - h);
418
+ return z > 0 ? H * (1 - Math.exp(-z / H)) : 0;
419
+ }
420
+
421
+ // Where a cube's base is DRAWN under the oblique camera: its flight altitude
422
+ // compressed into the headroom, plus `entry` -- the share of the height that
423
+ // puts it wholly outside the canvas (1), for an arrival still falling in or a
424
+ // departure flying off -- see offscreenLift. Used by the projector and by the paint order alike.
425
+ export function visualBase(tile, o = {}) {
426
+ const z = tile.z ?? 0;
427
+ let zv = 0;
428
+ if (z > 0) {
429
+ if (o.oblique && o.viewRect) {
430
+ // HIGHER (operator, 2026-09-11: "the blocks can rise higher than they do
431
+ // during reshuffling ... it will help sell the depth and shadows more").
432
+ // Flights were squeezed into one fixed band (headroom + dome - side) so
433
+ // that the tallest cube at the back edge stayed in frame. With the board
434
+ // centred on the sphere most cubes have far more room than that: each
435
+ // flight now gets the room at ITS spot -- the height at which its own
436
+ // top would reach the panel's edge along its flight direction
437
+ // (flightRoom) -- up to oblique.flight. Front and centre climb high,
438
+ // the back rows stay in frame, and nothing is ever cut off.
439
+ // The room is measured for the SPOT (a point at the cube's centre), not
440
+ // the cube: a size-dependent room drew a big cube that was really above a
441
+ // small one LOWER than it, and the pair flickered (see obliqueOrder). The
442
+ // cube's own room then only clips the biggest at the ceiling, so none
443
+ // leaves the frame.
444
+ //
445
+ // ...AND THAT IS NOW REVERSED: FLIGHTS LEAVE THE FRAME (operator, 2026-09-14: "the blocks are
446
+ // wrapping at the viewport extents. They need to move off-screen instead of bunching up at the
447
+ // extents"). The room was the panel's, so it shrank with the panel: on the square Overview
448
+ // board a rim cube climbed only until it touched the edge and then slid along it, following
449
+ // the board's curve, while every lane above it was drawn at the same place -- the pile along
450
+ // the edges. Departures and arrivals then burst out of that pile in their last frames, which
451
+ // is the popping "worse the narrower the display". Height is now one curve for every spot
452
+ // and every panel -- oblique.flight's soft ceiling, nothing measured from the canvas -- so a
453
+ // flight near an edge simply carries on past it and comes back when it lands. Being the same
454
+ // everywhere it also cannot draw a higher cube lower than a neighbour, which is what the
455
+ // per-spot room had to be careful about.
456
+ const F = Math.max(1, o.oblique.flight ?? 120);
457
+ const view = (zz) => F * (1 - Math.exp(-zz / F));
458
+ // A LANDING RUNS IN DRAWN SPACE (operator, 2026-09-11: "The items slowly start
459
+ // dropping. They need to drop like they were just let go, immediately succumb
460
+ // to gravity, and bounce to a stop, following real physics"). Mapped point by
461
+ // point through the compression above, a fall's real parabola was drawn slow
462
+ // at the top -- where the compression is strongest -- and plunging at the
463
+ // bottom: a hover, then a lurch. Now the DRAWN height is the gravity curve
464
+ // itself: the release height as drawn, times bounceDrop's profile (1 - u^2 to
465
+ // the floor, then rebounds of e^2 of it, each e as long). The real height --
466
+ // the collision proof, the paint order -- keeps the same profile.
467
+ zv = tile.landV != null && tile.fallFrom > 0 ? view(tile.fallFrom) * Math.max(0, tile.landV) : view(z);
468
+ } else zv = altitudeView(z, o, cubeHeight(tile));
469
+ }
470
+ if (o.oblique && tile.entry > 0) zv += tile.entry * offscreenLift(tile, o);
471
+ return zv;
472
+ }
473
+
474
+ // OFF SCREEN (operator, 2026-09-11: "I can see the new blocks spawning on
475
+ // screen. Have the spawning happen off-screen for new blocks being dropped
476
+ // in"). Under the oblique camera height goes up AND right, so a cube lifted
477
+ // high enough leaves over the top edge or the right one, whichever comes
478
+ // first; this is the height that takes its nearest corner past that edge,
479
+ // with two units to spare. viewUp / viewRight are the canvas's extent in grid
480
+ // units, from the renderer's constant fit; without them (tests, no panel) the
481
+ // board plus its reserved strip stands in.
482
+ //
483
+ // Since flight follows the sphere's normal (2026-09-11) a block's path can
484
+ // leave through ANY edge -- up-right at the middle of the board, out to the
485
+ // left near its left side -- so this finds the height at which the whole cube
486
+ // has passed whichever edge its path meets first, one unit beyond it.
487
+ // o.viewRect is the panel's extent in grid units, from the renderer's
488
+ // constant fit; without it (tests, no panel) the board plus the reserved
489
+ // margin on every side stands in.
490
+ //
491
+ // THE ESTIMATE IS ONLY WHERE THE SEARCH STARTS (operator, 2026-09-14: "blocks still disappear in
492
+ // mid-air instead of flying off the screen"). The straight-line figure below models the flight
493
+ // with a worst-case lean and a flat step per unit of height, and the real projector does not move
494
+ // a cube that way. Measured on a 500x900 panel: 9 of 90 departures were still on screen on their
495
+ // last frame -- at entry 0.995, i.e. at this height -- and were then deleted there, which is the
496
+ // mid-air vanish; the same shortfall had 1x1 arrivals appearing 49px inside the panel. So the
497
+ // height is now checked against the projector itself, the one that draws the cube, and raised
498
+ // until every corner is past one edge of the panel. Whatever the estimate gets wrong, the answer
499
+ // is measured on the picture.
500
+ const liftMemo = new Map();
501
+ export function offscreenLift(tile, o = {}) {
502
+ const { v, dX, dY, x0, x1, up0, up1 } = flightFrame(tile, o);
503
+ let h = Infinity;
504
+ if (dX > 1e-6) h = Math.min(h, (v.x1 + 1 - x0) / dX);
505
+ if (dX < -1e-6) h = Math.min(h, (x1 - (v.x0 - 1)) / -dX);
506
+ if (dY > 1e-6) h = Math.min(h, (v.y1 + 1 - up0) / dY);
507
+ if (dY < -1e-6) h = Math.min(h, (up1 - (v.y0 - 1)) / -dY);
508
+ h = Number.isFinite(h) ? Math.max(0, h) : 0;
509
+ if (!o.oblique) return h;
510
+ // per frame this runs for every cube entering or leaving, several times each; the answer depends
511
+ // only on the cube's footprint and the camera, so it is kept
512
+ const ob = o.oblique;
513
+ const key = [tile.x, tile.y, tile.s, tile.tall, tile.floor, tile.fxz, v.x0, v.x1, v.y0, v.y1,
514
+ ob.ox, ob.oy, ob.dy, ob.rise, ob.headroom, o.dome, o.gridW, o.gridH, o.flipY, o.departures].join();
515
+ const hit = liftMemo.get(key);
516
+ if (hit !== undefined) return hit;
517
+ const out = (zv) => {
518
+ const P = obliqueProjector(tile, zv, o);
519
+ const base = (tile.z ?? 0) + (tile.floor ?? 0) + (tile.fxz ?? 0), top = base + cubeHeight(tile);
520
+ const unit = o.unit ?? 1, flip = o.flipY === false ? -1 : 1;
521
+ let left = true, right = true, above = true, below = true;
522
+ for (const gz of [base, top]) for (const [gx, gy] of [[tile.x, tile.y], [tile.x + tile.s, tile.y], [tile.x, tile.y + tile.s], [tile.x + tile.s, tile.y + tile.s]]) {
523
+ const p = P(gx, gy, gz);
524
+ const cx = p.x / unit, row = -flip * p.y / unit; // back into the panel's grid units
525
+ if (cx > v.x0 - 1) left = false;
526
+ if (cx < v.x1 + 1) right = false;
527
+ if (row < v.y1 + 1) above = false;
528
+ if (row > v.y0 - 1) below = false;
529
+ }
530
+ return left || right || above || below;
531
+ };
532
+ for (let i = 0; i < 80 && !out(h); i++) h += Math.max(1, h * 0.1);
533
+ if (liftMemo.size > 20000) liftMemo.clear();
534
+ liftMemo.set(key, h);
535
+ return h;
536
+ }
537
+
538
+ // A cube's drawn extent at rest, the panel, and how far the cube moves across
539
+ // (dX) and up (dY) the panel per unit of flight along the sphere's normal.
540
+ function flightFrame(tile, o = {}) {
541
+ const { ox = 0.15, oy = 0.36, headroom = 10 } = o.oblique || {};
542
+ const head = headroom + (o.dome || 0);
543
+ const W = o.gridW || 0, H = o.gridH || 0;
544
+ const v = o.viewRect ?? { x0: -head * ox, x1: W + head * ox, y0: -head * oy, y1: H + head * oy };
545
+ const cx = tile.x + tile.s / 2, cy = tile.y + tile.s / 2;
546
+ const n = flightDir(cx, cy, o);
547
+ const flip = o.flipY === false ? -1 : 1;
548
+ // the lean where it stands, and the most it can have by the end of the flight (obliqueLean is
549
+ // bounded by +/-ox), taken in the direction it is travelling: the conservative case
550
+ const leanRest = obliqueLean(cx, o);
551
+ const lean = n.x + leanRest * n.z >= 0 ? ox : -ox;
552
+ const dX = n.x + lean * n.z, dY = flip * n.y + oy * n.z;
553
+ const c = capZ(cx, cy, o), s = tile.s, reach = cubeHeight(tile);
554
+ const x0 = tile.x + Math.min(c * lean, (c + reach) * lean);
555
+ const x1 = tile.x + s + Math.max(c * lean, (c + reach) * lean);
556
+ const up0 = (flip > 0 ? tile.y : -(tile.y + s)) + c * oy, up1 = up0 + s + reach * oy;
557
+ return { v, dX, dY, x0, x1, up0, up1 };
558
+ }
559
+
560
+ // How high a cube can fly at its spot before any part of it reaches the
561
+ // panel's edge (half a unit short of it): the ceiling visualBase compresses
562
+ // its flight into, so a flight is as high as the panel allows and no higher.
563
+ export function flightRoom(tile, o = {}) {
564
+ const { v, dX, dY, x0, x1, up0, up1 } = flightFrame(tile, o);
565
+ const M = 0.5;
566
+ let h = Infinity;
567
+ if (dX > 1e-6) h = Math.min(h, (v.x1 - M - x1) / dX);
568
+ if (dX < -1e-6) h = Math.min(h, (x0 - (v.x0 + M)) / -dX);
569
+ if (dY > 1e-6) h = Math.min(h, (v.y1 - M - up1) / dY);
570
+ if (dY < -1e-6) h = Math.min(h, (up0 - (v.y0 + M)) / -dY);
571
+ return h;
572
+ }
573
+
574
+ export function liftProjector(tile, o = {}) {
575
+ const z = tile.z ?? 0;
576
+ // the camera's lean belongs to the block, not to each corner of it: settle it once per cube so
577
+ // it stays rigid however wide it is, and take it where the cube IS -- a block in flight has
578
+ // moved along the sphere's normal, and a lean from the slot it left is the wrong lean (see
579
+ // obliqueLean)
580
+ if (o.oblique) return obliqueProjector(tile, z > 0 ? visualBase(tile, o) : 0, o);
581
+ return liftProjectorAt(tile, o, z);
582
+ }
583
+
584
+ // The oblique projector for a cube whose base is DRAWN at `zv` -- split out of liftProjector so
585
+ // offscreenLift can ask where a cube would be drawn at a trial height without going back through
586
+ // visualBase, which is what calls it.
587
+ function obliqueProjector(tile, zv, o) {
588
+ const z = tile.z ?? 0;
589
+ const g = flightGeom(tile, zv, o);
590
+ // draw with the SHAPE lean (re-settled each frame), then put the cube's centre back on the
591
+ // departure mode's PATH. The lean enters project()'s x term only, so a single x offset moves
592
+ // the whole cube rigidly -- its faces keep the shape the camera gives them at this height.
593
+ // At rest the two leans are equal and this is exactly the shipped resting projection.
594
+ o = { ...o, leanFixed: g.lean };
595
+ if (g.leanPath !== g.lean) {
596
+ const cx = tile.x + tile.s / 2, cy = tile.y + tile.s / 2;
597
+ const inner = liftProjectorAt(tile, o, z, zv);
598
+ const onPath = liftProjectorAt(tile, { ...o, leanFixed: g.leanPath }, z, zv);
599
+ const dx = onPath(cx, cy, z).x - inner(cx, cy, z).x;
600
+ if (dx) return (gx, gy, gz) => { const p = inner(gx, gy, gz); return { x: p.x + dx, y: p.y }; };
601
+ return inner;
602
+ }
603
+ return liftProjectorAt(tile, o, z, zv);
604
+ }
605
+
606
+ // the projection with the lean already settled onto `o` -- split out so liftProjector can evaluate
607
+ // it twice, once for the cube's shape and once for the path its centre must follow. `zv` is the
608
+ // drawn base height when the caller already has it (obliqueProjector); otherwise visualBase.
609
+ function liftProjectorAt(tile, o, z, zvGiven) {
610
+ if (!(z > 0)) return (gx, gy, gz) => project(gx, gy, gz, o);
611
+ if (o.oblique) {
612
+ // flight goes along the sphere's normal at the block's centre, so the
613
+ // board's blocks leave and arrive fanned out from its middle; the cube
614
+ // itself stays upright. project() adds the sphere's height where a point
615
+ // is DRAWN, so the difference to where it stands is put back: the flight
616
+ // is measured from the block's own spot on the sphere.
617
+ const zv = zvGiven ?? visualBase(tile, o);
618
+ const off = flightOffset(tile.x + tile.s / 2, tile.y + tile.s / 2, zv, o);
619
+ const sx = off.x, sy = off.y, sz = off.z;
620
+ if (!sx && !sy) return (gx, gy, gz) => project(gx, gy, sz + (gz - z), o);
621
+ return (gx, gy, gz) => project(gx + sx, gy + sy, sz + (gz - z) + capZ(gx, gy, o) - capZ(gx + sx, gy + sy, o), o);
622
+ }
623
+ const { risePerUnit = 0.075, persp = 0.55 } = o;
624
+ const { vanishX = 0, vanishY = 0 } = o;
625
+ const boost = Number.isFinite(tile.boost) ? tile.boost : liftBoost(z);
626
+ const { dx, dy } = airTop(tile, boost, o);
627
+ return (gx, gy, gz) => {
628
+ const h = gz - z; // height within the block: 0 at its base, its side on top
629
+ const f = project(gx, gy, 0, o);
630
+ // a real pinhole: position AND size scale about the vanishing point; at
631
+ // boost 1 this is exactly the resting projection, so touchdown is seamless
632
+ const k = (1 + h * risePerUnit * persp) * boost;
633
+ return { x: vanishX + (f.x - vanishX) * k + dx, y: vanishY + (f.y - vanishY) * k + dy };
634
+ };
635
+ }
636
+
637
+ // AIRBORNE BLOCKS DO NOT GROW INTO EACH OTHER (operator: "the large bouncing
638
+ // blocks intersect each other when growing in size. We need to find a
639
+ // mitigation for that"). Growing each block about its own centre is what
640
+ // keeps it straight over its slot, but it is not a real projection: under a
641
+ // true pinhole two blocks at the same height scale APART together and never
642
+ // meet, while two neighbours swelling in place run into each other. So each
643
+ // frame the airborne blocks are placed highest first, and each one grows only
644
+ // as far as it can without touching a block already placed -- never below its
645
+ // true size, which is always clear, because in the drop every block is in its
646
+ // own column. A pair whose FOOTPRINTS overlap (a high lane crossing a low one
647
+ // in travel) is exempt: there one really is above the other, and the paint
648
+ // order already says so. Growing over RESTING blocks is left alone: that is
649
+ // what something above them looks like, and the offset shadow says so.
650
+ export function settleGrowth(tiles, o = {}) {
651
+ const air = tiles.filter((t) => (t.z ?? 0) > 0.02).sort((p, q) => (q.z - p.z) || String(p.txid).localeCompare(String(q.txid)));
652
+ const out = new Map();
653
+ if (air.length < 2) { for (const t of air) out.set(t, liftBoost(t.z)); return out; }
654
+ const feet = (p, q) => p.x < q.x + q.s && q.x < p.x + p.s && p.y < q.y + q.s && q.y < p.y + p.s;
655
+ const hits = (A, B) => Math.abs(A.x - B.x) < A.half + B.half - 1e-9 && Math.abs(A.y - B.y) < A.half + B.half - 1e-9;
656
+ // Everyone starts at TRUE size, which is always clear: at g = 1 an airborne
657
+ // top is exactly a resting top, and resting tops of disjoint footprints do
658
+ // not overlap. Each block, highest first, then grows as far as it can
659
+ // without touching any other -- the ones already placed at their grown
660
+ // size, the rest at true size -- so every later block still has its true
661
+ // size to fall back on. (A first cut only looked at blocks already placed;
662
+ // the highest then grew freely over a lower one that could not shrink.)
663
+ // resting blocks are obstacles too, at their true size, for blocks near the plane
664
+ const ground = tiles.filter((t) => !((t.z ?? 0) > 0.02));
665
+ const box = new Map([...air, ...ground].map((t) => [t, airTop(t, 1, o)]));
666
+ // With CUBES, true size is not always clear: a taller cube's top is nearer
667
+ // the camera and drawn larger, so it covers the edge of a shorter neighbour.
668
+ // That is correct occlusion, painted in the right order by top height. What
669
+ // is policed is overlap that GROWTH adds, so a pair already overlapping at
670
+ // true size is exempt, like a crossing.
671
+ const rest1 = new Map(box);
672
+ for (const t of air) {
673
+ const want = liftBoost(t.z);
674
+ const A1 = box.get(t);
675
+ const Aw = airTop(t, want, o);
676
+ // Through a real camera a growing block also TRAVELS, out along its ray,
677
+ // so a neighbour can be touched at true size, passed through on the way,
678
+ // and clear again at full growth. The first cut only checked full growth,
679
+ // missed exactly that neighbour, and was measured overlapping it at
680
+ // g = 1.07. So the watch list is everything touching the whole SWEPT box
681
+ // (the centre moves monotonically and the size grows, so the boxes in
682
+ // between stay inside the bounds of the two ends, edge hold included).
683
+ const sw = {
684
+ x0: Math.min(A1.x - A1.half, Aw.x - Aw.half), x1: Math.max(A1.x + A1.half, Aw.x + Aw.half),
685
+ y0: Math.min(A1.y - A1.half, Aw.y - Aw.half), y1: Math.max(A1.y + A1.half, Aw.y + Aw.half),
686
+ };
687
+ const inSweep = (B) => B.x + B.half > sw.x0 + 1e-9 && B.x - B.half < sw.x1 - 1e-9 && B.y + B.half > sw.y0 + 1e-9 && B.y - B.half < sw.y1 - 1e-9;
688
+ // EVERY airborne pair is policed, whatever their heights. Letting a block
689
+ // more than a slab higher overlap a lower one (true occlusion, in
690
+ // principle) was measured at ~750 overlapping pairs per frame on a
691
+ // realistic refresh, and read as a heap (operator: "There is way too much
692
+ // shit overlapping in 3D space"). And near the plane it is policed against
693
+ // RESTING blocks too: a low bounce swelling over its neighbours reads as
694
+ // sinking into them. Only a pair whose footprints genuinely overlap -- one
695
+ // crossing over the other in travel -- is exempt; nothing can separate those.
696
+ const obstacles = t.z < 2 ? [...air, ...ground] : air;
697
+ const near = obstacles.filter((q) => q !== t && !feet(t, q) && !hits(A1, rest1.get(q)) && inSweep(box.get(q)));
698
+ let g = want;
699
+ if (near.length) {
700
+ // Grow only up to the FIRST contact, never beyond it even where it would
701
+ // be clear again: otherwise a block could pop over its neighbour from one
702
+ // frame to the next as its height changes. Stepped, then refined.
703
+ const clear = (k) => { const B = airTop(t, k, o); return near.every((q) => !hits(B, box.get(q))); };
704
+ const STEPS = 32;
705
+ let lo = 1, hi = null;
706
+ for (let i = 1; i <= STEPS; i++) {
707
+ const k = 1 + ((want - 1) * i) / STEPS;
708
+ if (clear(k)) lo = k; else { hi = k; break; }
709
+ }
710
+ if (hi !== null) for (let i = 0; i < 14; i++) { const mid = (lo + hi) / 2; if (clear(mid)) lo = mid; else hi = mid; }
711
+ g = lo;
712
+ }
713
+ box.set(t, airTop(t, g, o));
714
+ out.set(t, g);
715
+ }
716
+ return out;
717
+ }
718
+
719
+ // Painter's depth: rows draw back to front, so a nearer block's lip overlaps
720
+ // the one behind it. One axis, because there is only one now.
721
+ export function depthOf(tile, o = {}) {
722
+ // nearer the viewer means lower on screen: with the rows flipped that is
723
+ // the SMALLER row index, so the order reverses with the axis
724
+ return (o.flipY === false) ? (tile.y + tile.s) : -tile.y;
725
+ }
726
+
727
+ export function depthSort(tiles, o = {}) {
728
+ return [...tiles]
729
+ .map((t) => ({ t, d: depthOf(t, o), z: t.z ?? 0 }))
730
+ // lowest paints first: a block risen toward the viewer is in front of
731
+ // everything still on the plane
732
+ .sort((a, b) => (a.z - b.z) || (a.d - b.d) || String(a.t.txid).localeCompare(String(b.t.txid)))
733
+ .map((e) => e.t);
734
+ }
735
+
736
+ // --- shading -----------------------------------------------------------
737
+ // Between two '#rrggbb' colours, k of the way from a to b. Anything else (a missing colour, a
738
+ // named one) cannot be blended, so it switches at the midpoint rather than at either end.
739
+ export function mixColor(a, b, k) {
740
+ if (a === b || k >= 1) return b;
741
+ if (k <= 0) return a;
742
+ const hex = /^#([0-9a-f]{6})$/i;
743
+ const ma = hex.exec(String(a)), mb = hex.exec(String(b));
744
+ if (!ma || !mb) return k < 0.5 ? a : b;
745
+ const pa = parseInt(ma[1], 16), pb = parseInt(mb[1], 16);
746
+ const ch = (sh) => Math.round(((pa >> sh) & 255) + ((((pb >> sh) & 255) - ((pa >> sh) & 255)) * k));
747
+ return `#${((ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).padStart(6, '0')}`;
748
+ }
749
+
750
+ // Alpha rides inside rgba(), never through globalAlpha.
751
+ export function shade(hex, k, alpha = 1) {
752
+ const h = String(hex).replace('#', '');
753
+ const cl = (v) => Math.max(0, Math.min(255, Math.round(v)));
754
+ const r = cl(parseInt(h.slice(0, 2), 16) * k);
755
+ const g = cl(parseInt(h.slice(2, 4), 16) * k);
756
+ const b = cl(parseInt(h.slice(4, 6), 16) * k);
757
+ return `rgba(${r},${g},${b},${alpha})`;
758
+ }
759
+
760
+ // --- one slab ----------------------------------------------------------
761
+ // With a free yaw we cannot know in advance which sides face the camera, so
762
+ // all four are emitted and sorted back-to-front within the tile. The
763
+ // overdraw is two quads and it removes a whole class of bug.
764
+ // --- one block ---------------------------------------------------------
765
+ //
766
+ // THE BLOCK IS A TETRIS CELL WITH A CYBER FINISH (operator, 2026-09-11: "That
767
+ // bejewelled shit with the sparkles isn't working out for me. Consider a more
768
+ // tetris look for our blocks, but think cyber/futuristic. maybe even metallic
769
+ // sheens"). The gem cut tried earlier the same day -- chamfered octagons,
770
+ // crown facets, glints and stars -- is gone; recorded so it is not re-added
771
+ // as a flourish. All of this is solid polygons in paint order:
772
+ //
773
+ // lip the front face along the near edge, in shadow
774
+ // top the square, with the dark seam stroked round it
775
+ // bevel the raised frame of a Tetris cell, lit from the upper left:
776
+ // far edge brightest, left lit, right dim, near edge darkest
777
+ // face the cell's face, in its feerate colour
778
+ // well the hollow in the middle of the face: four walls lit the OTHER
779
+ // way round (the far wall in its own shadow, the near wall
780
+ // catching the light) around a darker floor -- the inner square
781
+ // of the Tetris reference (operator: "The blocks don't have that
782
+ // hollow inner area")
783
+ // rim a thin line round the well's lip in a lifted tint: the neon edge
784
+ // Diagonal sheen streaks and a gloss band were tried the same day and removed
785
+ // ("I don't like the diagonal lines in our large block design").
786
+ //
787
+ // A mid-sized block has the frame and face; a tiny one is a plain slab. The
788
+ // feerate colour is untouched -- every layer is derived from it, or is white
789
+ // at low alpha laid over it.
790
+ export function blockBevel(s) { return Math.min(0.7, Math.max(0.12, s * 0.15)); }
791
+
792
+ export function tileFaces(tile, o = {}, cut = false) {
793
+ const { x, y, s } = tile;
794
+ // the plane or the flight altitude, plus a resting altitude (`floor`: a market candle floats at
795
+ // its price) and any idle-effect lift
796
+ const base = (tile.z ?? 0) + (tile.floor ?? 0) + (tile.fxz ?? 0);
797
+ const top = base + cubeHeight(tile);
798
+ const P = liftProjector(tile, o);
799
+
800
+ // A CUBE SHOWS THE SIDES THAT FACE THE CAMERA. The camera is a pinhole over
801
+ // the vanishing point, so a cube's top is pushed outward from it and the
802
+ // side faces turned TOWARD the vanishing point come into view -- up to two
803
+ // of them, none for a cube dead under the camera. A side is drawn when its
804
+ // outward normal on screen points at the vanishing point.
805
+ const topQuad = [P(x, y, top), P(x + s, y, top), P(x + s, y + s, top), P(x, y + s, top)];
806
+ const foot = [P(x, y, base), P(x + s, y, base), P(x + s, y + s, base), P(x, y + s, base)];
807
+ const vx = o.vanishX ?? 0, vy = o.vanishY ?? 0;
808
+ const fcx = (foot[0].x + foot[2].x) / 2, fcy = (foot[0].y + foot[2].y) / 2;
809
+ const sides = [];
810
+ for (const [i, j, key] of [[0, 1, 'near'], [1, 2, 'right'], [2, 3, 'far'], [3, 0, 'left']]) {
811
+ const A = foot[i], B = foot[j];
812
+ const mx = (A.x + B.x) / 2, my = (A.y + B.y) / 2;
813
+ let nx = B.y - A.y, ny = A.x - B.x;
814
+ if ((mx - fcx) * nx + (my - fcy) * ny < 0) { nx = -nx; ny = -ny; } // make it point outward
815
+ const len = Math.hypot(nx, ny) || 1;
816
+ // A side is seen when the top edge has been displaced AGAINST the side's
817
+ // outward normal -- the face then spans the gap between footprint and top.
818
+ // Under the pinhole that is exactly "faces the vanishing point"; under the
819
+ // oblique camera it is the west and south faces. One rule for both.
820
+ const tmx = (topQuad[i].x + topQuad[j].x) / 2, tmy = (topQuad[i].y + topQuad[j].y) / 2;
821
+ if (nx * (tmx - mx) + ny * (tmy - my) < -1e-9) {
822
+ sides.push({ key, nx: nx / len, ny: ny / len, points: [topQuad[i], topQuad[j], B, A] });
823
+ }
824
+ }
825
+ const out = { top: topQuad, sides, base, topZ: top, P };
826
+ if (!cut) return out;
827
+ const b = blockBevel(s);
828
+ out.bevel = b;
829
+ out.innerG = [[x + b, y + b], [x + s - b, y + b], [x + s - b, y + s - b], [x + b, y + s - b]];
830
+ out.inset = out.innerG.map(([gx, gy]) => P(gx, gy, top));
831
+ return out;
832
+ }
833
+
834
+ // Blend a hex colour toward white by t, alpha riding inside the rgba() --
835
+ // never through globalAlpha.
836
+ export function lift(hex, t, alpha = 1) {
837
+ const h = String(hex).replace('#', '');
838
+ const ch = (i) => parseInt(h.slice(i, i + 2), 16);
839
+ const m = (v) => Math.max(0, Math.min(255, Math.round(v + (255 - v) * t)));
840
+ return `rgba(${m(ch(0))},${m(ch(2))},${m(ch(4))},${alpha})`;
841
+ }
842
+
843
+ const round3 = (v) => Math.round(v * 1000) / 1000;
844
+ const flipOf = (o) => (o.flipY === false ? -1 : 1);
845
+
846
+ // On the curved board each block is lit by the SLOPE of the surface under it
847
+ // -- light from the upper left and above -- which is what makes a curve read
848
+ // as a curve. 1 on a flat board, so nothing changes when the dome is off.
849
+ // WHERE THE LAMP IS (operator, 2026-09-12: "The bottom of the tetrust board is too [dark]. We
850
+ // need direct overhead lighting in teh 3d scene for the game" ... "Move the block space light to
851
+ // be directly above the board centered ... we should add configurable light locations in
852
+ // settings"). Each lamp is a direction over the board for the dome's slope shading (grid x,
853
+ // screen-up rows, up) and a direction across the screen for the side faces (x right, y down);
854
+ // `overhead` is straight above, so no slope is in shade and every side takes the same light.
855
+ export const LIGHTS = Object.freeze({
856
+ 'overhead': { L: [0, 0, 1], side: [0, 0] },
857
+ 'upper-left': { L: [-0.55, 0.55, 0.63], side: [-0.7071, -0.7071] },
858
+ 'upper-right': { L: [0.55, 0.55, 0.63], side: [0.7071, -0.7071] },
859
+ 'front': { L: [0, -0.55, 0.63], side: [0, 0.9] },
860
+ });
861
+ export const LIGHT_DEFAULT = 'upper-left';
862
+ const NEON_HEX = /^#[0-9a-f]{6}$/i;
863
+ export function lightOf(o = {}) {
864
+ return LIGHTS[o.light] ? o.light : o.overheadLight === true ? 'overhead' : LIGHT_DEFAULT;
865
+ }
866
+ export function domeLight(t, o = {}) {
867
+ const { dome = 0, gridW = 0, gridH = 0 } = o;
868
+ if (!dome || !gridW || !gridH) return 1;
869
+ const lamp = LIGHTS[lightOf(o)];
870
+ // straight above: the slope shades nothing -- the bottom rows, which lean away from a corner
871
+ // lamp and sat at the 0.6 floor, read as bright as the middle
872
+ if (lamp.L[0] === 0 && lamp.L[1] === 0) return 1;
873
+ const u = (2 * (t.x + t.s / 2)) / gridW - 1, v = (2 * (t.y + t.s / 2)) / gridH - 1;
874
+ const gx = (dome * -2 * u * (1 - v * v) * 2) / gridW; // dz/dx in grid units
875
+ const gy = (dome * (1 - u * u) * -2 * v * 2) / gridH; // dz/dy
876
+ const up = o.flipY === false ? -1 : 1; // screen-up in grid rows
877
+ const L = [lamp.L[0], lamp.L[1] * up, lamp.L[2]];
878
+ const d = (-gx * L[0] + -gy * L[1] + L[2]) / Math.hypot(gx, gy, 1);
879
+ return Math.max(0.6, Math.min(1.35, d / L[2]));
880
+ }
881
+
882
+ // THE BOARD AT REST (operator, 2026-09-11: "There needs to be an occasional
883
+ // energy ripple or other effect that sweeps the grid when the blocks are at
884
+ // rest", then "add varied effects. More than a pulse. Maybe add an energy pulse
885
+ // that travels along from one side of the board to another via block outlines.
886
+ // Be creative and varied"). One effect at a time, chosen by the renderer:
887
+ //
888
+ // ripple a ring of light spreading from a point
889
+ // outline an energy front crossing the board, lighting each cube's OUTLINE
890
+ // as it passes and leaving a fading trail
891
+ // tide a wave that LIFTS the cubes as it passes
892
+ // cascade a gold flash running from the richest transaction to the cheapest
893
+ // -- the effect is also a reading of the fee structure
894
+ // twinkle scattered cubes glint, each at its own moment
895
+ // scan a bright line sweeping across the board
896
+ //
897
+ // fx = { kind, u (progress 0..1), amp (fade in/out), gridW, gridH, dx, dy,
898
+ // rank (cascade), seed (twinkle), x, y, r, w (ripple) }.
899
+ // Returns { glow 0..1, outline 0..1, lift (units), color [r,g,b] }. Pure, so it
900
+ // is tested directly; only resting blocks are touched.
901
+ // `hide` and `scale` are how an effect may ALTER THE BOARD and have it snap back (operator,
902
+ // 2026-09-13, asked whether effects may eat cubes: "Allow it, it snaps back"). They are applied
903
+ // per frame onto a COPY of the tile, exactly as `lift` already is -- nothing here ever mutates
904
+ // st.restTiles -- so when the effect ends, is interrupted by a transition, or the tab is hidden,
905
+ // the override simply stops being computed and the board is correct again by construction. That
906
+ // is the only version of "snaps back" that cannot leak.
907
+ // hide 1 = the cube is not drawn at all this frame (absorbed, dug through, collapsed away)
908
+ // scale 1 = full height; below 1 the cube is drawn shorter, for a collapse or a dig
909
+ export const FX_NONE = Object.freeze({ glow: 0, outline: 0, lift: 0, color: null, hide: 0, scale: 1 });
910
+
911
+ // where a sweeping front is, along (dx, dy), in grid units -- entering from
912
+ // just outside one side of the board and leaving past the other
913
+ export function fxFront(fx) {
914
+ const qs = [0, fx.gridW * fx.dx, fx.gridH * fx.dy, fx.gridW * fx.dx + fx.gridH * fx.dy];
915
+ const lo = Math.min(...qs) - 4, hi = Math.max(...qs) + 4;
916
+ return lo + fx.u * (hi - lo);
917
+ }
918
+
919
+ // a deterministic 0..1 from an integer: where a firework bursts, which cube flares. Hashed, not
920
+ // random, so an effect draws the same picture every time it replays -- which is what lets the
921
+ // tests above assert on it at all.
922
+ export function fxHash(n) {
923
+ const x = Math.sin((n | 0) * 12.9898 + 78.233) * 43758.5453;
924
+ return x - Math.floor(x);
925
+ }
926
+
927
+ export function fxAt(t, fx) {
928
+ if (!fx) return FX_NONE;
929
+ const A = fx.amp ?? 1;
930
+ const cx = t.x + t.s / 2, cy = t.y + t.s / 2;
931
+ const g = (k) => Math.exp(-k * k);
932
+ const along = () => cx * fx.dx + cy * fx.dy;
933
+ switch (fx.kind) {
934
+ case 'ripple': {
935
+ const k = (Math.hypot(cx - fx.x, cy - fx.y) - fx.r) / Math.max(0.5, fx.w);
936
+ return { glow: A * g(k), outline: 0.6 * A * g(k), lift: 0, color: [120, 255, 210] };
937
+ }
938
+ case 'outline': {
939
+ const q = along(), p = fxFront(fx);
940
+ const lead = g((q - p) / 3);
941
+ const trail = q < p ? 0.6 * Math.exp(-(p - q) / 9) : 0;
942
+ return { glow: 0.45 * A * lead, outline: A * Math.min(1, lead + trail), lift: 0, color: [90, 230, 255] };
943
+ }
944
+ case 'tide': {
945
+ const w = g((along() - fxFront(fx)) / 3.2);
946
+ return { glow: 0.6 * A * w, outline: 0.35 * A * w, lift: 2.2 * A * w, color: [140, 255, 180] };
947
+ }
948
+ case 'cascade': {
949
+ const r = fx.rank?.get(t.txid);
950
+ if (r == null) return FX_NONE;
951
+ const w = g((r - (fx.u * 1.25 - 0.1)) / 0.12);
952
+ return { glow: A * w, outline: 0.9 * A * w, lift: 0.6 * A * w, color: [255, 214, 120] };
953
+ }
954
+ case 'twinkle': {
955
+ if (jitterOf(t.txid, 'tw' + fx.seed) > 0.45) return FX_NONE;
956
+ const w = g((fx.u - 0.05 - jitterOf(t.txid, 'tt' + fx.seed) * 0.85) / 0.05);
957
+ return { glow: A * w, outline: 0.8 * A * w, lift: 0, color: [255, 255, 255] };
958
+ }
959
+ case 'scan': {
960
+ const w = g((along() - fxFront(fx)) / 1.6);
961
+ return { glow: 0.8 * A * w, outline: 0.5 * A * w, lift: 0, color: [120, 220, 255] };
962
+ }
963
+ case 'ball': {
964
+ // "illuminating everything it comes near": brightest right under the ball, gone ~5 units off
965
+ const b = fx.ball;
966
+ if (!b) return FX_NONE;
967
+ const ddx = Math.max(t.x - b.x, 0, b.x - (t.x + t.s)), ddy = Math.max(t.y - b.y, 0, b.y - (t.y + t.s));
968
+ const w = g(Math.hypot(ddx, ddy) / 2.6);
969
+ return w > 0.02 ? { glow: 0.95 * w, outline: 0.9 * w, lift: 0, color: [170, 225, 255] } : FX_NONE;
970
+ }
971
+ // ---------------------------------------------------------------------------------------
972
+ // THE ARCADE (operator, 2026-09-12: "Think of many more other video-game inspired effects ...
973
+ // at least 25 total different effects, all toggleable"). Every one below is a PURE function of
974
+ // the tile and the effect's clock -- no state is kept, nothing is allocated per frame, and the
975
+ // board-level choices (where a firework bursts, which tile flares) are hashed out of fx.seed,
976
+ // so an effect replays identically and can be asserted here rather than watched.
977
+ //
978
+ // `reach` is the distance from the effect's origin to the farthest corner: a radial effect
979
+ // that covers the board in the time it is given, whatever the board's shape.
980
+ case 'shockwave': {
981
+ // Smash-style: a hard ring that throws the cubes it passes UP, not a soft glow
982
+ const reach = Math.hypot(Math.max(fx.x, fx.gridW - fx.x), Math.max(fx.y, fx.gridH - fx.y));
983
+ const r = reach * (1 - Math.pow(1 - fx.u, 1.7));
984
+ const k = (Math.hypot(cx - fx.x, cy - fx.y) - r) / 1.7;
985
+ const w = g(k);
986
+ return { glow: 0.85 * A * w, outline: A * w, lift: 4.5 * A * w, color: [255, 240, 190] };
987
+ }
988
+ case 'nova': {
989
+ // an implosion that snaps back out: the ring races IN to the middle over the first half,
990
+ // then out again, brighter and whiter, over the second
991
+ const reach = Math.hypot(Math.max(fx.x, fx.gridW - fx.x), Math.max(fx.y, fx.gridH - fx.y));
992
+ const inward = fx.u < 0.5;
993
+ const v = inward ? fx.u / 0.5 : (fx.u - 0.5) / 0.5;
994
+ const r = inward ? reach * (1 - Math.pow(v, 0.7)) : reach * Math.pow(v, 0.6);
995
+ const w = g((Math.hypot(cx - fx.x, cy - fx.y) - r) / (inward ? 2.2 : 1.5));
996
+ const hot = inward ? 0.55 : 1;
997
+ return { glow: hot * A * w, outline: 0.8 * hot * A * w, lift: (inward ? 0.4 : 3.2) * A * w,
998
+ color: inward ? [150, 190, 255] : [255, 255, 245] };
999
+ }
1000
+ case 'firework': {
1001
+ // three bursts, each at its own moment and place, each a ring that expands and dies
1002
+ let glow = 0, outline = 0, lift = 0, col = [255, 200, 120];
1003
+ for (let i = 0; i < 3; i++) {
1004
+ const t0 = 0.05 + 0.26 * i, life = 0.45;
1005
+ const v = (fx.u - t0) / life;
1006
+ if (!(v > 0 && v < 1)) continue;
1007
+ const bx = fxHash(fx.seed + i * 31 + 1) * fx.gridW, by = fxHash(fx.seed + i * 31 + 2) * fx.gridH;
1008
+ const r = 9 * Math.pow(v, 0.55);
1009
+ const w = g((Math.hypot(cx - bx, cy - by) - r) / 1.4) * (1 - v);
1010
+ if (w > glow) {
1011
+ glow = w; outline = 0.9 * w; lift = 1.8 * w;
1012
+ col = [[255, 170, 110], [140, 220, 255], [220, 160, 255]][i];
1013
+ }
1014
+ }
1015
+ return glow > 0.02 ? { glow: A * glow, outline: A * outline, lift: A * lift, color: col } : FX_NONE;
1016
+ }
1017
+ case 'flare': {
1018
+ // one cube goes supernova and lights its neighbourhood -- the same hashed cube every replay
1019
+ const fxp = fxHash(fx.seed + 7) * fx.gridW, fyp = fxHash(fx.seed + 8) * fx.gridH;
1020
+ const bell = Math.sin(Math.PI * fx.u);
1021
+ const d = Math.hypot(cx - fxp, cy - fyp);
1022
+ const w = g(d / (1.2 + 7 * bell)) * bell;
1023
+ return w > 0.02 ? { glow: A * w, outline: 0.8 * A * w, lift: 1.4 * A * w, color: [255, 245, 205] } : FX_NONE;
1024
+ }
1025
+ case 'wave': {
1026
+ // a swell rolling across the board: the cubes rise and fall with it, several crests at once
1027
+ const d = along() - fxFront(fx);
1028
+ const env = g(d / 7);
1029
+ const phase = Math.sin(d * 0.75);
1030
+ const up = Math.max(0, phase);
1031
+ return { glow: 0.45 * A * env * up, outline: 0.3 * A * env * up, lift: 3 * A * env * up, color: [120, 200, 255] };
1032
+ }
1033
+ case 'quake': {
1034
+ // the board shakes: every cube jumps on its own beat, hardest at the start, dying out
1035
+ const decay = Math.pow(1 - fx.u, 2);
1036
+ const j = jitterOf(t.txid, 'qk' + fx.seed);
1037
+ const shake = Math.sin(fx.u * 60 + j * 6.28);
1038
+ const up = Math.max(0, shake) * decay;
1039
+ return { glow: 0.3 * A * up, outline: 0.5 * A * up, lift: 2.4 * A * up, color: [255, 180, 140] };
1040
+ }
1041
+ case 'rain': {
1042
+ // code rain: each column has its own drop, falling from the top of the board to the floor,
1043
+ // with a white head and a fading green tail behind it
1044
+ const col = Math.floor(cx);
1045
+ const ph = fxHash(fx.seed + col * 17);
1046
+ const head = fx.gridH + 3 - (fx.u * 1.35 - ph * 0.35) * (fx.gridH + 8);
1047
+ const d = cy - head; // above the head: the tail
1048
+ if (d < -1.2) return FX_NONE;
1049
+ const w = d < 1.2 ? 1 : Math.exp(-(d - 1.2) / 3.5);
1050
+ const white = d < 1.2 ? 1 : 0;
1051
+ return { glow: A * w, outline: 0.7 * A * w, lift: 0,
1052
+ color: white ? [225, 255, 235] : [60, 235, 140] };
1053
+ }
1054
+ case 'sparkle': {
1055
+ // a constellation lighting up a few cubes at a time, each in its own colour
1056
+ const pick = jitterOf(t.txid, 'sp' + fx.seed);
1057
+ if (pick > 0.3) return FX_NONE;
1058
+ const when = jitterOf(t.txid, 'st' + fx.seed);
1059
+ const w = g((fx.u - 0.08 - when * 0.8) / 0.07);
1060
+ const hue = jitterOf(t.txid, 'sc' + fx.seed);
1061
+ const col = hue < 0.34 ? [255, 230, 160] : hue < 0.67 ? [170, 220, 255] : [235, 175, 255];
1062
+ return { glow: A * w, outline: 0.85 * A * w, lift: 0.5 * A * w, color: col };
1063
+ }
1064
+ case 'checker': {
1065
+ // the board flips like a chessboard: black squares up while white squares are down
1066
+ const dark = (Math.floor(cx) + Math.floor(cy)) & 1;
1067
+ const beat = Math.sin(fx.u * Math.PI * 4 + (dark ? Math.PI : 0));
1068
+ const up = Math.max(0, beat) * Math.sin(Math.PI * fx.u);
1069
+ // (the first cut peaked at 0.26 of full brightness -- a flip nobody would notice across a
1070
+ // board of small cubes; the squares have to actually read as lifting against each other)
1071
+ return { glow: 0.85 * A * up, outline: 0.75 * A * up, lift: 2.8 * A * up,
1072
+ color: dark ? [140, 255, 210] : [255, 210, 130] };
1073
+ }
1074
+ case 'radar': {
1075
+ // a sweep hand turning once round the board, the cubes behind it fading like phosphor
1076
+ const ang = Math.atan2(cy - fx.y, cx - fx.x);
1077
+ const hand = -Math.PI + fx.u * Math.PI * 2;
1078
+ let d = ang - hand;
1079
+ while (d < 0) d += Math.PI * 2; // 0 at the hand, 2pi just before it
1080
+ const w = d < 0.18 ? 1 : Math.exp(-(d - 0.18) * 1.6);
1081
+ return { glow: 0.8 * A * w, outline: 0.5 * A * w, lift: 0, color: [110, 255, 170] };
1082
+ }
1083
+ case 'vortex': {
1084
+ // the radar's hand, wound into a spiral: the arms turn and the whole board drains inward
1085
+ const ang = Math.atan2(cy - fx.y, cx - fx.x);
1086
+ const rad = Math.hypot(cy - fx.y, cx - fx.x);
1087
+ const arm = ang + rad * 0.42 - fx.u * Math.PI * 4;
1088
+ const d = Math.abs(((arm % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - Math.PI);
1089
+ const w = g((Math.PI - d) / 0.55) * Math.sin(Math.PI * fx.u);
1090
+ return { glow: 0.9 * A * w, outline: 0.6 * A * w, lift: 1.2 * A * w, color: [165, 150, 255] };
1091
+ }
1092
+ case 'powerup': {
1093
+ // the board charges from the floor up, gold, with a bright lip at the top of the fill
1094
+ const fill = fx.u * (fx.gridH + 4) - 2;
1095
+ const below = fill - cy;
1096
+ if (below < -1) return FX_NONE;
1097
+ const lip = g(below / 1.1);
1098
+ const held = below > 0 ? Math.min(0.55, 0.55 * Math.pow(1 - fx.u, 0.6)) : 0;
1099
+ const w = Math.min(1, lip + held);
1100
+ return { glow: A * w, outline: A * lip, lift: 1.6 * A * lip,
1101
+ color: lip > 0.4 ? [255, 250, 220] : [255, 200, 90] };
1102
+ }
1103
+ case 'combo': {
1104
+ // a chain reaction running the diagonal, each link popping as it is reached
1105
+ const q = (cx + cy) / (fx.gridW + fx.gridH);
1106
+ const v = fx.u * 1.2 - 0.1;
1107
+ const w = g((q - v) / 0.05);
1108
+ const step = Math.floor(q * 12);
1109
+ const col = [[255, 120, 90], [255, 190, 80], [120, 255, 160], [110, 200, 255]][step & 3];
1110
+ return { glow: A * w, outline: A * w, lift: 2.2 * A * w, color: col };
1111
+ }
1112
+ case 'aurora': {
1113
+ // slow curtains of colour drifting over the board, brightest where they fold
1114
+ const band = Math.sin(cx * 0.24 + fx.u * 5) + Math.sin(cy * 0.17 - fx.u * 3.4);
1115
+ const w = Math.max(0, band) * 0.5 * Math.sin(Math.PI * fx.u);
1116
+ const mix = (band + 2) / 4;
1117
+ const col = [Math.round(80 + 60 * mix), Math.round(200 + 40 * mix), Math.round(255 - 70 * mix)];
1118
+ return { glow: 0.8 * A * w, outline: 0.25 * A * w, lift: 0.8 * A * w, color: col };
1119
+ }
1120
+ case 'plasma': {
1121
+ // the demoscene plasma: three sines over the board, the colour cycling with the clock
1122
+ const v = Math.sin(cx * 0.32) + Math.sin(cy * 0.29) + Math.sin((cx + cy) * 0.19 + fx.u * 7);
1123
+ const w = Math.max(0, v / 3) * Math.sin(Math.PI * fx.u);
1124
+ const ph = (v + 3) / 6;
1125
+ const col = [Math.round(140 + 115 * ph), Math.round(90 + 140 * (1 - ph)), Math.round(190 + 60 * ph)];
1126
+ return { glow: 0.85 * A * w, outline: 0.2 * A * w, lift: 0, color: col };
1127
+ }
1128
+ case 'lightcycle':
1129
+ case 'packets':
1130
+ case 'centipede':
1131
+ case 'tractor':
1132
+ case 'missile':
1133
+ case 'boulderdash':
1134
+ case 'stormball': {
1135
+ let best = FX_NONE, bw = 0;
1136
+ for (const hd of fx.heads ?? []) {
1137
+ const ddx = Math.max(t.x - hd.x, 0, hd.x - (t.x + t.s)), ddy = Math.max(t.y - hd.y, 0, hd.y - (t.y + t.s));
1138
+ const w = (hd.alpha ?? 1) * g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.r ?? 0.8));
1139
+ if (w > bw && w > 0.02) {
1140
+ bw = w;
1141
+ // HIDE AND SCALE TRAVEL WITH THE HEAD, or the machinery for them is unreachable. These
1142
+ // were hardcoded to "visible, full size" when the branch was written, which meant an
1143
+ // agent could ask to absorb or collapse a cube and nothing whatever would happen --
1144
+ // katamari, boulder dash and lemmings would all have drawn a glow and called it eating.
1145
+ // `hide` is a threshold rather than a blend: a cube is drawn or it is not, and a head
1146
+ // only hides what it has genuinely reached (w near 1), never what it is merely near.
1147
+ // ALTERATION IS GATED ON REACH, NOT ON ALPHA. `w` folds the head's alpha into its
1148
+ // distance falloff, which is right for LIGHTING -- a dim head lights dimly -- and wrong
1149
+ // for hiding: boulder dash fades its ring out as it collapses (alpha 0.8 * shrink), so
1150
+ // by the time a cube should vanish the alpha is near zero and the hide was cancelled by
1151
+ // the very fade that meant it was working. Measured: 0 cubes hidden over a whole run.
1152
+ // So the physical effect uses PROXIMITY alone and the visual effect keeps using w.
1153
+ const reach = g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.r ?? 0.8));
1154
+ best = {
1155
+ glow: 0.85 * w, outline: 0.8 * w, lift: (hd.lift ?? 0) * w, color: hd.color,
1156
+ hide: (hd.hide ?? 0) > 0 && reach > 0.45 ? 1 : 0,
1157
+ scale: hd.scale == null ? 1 : 1 - (1 - hd.scale) * reach,
1158
+ };
1159
+ }
1160
+ }
1161
+ return best;
1162
+ }
1163
+ default: return FX_NONE;
1164
+ }
1165
+ }
1166
+
1167
+ // --- the scene ---------------------------------------------------------
1168
+ //
1169
+ // Level of detail: `facetMinUnits` (frame + face) and `crownMinUnits` (gloss,
1170
+ // sheen, rim) are grid-unit sides below which a block is drawn plainer. The
1171
+ // renderer derives both from the constant board transform, so a block's detail
1172
+ // cannot change between frames.
1173
+ //
1174
+ // SHADOWS FROM ABOVE (operator: "consider casting shadows from above on the
1175
+ // movements"). Every airborne block casts a soft shadow straight down onto
1176
+ // the plane at its TRUE footprint -- not where the gathered, swollen block is
1177
+ // drawn -- so the shadow says which slot a block is over. The umbra is dark
1178
+ // and tight just above the plane and spreads and pales as the block climbs;
1179
+ // an arrival's shadow therefore sharpens on the very slot it is about to hit.
1180
+ // Shadows paint after every resting block and before every airborne one:
1181
+ // they fall ON the board, and under what casts them.
1182
+ //
1183
+ // Fades carry into the outline. The seam used to be stroked in one fixed
1184
+ // colour whatever the tile's alpha, so a departing block's fill faded while
1185
+ // its black frame stayed at full strength until the tile was dropped (operator:
1186
+ // "the black outlines for disappearing blocks do not fade out"). The stroke
1187
+ // now travels on the op as rgba scaled by the same alpha.
1188
+ // THE DENSE BOARD'S ORDER (Viewer Mode 2): thousands of low slabs whose footprints never overlap.
1189
+ // Under the oblique camera a slab's height leans STRAIGHT UP -- measured 2026-09-13: +1 of height
1190
+ // moves screen x by 0.00 and y by -3.06, and +1 of grid x moves screen x by 9.00 and y by 0.00.
1191
+ // So depth is grid Y alone; x contributes nothing to what can occlude what. (This comment used to
1192
+ // say "up and to the right", which is a leftover from an earlier camera and sent an investigation
1193
+ // of a paint-order report down the wrong path for an hour.) Resting slabs paint far row first, the
1194
+ // airborne
1195
+ // after them, lowest first. Linear-log where obliqueOrder's pairwise tests are quadratic -- at
1196
+ // 4,000 transactions that is the difference between a frame and a stall.
1197
+ export function diagonalOrder(tiles) {
1198
+ return tiles.map((t) => ({ t, air: (t.z ?? 0) > 0.02 ? 1 : 0, z: t.z ?? 0, d: t.x + t.y + t.s }))
1199
+ .sort((p, q) => (p.air - q.air) || (p.air ? p.z - q.z : 0) || (q.d - p.d) || String(p.t.txid).localeCompare(String(q.t.txid)))
1200
+ .map((e) => e.t);
1201
+ }
1202
+
1203
+ export function buildScene(tiles, o = {}) {
1204
+ // the oblique camera never swells a block, so there is no growth to police
1205
+ const growth = o.oblique ? new Map() : settleGrowth(tiles, o);
1206
+ // PAINT ORDER FOR CUBES: seen from above, a higher surface is nearer the
1207
+ // camera, so blocks paint by the height of their TOP, lowest first, and
1208
+ // among equals the one further from the vanishing point first (its sides
1209
+ // lean outward, away from its nearer neighbours).
1210
+ const vx0 = o.vanishX ?? 0, vy0 = o.vanishY ?? 0;
1211
+ const ordered = o.oblique && o.order === 'diagonal' ? diagonalOrder(tiles) : o.oblique ? obliqueOrder(tiles, o) : tiles.map((t) => (growth.has(t) ? { ...t, boost: growth.get(t) } : t))
1212
+ .map((t) => { const c = project(t.x + t.s / 2, t.y + t.s / 2, 0, o); return { t, top: (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t), d: Math.hypot(c.x - vx0, c.y - vy0) }; })
1213
+ // Under the oblique camera a cube reaches STRAIGHT UP from its footprint (measured: height
1214
+ // moves screen y only, grid x moves screen x only), so it can only cover blocks at smaller
1215
+ // grid y: paint from the far row in, the airborne after the resting and lowest
1216
+ // first. A fixed order -- nothing flips as blocks rise and fall past each
1217
+ // other at nearly the same height, which was the "z-fighting".
1218
+ .sort((p, q) => (p.top - q.top) || (q.d - p.d) || String(p.t.txid).localeCompare(String(q.t.txid)))
1219
+ .map((e) => e.t);
1220
+ const ground = [];
1221
+ const air = [];
1222
+ const shadows = [];
1223
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1224
+ const note = (p) => {
1225
+ if (p.x < minX) minX = p.x;
1226
+ if (p.x > maxX) maxX = p.x;
1227
+ if (p.y < minY) minY = p.y;
1228
+ if (p.y > maxY) maxY = p.y;
1229
+ };
1230
+ const facetMin = o.facetMinUnits ?? 0;
1231
+ const crownMin = o.crownMinUnits ?? 0;
1232
+ const seam = o.seamAlpha ?? 0.35;
1233
+ // LIT FROM THE VIEWER (the Markets board; operator, 2026-09-11: "move the lighting to the viewer
1234
+ // position for the market view. I want everything bright and clear on the faces"). The light
1235
+ // sits at the camera: the faces turned toward it are the brightest, the edges are drawn light
1236
+ // instead of dark, the cell bevel (made for a light from the upper left) is left off, and
1237
+ // nothing casts a shadow -- a shadow from a light at the eye falls behind what casts it.
1238
+ const viewerLit = o.light === 'viewer';
1239
+ // ...then "offset the light at 45 degrees to the right so older stuff gets dimmer": the lamp
1240
+ // stands to the front-right, so a face turned left falls toward shadow, and the light reaching
1241
+ // a cube falls off leftward across the board -- the newest hours (on the right) brightest,
1242
+ // the oldest at half
1243
+ const reachOf = (t) => (viewerLit && o.gridW ? 0.5 + 0.5 * Math.min(1, Math.max(0, (t.x + t.s / 2) / o.gridW)) : 1);
1244
+ const flip = flipOf(o);
1245
+ // CUBES SHADOW CUBES (operator, 2026-09-11: "can we get the cubes casting shadows
1246
+ // against other cubes during movement?"). A cube in flight throws its shadow
1247
+ // straight down -- the same light as its floor shadow -- onto the TOP of every cube
1248
+ // beneath it: the overlap of its footprint (a penumbra and a core) with that cube's
1249
+ // top square, a rectangle in grid space, so a plain rgba quad and no clip. It is
1250
+ // drawn straight after the lower cube's own faces, so everything nearer -- the flyer
1251
+ // included -- paints over it. Darker the nearer the flyer, and fading exactly as
1252
+ // its floor shadow does: across the last 1.5 units of a landing, and with it as it
1253
+ // flies off (or falls in from) off screen.
1254
+ const casters = o.shadows !== false && o.oblique && !viewerLit && o.order !== 'diagonal' ? tiles.filter((c) => (c.z ?? 0) > 0.02).map((c) => ({
1255
+ c, x0: c.x, y0: c.y, x1: c.x + c.s, y1: c.y + c.s, z0: c.z,
1256
+ k: (c.alpha ?? 1) * Math.min(1, c.z / 1.5) * (1 - (c.entry ?? 0)),
1257
+ })).filter((e) => e.k > 0.01) : [];
1258
+ // the lamp's direction across the screen, for the side faces (LIGHTS); under the overhead
1259
+ // lamp every side takes the same light, a shade under the top
1260
+ const lampSide = LIGHTS[lightOf(o)].side;
1261
+ for (const t of ordered) {
1262
+ const fxv = (t.z ?? 0) > 0.02 ? FX_NONE : fxAt(t, o.fx);
1263
+ // GONE FOR THE DURATION, not deleted: a hidden cube is skipped this frame and drawn again the
1264
+ // moment the effect stops asking for it to be hidden.
1265
+ if (fxv.hide > 0.5) continue;
1266
+ // A SHORTER CUBE, the same way: `tall` on a copy. A collapse or a dig is a height override,
1267
+ // never an edit to the tile the board was built from.
1268
+ const sc = fxv.scale == null ? 1 : fxv.scale;
1269
+ const shaped = sc < 0.999 ? { ...t, tall: Math.max(0.04, cubeHeight(t) * sc) } : t;
1270
+ // NEON is a flat cube: no facets, no crown -- solid faces and the tubes on their edges
1271
+ const f = tileFaces(fxv.lift > 0.001 ? { ...shaped, fxz: fxv.lift } : shaped, o, t.s >= facetMin && o.neon !== true);
1272
+ const a = t.alpha ?? 1;
1273
+ const airborne = (t.z ?? 0) > 0.02;
1274
+ const out = airborne && !o.oblique ? air : ground;
1275
+ // A tile that just locked flashes brighter for a moment -- the Tetris
1276
+ // lock, and the only cue that says "this one just arrived".
1277
+ const lock = t.lock ?? 0;
1278
+ // an idle effect (see fxAt) lights the cubes it passes
1279
+ const pulse = fxv.glow;
1280
+ // the pointer's glow (details3d setHover): lit up, outlined, fading when released
1281
+ const hover = o.hoverGlow?.get(t.txid) ?? 0;
1282
+ // NEON (o.neon, below) is a dim SOLID body in the block's own colour under bright tubes: the
1283
+ // faces keep their hue -- the feerate -- at a constant half light, no dome or lamp shading
1284
+ // (2026-09-12: the first cut dimmed the lit shading and the faces went near black, "don't
1285
+ // have any suitable color fill for their temperature. Need solid dim neon colored faces")
1286
+ const lit = o.neon === true
1287
+ ? 0.55 * (1 + 0.55 * lock + 0.45 * pulse + 0.6 * hover)
1288
+ : (1 + 0.55 * lock + 0.45 * pulse + 0.6 * hover) * domeLight(t, o) * reachOf(t);
1289
+ const c = t.color;
1290
+ // A WIREFRAME (Tetrust's ghost, operator 2026-09-12: "wireframes on teh bottom of the tetrust
1291
+ // playfield ... The solid dark colored stuff is too difficult to see"): the cube's
1292
+ // outline in `t.wire` and nothing else -- no fill, so what is behind shows through, and a wide
1293
+ // faint halo under a bright line so it reads as a lit tube. `always`: the seam switch (Stone
1294
+ // edges) does not govern it, the outline IS the tile.
1295
+ if (t.wire) {
1296
+ const halo = lift(t.wire, 0.2, round3(0.3 * a)), tube = lift(t.wire, 0.1, round3(0.98 * a));
1297
+ // how heavy the outline is (`wireWidth`, from settings; operator, 2026-09-12: "a way of
1298
+ // making the ghost marker have thinner lines"). Both passes scale together so the halo stays
1299
+ // a halo around the line rather than swallowing it.
1300
+ const ww = Math.max(0.2, Math.min(3, Number(o.wireWidth) || 1));
1301
+ for (const poly of [...f.sides.map((sd) => sd.points), f.top]) {
1302
+ out.push({ txid: t.txid, face: 'wire', points: poly, fill: 'rgba(0,0,0,0)', stroke: halo, lw: round3(9 * ww), always: true });
1303
+ out.push({ txid: t.txid, face: 'wire', points: poly, fill: 'rgba(0,0,0,0)', stroke: tube, lw: round3(3 * ww), always: true });
1304
+ poly.forEach(note);
1305
+ }
1306
+ continue;
1307
+ }
1308
+ // A BALL, NOT A BLOCK (operator, 2026-09-12: "Can we have a ball for blockout instead of a
1309
+ // block for the bouncing dot?"). An op is a filled polygon -- there are no arcs in the format,
1310
+ // and gradients and shadows are forbidden here (see the rules at the head of details3d.js) --
1311
+ // so a sphere is three nested many-sided discs: a dark rim, the body, and a highlight offset
1312
+ // toward the light. At the size a ball is actually drawn, 24 sides is a circle.
1313
+ if (t.sphere) {
1314
+ const bcx = t.x + t.s / 2, bcy = t.y + t.s / 2;
1315
+ const zc = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t) / 2;
1316
+ const mid = f.P(bcx, bcy, zc);
1317
+ const rim = f.P(bcx + t.s / 2, bcy, zc);
1318
+ const R = Math.hypot(rim.x - mid.x, rim.y - mid.y) || 1;
1319
+ const disc = (r, dx, dy, fill) => {
1320
+ const pts = [];
1321
+ for (let i = 0; i < 24; i++) {
1322
+ const ang = (i / 24) * Math.PI * 2;
1323
+ pts.push({ x: mid.x + dx + Math.cos(ang) * r, y: mid.y + dy + Math.sin(ang) * r });
1324
+ }
1325
+ out.push({ txid: t.txid, face: 'ball', points: pts, fill });
1326
+ return pts;
1327
+ };
1328
+ disc(R, 0, 0, shade(c, 0.5 * lit, a)).forEach(note);
1329
+ disc(R * 0.84, -R * 0.08, -R * 0.1, shade(c, 1.0 * lit, a));
1330
+ disc(R * 0.4, -R * 0.24, -R * 0.28, lift(c, 0.7, a));
1331
+ continue;
1332
+ }
1333
+ // A SHAPE WITH AN ORIENTATION (operator, 2026-09-12: "proper varied Minion graphics ...
1334
+ // powerups to look like elongated pills rotating and dropping ... morphing the player paddle
1335
+ // into something visually different when it's firing lasers").
1336
+ //
1337
+ // One primitive for all three, not three branches: a pill, a minion body and a laser cannon are
1338
+ // each a closed outline that can be turned. `t.poly` is that outline in UNIT SPACE -- points in
1339
+ // -0.5..0.5, so a shape is written once and scales with `s` -- and `t.rot` turns it. Built the
1340
+ // same way the sphere is (nested filled polygons, no arcs in the op format, no gradients under
1341
+ // the canvas rules): the outline, the body inset toward the light, and a highlight.
1342
+ //
1343
+ // Its own face name, because test/finish.test.js holds a sphere to emitting `ball` ops and
1344
+ // NOTHING else; a new kind that borrowed that name would quietly break that guarantee.
1345
+ if (t.poly && t.poly.length >= 3) {
1346
+ const pcx = t.x + t.s / 2, pcy = t.y + t.s / 2;
1347
+ const zc = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t) / 2;
1348
+ const mid = f.P(pcx, pcy, zc);
1349
+ const rim = f.P(pcx + t.s / 2, pcy, zc);
1350
+ // the screen radius of half a tile, so the outline scales with the camera like everything else
1351
+ const R = Math.hypot(rim.x - mid.x, rim.y - mid.y) || 1;
1352
+ const rot = Number(t.rot) || 0;
1353
+ const ca = Math.cos(rot), sa = Math.sin(rot);
1354
+ // shrink toward the shape's own centroid, which keeps a long thin pill from pinching
1355
+ const gx = t.poly.reduce((n, q) => n + q[0], 0) / t.poly.length;
1356
+ const gy = t.poly.reduce((n, q) => n + q[1], 0) / t.poly.length;
1357
+ const ring = (k, dx, dy, fill) => {
1358
+ const pts = t.poly.map(([ux, uy]) => {
1359
+ const sx = gx + (ux - gx) * k, sy = gy + (uy - gy) * k;
1360
+ return { x: mid.x + dx + (sx * ca - sy * sa) * 2 * R, y: mid.y + dy + (sx * sa + sy * ca) * 2 * R };
1361
+ });
1362
+ out.push({ txid: t.txid, face: 'poly', points: pts, fill });
1363
+ return pts;
1364
+ };
1365
+ ring(1, 0, 0, shade(c, 0.45 * lit, a)).forEach(note);
1366
+ ring(0.78, -R * 0.06, -R * 0.08, shade(c, 1.0 * lit, a));
1367
+ ring(0.34, -R * 0.2, -R * 0.24, lift(c, 0.65, a));
1368
+ // eyes: a minion reads as alive because something looks back. Drawn as their own small
1369
+ // discs rather than baked into the outline, so one body can blink or look about later.
1370
+ for (const [ex, ey, er] of t.eyes ?? []) {
1371
+ const px = mid.x + (ex * ca - ey * sa) * 2 * R, py = mid.y + (ex * sa + ey * ca) * 2 * R;
1372
+ const disc = (rr, fill) => {
1373
+ const pts = [];
1374
+ for (let i = 0; i < 12; i++) {
1375
+ const ang = (i / 12) * Math.PI * 2;
1376
+ pts.push({ x: px + Math.cos(ang) * rr, y: py + Math.sin(ang) * rr });
1377
+ }
1378
+ out.push({ txid: t.txid, face: 'poly', points: pts, fill });
1379
+ };
1380
+ disc(er * 2 * R, 'rgba(12,16,22,0.92)');
1381
+ disc(er * 2 * R * 0.45, 'rgba(235,245,255,0.95)');
1382
+ }
1383
+ continue;
1384
+ }
1385
+
1386
+ for (const side of f.sides) {
1387
+ // lit from the upper left of the screen: a side turned that way is
1388
+ // brighter, one turned away falls into shadow
1389
+ const d = side.nx * lampSide[0] + side.ny * lampSide[1];
1390
+ // from the viewer: a face pointing down the screen (toward the camera) takes the most light
1391
+ const k = viewerLit ? 0.6 + 0.42 * Math.max(0, side.ny) + 0.3 * Math.max(0, side.nx) - 0.16 * Math.max(0, -side.nx) : 0.34 + 0.26 * (d + 1);
1392
+ out.push({ txid: t.txid, face: 'side', key: side.key, points: side.points, fill: shade(c, k * lit, a), ...(viewerLit ? { stroke: lift(c, 0.45, round3(0.5 * a)) } : {}) });
1393
+ side.points.forEach(note);
1394
+ }
1395
+ out.push({ txid: t.txid, face: 'top', points: f.top, fill: shade(c, (viewerLit ? 1.02 : 0.8) * lit, a), stroke: viewerLit ? lift(c, 0.55, round3(0.6 * a)) : `rgba(0,0,0,${round3(seam * a)})` });
1396
+ f.top.forEach(note);
1397
+
1398
+ if (f.innerG && !viewerLit) {
1399
+ const [BL, BR, TR, TL] = f.top;
1400
+ const [bl, br, tr, tl] = f.inset;
1401
+ // the screen-top edge is row y+s with the rows flipped, row y without
1402
+ const farE = flip > 0 ? [TL, TR, tr, tl] : [BL, BR, br, bl];
1403
+ const nearE = flip > 0 ? [BR, BL, bl, br] : [TR, TL, tl, tr];
1404
+ out.push({ txid: t.txid, face: 'bevel', points: farE, fill: lift(c, 0.5 + 0.3 * lock, a) });
1405
+ out.push({ txid: t.txid, face: 'bevel', points: [BL, TL, tl, bl], fill: lift(c, 0.2 + 0.3 * lock, a) });
1406
+ out.push({ txid: t.txid, face: 'bevel', points: [TR, BR, br, tr], fill: shade(c, 0.62 * lit, a) });
1407
+ out.push({ txid: t.txid, face: 'bevel', points: nearE, fill: shade(c, 0.4 * lit, a) });
1408
+ out.push({ txid: t.txid, face: 'face', points: f.inset, fill: shade(c, lit, a) });
1409
+ if (t.s >= crownMin) {
1410
+ const w = t.s - 2 * f.bevel;
1411
+ const x0 = t.x + f.bevel;
1412
+ const at = ([p, q]) => f.P(x0 + p * w, flip > 0 ? (t.y + t.s - f.bevel - q * w) : (t.y + f.bevel + q * w), f.topZ);
1413
+ // the well: its lip at 20% in from the face's edge, its floor at 30%,
1414
+ // in (p, q) face coordinates with q running DOWN the screen
1415
+ const E = [[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8]].map(at); // lip: TL, TR, BR, BL on screen
1416
+ const F = [[0.3, 0.3], [0.7, 0.3], [0.7, 0.7], [0.3, 0.7]].map(at); // floor
1417
+ out.push({ txid: t.txid, face: 'wall', points: [E[0], E[1], F[1], F[0]], fill: shade(c, 0.5 * lit, a) });
1418
+ out.push({ txid: t.txid, face: 'wall', points: [E[3], E[0], F[0], F[3]], fill: shade(c, 0.66 * lit, a) });
1419
+ out.push({ txid: t.txid, face: 'wall', points: [E[1], E[2], F[2], F[1]], fill: lift(c, 0.16 + 0.3 * lock, a) });
1420
+ out.push({ txid: t.txid, face: 'wall', points: [E[2], E[3], F[3], F[2]], fill: lift(c, 0.34 + 0.3 * lock, a) });
1421
+ out.push({ txid: t.txid, face: 'floor', points: F, fill: shade(c, 0.84 * lit, a) });
1422
+ out.push({ txid: t.txid, face: 'rim', points: E, fill: 'rgba(0,0,0,0)', stroke: lift(c, 0.7, round3(0.7 * a)) });
1423
+ }
1424
+ }
1425
+ // THE FINISH (operator, 2026-09-12: "consider neon-izing each of teh blocks, and adding an
1426
+ // optional specular metallic sheen to the blocks. Have it toggle. I want to be able to apply
1427
+ // the sheen onto simple cube mode if I want"). Both are laid over the top face every tile
1428
+ // has, so they work at every level of detail. What was tried and rejected before is recorded
1429
+ // at the head of this section -- diagonal streaks -- and neither of these is a streak.
1430
+ if (o.sheen === true && o.sheenStyle === 'chrome') {
1431
+ // CHROME (operator, 2026-09-14: "make the specular metallic effect more prominent. Maybe give
1432
+ // it a chrome or faux reflective effect ... really improve the metallic look"). The satin
1433
+ // sheen below is a highlight along one edge -- a lit surface. Chrome is not lit, it REFLECTS:
1434
+ // what reads as chrome is a hard horizon mirrored in the face -- bright sky above a white line,
1435
+ // near-black ground below it, lightening again toward the near edge -- and a reflection that
1436
+ // SLIDES as the object moves. So the horizon's place on each face comes from where the cube
1437
+ // is on the board and how high it flies: cubes side by side show it at different heights,
1438
+ // and a cube in flight has it travel across its faces. Hue is kept in the sky and the bounce
1439
+ // so the fee colour still reads; the colour is the data, the chrome is the finish.
1440
+ const [BL, BR, TR, TL] = f.top;
1441
+ const L = (p, q, k) => ({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k });
1442
+ const gw = o.gridW || 44, gh = o.gridH || gw;
1443
+ // the horizon changes from cube to cube (a board of equal slabs is not one flat mirror) and
1444
+ // travels with height, so a flight carries its reflection across its faces
1445
+ const env = ((t.x + t.s / 2) / gw) * 2.3 + ((t.y + t.s / 2) / gh) * 1.1 + (t.z ?? 0) * 0.02;
1446
+ const wave = (ph) => Math.sin(2 * Math.PI * (env + ph));
1447
+ // the far edge of the top on screen is TL-TR, the near edge BL-BR; q runs far (0) to near (1)
1448
+ const strip = (q0, q1) => [L(TL, BL, Math.max(0, q0)), L(TR, BR, Math.max(0, q0)), L(TR, BR, Math.min(1, q1)), L(TL, BL, Math.min(1, q1))];
1449
+ const k = 0.45 + 0.25 * wave(0);
1450
+ // RAMPS ARE NESTED BANDS, as the satin sheen's are: n translucent quads anchored on one line
1451
+ // accumulate into an n-step gradient, where adjacent strips of differing alpha read as stripes.
1452
+ out.push({ txid: t.txid, face: 'sheen', points: f.top, fill: `rgba(160,170,182,${round3(0.14 * a)})` }); // a light steel wash
1453
+ const RAMP = 6;
1454
+ for (let i = 0; i < RAMP; i++) {
1455
+ const u = 1 - i / RAMP; // 1 (widest) toward the horizon
1456
+ // the sky: brightest at the horizon, still carrying the cube's hue
1457
+ out.push({ txid: t.txid, face: 'sheen', points: strip(k - k * Math.pow(u, 1.3), k), fill: lift(c, 0.72, round3(0.1 * a)) });
1458
+ // the ground: darkest just under the horizon, in the cube's own colour darkened, not black
1459
+ out.push({ txid: t.txid, face: 'sheen', points: strip(k, k + (1 - k) * Math.pow(u, 1.6)), fill: shade(c, 0.18, round3(0.125 * a)) });
1460
+ // the bounce: light again toward the near edge
1461
+ out.push({ txid: t.txid, face: 'sheen', points: strip(1 - 0.28 * Math.pow(u, 1.5), 1), fill: lift(c, 0.5, round3(0.065 * a)) });
1462
+ }
1463
+ // the horizon itself: a soft glow over a hard white line
1464
+ out.push({ txid: t.txid, face: 'sheen', points: strip(k - 0.1, k), fill: `rgba(240,248,255,${round3(0.3 * a)})` });
1465
+ out.push({ txid: t.txid, face: 'sheen', points: strip(k - 0.03, k + 0.01), fill: `rgba(255,255,255,${round3(0.92 * a)})` });
1466
+ // the sides mirror their own horizon, offset so it never lines up with the top's
1467
+ for (const side of f.sides) {
1468
+ const lamp = viewerLit ? Math.max(0, side.ny) : Math.max(0, side.nx * lampSide[0] + side.ny * lampSide[1]);
1469
+ const [p0, p1, p2, p3] = side.points; // top edge p0-p1, bottom edge p3-p2
1470
+ const band = (v0, v1) => [L(p0, p3, Math.max(0, v0)), L(p1, p2, Math.max(0, v0)), L(p1, p2, Math.min(1, v1)), L(p0, p3, Math.min(1, v1))];
1471
+ const kv = 0.36 + 0.18 * wave(0.37 + (side.key === 'left' || side.key === 'right' ? 0.21 : 0));
1472
+ for (let i = 0; i < 3; i++) {
1473
+ const u = 1 - i / 3;
1474
+ out.push({ txid: t.txid, face: 'sheen', points: band(kv - kv * u, kv), fill: lift(c, 0.65, round3((0.1 + 0.1 * lamp) * a)) });
1475
+ out.push({ txid: t.txid, face: 'sheen', points: band(kv, kv + (1 - kv) * Math.pow(u, 1.4)), fill: shade(c, 0.15, round3(0.18 * a)) });
1476
+ }
1477
+ out.push({ txid: t.txid, face: 'sheen', points: band(0.88, 1), fill: lift(c, 0.4, round3(0.22 * a)) });
1478
+ out.push({ txid: t.txid, face: 'sheen', points: band(kv - 0.05, kv + 0.02), fill: `rgba(255,255,255,${round3((0.5 + 0.4 * lamp) * a)})` });
1479
+ }
1480
+ // crisp polished edges, drawn whether or not the dark seam is on
1481
+ out.push({ txid: t.txid, face: 'sheen', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(255,255,255,${round3(0.55 * a)})`, lw: 0.9, always: true });
1482
+ // a star glint on some cubes, at the corner facing the lamp; it moves on when the cube does
1483
+ const g = env * 7.3 - Math.floor(env * 7.3);
1484
+ if (g > 0.82) {
1485
+ const corner = viewerLit ? BL : (flip > 0 ? TL : BL);
1486
+ const across = viewerLit ? BR : (flip > 0 ? TR : BR), down = viewerLit ? TL : (flip > 0 ? BL : TL);
1487
+ const cx = corner.x + (across.x - corner.x) * 0.16 + (down.x - corner.x) * 0.16;
1488
+ const cy = corner.y + (across.y - corner.y) * 0.16 + (down.y - corner.y) * 0.16;
1489
+ const r = Math.hypot(across.x - corner.x, across.y - corner.y) * (0.1 + 0.12 * (g - 0.82) / 0.18), w2 = r * 0.12;
1490
+ const glint = (alpha, rr, ww) => {
1491
+ out.push({ txid: t.txid, face: 'sheen', points: [{ x: cx - rr, y: cy }, { x: cx, y: cy - ww }, { x: cx + rr, y: cy }, { x: cx, y: cy + ww }], fill: `rgba(255,255,255,${round3(alpha * a)})` });
1492
+ out.push({ txid: t.txid, face: 'sheen', points: [{ x: cx, y: cy - rr }, { x: cx + ww, y: cy }, { x: cx, y: cy + rr }, { x: cx - ww, y: cy }], fill: `rgba(255,255,255,${round3(alpha * a)})` });
1493
+ };
1494
+ glint(0.35, r * 1.5, w2 * 2.2);
1495
+ glint(0.95, r, w2);
1496
+ }
1497
+ } else if (o.sheen === true) {
1498
+ // a specular band hugging the LIT edge of the top: the far edge under the upper-left lamp,
1499
+ // the near edge when the light sits at the viewer. Two nested bands, the inner one hotter:
1500
+ // a metallic gleam along an edge, not a gloss stripe across the face.
1501
+ const [BL, BR, TR, TL] = f.top;
1502
+ const litFar = !viewerLit;
1503
+ const e0 = litFar ? (flip > 0 ? TL : BL) : (flip > 0 ? BL : TL);
1504
+ const e1 = litFar ? (flip > 0 ? TR : BR) : (flip > 0 ? BR : TR);
1505
+ const o0 = litFar ? (flip > 0 ? BL : TL) : (flip > 0 ? TL : BL);
1506
+ const o1 = litFar ? (flip > 0 ? BR : TR) : (flip > 0 ? TR : BR);
1507
+ const L = (p, q, k) => ({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k });
1508
+ const band = (k) => [e0, e1, L(e1, o1, k), L(e0, o0, k)];
1509
+ // A RAMP, NOT STEPS (2026-09-12, operator: "we need more specular on the metallic sheen, and
1510
+ // have the gradient be less coarse"). The first cut was two bands, the second three, and three
1511
+ // wide bands read as three stripes because that is what they are.
1512
+ //
1513
+ // The bands are NESTED and all anchored on the lit edge, so drawing them widest-first lets
1514
+ // each narrower one lay over the last and the alpha ACCUMULATE toward the edge: n translucent
1515
+ // quads are an n-step ramp, and the fineness of the gradient is just n. Each step is kept
1516
+ // under a tenth of full opacity so no single one of them can be seen as an edge. (A real
1517
+ // canvas gradient is not forbidden here -- the rules ban clip, globalAlpha, composite modes
1518
+ // and shadowBlur, not gradients -- but an op's `fill` is a plain rgba STRING that paintFrame
1519
+ // assigns straight to fillStyle, and which the recording-canvas tests read; a CanvasGradient
1520
+ // would need a new op shape and would blind them. Layered fills are the house idiom.)
1521
+ const SHEEN_STEPS = 14;
1522
+ for (let k = 0; k < SHEEN_STEPS; k++) {
1523
+ const t2 = 1 - k / SHEEN_STEPS; // 1 (widest) down to one step's width
1524
+ const w = 0.5 * Math.pow(t2, 1.5); // bunched toward the lit edge
1525
+ const hot = 1 - t2; // 0 at the sheen's inner edge, 1 at the lit one
1526
+ out.push({ txid: t.txid, face: 'sheen', points: band(w),
1527
+ fill: lift(c, 0.4 + 0.55 * hot, round3((0.03 + 0.075 * Math.pow(hot, 1.8)) * a)) });
1528
+ }
1529
+ // THE SPECULAR ITSELF: a tight near-white sliver right on the edge, which is the part that
1530
+ // reads as polished metal rather than as a lit surface
1531
+ out.push({ txid: t.txid, face: 'sheen', points: band(0.055), fill: `rgba(255,255,255,${round3(0.9 * a)})` });
1532
+ out.push({ txid: t.txid, face: 'sheen', points: band(0.022), fill: `rgba(255,255,255,${round3(0.96 * a)})` });
1533
+ // and the roll-off into shadow on the far edge, graded the same way
1534
+ const dark = (k) => [o0, o1, L(o1, e1, k), L(o0, e0, k)];
1535
+ const DARK_STEPS = 8;
1536
+ for (let k = 0; k < DARK_STEPS; k++) {
1537
+ const t2 = 1 - k / DARK_STEPS;
1538
+ const w = 0.34 * Math.pow(t2, 1.4);
1539
+ const deep = 1 - t2;
1540
+ out.push({ txid: t.txid, face: 'sheen', points: dark(w), fill: `rgba(0,0,0,${round3((0.035 + 0.06 * deep) * a)})` });
1541
+ }
1542
+ for (const side of f.sides) {
1543
+ // the side turned to the lamp carries the same ramp up its outer edge
1544
+ const d = viewerLit ? Math.max(0, side.ny) : Math.max(0, side.nx * lampSide[0] + side.ny * lampSide[1]);
1545
+ if (d < 0.3) continue;
1546
+ const [p0, p1, p2, p3] = side.points;
1547
+ const SIDE_STEPS = 6;
1548
+ for (let k = 0; k < SIDE_STEPS; k++) {
1549
+ const t2 = 1 - k / SIDE_STEPS;
1550
+ const w = 0.32 * Math.pow(t2, 1.4);
1551
+ const hot = 1 - t2;
1552
+ out.push({ txid: t.txid, face: 'sheen', points: [p0, L(p0, p1, w), L(p3, p2, w), p3],
1553
+ fill: lift(c, 0.55 + 0.4 * hot, round3((0.05 + 0.12 * hot) * d * a)) });
1554
+ }
1555
+ }
1556
+ }
1557
+ if (o.neon === true) {
1558
+ // every edge the camera sees, stroked in the block's own colour lit up: a wide faint halo
1559
+ // under a thin bright line, the way a neon tube reads. `always`: drawn even with the dark
1560
+ // seam (Stone edges) switched off, because it is the seam's replacement, not its companion.
1561
+ // (2026-09-12, second cut: the first stroked a 1.4 x tube over a 0.6-pixel base line --
1562
+ // under a pixel, and half of it under the next cube's fill -- "I don't see neon blocks
1563
+ // working". `lw` multiplies paintFrame's base width, so these are device pixels x 1.7.)
1564
+ // tuned (settings.js neonSource / neonColour / neonBrightness): the tube in the block's own
1565
+ // colour or one chosen colour, glowing as hard as asked -- brightness into alpha and width
1566
+ const nc = o.neonSource === 'colour' && NEON_HEX.test(o.neonColour || '') ? o.neonColour : c;
1567
+ const nb = Math.max(0.2, Math.min(2, Number(o.neonBrightness) || 1));
1568
+ const halo = lift(nc, 0.25, round3(Math.min(1, 0.3 * nb) * a)), tube = lift(nc, 0.3, round3(Math.min(1, nb) * a)), core = lift(nc, 0.75, round3(Math.min(1, 0.7 * nb) * a)); // the tube keeps the hue; only the thin core goes toward white
1569
+ for (const poly of [f.top, ...f.sides.map((sd) => sd.points)]) {
1570
+ out.push({ txid: t.txid, face: 'neon', points: poly, fill: 'rgba(0,0,0,0)', stroke: halo, lw: round3(11 * (0.6 + 0.4 * nb)), always: true });
1571
+ out.push({ txid: t.txid, face: 'neon', points: poly, fill: 'rgba(0,0,0,0)', stroke: tube, lw: round3(4 * (0.7 + 0.3 * nb)), always: true });
1572
+ out.push({ txid: t.txid, face: 'neon', points: poly, fill: 'rgba(0,0,0,0)', stroke: core, lw: 1.6, always: true });
1573
+ }
1574
+ }
1575
+ // the lock: the whole cell flashes white for a moment, then settles
1576
+ if (pulse > 0.03) out.push({ txid: t.txid, face: 'glow', points: f.top, fill: `rgba(${fxv.color.join(',')},${round3(0.5 * pulse * a)})` });
1577
+ // ON THE PRICE BOARD THE SIDES LIGHT TOO. The camera there is low (Markets' CAMERA_3D: the
1578
+ // depth drawn at 0.3), so a candle's top is a sliver and a glow painted on it alone is
1579
+ // invisible: a scan crossed the board in 2026-09-14's captures and lit nothing anyone could
1580
+ // see. The faces the camera sees carry the glow, at a little under the top's weight.
1581
+ // The faces the camera sees carry the glow, and heavily: a candle is already a bright body, so
1582
+ // a tint at the top's weight (0.5) was there in the pixels and nowhere to the eye (measured:
1583
+ // the scan's peak moved a candle's face by a 0.26 cyan wash). At 0.85 it reads as struck.
1584
+ if (pulse > 0.03 && o.axes) for (const side of f.sides) out.push({ txid: t.txid, face: 'glow', points: side.points, fill: `rgba(${fxv.color.join(',')},${round3(0.85 * pulse * a)})` });
1585
+ if (fxv.outline > 0.03) {
1586
+ const col = fxv.color.join(',');
1587
+ out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(${col},${round3(0.95 * fxv.outline * a)})`, lw: 1 + 4 * fxv.outline });
1588
+ // and round the sides the camera sees, so the whole cube is traced
1589
+ if (fxv.outline > (o.axes ? 0.05 : 0.15)) for (const side of f.sides) {
1590
+ out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: `rgba(${col},${round3(0.7 * fxv.outline * a)})`, lw: 1 + 2 * fxv.outline });
1591
+ }
1592
+ }
1593
+ if (hover > 0.01) {
1594
+ out.push({ txid: t.txid, face: 'glow', points: f.top, fill: `rgba(215,255,235,${round3(0.38 * hover * a)})` });
1595
+ out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(175,255,225,${round3(0.95 * hover * a)})`, lw: 1 + 3 * hover });
1596
+ for (const side of f.sides) out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: `rgba(175,255,225,${round3(0.7 * hover * a)})`, lw: 1 + 2 * hover });
1597
+ }
1598
+ if (lock > 0.02) out.push({ txid: t.txid, face: 'flash', points: f.top, fill: `rgba(255,255,255,${round3(0.18 * lock * a)})` });
1599
+ if (casters.length) {
1600
+ const bTop = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t) + (fxv.lift > 0.001 ? fxv.lift : 0);
1601
+ for (const e of casters) {
1602
+ if (e.c === t) continue;
1603
+ const gap = e.z0 - bTop;
1604
+ if (gap < -1e-6) continue; // only onto cubes whose top it is above
1605
+ for (const [grow, share] of [[Math.min(1.2, 0.1 + gap * 0.06), 0.16], [0, 0.34]]) {
1606
+ const x0 = Math.max(t.x, e.x0 - grow), x1 = Math.min(t.x + t.s, e.x1 + grow);
1607
+ const y0 = Math.max(t.y, e.y0 - grow), y1 = Math.min(t.y + t.s, e.y1 + grow);
1608
+ if (x1 - x0 < 1e-3 || y1 - y0 < 1e-3) continue;
1609
+ const dk = share * e.k * Math.min(1, 1.6 / (1 + gap * 0.05));
1610
+ if (dk < 0.01) continue;
1611
+ out.push({ txid: t.txid, face: 'cast', points: [f.P(x0, y0, f.topZ), f.P(x1, y0, f.topZ), f.P(x1, y1, f.topZ), f.P(x0, y1, f.topZ)], fill: `rgba(0,0,0,${round3(dk)})` });
1612
+ }
1613
+ }
1614
+ }
1615
+ // SHADOWS FADE, THEY DO NOT SWITCH (operator, 2026-09-11: "the shadows just
1616
+ // disappear. they need to fade out"). A departure kept a full shadow on its
1617
+ // slot until the frame it was gone, and lift-off and touchdown swapped one
1618
+ // shadow for another. Under the oblique camera the resting shadow now
1619
+ // fades out over the first 1.5 units of a lift (and in over the last 1.5
1620
+ // of a landing), the flight shadow fades in and out across the same band,
1621
+ // and a block flying off screen -- or falling in from it -- takes its
1622
+ // shadow with it as it goes: (1 - entry).
1623
+ const zt = t.z ?? 0;
1624
+ // SHADOWS ARE OPTIONAL (operator, 2026-09-12: "remove shadows ... anything to make it run
1625
+ // faster"). They are the most expensive thing on a full board -- one quad per stone at rest,
1626
+ // more in flight -- so a machine that struggles can have the scene without them. Paint order
1627
+ // is unchanged: an empty shadow list still comes first.
1628
+ if (o.shadows !== false) {
1629
+ if (airborne && !viewerLit) shadows.push(...shadowOps(t, a * (o.oblique ? Math.min(1, zt / 1.5) * (1 - (t.entry ?? 0)) : 1), o));
1630
+ if (o.oblique && !viewerLit && zt < 1.5) shadows.push(...restingShadowOps(t, o, 1 - zt / 1.5));
1631
+ }
1632
+ }
1633
+ const ops = o.oblique ? [...shadows, ...ground] : [...ground, ...shadows, ...air];
1634
+ return { ops, bounds: ops.length ? { minX, maxX, minY, maxY } : null, count: ordered.length };
1635
+ }
1636
+
1637
+ // PAINT ORDER UNDER THE OBLIQUE CAMERA (operator, 2026-09-11: "Blocks
1638
+ // bouncing around during movements and z-fighting in a weird way" / "new
1639
+ // blocks landing and bouncing is totally broken visually"). The fixed rule
1640
+ // was "the far corner first, everything airborne last", and with cubes of
1641
+ // real height that is wrong: a small cube bouncing a unit off the floor
1642
+ // BEHIND a tall resting one was painted over it, so landings looked like
1643
+ // cubes sliding through each other.
1644
+ //
1645
+ // The real rule, for two cubes whose pictures overlap: find an axis that
1646
+ // separates them and paint the one on the viewer's side of it later. Height
1647
+ // first -- the planner keeps any two flights that pass over each other in
1648
+ // disjoint altitude intervals, so a pair one above the other always splits
1649
+ // here, and the higher paints later -- then the row (the camera sits past
1650
+ // row 0 when rows are flipped), then the column (it sits past column 0).
1651
+ // Those pairwise facts are sorted topologically; the old diagonal key only
1652
+ // breaks ties, so a still board paints exactly as before.
1653
+ export function obliqueOrder(tiles, o = {}) {
1654
+ const n = tiles.length;
1655
+ const flipped = flipOf(o) > 0;
1656
+ // A cube's outline on screen: the convex hull of its eight corners as drawn.
1657
+ const hullOf = (pts) => {
1658
+ const s = pts.slice().sort((p, q) => p.x - q.x || p.y - q.y);
1659
+ const cross = (a, p, q) => (p.x - a.x) * (q.y - a.y) - (p.y - a.y) * (q.x - a.x);
1660
+ const lo = [], hi = [];
1661
+ for (const p of s) { while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p) <= 0) lo.pop(); lo.push(p); }
1662
+ for (let i = s.length - 1; i >= 0; i--) { const p = s[i]; while (hi.length >= 2 && cross(hi[hi.length - 2], hi[hi.length - 1], p) <= 0) hi.pop(); hi.push(p); }
1663
+ return lo.slice(0, -1).concat(hi.slice(0, -1));
1664
+ };
1665
+ // Do two outlines overlap by more than `tol` on every axis? (separating axis
1666
+ // theorem for convex polygons)
1667
+ const overlaps = (A, B, tol) => {
1668
+ for (const poly of [A, B]) {
1669
+ for (let i = 0; i < poly.length; i++) {
1670
+ const p = poly[i], q = poly[(i + 1) % poly.length];
1671
+ let nx = q.y - p.y, ny = p.x - q.x;
1672
+ const L = Math.hypot(nx, ny);
1673
+ if (!L) continue;
1674
+ nx /= L; ny /= L;
1675
+ let a0 = Infinity, a1 = -Infinity, b0 = Infinity, b1 = -Infinity;
1676
+ for (const v of A) { const d = v.x * nx + v.y * ny; if (d < a0) a0 = d; if (d > a1) a1 = d; }
1677
+ for (const v of B) { const d = v.x * nx + v.y * ny; if (d < b0) b0 = d; if (d > b1) b1 = d; }
1678
+ if (Math.min(a1, b1) - Math.max(a0, b0) < tol) return false;
1679
+ }
1680
+ }
1681
+ return true;
1682
+ };
1683
+ const info = tiles.map((t, i) => {
1684
+ const z0 = (t.z ?? 0) + (t.floor ?? 0), h = cubeHeight(t);
1685
+ const P = liftProjector(t, o);
1686
+ const pts = [];
1687
+ for (const gx of [t.x, t.x + t.s]) for (const gy of [t.y, t.y + t.s]) for (const gz of [z0, z0 + h]) pts.push(P(gx, gy, gz));
1688
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
1689
+ for (const p of pts) { if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x; if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y; }
1690
+ // Footprints are the SLOT, deliberately, even in flight. Judging a flight by where it had drifted
1691
+ // to was tried (2026-09-14) and made pairs flip on every bounce; what fixed the resting-over-flyer
1692
+ // errors was the path instead -- flightOffset rises straight up until clear of the neighbours.
1693
+ const dx = 0, dy = 0;
1694
+ // the drawn base and top, for the one decision that must follow the picture (nearer, below)
1695
+ const air = (t.z ?? 0) > 0 || (t.entry ?? 0) > 0;
1696
+ const ccx = t.x + t.s / 2, ccy = t.y + t.s / 2;
1697
+ const dBottom = (air ? flightOffset(ccx, ccy, visualBase(t, o), o).z : 0) + (t.floor ?? 0) + capZ(ccx, ccy, o);
1698
+ return { t, i, air, dBottom, dTop: dBottom + h, zv0: z0, zv1: z0 + h, hull: hullOf(pts), bx0: x0, bx1: x1, by0: y0, by1: y1,
1699
+ fx0: t.x + dx, fx1: t.x + t.s + dx, fy0: t.y + dy, fy1: t.y + t.s + dy, key: String(t.txid), diag: t.x + t.y + t.s };
1700
+ });
1701
+ // The priority among cubes nothing constrains, and where a cycle is cut: the
1702
+ // old diagonal order, by FOOTPRINT only. It used to include the current height
1703
+ // and whether a cube was airborne, which change every frame, so a cycle was cut
1704
+ // in a different place each frame and the picture flickered (2026-09-11).
1705
+ const rank = new Array(n);
1706
+ info.slice().sort((p, q) => (q.diag - p.diag) || (p.key < q.key ? -1 : p.key > q.key ? 1 : 0))
1707
+ .forEach((e, k) => { rank[e.i] = k; });
1708
+ // 1e-3, not 1e-6: an eased flight lands its footprint on a whole cell only to ~1e-5 (measured: x
1709
+ // 23.9999929 against a neighbour at 25.9999858), so two abutting cubes kept crossing a 1e-6
1710
+ // tolerance on rounding noise and swapped between the column rule and the height rule -- 60px of
1711
+ // faces changing hands in one frame at the travel/drop boundary. No real overlap is that thin.
1712
+ const EPS = 1e-3;
1713
+ // IN REAL SPACE, footprint first, height only when the footprints overlap: in
1714
+ // real space the planner guarantees no two cubes intersect, so some axis always
1715
+ // separates a pair, and a bouncing cube (which keeps crossing its neighbour's
1716
+ // top) stays decided by the footprints.
1717
+ const memo = o.orderMemo instanceof Map ? o.orderMemo : null;
1718
+ const ABOVE_BAND = 0.75;
1719
+ const nearer = (a, b) => {
1720
+ // A CUBE DRAWN WHOLLY ABOVE A RESTING ONE IS IN FRONT OF IT (operator, 2026-09-14, twice: "The small
1721
+ // blocks that remain on the board always sort over the cubes flying above it", then, on Depth
1722
+ // 0.023 with Arcing: "items that remain on the board during transitions ... still sort above
1723
+ // everything else" and "blocks moving through other tall blocks"). Depth scales a point by its
1724
+ // height, so a rising cube's picture swells over the cube BESIDE it, and the footprint rules
1725
+ // below -- right for a parallel camera -- let the resting neighbour paint over it: 362 such pairs
1726
+ // on one live refresh at the operator's settings. The camera looks down, so a surface drawn above
1727
+ // another's top is nearer. First tried when flights still drifted sideways from the floor, this
1728
+ // rule made a bouncing cube swap with its neighbour on every hop; flights now rise straight up
1729
+ // until clear (flightOffset), and it is measured again here rather than assumed.
1730
+ // WITH A DEAD BAND, because the plain comparison was measured and failed: correct for the pair
1731
+ // (362 wrong -> 5) but a landing cube's bottom crosses its neighbour's top on every hop, and each
1732
+ // crossing swapped the pair over pictures Depth had already made overlap -- 844 px changing hands.
1733
+ // Clearly above: the flyer in front. Clearly below: the footprint rules. In between, whatever the
1734
+ // pair was last frame, so a hop that only grazes the neighbour's top changes nothing.
1735
+ // ONLY WITH DEPTH ON. At Depth 0 nothing swells, the footprint rules alone measured 0 wrong pairs
1736
+ // and 0 pops, and this rule added 82 px of pops there for nothing. With Depth it is a trade, and
1737
+ // the numbers chose it (Depth 0.023, Arcing, one refresh): wrong pairs 362 -> 72, visible pops
1738
+ // 0 -> 356 px, mostly small landings. Wider bands gave fewer pops and more wrong pairs (1.5: 164 /
1739
+ // 269 px; 3: 337 / 257 px), so 0.75 is where most of the errors go for the fewest pops.
1740
+ if (a.air !== b.air && (o.oblique?.rise ?? 0) > 0) {
1741
+ const fly = a.air ? a : b, rest = a.air ? b : a;
1742
+ const gap = fly.dBottom - rest.dTop;
1743
+ if (gap >= ABOVE_BAND) return a.air ? 1 : -1;
1744
+ if (gap > -ABOVE_BAND && memo) {
1745
+ const pa = memo.get(a.key), pb = memo.get(b.key);
1746
+ if (pa !== undefined && pb !== undefined && pa !== pb) return pa > pb ? 1 : -1;
1747
+ }
1748
+ }
1749
+ if (a.fy1 <= b.fy0 + EPS) return flipped ? 1 : -1;
1750
+ if (b.fy1 <= a.fy0 + EPS) return flipped ? -1 : 1;
1751
+ // ACROSS COLUMNS THE LEAN DECIDES, not "left after right" (operator, 2026-09-14: "shit popping
1752
+ // over other shit at end of movements"). Height pushes a cube's top sideways by the lean, which
1753
+ // is radial: rightward on the right half, LEFTWARD on the left. A cube covers the neighbour its
1754
+ // top leans over, so on the left half the RIGHT cube paints after. The column rule was right
1755
+ // only on the right half, and leanEdge below corrected the left half on a settled board only --
1756
+ // so the order was wrong the whole time anything flew and flipped the frame the last cube
1757
+ // landed. Measured on a 90-cube transition: 18px of faces changing owner on that frame. The
1758
+ // lean is taken at the boundary between the two footprints, a function of the slots alone, so
1759
+ // it cannot change from frame to frame.
1760
+ if (a.fx1 <= b.fx0 + EPS) return obliqueLean((a.fx1 + b.fx0) / 2, o) < 0 ? -1 : 1;
1761
+ if (b.fx1 <= a.fx0 + EPS) return obliqueLean((b.fx1 + a.fx0) / 2, o) < 0 ? 1 : -1;
1762
+ if (a.zv0 >= b.zv1 - EPS) return 1;
1763
+ if (b.zv0 >= a.zv1 - EPS) return -1;
1764
+ // overlapping even in real space (not in a planned flight): the axis of least overlap
1765
+ const oz = Math.min(a.zv1, b.zv1) - Math.max(a.zv0, b.zv0);
1766
+ const oy = Math.min(a.fy1, b.fy1) - Math.max(a.fy0, b.fy0);
1767
+ const ox = Math.min(a.fx1, b.fx1) - Math.max(a.fx0, b.fx0);
1768
+ if (oz <= oy && oz <= ox) return a.zv0 + a.zv1 > b.zv0 + b.zv1 ? 1 : -1;
1769
+ if (oy <= ox) return (a.fy0 + a.fy1 < b.fy0 + b.fy1) === flipped ? 1 : -1;
1770
+ return a.fx0 + a.fx1 < b.fx0 + b.fx1 ? 1 : -1;
1771
+ };
1772
+ // CONSTRAINTS ONLY WHERE THE PICTURES TRULY OVERLAP. A padded bounding box
1773
+ // added one for every pair within ~11 px of each other; between cubes whose
1774
+ // outlines do not actually overlap those constraints are free to contradict
1775
+ // one another, and the cycles they formed were cut arbitrarily -- replayed, the
1776
+ // final order contradicted a pairwise decision in 1267 of 1452 frames while no
1777
+ // decision itself ever changed. Now: outlines overlapping at all.
1778
+ // 0, not 0.5 (2026-09-14): with the lean deciding columns (nearer) the pair rules agree, so a
1779
+ // touching pair no longer needs to be kept out -- and keeping it out is what made it pop. A cube
1780
+ // bouncing beside a neighbour crossed the half-pixel threshold on every hop, the edge came and
1781
+ // went, and without it the pair fell back to the diagonal rank and swapped: measured, 212px of
1782
+ // faces changing hands during the landings of a 90-cube transition at 0.5, 127px at 0.
1783
+ const TOL = 0;
1784
+ const after = Array.from({ length: n }, () => []);
1785
+ const indeg = new Array(n).fill(0);
1786
+ // A LEANING FACE OVER A SHORTER NEIGHBOUR (operator, 2026-09-12: "Height sorting issue on bottom
1787
+ // left larger blocks next to smaller blocks"). Measured on the live Simple board: 6 of 33
1788
+ // same-row tall/short pairs painted the short one AFTER the tall one, clipping the tall cube's
1789
+ // side face -- every one of them on the LEFT half. Two things together: the pair rule below
1790
+ // orders an x-overlap by column alone (left after right), which is right where faces lean
1791
+ // right and backwards where the radial lean points them left; and abutting neighbours never
1792
+ // reach that rule anyway, because the bounding-box check skips any pair closer than TOL while
1793
+ // a 2-tall face leans over its neighbour by ~0.18 units. So: for same-row neighbours of
1794
+ // unequal height, the taller paints after the one its face leans over, the side taken from the
1795
+ // lean at its own column. +1: i after j. -1: j after i. 0: not this case.
1796
+ // ON A SETTLED BOARD ONLY. A clipped face is a resting-board artefact -- a cube standing beside
1797
+ // a shorter one. Applied during a transition this rule made pairs flicker (the guard caught 21
1798
+ // of 785 overlapping pairs swapping mid-flight), and gating it per pair was not enough: an
1799
+ // extra edge changes the SHAPE of the constraint graph, so as third cubes fly past and their
1800
+ // hull edges come and go, the group a resting pair belongs to is re-cut differently from frame
1801
+ // to frame and the pair swaps without either of them moving. So while anything on the board is
1802
+ // in flight, no leaning-face edges at all: the graph is exactly what it was before this rule
1803
+ // existed, and the resting order takes over once, at settle -- a single change, not a swap.
1804
+ const flying = (t) => (t.z ?? 0) > 0 || (t.entry ?? 0) > 0;
1805
+ const settledBoard = !tiles.some(flying);
1806
+ const leanEdge = (p, q) => {
1807
+ if (!settledBoard) return 0;
1808
+ const hp = cubeHeight(p.t), hq = cubeHeight(q.t);
1809
+ if (hp === hq) return 0;
1810
+ const [tall, short, sign] = hp > hq ? [p.t, q.t, 1] : [q.t, p.t, -1];
1811
+ if (!(short.y < tall.y + tall.s && tall.y < short.y + short.s)) return 0; // same rows
1812
+ const lean = obliqueLean(tall.x + tall.s / 2, o);
1813
+ const onLeanSide = (lean > 0 && short.x === tall.x + tall.s) || (lean < 0 && short.x + short.s === tall.x);
1814
+ return onLeanSide ? sign : 0;
1815
+ };
1816
+ for (let i = 0; i < n; i++) {
1817
+ const a = info[i];
1818
+ for (let j = i + 1; j < n; j++) {
1819
+ const c2 = info[j];
1820
+ const le = leanEdge(a, c2);
1821
+ if (le > 0) { after[j].push(i); indeg[i]++; continue; }
1822
+ if (le < 0) { after[i].push(j); indeg[j]++; continue; }
1823
+ if (a.bx1 - TOL < c2.bx0 || c2.bx1 - TOL < a.bx0 || a.by1 - TOL < c2.by0 || c2.by1 - TOL < a.by0) continue;
1824
+ if (!overlaps(a.hull, c2.hull, TOL)) continue;
1825
+ const c = nearer(a, c2);
1826
+ if (c > 0) { after[j].push(i); indeg[i]++; } else if (c < 0) { after[i].push(j); indeg[j]++; }
1827
+ }
1828
+ }
1829
+ // CYCLES ARE RESOLVED AS A GROUP, NOT CUT (2026-09-11). Even with constraints
1830
+ // only between truly overlapping outlines, a cube in flight can be tangled
1831
+ // with several others: the pair decisions are made in real space and the
1832
+ // outlines are drawn on the sphere, so a few groups contradict each other. The
1833
+ // old loop broke a cycle by forcing the lowest-ranked cube LEFT ANYWHERE --
1834
+ // often one outside the cycle -- and which cubes were left changed as others
1835
+ // moved, so the same tangle was cut differently from frame to frame (replayed:
1836
+ // 21 of the last 23 flickers, with the pair decision and the overlap both
1837
+ // unchanged). Now the tangles are found (strongly connected components), the
1838
+ // groups are ordered among themselves -- that order cannot cycle -- and inside
1839
+ // a group the cubes go by how near their drawn centre is to the camera, a
1840
+ // smooth measure that cannot be cut two ways.
1841
+ // the camera looks along (-ox, -oy/dy, 1): a squeezed depth weighs a row further back more
1842
+ const oby = (o.oblique?.oy ?? 0.36) / (o.oblique?.dy ?? 1);
1843
+ const depth = info.map((e2) => {
1844
+ const t = e2.t, h = cubeHeight(t), cy = t.y + t.s / 2;
1845
+ const zb = (t.z ?? 0) > 0 || t.entry > 0 ? visualBase(t, o) : 0;
1846
+ // where this cube is actually DRAWN: its slot, plus the flight along the sphere's normal,
1847
+ // plus the camera's own sideways push -- settled by the SAME function liftProjector uses, so
1848
+ // the order and the geometry cannot be measured at different points (obliqueLean is radial, so
1849
+ // a block that has travelled leans by where it has got to, not by the slot it left). This was
1850
+ // six lines copied from there; with a departure mode to honour, a copy is a latent flicker.
1851
+ const { lean, cx } = flightGeom(t, zb, o);
1852
+ return -lean * cx - (flipped ? 1 : -1) * oby * cy + capZ(cx, cy, o) + zb + (t.floor ?? 0) + h / 2;
1853
+ });
1854
+ const index = new Array(n).fill(-1), low = new Array(n).fill(0), onStack = new Array(n).fill(false), comp = new Array(n).fill(-1);
1855
+ const stack = [];
1856
+ let idx = 0, nComp = 0;
1857
+ for (let s0 = 0; s0 < n; s0++) {
1858
+ if (index[s0] >= 0) continue;
1859
+ const work = [[s0, 0]];
1860
+ index[s0] = low[s0] = idx++; stack.push(s0); onStack[s0] = true;
1861
+ while (work.length) {
1862
+ const top = work[work.length - 1], v = top[0];
1863
+ if (top[1] < after[v].length) {
1864
+ const w = after[v][top[1]++];
1865
+ if (index[w] < 0) { index[w] = low[w] = idx++; stack.push(w); onStack[w] = true; work.push([w, 0]); }
1866
+ else if (onStack[w]) low[v] = Math.min(low[v], index[w]);
1867
+ } else {
1868
+ work.pop();
1869
+ if (work.length) { const u = work[work.length - 1][0]; low[u] = Math.min(low[u], low[v]); }
1870
+ if (low[v] === index[v]) { let w; do { w = stack.pop(); onStack[w] = false; comp[w] = nComp; } while (w !== v); nComp++; }
1871
+ }
1872
+ }
1873
+ }
1874
+ const members = Array.from({ length: nComp }, () => []);
1875
+ for (let v = 0; v < n; v++) members[comp[v]].push(v);
1876
+ const cRank = members.map((m) => Math.min(...m.map((v) => rank[v])));
1877
+ const cAfter = Array.from({ length: nComp }, () => new Set());
1878
+ const cIn = new Array(nComp).fill(0);
1879
+ for (let v = 0; v < n; v++) for (const w of after[v]) {
1880
+ const p = comp[v], q = comp[w];
1881
+ if (p !== q && !cAfter[p].has(q)) { cAfter[p].add(q); cIn[q]++; }
1882
+ }
1883
+ // the groups, lowest-ranked ready group first (a DAG: nothing to cut)
1884
+ const heap = [];
1885
+ const push = (c) => { heap.push(c); let k = heap.length - 1; while (k > 0) { const p = (k - 1) >> 1; if (cRank[heap[p]] <= cRank[c]) break; heap[k] = heap[p]; k = p; } heap[k] = c; };
1886
+ const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { let k = 0; for (;;) { const l = 2 * k + 1, r = l + 1; let m = k; const vm = () => (m === k ? last : heap[m]); if (l < heap.length && cRank[heap[l]] < cRank[vm()]) m = l; if (r < heap.length && cRank[heap[r]] < cRank[vm()]) m = r; if (m === k) break; heap[k] = heap[m]; k = m; } heap[k] = last; } return top; };
1887
+ for (let c = 0; c < nComp; c++) if (cIn[c] === 0) push(c);
1888
+ // A TANGLE REMEMBERS ITS ORDER (2026-09-12, operator: "Still showing block z-fighting during
1889
+ // transitions"). Depth alone re-ordered a tangle every frame it was re-cut, so a pair whose
1890
+ // decision never changed still swapped whenever a bystander flying past pulled it into a
1891
+ // tangle or let it out (replayed: 18 of 19 flickers had the edge and the decision unchanged
1892
+ // and the final order flipped). With `orderMemo` (render3d keeps one per canvas) the cubes of
1893
+ // a tangle keep the relative order they had LAST frame -- the order the edges gave them before
1894
+ // the tangle formed -- and only cubes with no last frame fall in by depth. In and out of a
1895
+ // tangle the pair's order is then the same, so nothing can flicker; a cube genuinely passing
1896
+ // another is decided by the edges again the moment the tangle dissolves.
1897
+ const prev = memo ? (v) => memo.get(String(tiles[v].txid)) : () => undefined;
1898
+ const out = [];
1899
+ while (heap.length) {
1900
+ const c = pop();
1901
+ const m = members[c];
1902
+ if (m.length > 1) m.sort((p, q) => {
1903
+ const a = prev(p), b = prev(q);
1904
+ if (a !== undefined && b !== undefined && a !== b) return a - b;
1905
+ if (a !== undefined && b === undefined) return -1;
1906
+ if (a === undefined && b !== undefined) return 1;
1907
+ return (depth[p] - depth[q]) || (rank[p] - rank[q]);
1908
+ });
1909
+ for (const v of m) out.push(tiles[v]);
1910
+ for (const d of cAfter[c]) if (--cIn[d] === 0) push(d);
1911
+ }
1912
+ if (memo) { memo.clear(); out.forEach((t, k) => memo.set(String(t.txid), k)); }
1913
+ return out;
1914
+ }
1915
+
1916
+ // RESTING CUBES CAST SHADOWS TOO (operator, 2026-09-11: "a more interesting
1917
+ // ground texture ... shows the shadows well"). Only flights cast one before,
1918
+ // so at rest -- most of the time -- nothing on the ground showed a shadow. A
1919
+ // standing cube throws a short one away from the light (upper left, above):
1920
+ // down and to the right of its footprint, longer the taller the cube -- the
1921
+ // hull of the footprint and its shifted copy -- with a fainter penumbra round
1922
+ // it. It lies on the floor, so every cube paints over it (buildScene paints
1923
+ // shadows first).
1924
+ export function restingShadowOps(t, o = {}, k = 1) {
1925
+ if (!(k > 0.005)) return [];
1926
+ const flip = o.flipY === false ? -1 : 1;
1927
+ const d = Math.min(2.5, 0.2 + 0.28 * (cubeHeight(t) + (t.floor ?? 0)));
1928
+ const hull = (e, dd) => {
1929
+ const x0 = t.x - e, y0 = t.y - e, x1 = t.x + t.s + e, y1 = t.y + t.s + e, dy = -flip * dd; // screen-down is -row
1930
+ return [[x0, y1], [x1, y1], [x1 + dd, y1 + dy], [x1 + dd, y0 + dy], [x0 + dd, y0 + dy], [x0, y0]].map(([gx, gy]) => project(gx, gy, 0, o));
1931
+ };
1932
+ return [
1933
+ { txid: t.txid, face: 'shadow', points: hull(0.15, d * 1.35), fill: `rgba(0,0,0,${round3(0.12 * k)})` },
1934
+ { txid: t.txid, face: 'shadow', points: hull(0, d), fill: `rgba(0,0,0,${round3(0.26 * k)})` },
1935
+ ];
1936
+ }
1937
+
1938
+ // The shadow an airborne tile casts on the plane, as up to three nested
1939
+ // rgba squares: a soft penumbra and a darker umbra, both at the tile's TRUE
1940
+ // footprint on the tops of the resting stones.
1941
+ export function shadowOps(t, a = 1, o = {}) {
1942
+ const z = t.z ?? 0;
1943
+ if (!(z > 0.02) || !(a > 0)) return [];
1944
+ const blur = Math.min(3, 0.15 + z * 0.13); // spreads as it climbs (gentler since flights climb higher)
1945
+ const dark = Math.min(0.78, 0.78 / (1 + z * 0.05)) * a; // and pales -- slowly, so a high flight still marks its slot
1946
+ // Cast by a light above and to the upper left: the shadow slides down and to
1947
+ // the right of the slot as the block climbs, and that growing gap is what
1948
+ // says "lifted toward the viewer" (directly underneath, a spreading dark
1949
+ // ring read as the block sinking).
1950
+ const off = o.oblique ? 0 : Math.min(2, z * 0.3); // oblique: the block itself is displaced, the shadow stays on its slot
1951
+ const ox = off, oy = (o.flipY === false ? off : -off); // screen-down is -row when rows are flipped
1952
+ const P = (gx, gy) => project(gx + ox, gy + oy, 0, o); // on the floor: resting cubes are of every height now
1953
+ const sq = (e) => [P(t.x - e, t.y - e), P(t.x + t.s + e, t.y - e), P(t.x + t.s + e, t.y + t.s + e), P(t.x - e, t.y + t.s + e)];
1954
+ const core = Math.min(t.s * 0.3, z * 0.04);
1955
+ return [
1956
+ { txid: t.txid, face: 'shadow', points: sq(blur), fill: `rgba(0,0,0,${round3(dark * 0.22)})` },
1957
+ { txid: t.txid, face: 'shadow', points: sq(blur * 0.45), fill: `rgba(0,0,0,${round3(dark * 0.3)})` },
1958
+ { txid: t.txid, face: 'shadow', points: sq(-core), fill: `rgba(0,0,0,${round3(dark * 0.5)})` },
1959
+ ];
1960
+ }
1961
+
1962
+ // FILL the box, not fit inside it. The packing is rarely exactly square --
1963
+ // sides round to whole units, so a block packs a few rows taller or shorter
1964
+ // than it is wide (blockpack.js) -- and an aspect-preserving fit into a
1965
+ // square panel left black bars down both sides -- "too much black ...
1966
+ // wasted space".
1967
+ //
1968
+ // The cost, stated rather than hidden: scaling the axes separately makes a
1969
+ // block up to about 10% off square. That is below the threshold where anyone reads
1970
+ // it as a rectangle, and it buys the whole panel. Areas still track vbytes
1971
+ // exactly, because both axes scale every block identically.
1972
+ export function fitToBox(bounds, boxW, boxH, pad = 8) {
1973
+ if (!bounds) return { scaleX: 1, scaleY: 1, scale: 1, dx: 0, dy: 0 };
1974
+ const w = bounds.maxX - bounds.minX || 1;
1975
+ const h = bounds.maxY - bounds.minY || 1;
1976
+ const scaleX = (boxW - pad * 2) / w;
1977
+ const scaleY = (boxH - pad * 2) / h;
1978
+ return {
1979
+ scaleX, scaleY,
1980
+ scale: Math.min(scaleX, scaleY), // for callers that want the uniform one
1981
+ dx: pad - bounds.minX * scaleX,
1982
+ dy: pad - bounds.minY * scaleY,
1983
+ };
1984
+ }
1985
+
1986
+ // --- easing ------------------------------------------------------------
1987
+ export function easeInOutCubic(t) {
1988
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1989
+ }
1990
+ export function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
1991
+ // Gravity: a piece does not ease into the floor, it accelerates into it.
1992
+ export function gravity(t) { const c = Math.max(0, Math.min(1, t)); return c * c; }
1993
+
1994
+ // The landing, with real gravity and a decaying series of bounces. Returns
1995
+ // the REMAINING height as a fraction of the drop: 1 at release, 0 at rest.
1996
+ //
1997
+ // The fall is h = 1 - u^2, which is gravity: the block covers three quarters
1998
+ // of the distance in the second half of the fall, so it visibly accelerates
1999
+ // into the floor. Each bounce afterwards is a parabola of decaying height
2000
+ // (restitution 0.55) and correspondingly shorter duration, exactly as a
2001
+ // dropped object behaves -- quick little hops at the end rather than one
2002
+ // polite rebound.
2003
+ //
2004
+ // `bounces` is 1..5 per block, from its own hash, so no two settle the same
2005
+ // way (operator: "Make every block bounce at least 1-5 times randomly before
2006
+ // settling into place").
2007
+ export function bounceDrop(t, bounce = 0.12, bounces = 3, rest = 0.55) {
2008
+ const c = Math.max(0, Math.min(1, t));
2009
+ const n = Math.max(1, Math.min(12, Math.round(bounces)));
2010
+ const REST = rest;
2011
+
2012
+ const heights = [];
2013
+ let h = Math.max(bounce, 1e-6);
2014
+ for (let k = 0; k < n; k++) { heights.push(h); h *= REST; }
2015
+
2016
+ // PHYSICS FOR THE TIMING (operator, 2026-09-11: "The bouncing needs to be
2017
+ // sinusoidal and apply physics so each bounce isn't the same length of
2018
+ // time"). Under constant gravity a fall from height 1 takes time T, and a
2019
+ // hop up to height h and back takes 2 T sqrt(h). The old weights were
2020
+ // 1.25 * sqrt(h / h0): relative to the FIRST hop, not to the fall, so the
2021
+ // first hop always lasted 1.25 falls whatever its height -- a 3% rebound
2022
+ // floated as long as a 22% one. With heights kept at REST = 0.55 per bounce,
2023
+ // each hop is now sqrt(0.55) = 0.74 as long as the one before it.
2024
+ const segs = [1, ...heights.map((v) => 2 * Math.sqrt(v))];
2025
+ const total = segs.reduce((a, b) => a + b, 0);
2026
+
2027
+ let acc = 0;
2028
+ for (let i = 0; i < segs.length; i++) {
2029
+ const w = segs[i] / total;
2030
+ const last = i === segs.length - 1;
2031
+ if (c < acc + w || last) {
2032
+ const u = w > 0 ? Math.min(1, Math.max(0, (c - acc) / w)) : 1;
2033
+ if (i === 0) return 1 - u * u; // the fall: accelerating
2034
+ if (last && c >= 1) return 0; // and it does come to rest
2035
+ return heights[i - 1] * Math.sin(Math.PI * u); // a hop: a sine arc, lower and shorter each time
2036
+ }
2037
+ acc += w;
2038
+ }
2039
+ return 0;
2040
+ }
2041
+
2042
+ // GRAVITY TIMES EVERY LANDING (operator, 2026-09-11: "I don't get any sense
2043
+ // of blocks falling and bouncing ... Need much more randomness and
2044
+ // variability"). Every block's landing used to be squeezed into the same
2045
+ // fixed drop window whatever it fell from, so a block dropping one unit took
2046
+ // as long as one dropping twelve and floated down. Now a fall from h takes
2047
+ // sqrt(2h / g) -- a fall from 12 units takes 2.4 s -- and the whole landing
2048
+ // is that fall plus its own hops (bounceDrop's segments are in the same
2049
+ // proportions, so the curve and the clock agree). Low blocks land fast, high
2050
+ // ones take longer, and no two finish together.
2051
+ // 2026-09-11: a 12-unit fall took 1.6 s, which on this board read as slow
2052
+ // motion (operator: "They bounce too slowly right now"); 0.7 s was then far
2053
+ // too fast ("Holy shit the bouncing is too fast now. cut speed in half at
2054
+ // least"). 1.4 s: half the speed of 0.7 s, every hop with it.
2055
+ // ...and 1.4 s was STILL "way too fast. Calm that shit down": 2.4 s now, with
2056
+ // deader rebounds (restitutionOf below).
2057
+ // 12 units in 0.6 s since 2026-09-11 -- 2.4 s was "The items slowly start dropping.
2058
+ // They need to drop like they were just let go, immediately succumb to gravity", and
2059
+ // 1.2 s "gravity still doesn't seem strong enough"
2060
+ export const GRAVITY = (2 * 12) / (600 * 600); // grid units per ms^2
2061
+ export function fallMs(h) { return Math.sqrt((2 * Math.max(0, h)) / GRAVITY); }
2062
+ export function landingMs(h, bounce, bounces, rest = 0.55) {
2063
+ const n = Math.max(1, Math.min(12, Math.round(bounces)));
2064
+ let b = Math.max(bounce, 1e-6), hops = 0;
2065
+ for (let k = 0; k < n; k++) { hops += 2 * Math.sqrt(b); b *= rest; }
2066
+ return fallMs(h) * (1 + hops);
2067
+ }
2068
+
2069
+ // One block's landing: where it falls from, how it bounces, and when it lets
2070
+ // go and comes to rest. Deterministic from the block's own hashes, so it has
2071
+ // the same character on every repaint.
2072
+ export function landingOf(tw, plan) {
2073
+ const j = tw.jitter ?? 0;
2074
+ const jH = tw.jH ?? j;
2075
+ const spread = plan.cfg.bounceSpread;
2076
+ // MASS (operator, 2026-09-11: "Have the blocks obey the natural laws of
2077
+ // gravity with their bouncing ... Give the blocks actual weight/mass for
2078
+ // physics to simulate"). Mass does not change how fast a block falls
2079
+ // (Galileo); it changes how it BOUNCES, through its coefficient of
2080
+ // restitution e: a rebound reaches e^2 of the height it fell from, and each
2081
+ // hop lasts e as long as the one before. A heavy block (large area, i.e. many
2082
+ // vbytes) thuds and settles; a light one rebounds and keeps hopping.
2083
+ const e = restitutionOf(tw.to?.s ?? 1, tw.jR ?? jitterOf(tw.txid, 'r'));
2084
+ const rest = e * e;
2085
+ const bounce = rest; // the first rebound is e^2 of the drop, like every one after it
2086
+ const nBounce = bouncesUntil(bounce, rest);
2087
+ // an arrival lets go from its own height, always above the flight stack
2088
+ const from = tw.kind === 'enter' ? plan.cfg.enterFrom * (1 + 0.9 * (tw.jA ?? 0)) : (tw.lane ?? 0);
2089
+ // Cube lanes can stack high over a busy board; gravity from forty units
2090
+ // would take seconds. The fall is TIMED as if from at most 24 units (~3.4 s;
2091
+ // it was forty for the 40 s transition, twelve and sixteen before that),
2092
+ // which the eye reads as the same drop -- the heights themselves are kept,
2093
+ // because the no-collision proof lives in them.
2094
+ const t0 = plan.phases.travel + j * (plan.cfg.dropStagger || 0);
2095
+ return { bounce, nBounce, rest, from, t0, t1: t0 + landingMs(Math.min(from, 24), bounce, nBounce, rest) };
2096
+ }
2097
+
2098
+ // COMING TO REST THE WAY A DROPPED THING DOES (operator, 2026-09-11: "The
2099
+ // blocks bouncing and coming to rest seems too abrupt. It needs to really look
2100
+ // like physics is making it come to rest naturally"). A fixed 1-5 bounces
2101
+ // stopped a block while its hops were still plainly visible. It now bounces
2102
+ // until a hop would be under 0.4% of the drop -- each lower by e^2 and shorter
2103
+ // by e, where e is the block's coefficient of restitution (from its mass, see
2104
+ // restitutionOf) -- a converging series of ever quicker, ever smaller hops,
2105
+ // which is how a real object comes to rest in finite time. Variety comes from
2106
+ // mass and a small per-block share rather than a dice roll on the count.
2107
+ // (2026-09-11, "come to rest sooner": the floor 0.4% -> 1% and one hop allowed,
2108
+ // so a light block gets a small second hop and a heavy one settles after one)
2109
+ export function bouncesUntil(bounce, rest, floor = 0.01) {
2110
+ const n = Math.ceil(Math.log(floor / Math.max(bounce, 1e-6)) / Math.log(rest));
2111
+ return Math.max(1, Math.min(8, n));
2112
+ }
2113
+
2114
+ // Coefficient of restitution from mass. Mass goes as a block's AREA (its
2115
+ // vbytes), and a heavier block is a deader bounce: e falls with log2 of the
2116
+ // side, from ~0.78 for a one-unit block to ~0.4 for the largest, plus a +-0.05
2117
+ // share from the block's own hash so two blocks of one size do not settle in
2118
+ // lockstep.
2119
+ export function restitutionOf(side, jitter = 0.5) {
2120
+ // calmer (2026-09-11): e in 0.28-0.6; then heavier still ("less bouncing.
2121
+ // Make them come to rest sooner. More weight to each block"): e in 0.15-0.36,
2122
+ // so a one-unit block keeps at most 13% of its fall and a big one ~3%
2123
+ return Math.max(0.15, Math.min(0.36, 0.36 - 0.05 * Math.log2(Math.max(1, side)) + (jitter - 0.5) * 0.06));
2124
+ }
2125
+
2126
+ // how many times a given block bounces: 1..5, stable from its id (the old
2127
+ // policy, kept for anything that still asks)
2128
+ export function bouncesFor(jitter) { return 1 + Math.floor(Math.max(0, Math.min(0.999, jitter)) * 5); }
2129
+
2130
+ // Heavier (bigger) blocks barely bounce; small ones do. Bounded so nothing
2131
+ // bounces absurdly and nothing is perfectly dead.
2132
+ // 2026-09-11: raised from [0.03, 0.22] -- with a drop of a unit or two a 3%
2133
+ // rebound is a few hundredths of a unit, and nobody saw a bounce at all.
2134
+ export function bounceFor(side) { return Math.max(0.1, Math.min(0.4, 0.9 / Math.max(1, side))); }
2135
+
2136
+ // A stable per-block number in [0,1), from its id. DETERMINISTIC on purpose:
2137
+ // a random draw per frame would make every block jitter its own landing, and
2138
+ // the same block must get the same character on every repaint or the picture
2139
+ // shimmers. FNV-1a, which is four lines and needs no dependency.
2140
+ export function jitterOf(txid, salt = '') {
2141
+ let h = 2166136261;
2142
+ const s = salt + String(txid);
2143
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
2144
+ return ((h >>> 0) % 10007) / 10007;
2145
+ }
2146
+
2147
+
2148
+ // --- the choreography --------------------------------------------------
2149
+ // rise + travel + drop = 5.4 s by default: inside a 10 s refresh, and slow
2150
+ // enough to follow one tile across the board.
2151
+ // Thirty seconds end to end (operator, 2026-09-10), against a 20 s data
2152
+ // refresh: a transition therefore overlaps the next poll, and the replan
2153
+ // guard in the renderer is what keeps that from restarting it -- an unchanged
2154
+ // layout leaves the running choreography alone.
2155
+ export const TRANSITION = {
2156
+ // 40 s nominal (operator, 2026-09-11: "increase the animation time to at
2157
+ // least 40 seconds if it's refreshed every 60 seconds"); was 5 + 18.5 + 6.5
2158
+ // 20 s since the second 2026-09-11 change ("Faster refresh": the viewer
2159
+ // refreshes every 30 s now, so a 40 s transition would never finish)
2160
+ rise: 3000, travel: 11000, drop: 6000,
2161
+ // > a tile is thick, which IS the separation proof, and no higher: the fit
2162
+ // has to include the highest tile in flight, so a tall choreography zooms
2163
+ // the whole board out (operator: "it zooms out too much").
2164
+ laneGap: TILE_H * 1.35,
2165
+ // every mover lifts at least this far (2026-09-11: "the blocks can rise
2166
+ // higher ... it will help sell the depth and shadows"): a block with nothing
2167
+ // under its path used to skim a lane-gap above the board. Higher is always
2168
+ // safe -- lanes still stack as disjoint intervals above it.
2169
+ // 8 -> 24 (2026-09-11: "the blocks can rise even higher than they doing for a
2170
+ // reshuffle. Really make use of verticality within the viewport"): the lift
2171
+ // itself is higher, rather than height being stretched near the floor, which
2172
+ // would have inflated every bounce as well
2173
+ liftMin: 24,
2174
+ lockMs: 260,
2175
+ // Landings were all on the same frame, which read as machinery rather than
2176
+ // as objects (operator: "Everything is too uniform on landing"). Each block
2177
+ // now starts its drop somewhere inside this window and bounces to its own
2178
+ // height. Staggering only the DROP is safe: every block descends into its
2179
+ // OWN slot, and slots never overlap, so the no-collision proof is untouched.
2180
+ entryMs: 1300, // (2.4 s until gravity went to 12 units in 0.6 s: an arrival's fall from off screen must keep pace)
2181
+ // an arrival's fall from off screen starts this long before its drop slot (2026-09-11)
2182
+ dropStagger: 3500, // 1400 -> 4500 -> 6000 (2026-09-11: "much more randomness", then "at least 40 seconds")
2183
+ // Lift-offs stagger too, so the board does not heave all at once (operator:
2184
+ // "don't have to all move at once"). Bounded INSIDE the rise phase: every
2185
+ // block must be airborne before any block descends, or one could drop into
2186
+ // a slot its previous occupant has not vacated. That is exactly why rise and
2187
+ // travel keep shared boundaries while the drop does not.
2188
+ riseStagger: 1300,
2189
+ bounceSpread: 0.85,
2190
+ heightCap: 0.22, // the flight stack may reach this fraction of the board
2191
+ // HOW FAR A BLOCK IN FLIGHT MAY SWELL, and it is the real lever for "moving
2192
+ // too high". In this projection height does NOT move a block up the screen
2193
+ // -- it only scales it outward from the vanishing point -- so a 42% swell
2194
+ // threw edge blocks well past the board while the raw lane number looked
2195
+ // irrelevant. 15% is a clear lift that stays near the grid.
2196
+ maxGrowth: 0.15, // how much the nearest lane may swell; the fit reserves it
2197
+ };
2198
+
2199
+ export function planTransition(prev, next, opts = {}) {
2200
+ const cfg = { ...TRANSITION, ...opts };
2201
+ const now = opts.now ?? 0;
2202
+ const byId = new Map((prev || []).map((t) => [t.txid, t]));
2203
+ const nextIds = new Set((next || []).map((t) => t.txid));
2204
+
2205
+ const movers = [];
2206
+ const tweens = [];
2207
+ for (const t of next || []) {
2208
+ const from = byId.get(t.txid);
2209
+ if (!from) tweens.push({ kind: 'enter', txid: t.txid, to: t, from: t });
2210
+ else if (from.x !== t.x || from.y !== t.y || from.s !== t.s) movers.push({ from, to: t });
2211
+ // `from` is the tile as it WAS: a hold keeps its slot but not always its colour (below)
2212
+ else tweens.push({ kind: 'hold', txid: t.txid, from, to: t });
2213
+ }
2214
+ for (const t of prev || []) if (!nextIds.has(t.txid)) tweens.push({ kind: 'exit', txid: t.txid, from: t, to: t });
2215
+ for (const tw of tweens) if (tw.kind === 'enter') {
2216
+ tw.jitter = jitterOf(tw.txid);
2217
+ tw.jN = jitterOf(tw.txid, 'n');
2218
+ tw.jH = jitterOf(tw.txid, 'h');
2219
+ tw.jA = jitterOf(tw.txid, 'a');
2220
+ tw.jR = jitterOf(tw.txid, 'r');
2221
+ }
2222
+
2223
+ // FLIGHT LANES. One lane per mover would be correct but absurd now that
2224
+ // tiles are cubes: a cube of side 10 is 10 units tall, so 100 movers would
2225
+ // stack a thousand units into the air and the fit would shrink the board to
2226
+ // a smudge. Two movers can share an altitude whenever their SWEPT
2227
+ // footprints are disjoint -- neither can ever be where the other is -- so
2228
+ // lanes are assigned greedily by overlap. A mostly-local rearrangement then
2229
+ // needs a handful of lanes instead of hundreds, and the guarantee is
2230
+ // unchanged: same lane implies disjoint sweeps, different lane implies
2231
+ // vertical separation.
2232
+ // BIGGEST FIRST, so the big blocks take the LOWEST lanes. Lanes are handed
2233
+ // out in order, and a large block on a high lane is exactly what leaves the
2234
+ // top of the panel (operator: "The big green squares are moving too high off
2235
+ // the display"). Distance breaks ties, so among equals short hops stay low.
2236
+ movers.sort((a, b) => {
2237
+ const sa = Math.max(a.from.s, a.to.s), sb = Math.max(b.from.s, b.to.s);
2238
+ if (sa !== sb) return sb - sa;
2239
+ const da = Math.max(Math.abs(a.to.x - a.from.x), Math.abs(a.to.y - a.from.y));
2240
+ const db = Math.max(Math.abs(b.to.x - b.from.x), Math.abs(b.to.y - b.from.y));
2241
+ return da - db;
2242
+ });
2243
+ // The swept region must contain the whole ROUTE. Now that travel is
2244
+ // L-shaped the block also passes through a corner -- (to.x, from.y) or
2245
+ // (from.x, to.y) -- and it may be at its LARGER size when it does. Taking
2246
+ // the endpoints only left that corner outside the box, two blocks were
2247
+ // given the same lane on the strength of it, and the pairwise collision
2248
+ // test failed on the next run.
2249
+ // ONE BOX PER LEG, AND LEGS ONLY MEET THEIR OWN HALF (operator, 2026-09-14: "a lot of popping at
2250
+ // the edges"). sampleTween runs every first leg in the first half of travel and every second leg
2251
+ // in the second, each ending exactly at the half, so a first leg can only ever be where another
2252
+ // mover's FIRST leg is, never its second. One box round the whole L claimed the whole rectangle
2253
+ // between the two corners -- replayed on the live pool, 201 movers stacked 110 units up, and the
2254
+ // squeeze into the panel's room drew neighbouring lanes on top of each other, which is where the
2255
+ // rim pops were. Still conservative: each leg's box is the largest the cube can be on it.
2256
+ const box = (x0, y0, x1, y1, s) => ({ x0: Math.min(x0, x1), y0: Math.min(y0, y1), x1: Math.max(x0, x1) + s, y1: Math.max(y0, y1) + s });
2257
+ const swept = (m) => {
2258
+ const s = Math.max(m.from.s, m.to.s);
2259
+ const xFirst = jitterOf(m.to.txid) < 0.5; // the same draw sampleTween reads
2260
+ const cx = xFirst ? m.to.x : m.from.x, cy = xFirst ? m.from.y : m.to.y; // the corner
2261
+ const legs = [box(m.from.x, m.from.y, cx, cy, s), box(cx, cy, m.to.x, m.to.y, s)];
2262
+ return { legs, ...box(m.from.x, m.from.y, m.to.x, m.to.y, s) };
2263
+ };
2264
+ const rectHit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
2265
+ const hits = (a, b) => (a.legs && b.legs
2266
+ ? rectHit(a.legs[0], b.legs[0]) || rectHit(a.legs[1], b.legs[1])
2267
+ : (a.legs ?? [a]).some((p) => (b.legs ?? [b]).some((q) => rectHit(p, q))));
2268
+ // CUBES STACK BY THEIR OWN HEIGHT. A mover at base altitude L occupies
2269
+ // [L, L + s], not a slab, so an altitude is an INTERVAL: movers whose swept
2270
+ // footprints overlap get disjoint intervals, first fit, biggest first; and
2271
+ // every mover travels clear of the tallest RESTING cube under its sweep
2272
+ // (the holds are the only cubes standing during travel). Same proof as
2273
+ // before, with the real heights in it -- and the pairwise collision test
2274
+ // uses cubeHeight, so it checks exactly this.
2275
+ const resting = tweens.filter((t) => t.kind === 'hold').map((t) => t.to);
2276
+ const flying = [];
2277
+ const CLEAR = 0.25 * cfg.laneGap;
2278
+ for (const m of movers) {
2279
+ const sw = swept(m);
2280
+ const h = Math.max(m.from.s, m.to.s); // the tallest it can be in flight
2281
+ // each block its own lift, liftMin x 0.55..1.45: one shared minimum made
2282
+ // every drop 8-12 units, so every landing took nearly the same time
2283
+ let base = Math.max(cfg.laneGap, (cfg.liftMin ?? 0) * (0.55 + 0.9 * jitterOf(m.to.txid, 'lift')));
2284
+ for (const r of resting) {
2285
+ if (hits(sw, { x0: r.x, y0: r.y, x1: r.x + r.s, y1: r.y + r.s })) base = Math.max(base, cubeHeight(r) + CLEAR);
2286
+ }
2287
+ const busy = flying.filter((f2) => hits(sw, f2.sw)).sort((p, q) => p.lo - q.lo);
2288
+ for (const f2 of busy) {
2289
+ if (base + h + CLEAR <= f2.lo) break; // fits in the gap below this one
2290
+ if (base < f2.hi + CLEAR) base = f2.hi + CLEAR; // otherwise go above it
2291
+ }
2292
+ flying.push({ sw, lo: base, hi: base + h });
2293
+ tweens.push({
2294
+ kind: 'move', txid: m.to.txid, from: m.from, to: m.to,
2295
+ lane: base,
2296
+ laneIndex: flying.length - 1,
2297
+ jitter: jitterOf(m.to.txid),
2298
+ // INDEPENDENT draws: one hash per property, each stable for its block.
2299
+ jN: jitterOf(m.to.txid, 'n'),
2300
+ jH: jitterOf(m.to.txid, 'h'),
2301
+ jR: jitterOf(m.to.txid, 'r'),
2302
+ cells: Math.max(Math.abs(m.to.x - m.from.x), Math.abs(m.to.y - m.from.y)),
2303
+ });
2304
+ }
2305
+ const gridN = Math.max(8, opts.gridN || 0);
2306
+ const ceiling = gridN * cfg.heightCap;
2307
+ cfg.maxLane = flying.length ? Math.max(...flying.map((f2) => f2.hi)) : cfg.laneGap;
2308
+ // THE CAMERA IS NOT ALLOWED TO CHANGE BETWEEN ROUNDS.
2309
+ //
2310
+ // This used to be derived from cfg.maxLane -- how tall THIS round's lane
2311
+ // stack happened to be. A quiet round with no movers has one lane and got
2312
+ // 0.075; a busy round with seven lanes got 0.028. Same board, two cameras
2313
+ // nearly 3x apart in depth scaling, swapped at the moment a plan was made.
2314
+ // The visible symptom was the first paint (operator: "the initial view on
2315
+ // load is buggy, it has dark triangles, but things return to normal on the
2316
+ // first animation"): the first plan is a no-op self-transition, so it took
2317
+ // the shallow-stack camera, drew every cube's dark front lip almost three
2318
+ // times too tall, and those lips read as dark wedges -- worst either side
2319
+ // of the vanishing line, where the lip flips from pointing up to pointing
2320
+ // down and passes through a degenerate triangle.
2321
+ //
2322
+ // Derived from the GRID and the height cap instead: both are constants, so
2323
+ // every plan on a given board produces the same camera, and the first
2324
+ // paint already looks like the steady state. The bound is still honest --
2325
+ // lanes are compressed to fit under `ceiling` just above, so the tallest
2326
+ // lane any round can reach is the value used here.
2327
+ // The bound is `ceiling`, the altitude lane compression aims for. While the
2328
+ // stack fits under it -- which is every round whose lane count leaves the
2329
+ // gap above its floor -- growth stays inside the reserved margin exactly as
2330
+ // before. A round needing more lanes than the floor gap allows under the
2331
+ // ceiling stacks past it and those blocks fly beyond the panel edge, which
2332
+ // is the licence blocks in flight already have and is far cheaper than a
2333
+ // camera that changes under the operator between rounds.
2334
+ cfg.risePerUnit = Math.min(0.075, cfg.maxGrowth / Math.max(1e-6, ceiling * 0.55));
2335
+ cfg.camCeiling = ceiling;
2336
+
2337
+
2338
+ // Parking altitudes for arrivals and departures must clear every travel
2339
+ // lane AND each other, or an invisible waiting tile would occupy the same
2340
+ // space as a departing one. Derived from the lane stack, not hard-coded.
2341
+ const maxLane = cfg.maxLane;
2342
+ cfg.exitTo = maxLane + cfg.laneGap;
2343
+ // ON SCREEN (operator, 2026-09-11: "keep as much movement on screen as
2344
+ // possible"). This used to be far enough that the projected block started
2345
+ // OUTSIDE the panel (2026-09-10: "drops in from off screen"); the newer ask
2346
+ // supersedes it. An arrival now lets go just above the flight stack, where
2347
+ // the gather pull holds it over the board, fades up as it falls, and its
2348
+ // shadow sharpens on the slot it is about to land in.
2349
+ cfg.enterFrom = maxLane + 2 * cfg.laneGap;
2350
+
2351
+ const t0 = now;
2352
+ const phases = {
2353
+ t0,
2354
+ rise: t0 + cfg.rise,
2355
+ travel: t0 + cfg.rise + cfg.travel,
2356
+ end: t0 + cfg.rise + cfg.travel + cfg.drop,
2357
+ };
2358
+ // a recolour is motion too: a board whose only change is colour still needs frames to blend in
2359
+ const anyMotion = tweens.some((t) => t.kind !== 'hold' || t.from.color !== t.to.color);
2360
+ const plan = { tweens, phases, cfg, settleAt: now, duration: cfg.rise + cfg.travel + cfg.drop };
2361
+ if (anyMotion) {
2362
+ // settled when the LAST landing is done: landings are timed per block now
2363
+ let last = phases.end;
2364
+ for (const tw of tweens) if (tw.kind === 'move' || tw.kind === 'enter') last = Math.max(last, landingOf(tw, plan).t1);
2365
+ plan.settleAt = last + cfg.lockMs;
2366
+ }
2367
+ return plan;
2368
+ }
2369
+
2370
+ // A tween at a moment. `z` is the slab's BASE above the plane; every resting
2371
+ // tile has z = 0, which is what keeps the plane a plane.
2372
+ export function sampleTween(tw, now, plan) {
2373
+ const p = plan.phases;
2374
+ // this block's own landing: when it lets go, and how hard it bounces
2375
+ const j = tw.jitter ?? 0;
2376
+ const L = (tw.kind === 'move' || tw.kind === 'enter') ? landingOf(tw, plan) : null;
2377
+ const dropT0 = L?.t0 ?? p.travel;
2378
+ const dropT1 = L?.t1 ?? p.end;
2379
+ const bounce = L?.bounce ?? 0;
2380
+ const nBounce = L?.nBounce ?? 1;
2381
+ const rest = L?.rest ?? 0.55;
2382
+ const lockOf = () => {
2383
+ const dt = now - dropT1;
2384
+ return dt >= 0 && dt < plan.cfg.lockMs ? 1 - dt / plan.cfg.lockMs : 0;
2385
+ };
2386
+ // COLOURS BLEND, THEY DO NOT SWITCH (operator, 2026-09-14: "when blocks are ready to leave the
2387
+ // display, they change tints before doing so ... It looks bad with colors popping in"). The
2388
+ // pool's tail is drawn as equal pieces named by their SLOT (aggregate@x,y) and coloured by the
2389
+ // fee bands, so a refresh that shifts the bands recolours a piece that has not moved. The hold
2390
+ // was sampled as `to` from its first frame -- measured on the live pool, 151 pieces changed tint
2391
+ // on frame one, while the departures around them were only starting to lift. A hold now fades
2392
+ // from its old colour to its new one across the rise; a mover across its travel (it used to
2393
+ // switch the instant it finished rising). Exits keep their colour and arrivals arrive in theirs.
2394
+ if (tw.kind === 'hold') {
2395
+ const k = tw.from.color === tw.to.color ? 1 : easeInOutCubic(Math.min(1, Math.max(0, (now - p.t0) / Math.max(1, p.rise - p.t0))));
2396
+ return { ...tw.to, color: mixColor(tw.from.color, tw.to.color, k), z: 0, alpha: 1, lock: 0 };
2397
+ }
2398
+
2399
+ if (tw.kind === 'enter') {
2400
+ // A NEW block drops in from OFF SCREEN and fades up as it comes (operator,
2401
+ // 2026-09-10). Not drawn at all before it starts: it has not arrived yet.
2402
+ // enterFrom is chosen so the projected block really is outside the panel
2403
+ // when it lets go, not merely high.
2404
+ // ...and since 2026-09-11 ("Have the spawning happen off-screen for new
2405
+ // blocks being dropped in") it FALLS IN from outside the canvas. One fall:
2406
+ // the off-screen height (entry, drawn by visualBase) comes off on a single
2407
+ // gravity parabola that starts entryMs before the drop slot and reaches
2408
+ // zero exactly at impact, so the block accelerates all the way down, hits
2409
+ // and goes straight into its weighted bounce. The first cut eased to a
2410
+ // stop at the top of the ordinary fall and then dropped from rest
2411
+ // (operator: "should hit the ground and start bouncing. Not pause and
2412
+ // then drop again").
2413
+ // Its real altitude is the top of the fall throughout -- above every
2414
+ // travel lane -- so the collision proof is untouched; only the drawing
2415
+ // adds the off-screen height (visualBase).
2416
+ const entryMs = plan.cfg.entryMs ?? 0;
2417
+ if (now < dropT0 - entryMs) return null;
2418
+ const impact = dropT0 + fallShare(bounce, nBounce, rest) * (dropT1 - dropT0);
2419
+ const since = (now - (dropT0 - entryMs)) / Math.max(1, impact - (dropT0 - entryMs));
2420
+ const entry = since < 1 ? 1 - since * since : 0;
2421
+ // its REAL altitude stays at the top of the fall until the drop slot --
2422
+ // above every travel lane, so the collision proof is untouched
2423
+ if (now < dropT0) return { ...tw.to, z: L.from, entry, alpha: 1, lock: 0 };
2424
+ const t = Math.max(0, Math.min(1, (now - dropT0) / Math.max(1, dropT1 - dropT0)));
2425
+ // SOLID (2026-09-11): it used to fade up, and a see-through cube over
2426
+ // solid ones read as a ghost sliding through them ("new blocks landing
2427
+ // and bouncing is totally broken visually"). Briefly it grew in from a
2428
+ // point instead; now it arrives whole, from off screen.
2429
+ const dv = bounceDrop(t, bounce, nBounce, rest);
2430
+ return { ...tw.to, z: L.from * dv, landV: dv, fallFrom: L.from, entry, alpha: 1, lock: lockOf() };
2431
+ }
2432
+
2433
+ if (tw.kind === 'exit') {
2434
+ // A block that has left the pool rises away and fades as it goes. Its
2435
+ // slot is empty long before anyone lands, which is what the shared rise
2436
+ // phase buys. (Briefly this stayed opaque on the way up; the operator
2437
+ // asked for that and then recognised why the fade is there -- a block
2438
+ // that simply vanished at the top read worse than one that departs.)
2439
+ if (now >= p.rise) return null;
2440
+ // NOT IN UNISON (operator, 2026-09-11: "All the blocks are flying up and
2441
+ // away at a uniform rate. It looks strange. Mix it up a bit with speed").
2442
+ // Each departure leaves on its own beat -- a start somewhere in the first
2443
+ // 55% of the rise, the big ones tending earlier and so climbing slower
2444
+ // (more mass) -- and with its own acceleration: an exponent of 1.3-2.6 on
2445
+ // the off-screen share, so some drift and then bolt while others pull away
2446
+ // steadily. Every one is still gone by the end of the rise, before any
2447
+ // mover travels, so the collision proof is untouched.
2448
+ const R = p.rise - p.t0;
2449
+ const heavy = Math.min(1, (tw.from.s || 1) / 12);
2450
+ const go = p.t0 + 0.55 * R * jitterOf(tw.txid, 'xgo') * (1 - 0.4 * heavy);
2451
+ if (now <= go) return { ...tw.from, z: 0, entry: 0, alpha: 1, lock: 0 };
2452
+ const t = Math.min(1, (now - go) / Math.max(1, p.rise - go));
2453
+ // FROM REST (operator, 2026-09-14: "still seeing a lot of popping at the edges"). This was
2454
+ // easeOutCubic, which starts at its FULL speed: replayed on the live pool, where exits park
2455
+ // 100+ units up, a departing cube went from standing to ~6px a frame on its first frame -- 36
2456
+ // such jumps in one refresh, 30 of them on the rim where the flight tips sideways. Ease in and
2457
+ // out instead: it leaves the ground at rest and still arrives by the end of the rise.
2458
+ const e = easeInOutCubic(t);
2459
+ const acc = 1.3 + 1.3 * jitterOf(tw.txid, 'xacc');
2460
+ // UP AND OFF THE SCREEN (operator, 2026-09-11: "I want to see old blocks
2461
+ // flying up and off the screen instead of disappearing"). Whole and solid
2462
+ // -- a fading block was see-through over everything it passed, and the
2463
+ // shrink that replaced the fade read as vanishing. The off-screen height
2464
+ // arrivals fall from (entry, drawn by visualBase) is taken on as t^2, so
2465
+ // it lifts off from rest and accelerates away, wholly outside the canvas
2466
+ // by the end of the rise. Its REAL altitude still eases up to exitTo,
2467
+ // clear of every lane, so the collision proof is untouched.
2468
+ return { ...tw.from, z: plan.cfg.exitTo * e, entry: Math.pow(t, acc), alpha: 1, lock: 0 };
2469
+ }
2470
+
2471
+ // move: lift out, shift in whole cells at the lane, then drop under gravity
2472
+ const liftAt = p.t0 + (1 - j) * (plan.cfg.riseStagger || 0);
2473
+ if (now <= liftAt) return { ...tw.from, z: 0, alpha: 1, lock: 0 };
2474
+ if (now < p.rise) {
2475
+ // from rest, for the same reason as a departure above: a lane is 100 units up on a busy board
2476
+ const e = easeInOutCubic((now - liftAt) / Math.max(1, p.rise - liftAt));
2477
+ return { ...tw.from, z: tw.lane * e, alpha: 1, lock: 0 };
2478
+ }
2479
+ if (now < p.travel) {
2480
+ // TWO MOVEMENTS, not one diagonal glide. A tetromino shifts along one axis
2481
+ // and then the other; a straight diagonal is the one motion it never
2482
+ // makes, and a board full of them reads as linear drift (operator: "break
2483
+ // things up so they aren't so linear ... Can translate in two movements").
2484
+ // Which axis leads is per-block, from its own hash, so the board does not
2485
+ // all pivot together, and each leg eases separately so there is a real
2486
+ // beat where the block changes direction.
2487
+ const raw = Math.min(1, (now - p.rise) / Math.max(1, p.travel - p.rise));
2488
+ const xFirst = (tw.jitter ?? 0) < 0.5;
2489
+ const leg1 = easeInOutCubic(Math.min(1, raw / 0.5));
2490
+ const leg2 = easeInOutCubic(Math.max(0, (raw - 0.5) / 0.5));
2491
+ const ex = xFirst ? leg1 : leg2;
2492
+ const ey = xFirst ? leg2 : leg1;
2493
+ return {
2494
+ ...tw.to,
2495
+ color: mixColor(tw.from.color, tw.to.color, easeInOutCubic(raw)),
2496
+ x: tw.from.x + (tw.to.x - tw.from.x) * ex,
2497
+ y: tw.from.y + (tw.to.y - tw.from.y) * ey,
2498
+ s: tw.from.s + (tw.to.s - tw.from.s) * Math.max(ex, ey),
2499
+ z: tw.lane, // constant: this is the guarantee
2500
+ alpha: 1, lock: 0,
2501
+ };
2502
+ }
2503
+ if (now < dropT1) {
2504
+ const t = (now - dropT0) / Math.max(1, dropT1 - dropT0);
2505
+ // settles with its own weighted bounce, on its own beat
2506
+ const dv = bounceDrop(t, bounce, nBounce, rest);
2507
+ return { ...tw.to, z: tw.lane * dv, landV: dv, fallFrom: tw.lane, alpha: 1, lock: 0 };
2508
+ }
2509
+ return { ...tw.to, z: 0, alpha: 1, lock: lockOf() }; // landed flat, flashing
2510
+ }
2511
+
2512
+ // The share of a bounceDrop() spent in the first fall, before the first
2513
+ // impact -- the same segment weights bounceDrop uses.
2514
+ export function fallShare(bounce = 0.12, bounces = 3, rest = 0.55) {
2515
+ const n = Math.max(1, Math.min(12, Math.round(bounces)));
2516
+ let h = Math.max(bounce, 1e-6), total = 1;
2517
+ for (let k = 0; k < n; k++) { total += 2 * Math.sqrt(h); h *= rest; }
2518
+ return 1 / total;
2519
+ }
2520
+
2521
+ export function frameAt(plan, now, o = {}) {
2522
+ const live = [];
2523
+ for (const tw of plan.tweens) {
2524
+ const s = sampleTween(tw, now, plan);
2525
+ if (s) live.push(s);
2526
+ }
2527
+ const scene = buildScene(live, o);
2528
+ return { ...scene, settled: now >= plan.settleAt, tiles: live };
2529
+ }
2530
+
2531
+ // LIGHT CYCLES (operator, 2026-09-11: "I was thinking like a TRON Light cycle or something
2532
+ // navigating the block grid from one end of the board to another, and then the line begins
2533
+ // to fade out quickly? ... moving at 90 degree turns along the blocks from one side to
2534
+ // another"). A route is a walk along the grid LINES -- the cube edges -- from one edge of the
2535
+ // board to the opposite one, in unit steps: a run of 2-7 along the way across, then a jink of
2536
+ // 1-5 to one side, never doubling back and never off the board. Deterministic by seed.
2537
+ function lcg(seed) {
2538
+ let s = (seed >>> 0) || 1;
2539
+ return () => ((s = (Math.imul(s, 1103515245) + 12345) >>> 0) / 4294967296);
2540
+ }
2541
+ // `lanes` [lo, hi]: the side-to-side room the route may use, in grid lines; the whole board by
2542
+ // default. The price board hands the candles' own rows, so a rider's wall runs along the candle
2543
+ // tops rather than out to the empty front (operator, 2026-09-14: "Always interacting with either
2544
+ // the grid price line or candles").
2545
+ export function cyclePath(seed, W, H, from = 'left', lanes = null) {
2546
+ const rnd = lcg(seed);
2547
+ const horiz = from === 'left' || from === 'right';
2548
+ const sign = from === 'left' || from === 'bottom' ? 1 : -1;
2549
+ const lenMain = horiz ? W : H, lenSide = horiz ? H : W;
2550
+ const qLo = lanes ? Math.max(1, Math.floor(lanes[0])) : 1, qHi = lanes ? Math.min(lenSide - 1, Math.ceil(lanes[1])) : lenSide - 1;
2551
+ let m = sign > 0 ? 0 : lenMain;
2552
+ let q = lanes ? qLo + Math.floor(rnd() * Math.max(1, qHi - qLo + 1)) : 2 + Math.floor(rnd() * Math.max(1, lenSide - 4));
2553
+ q = Math.max(qLo, Math.min(qHi, q));
2554
+ const pts = [];
2555
+ const push = () => pts.push(horiz ? { x: m, y: q } : { x: q, y: m });
2556
+ push();
2557
+ const done = () => (sign > 0 ? m >= lenMain : m <= 0);
2558
+ while (!done()) {
2559
+ const run = Math.min(2 + Math.floor(rnd() * 6), sign > 0 ? lenMain - m : m);
2560
+ for (let i = 0; i < run; i++) { m += sign; push(); }
2561
+ if (done()) break;
2562
+ let dir = rnd() < 0.5 ? -1 : 1;
2563
+ const jink = 1 + Math.floor(rnd() * 5);
2564
+ if (q + dir * jink < qLo || q + dir * jink > qHi) dir = -dir;
2565
+ const steps = Math.max(0, Math.min(jink, dir > 0 ? qHi - q : q - qLo));
2566
+ for (let i = 0; i < steps; i++) { q += dir; push(); }
2567
+ }
2568
+ return pts;
2569
+ }
2570
+
2571
+ // LIGHT CYCLES CRASH (operator, 2026-09-11: "if one light cycle crashes into the tail of another
2572
+ // light cycle, it should de-res (die with visual effects) and the winner keeps going"). Worked out
2573
+ // once, when the race starts, from both routes and their timing: the race is stepped in small
2574
+ // slices of the effect; a head that arrives on a grid node the other cycle's live wall covers dies
2575
+ // there, and two heads on one node at once both die. A dead cycle's wall de-reses and is no
2576
+ // longer solid, so the other rides on through it. Pure: paths [{ pts, lag }] in, per path
2577
+ // { u, d, at } or null out.
2578
+ // `trail`: how much of a cycle's wall is solid, counted back from its head. It is the whole wall
2579
+ // now (operator, 2026-09-12: the tails last the entire board), so a rider dies on any part of a
2580
+ // route another cycle has already laid -- which is what the walls look like.
2581
+ export function cycleCrashes(paths, { trail = Infinity, step = 0.002, runShare = 0.8 } = {}) {
2582
+ const n = paths.length;
2583
+ const out = paths.map(() => null);
2584
+ const len = paths.map((p) => p.pts.length - 1);
2585
+ const headAt = (i, u) => { const v = Math.max(0, (u - paths[i].lag) / (1 - paths[i].lag)); return Math.min(1, v / runShare) * len[i]; };
2586
+ const key = (p) => `${p.x},${p.y}`;
2587
+ for (let s = 0; s * step <= 1 + 1e-9; s++) {
2588
+ const u = s * step;
2589
+ const d = paths.map((_, i) => (out[i] ? null : headAt(i, u)));
2590
+ const hits = [];
2591
+ for (let i = 0; i < n; i++) {
2592
+ if (d[i] == null || d[i] <= 0) continue;
2593
+ const hk = key(paths[i].pts[Math.floor(d[i] + 1e-9)]);
2594
+ let hit = false;
2595
+ for (let j = 0; j < n && !hit; j++) {
2596
+ if (j === i || d[j] == null || d[j] <= 0) continue;
2597
+ for (let k = Math.ceil(Math.max(0, d[j] - trail)); k <= Math.floor(d[j] + 1e-9); k++) if (key(paths[j].pts[k]) === hk) { hit = true; break; }
2598
+ }
2599
+ if (hit) hits.push(i);
2600
+ }
2601
+ for (const i of hits) { const k = Math.floor(d[i] + 1e-9); out[i] = { u, d: k, at: { ...paths[i].pts[k] } }; }
2602
+ if (out.every(Boolean)) break;
2603
+ }
2604
+ return out;
2605
+ }
2606
+
2607
+ // THE LIGHTNING BALL's route (operator, 2026-09-11, of the data packets: "It comes off more as
2608
+ // wandering lights. I was hoping for something that comes from off-screen along a grid line, and
2609
+ // then starts tracing through the grid to the opposite side"). A light-cycle route from one edge
2610
+ // to the opposite one, run in straight along its first grid line from E units OFF the board and
2611
+ // out along its last one E units past the far edge, in unit steps throughout.
2612
+ export function ballPath(seed, W, H, from = 'left', E = 16) {
2613
+ const core = cyclePath(seed, W, H, from);
2614
+ const a = core[0], b = core[core.length - 1];
2615
+ const dx = from === 'left' ? -1 : from === 'right' ? 1 : 0;
2616
+ const dy = from === 'bottom' ? -1 : from === 'top' ? 1 : 0;
2617
+ const lead = [];
2618
+ for (let k = E; k >= 1; k--) lead.push({ x: a.x + dx * k, y: a.y + dy * k });
2619
+ const tail = [];
2620
+ for (let k = 1; k <= E; k++) tail.push({ x: b.x - dx * k, y: b.y - dy * k });
2621
+ return [...lead, ...core, ...tail];
2622
+ }
2623
+
2624
+ // DATA PACKETS: short walks along the grid lines from random points, turning now and then,
2625
+ // each starting at its own moment (s0, a share of the effect).
2626
+ export function packetPaths(seed, W, H, n = 8) {
2627
+ const rnd = lcg(seed ^ 0x5bd1e995);
2628
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
2629
+ const out = [];
2630
+ for (let k = 0; k < n; k++) {
2631
+ let x = 1 + Math.floor(rnd() * Math.max(1, W - 2)), y = 1 + Math.floor(rnd() * Math.max(1, H - 2));
2632
+ let d = dirs[Math.floor(rnd() * 4)];
2633
+ const pts = [{ x, y }];
2634
+ const len = 6 + Math.floor(rnd() * 9);
2635
+ const inside = (a, b) => a >= 0 && a <= W && b >= 0 && b <= H;
2636
+ for (let i = 0; i < len; i++) {
2637
+ if (i > 0 && rnd() < 0.3) d = rnd() < 0.5 ? [d[1], -d[0]] : [-d[1], d[0]];
2638
+ if (!inside(x + d[0], y + d[1])) { d = [-d[1], d[0]]; if (!inside(x + d[0], y + d[1])) break; }
2639
+ x += d[0]; y += d[1];
2640
+ pts.push({ x, y });
2641
+ }
2642
+ out.push({ pts, s0: rnd() * 0.55 });
2643
+ }
2644
+ return out;
2645
+ }
2646
+
2647
+ // The top of the resting cube over each grid cell (0 for the floor): what a route rides on.
2648
+ export function cellTops(tiles, W, H) {
2649
+ const tops = new Float32Array(Math.max(0, W * H));
2650
+ for (const t of tiles ?? []) {
2651
+ const top = (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t);
2652
+ // EVERY CELL THE TILE TOUCHES. The loops began at t.x and t.y themselves, which on the block
2653
+ // board are whole numbers; the candles sit between grid lines (x = 2i + 0.3, y = 3, s = 1.4),
2654
+ // and a fractional index into a typed array stores nothing -- so the price board's tops were
2655
+ // all zero, no route rode a candle and ball lightning had nothing to strike (operator,
2656
+ // 2026-09-14: "the ball lightning was not arcing out to any of the candles").
2657
+ for (let cy = Math.max(0, Math.floor(t.y)); cy < Math.min(H, Math.ceil(t.y + t.s)); cy++) {
2658
+ for (let cx = Math.max(0, Math.floor(t.x)); cx < Math.min(W, Math.ceil(t.x + t.s)); cx++) if (top > tops[cy * W + cx]) tops[cy * W + cx] = top;
2659
+ }
2660
+ }
2661
+ return tops;
2662
+ }
2663
+
2664
+ // Each unit stretch of a route rides the TALLER of the two cells either side of the grid line
2665
+ // it runs along; where two stretches meet at different heights the wall steps straight up or
2666
+ // down, so every change -- of direction or of level -- is a right angle.
2667
+ export function pathHeights(pts, tops, W, H) {
2668
+ const top = (cx, cy) => (cx >= 0 && cx < W && cy >= 0 && cy < H ? tops[cy * W + cx] : 0);
2669
+ const hs = [];
2670
+ for (let k = 0; k + 1 < pts.length; k++) {
2671
+ const a = pts[k], b = pts[k + 1];
2672
+ const h = a.y === b.y
2673
+ ? Math.max(top(Math.min(a.x, b.x), a.y - 1), top(Math.min(a.x, b.x), a.y))
2674
+ : Math.max(top(a.x - 1, Math.min(a.y, b.y)), top(a.x, Math.min(a.y, b.y)));
2675
+ hs.push(h + 0.04);
2676
+ }
2677
+ return hs;
2678
+ }