@homie-rocks/studio 0.46.5 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,74 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.47.0] - 2026-10-13
21
+
22
+ **Plugin 0.47.0** · Wrapped worlds and generated collision maps.
23
+
24
+ Games can wrap across map edges and replace seeded tile levels during play, with the same collision geometry in server rules and prediction.
25
+
26
+ ### Added
27
+
28
+ - Maps may opt into wrapping independently on x, y and z with `wrap: {x:true}`;
29
+ periods come from bounds. Bodies, sweeps, rays, overlaps, support, near/box queries,
30
+ area events, ranged effects, spatial delivery, rewind and view interpolation use
31
+ periodic geometry. Crossing an edge wraps rather than clamps. Sweeps/rays retain
32
+ their supplied direction and pay for each periodic image they visit.
33
+ - Room handlers can call `world.map.replace(data)`. A replacement is validated,
34
+ indexed and activated atomically at the next tick, preserving bounds and wrapping.
35
+ Compact current/pending revisions are saved and sent once per collision revision;
36
+ local prediction replays the same compiled map by activation tick.
37
+ - Maps accept row-major `tiles` with width, height, size, optional origin and solid
38
+ height. Nonzero cells compile to indexed horizontal runs. Rules and movement can
39
+ query `map.tile(x,y)`; views read `room.map.tiles`.
40
+ - Named multi-source cardinal `tiles.flows` bake during map compilation. Rules and
41
+ movement use constant-work `map.distance(name,x,y)` and `map.flow(name,x,y)`.
42
+ The standalone rules runtime ships the nav package's new `Flow.js` baker, checked
43
+ against its source in the release gate. Generation, copying, indexing and baking
44
+ are charged to the rules handler; ordinary static-map budget charges are unchanged.
45
+ - Seeded virtual-time release cases cover two-view seam crossing in 2D/3D and
46
+ generated-map collision, mid-round regeneration and restore, including 300 ms
47
+ delay and 12% loss. Geometry and navigation tests cover periodic casts,
48
+ multi-period movement, atomic failure and independently checked shortest paths.
49
+ Eight-player generation/restore replays also match every save and snapshot
50
+ across Node, Chrome and workerd for 240 seeded ticks.
51
+
52
+ ### Fixed
53
+
54
+ - Handlers reached synchronously by `world.dispatch` now report their coverage to
55
+ the build checker, including failures, without opening a second handler budget.
56
+ - The rewrite guide specifies named helper re-exports (`export *` is refused), and
57
+ the rules guide explains that mandatory spawn fields precede deferred `arrive`.
58
+
59
+ This release covers wrapped worlds and generated levels. Arbitrary triangle-mesh
60
+ collision, the measured Rapier-versus-kernel decision, first-class integration
61
+ substeps, and the assisted rewrite command remain follow-up work.
62
+
63
+ ## [0.46.6] - 2026-10-11
64
+
65
+ **Plugin 0.46.6** · Big rooms: what one room carries, measured. · [release-2026-10-11-studio-0.46.6](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-11-studio-0.46.6)
66
+
67
+ What one room object carries on Cloudflare, measured in six trials and stated: with a light game at 20 ticks a second, 300 players with margin, 600 at the edge, 800 to 1,000 only by luck.
68
+
69
+ ### Changed
70
+
71
+ - A big match room's once-a-second checkpoint no longer holds its frames. Cloudflare holds whatever an object sends after a storage write until the write is confirmed, so one or two worlds a second reached every Gate a tick late, at every size. The checkpoint is now written with `allowUnconfirmed`; a save an admission asks for is confirmed as before and replaces it. A lasting world's writes are unchanged. At 300 players the gap every Gate saw once a second is gone, and a Gate's round trip to the room fell from 40 ms to 18.
72
+ - A big room costs less of its one thread than on 0.46.3 (a fifth less; a quarter less when inputs arrive late): it no longer builds the whole snapshot as text for nobody, does not encode an ASCII message to measure it, and reads the round trip it tells the rules, for a player behind a Gate, once a second from its ten-second window, not once an input.
73
+ - A player is sent fewer frames only when behind and not catching up. A connection that stalls for a second and then delivers what it held catches up by itself; 0.46.2 slowed every player of a healthy room of 600 when their one home link hiccuped.
74
+ - A Gate nobody is on gives its channel to the room back after ten seconds; the next player to arrive opens another. An open channel kept a big room and its Gates in memory after everybody had left.
75
+ - Each Gate reports the share of worlds over 100, 200 and 400 ms old and the players more than a second behind, beside the worst of each second.
76
+
77
+ ### Added
78
+
79
+ - Measuring a game's capacity on your own Cloudflare account (`lib/crowd-probe/README.md`): the in-Cloudflare probe offers compression as a browser does, reports every thirty seconds so a request cut short has still said what it saw, can play slow players among healthy ones (`--slow-every`), and times a fixed piece of work against your computer with the stopwatch outside Cloudflare (`--calibrate`). `lib/crowd-probe/run-trial.sh` runs a whole trial, each in-Cloudflare step three times, and writes one summary. The home driver can stop every Kth player's socket reading (`--slow-every`). Slow players' own figures are kept apart from everybody else's.
80
+
81
+ ### Upgrade notes
82
+
83
+ - 1,000 players in one room is not promised; past what one room carries a world is split across rooms. Rooms of 64 seats or fewer and games a player's browser hosts are not on any of this path and behave as on 0.46.3.
84
+ - **What one room carries** is in the README ("Room size and delivery") and, with the measurements and the ways past one room, in `docs/rooms-capacity-findings.md`. Homie still sets no limit on `players.max`: choose it with your own game's numbers.
85
+ - Nothing an object measures about itself by its own clock is to be trusted on Cloudflare: a Worker's clock stands still while its code runs and reads a timer's due time when the timer fires. Each Gate's 20 ms lateness timer (added early in this release) read 0 in every step of every trial, fallen ones too. Read a Gate's round trip to the room and the share of worlds over 200 ms old.
86
+ - `HOMIE_GATE_PLAYERS` on a studio's Worker sets the players a Gate holds, for a trial that is finding the right number. A studio does not set it.
87
+ - The local crowd reproduction (eight workerd processes, two Chrome players and the load driver on one computer) is no longer a check. Run it by hand with `npm run test:crowd`; `docs/crowd-transport-0462.md` says what it can and cannot show.
20
88
  ## [0.46.5] - 2026-10-11
21
89
 
22
90
  **Plugin 0.46.5**
package/README.md CHANGED
@@ -222,7 +222,18 @@ For example, a 300-player room can choose spatial delivery explicitly:
222
222
  The studio chooses the radius, precision and distant update rate for its game. The
223
223
  controlled body stays precise. Whole-room state remains the default. The build runs a
224
224
  local capacity trial; its numbers are not Cloudflare capacity guarantees. Test the actual
225
- mechanic at the chosen size. Rules builds derive their revision from rules and view;
225
+ mechanic at the chosen size.
226
+
227
+ **What one room carries.** A room's rules run in one object on one thread, whatever its
228
+ `players.max`. Measured on Cloudflare in October 2026 with a light game at 20 ticks a
229
+ second (every player in one dense arena, ten inputs a second each): 300 players with
230
+ margin (clean in every run), 600 at the edge (four runs of six), and 800 to 1,000 only by
231
+ luck (five runs of twenty-five). A game with heavier rules carries fewer. Homie sets no
232
+ limit on `players.max`; it is the studio's to choose with its own game's numbers:
233
+ `lib/crowd-probe/README.md` is how to measure them on your own Cloudflare account. Beyond
234
+ what one room carries, a world is split across rooms (several rooms of a game today; one
235
+ world of many areas is on the roadmap). `docs/rooms-capacity-findings.md` in the Homie
236
+ repository has the measurements. Rules builds derive their revision from rules and view;
226
237
  do not manually bump `netplay.version`. Optional `netplay.params` still selects play-page
227
238
  query parameters passed to the view, beside `debug` and `q`.
228
239
 
@@ -17,7 +17,8 @@ Read RULES.md before starting.
17
17
  An old file named rules.ts may already export pure helpers that tests import.
18
18
  Preserve that public test API and every existing test file byte-for-byte,
19
19
  including imports. Re-export the old helpers from rules.ts if extracted into
20
- another module; do not redirect tests to that module. Record test-file hashes
20
+ another module with named exports (`export { helper } from './helper'`);
21
+ `export *` is refused. Do not redirect tests to that module. Record test-file hashes
21
22
  before editing and compare them at the end. The filename alone does not
22
23
  mean it is a rules-plus-view game. If tests now load toolkit TypeScript under
23
24
  Node, use a narrowly scoped TypeScript loader and keep the normal test command
@@ -1178,3 +1178,70 @@ still snap to authority. Opt in only after measuring stop/reversal and delayed
1178
1178
  collision behavior for the game's mover.
1179
1179
 
1180
1180
  For multi-cast movement or scratch trajectory checks, `ctx.world.sweep(body, delta, {ground:false})` (also captured-scene `sweep`) moves and returns the same collision but leaves `body.grounded` unchanged. Use it only when your mover computes support explicitly. Omitting the option retains the existing support query and grounding behavior. This avoids paying for identical support checks after horizontal and upward probes.
1181
+
1182
+
1183
+ ## Wrapped worlds and generated levels (0.47.0)
1184
+
1185
+ Declare wrapping in the map, independently per axis. The period is the bounds'
1186
+ extent; an omitted axis keeps its existing bounded behaviour:
1187
+
1188
+ ```json
1189
+ {"bounds":{"min":[0,0,0],"max":[25,25,10]},"wrap":{"x":true}}
1190
+ ```
1191
+
1192
+ Bodies and hit positions use canonical coordinates in `[min,max)`. Sweeps and rays
1193
+ follow the supplied displacement through periodic copies of the geometry; they can
1194
+ cross more than one period and pay for each visited image. Near/box queries, area
1195
+ events, effect ranges, spatial delivery, rewind and drawing interpolation use the
1196
+ short way around. Exactly half a period chooses the negative direction. A renderer
1197
+ can read `room.map.wrap.x` (the pair `[min,max]`) to draw copies at the viewport's
1198
+ edges. The server does not duplicate entities. In a maze with one tunnel, keep
1199
+ solid border cells on every other row; wrapping itself is per axis, not per row.
1200
+
1201
+ For a generated maze, replace the room's current map in `room.start` or a room
1202
+ handler. Build its data using the rules' seeded random source, then publish once:
1203
+
1204
+ ```ts
1205
+ world.map.replace({
1206
+ bounds: { min: [0, 0], max: [25, 25] },
1207
+ wrap: { x: true },
1208
+ tiles: { width: 25, height: 25, size: 1, data: cells,
1209
+ flows: { exit: [312] } },
1210
+ spots: { start: [[12.5, 19.5]] },
1211
+ });
1212
+ ```
1213
+
1214
+ `replace` preserves the original bounds and wrapping. It validates, bakes cardinal
1215
+ navigation fields and compiles the spatial index before publishing anything. A
1216
+ failed bake leaves the previous revision intact. The last successful replacement
1217
+ in a tick becomes current at the next tick; all bodies use the same revision.
1218
+ The compact map data and activation tick travel with collision revisions and saves,
1219
+ including both current and pending maps. Prediction replays against that tick's
1220
+ compiled geometry. Late knowledge is corrected by the normal reconciliation path.
1221
+
1222
+ Tiles are row-major integers: zero is open, nonzero is solid. `at` defaults to
1223
+ `[0,0,0]`, `size` and `solidHeight` to 1. Horizontal runs become indexed boxes,
1224
+ including in 3D. On a wrapped axis the grid must span the full map. Authored boxes,
1225
+ round shapes, terrain and spots can be included in the same replacement. The view
1226
+ reads `room.map.tiles`; rules and movement read `world.map.tile(x,y)` and
1227
+ `ctx.map.tile(x,y)`. Cell coordinates are integers, not metres. Outside an unwrapped
1228
+ grid is solid. A custom tile mover may read these cells instead of using sweeps.
1229
+
1230
+ `flows` names lists of open destination cell indices. `map.distance(name,x,y)` gives
1231
+ cardinal hops to the nearest destination, or -1 when unreachable/unknown;
1232
+ `map.flow(name,x,y)` gives the next unit direction, zero at a destination, or
1233
+ undefined when unreachable/unknown. Each lookup is constant work (4 or 8 budget
1234
+ units). Baking uses `@homie-rocks/nav`'s shared cardinal flow implementation;
1235
+ left/up/right/down resolves ties, including tunnels. Change destinations by
1236
+ publishing new flow declarations with the map. This is a bake, not an unmetered
1237
+ path search: budget for every field and regenerate only when its goals change.
1238
+
1239
+ Room replacement charges copying, grid scanning, index construction and each flow
1240
+ bake to the calling handler. Snapshots charge for the compact data. Existing static
1241
+ maps need no replacement and retain their previous budget charges. A few thousand
1242
+ cells regenerated a few times a round belong in one map revision; live collider
1243
+ entities remain suitable for independently moving doors, cover and platforms.
1244
+
1245
+ `spawn` exposes a new entity before its deferred `on.arrive` handler. Pass every
1246
+ field that must be valid immediately in the spawn data; use `arrive` for subsequent
1247
+ work, not for mandatory initialization.
@@ -75,6 +75,12 @@ Cloudflare or physical-phone capacity guarantees.
75
75
 
76
76
  ## Write and change rules plus view
77
77
 
78
+ For wrapping worlds or generated levels, use the map's opt-in `wrap` and room-scoped
79
+ `world.map.replace` APIs described in RULES.md's **Wrapped worlds and generated levels**.
80
+ Prefer one indexed map revision for a generated maze; keep independently moving
81
+ doors and platforms as live colliders. Generation and navigation baking spend the
82
+ room handler's budget.
83
+
78
84
  **Read [RULES.md](RULES.md) before writing code.** All five starters use this contract.
79
85
  Work in few, large edits: read each source file once, decide its changes, then
80
86
  make every change it needs in ONE file_edit with an edits list. Build after each
@@ -3,7 +3,8 @@
3
3
  * One crowd step played from inside Cloudflare, through the in-Cloudflare probe (lib/crowd-probe/README.md).
4
4
  *
5
5
  * node lib/crowd-cloud.mjs --probe <probe address> --key <PROBE_KEY> --url <studio> --n 300
6
- * [--seconds 150] [--room <name>] [--out <dir>] [--ramp-ms 50] [--game crowd-circuit]
6
+ * [--seconds 150] [--room <name>] [--out <dir>] [--ramp-ms 50] [--game crowd-circuit] [--compress on] [--slow-every 10]
7
+ * node lib/crowd-cloud.mjs --probe <probe address> --key <PROBE_KEY> --calibrate <file>
7
8
  *
8
9
  * It opens a request to the probe for every five players, holds them open for the step, and writes report.json in
9
10
  * the shape crowd-load.mjs writes (what applies: there is no browser and no home connection in this one).
@@ -12,12 +13,44 @@ import { mkdir, writeFile } from 'node:fs/promises';
12
13
  import { resolve } from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { Tally } from './crowd-probe/client.mjs';
16
+ import { referenceWork } from './crowd-probe/reference-work.mjs';
15
17
 
16
18
  const pick = (sorted, q) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))] : null);
17
19
 
18
20
  /** Every probe request's last line, added up into one report. */
19
- export function cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures = [], toolkit = null }) {
20
- const players = finals.flatMap((f) => f.players), tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
21
+ /**
22
+ * How fast Cloudflare runs the reference work against this computer. A Worker cannot time its own work, so the
23
+ * stopwatch is here: a request that does the work `rounds` times, less a request that does none, each the quickest
24
+ * of several (the least delayed by the connection), divided by the rounds. This process times the same call.
25
+ * `ratio` is Cloudflare's milliseconds over this computer's: what a millisecond of test/crowd-room-cost.mjs costs there.
26
+ */
27
+ export async function calibrate({ probe, key, requests = 6, rounds = 10, fetchFn = fetch, now = () => performance.now() }) {
28
+ referenceWork(4);
29
+ const local = [];
30
+ for (let i = 0; i < 5; i += 1) { const at = now(); referenceWork(); local.push(Math.round((now() - at) * 100) / 100); }
31
+ const check = referenceWork(), mine = Math.min(...local);
32
+ const ask = async (n) => { const at = now(); const answer = await (await fetchFn(`${probe}/calibrate?rounds=${n}&i=${Math.round(at)}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(120000) })).json(); return { ms: Math.round((now() - at) * 100) / 100, ...answer }; };
33
+ const empty = [], worked = [];
34
+ for (let i = 0; i < requests; i += 1) {
35
+ try { empty.push(await ask(0)); } catch { /* one reading fewer */ }
36
+ try { const answer = await ask(rounds); if (answer.ok && answer.rounds === rounds) worked.push(answer); } catch { /* one reading fewer */ }
37
+ }
38
+ const base = Math.min(...empty.map((e) => e.ms)), each = worked.map((w) => Math.round((w.ms - base) / rounds * 100) / 100).sort((a, b) => a - b);
39
+ const of = (ms) => (Number.isFinite(ms) && mine > 0 ? Math.round(ms / mine * 100) / 100 : null);
40
+ return { at: new Date().toISOString(), rounds, localMs: local, emptyRequestMs: empty.map((e) => e.ms), workedRequestMs: worked.map((w) => w.ms), colos: [...new Set(worked.map((w) => w.colo).filter(Boolean))],
41
+ // What the Worker's own clock said the same rounds took: next to the stopwatch's figure, what a clock inside is worth.
42
+ workerOwnClockMs: worked.map((w) => w.own),
43
+ cloudMsARound: { min: each[0] ?? null, median: pick(each, 0.5), max: each.at(-1) ?? null },
44
+ ratio: each.length && Number.isFinite(base) ? { min: of(each[0]), median: of(pick(each, 0.5)), max: of(each.at(-1)) } : null,
45
+ same: worked.every((w) => w.check === check) };
46
+ }
47
+
48
+ export function cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures = [], toolkit = null, compress = false, lost = 0, roomHz = 20 }) {
49
+ // A probe's clock is a Worker's: it reads a timer's due time and runs slow when the Worker runs late, so a rate
50
+ // timed by it reads high (the fifth trial's rows of 25 to 32 "ticks a second" from a 20-tick room). Frames for
51
+ // each tick the room advanced need no clock: times the room's rate, that is frames a second. (Not for a slow
52
+ // player, whose ticks are the ones it has got to: its rate stays by the clock.)
53
+ const players = finals.flatMap((f) => f.players).map((p) => (Number.isFinite(p.framesPerTick) && !p.slow ? { ...p, clockDeliveredHz: p.deliveredHz, deliveredHz: p.framesPerTick * roomHz } : p)), tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
21
54
  for (const f of finals) for (const key of Object.keys(tallies)) tallies[key].merge(f.tallies?.[key]);
22
55
  const of = (key) => players.map((p) => p[key]).filter(Number.isFinite).sort((a, b) => a - b);
23
56
  const range = (key) => { const a = of(key); return { count: a.length, min: a[0] ?? null, median: pick(a, 0.5), p50: pick(a, 0.5), max: a.at(-1) ?? null }; };
@@ -36,10 +69,25 @@ export function cloudReport({ origin, room, n, seconds, started, measureAt, endA
36
69
  inputAckMs: tallies.ack.json(), receiveGapMs: tallies.gaps.json(),
37
70
  // From the tick a player held when it stamped an input to the tick that input reached the rules.
38
71
  inputLoopMs: tallies.loops.json(),
39
- tickHz: range('tickHz'), deliveredHz: range('deliveredHz'),
40
- payload: { upBytes: sum('upBytes'), downBytes: sum('downBytes'), inputFrames: sum('inputs'), downFrames: sum('frames'), wireDownBytes: 0, compressedSockets: 0 },
41
- // Sockets that never leave Cloudflare are not compressed and cross no home connection.
42
- compression: { offered: false, sockets: 0, of: players.length, ratio: null },
72
+ // `tickHz` is by the probe's own clock (see above): above the room's rate, it says the probe ran late.
73
+ tickHz: range('tickHz'), tickHzClock: 'probe', deliveredHz: range('deliveredHz'), framesPerTick: range('framesPerTick'),
74
+ payload: { upBytes: sum('upBytes'), downBytes: sum('downBytes'), inputFrames: sum('inputs'), downFrames: sum('frames'), wireDownBytes: 0, compressedSockets: players.filter((p) => p.compressed).length },
75
+ // Sockets that never leave Cloudflare cross no home connection; with --compress on the studio's Gates compress
76
+ // for them as for a browser (the bytes saved cannot be seen from inside a Worker).
77
+ compression: { offered: Boolean(compress), sockets: players.filter((p) => p.compressed).length, of: players.length, ratio: null },
78
+ // Probe requests whose last word never came: their players were in the room, and are counted from each
79
+ // request's last thirty-second report where there was one.
80
+ lostReports: lost, partialReports: finals.filter((f) => f.t === 'partial').length,
81
+ // --slow-every K: every Kth player takes six frames a second. `healthy` is everybody else: what the slow
82
+ // ones cost them is the difference from the same step without any.
83
+ slowPlayers: players.filter((p) => p.slow).length,
84
+ healthy: (() => {
85
+ const a = players.filter((p) => !p.slow), list = (key) => a.map((p) => p[key]).filter(Number.isFinite).sort((x, y) => x - y);
86
+ const hz = list('deliveredHz'), gap = list('maxStateGapMs');
87
+ return { players: a.length, deliveredHz: { min: hz[0] ?? null, p05: pick(hz, 0.05), median: pick(hz, 0.5) }, maxStateGapMs: { median: pick(gap, 0.5), max: gap.at(-1) ?? null } };
88
+ })(),
89
+ slowInputAckMs: (() => { const t = new Tally(); for (const f of finals) t.merge(f.slowAck); return t.json(); })(),
90
+ slow: (() => { const a = players.filter((p) => p.slow), hz = a.map((p) => p.deliveredHz).filter(Number.isFinite).sort((x, y) => x - y); return a.length ? { players: a.length, deliveredHz: { min: hz[0] ?? null, median: pick(hz, 0.5), max: hz.at(-1) ?? null } } : null; })(),
43
91
  inputLead: { ticksMedian: pick(leads, 0.5), ticksMax: leads.at(-1) ?? null, lateShare: sum('reports') ? Math.round(sum('late') / sum('reports') * 1000) / 1000 : null },
44
92
  driver: { requests: finals.length, failedRequests: failures.length, workerLoopP99Ms: [], peakTimerLagMs: finals.map((f) => f.worstTimerLagMs) },
45
93
  errors: [...errors].map(([text, count]) => ({ text, count })),
@@ -50,9 +98,17 @@ export function cloudReport({ origin, room, n, seconds, started, measureAt, endA
50
98
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
99
  const opts = {};
52
100
  for (let i = 2; i < process.argv.length; i += 2) { if (!process.argv[i].startsWith('--') || process.argv[i + 1] === undefined) throw Error('Use --key value'); opts[process.argv[i].slice(2)] = process.argv[i + 1]; }
53
- const probe = String(opts.probe ?? '').replace(/\/$/, ''), key = opts.key, origin = new URL(opts.url).origin, n = Number(opts.n ?? 50), seconds = Number(opts.seconds ?? 150);
54
- const rampMs = Number(opts['ramp-ms'] ?? 50), game = opts.game ?? 'crowd-circuit', room = opts.room ?? `cloud-${n}-${Date.now().toString(36)}`, out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
55
- if (!probe || !key || !Number.isInteger(n) || n < 1 || n > 1000 || !(seconds >= 1 && seconds <= 600)) throw Error('--probe, --key, --url, --n 1..1000, --seconds 1..600');
101
+ const probe = String(opts.probe ?? '').replace(/\/$/, ''), key = opts.key, origin = opts.url ? new URL(opts.url).origin : null, n = Number(opts.n ?? 50), seconds = Number(opts.seconds ?? 150);
102
+ const compress = opts.compress === 'on', rampMs = Number(opts['ramp-ms'] ?? 50), game = opts.game ?? 'crowd-circuit', room = opts.room ?? `cloud-${n}-${Date.now().toString(36)}`, out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
103
+ if (opts.calibrate) {
104
+ // `--calibrate <file>`: only the reference work, here and in the probe; no players.
105
+ const result = await calibrate({ probe, key });
106
+ await writeFile(resolve(opts.calibrate), JSON.stringify(result, null, 2));
107
+ console.log(JSON.stringify({ phase: 'calibrated', localMs: Math.min(...result.localMs), cloudMsARound: result.cloudMsARound, ratio: result.ratio, workerOwnClockMs: result.workerOwnClockMs }));
108
+ process.exit(0);
109
+ }
110
+ const slowEvery = Math.max(0, Math.floor(Number(opts['slow-every'] ?? 0)) || 0);
111
+ if (!probe || !key || !origin || !Number.isInteger(n) || n < 1 || n > 1000 || !(seconds >= 1 && seconds <= 600)) throw Error('--probe, --key, --url, --n 1..1000, --seconds 1..600');
56
112
  await mkdir(out, { recursive: true });
57
113
  const hello = await fetch(`${probe}/hello`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(15000) });
58
114
  if (!hello.ok) throw Error(`The probe did not answer (${hello.status}): is it deployed, and is the key its PROBE_KEY?`);
@@ -62,26 +118,28 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
62
118
  // (fifteen seconds after the last has joined) and when to stop.
63
119
  const started = Date.now(), joinAt = started + 5000, measureAt = joinAt + n * rampMs + 15000, endAt = measureAt + seconds * 1000;
64
120
  console.log(JSON.stringify({ phase: 'start', generator: 'cloudflare', origin, room, n, seconds, requests: Math.ceil(n / 5), toolkit }));
65
- const finals = [], failures = []; let lastNote = 0; const progress = new Map();
121
+ const finals = [], failures = []; let lastNote = 0, lost = 0; const progress = new Map();
66
122
  await Promise.all(Array.from({ length: Math.ceil(n / 5) }, async (_, r) => {
67
- const from = r * 5, count = Math.min(5, n - from);
123
+ const from = r * 5, count = Math.min(5, n - from); let partial = null;
124
+ // Not all at once: a thousand players are two hundred requests.
125
+ await new Promise((done) => setTimeout(done, r * 10));
68
126
  try {
69
- const res = await fetch(`${probe}/play?origin=${encodeURIComponent(origin)}&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&from=${from}&count=${count}&joinAt=${joinAt}&rampMs=${rampMs}&measureAt=${measureAt}&endAt=${endAt}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(endAt - Date.now() + 60000) });
127
+ const res = await fetch(`${probe}/play?origin=${encodeURIComponent(origin)}&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&from=${from}&count=${count}&joinAt=${joinAt}&rampMs=${rampMs}&measureAt=${measureAt}&endAt=${endAt}${compress ? '&compress=1' : ''}${slowEvery ? `&slowEvery=${slowEvery}` : ''}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(endAt - Date.now() + 30000) });
70
128
  if (!res.ok || !res.body) throw Error(`probe HTTP ${res.status}`);
71
- let text = '', final = null;
129
+ let text = '', final = null; partial = null;
72
130
  for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
73
131
  text += chunk;
74
132
  for (let at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n')) {
75
133
  const row = JSON.parse(text.slice(0, at)); text = text.slice(at + 1);
76
- if (row.t === 'final') final = row; else if (row.t === 'failed') throw Error(row.error); else if (row.t === 'sample') progress.set(from, row);
134
+ if (row.t === 'final') final = row; else if (row.t === 'partial') partial = row; else if (row.t === 'failed') throw Error(row.error); else if (row.t === 'sample') progress.set(from, row);
77
135
  }
78
136
  if (Date.now() - lastNote > 10000) { lastNote = Date.now(); const rows = [...progress.values()]; console.log(JSON.stringify({ phase: rows.some((x) => x.measuring) ? 'steady' : 'ramp', joined: rows.reduce((a, x) => a + x.joined, 0), open: rows.reduce((a, x) => a + x.open, 0) })); }
79
137
  }
80
138
  if (!final) throw Error('the probe ended without its report');
81
139
  finals.push(final);
82
- } catch (error) { failures.push(`probe request ${from}: ${String(error?.message ?? error).slice(0, 160)}`); }
140
+ } catch (error) { failures.push(`probe request ${from}: ${String(error?.message ?? error).slice(0, 160)}`); if (partial) finals.push(partial); else lost += 1; }
83
141
  }));
84
- const report = cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures, toolkit });
142
+ const report = cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures, toolkit, compress, lost });
85
143
  await writeFile(`${out}/report.json`, JSON.stringify(report, null, 2));
86
144
  console.log(JSON.stringify({ phase: 'done', out: `${out}/report.json`, joined: report.joined, connectedAtEnd: report.connectedAtEnd, ack: report.inputAckMs, loop: report.inputLoopMs, deliveredHz: report.deliveredHz, lead: report.inputLead, failedRequests: failures.length }));
87
145
  if (!finals.length) process.exitCode = 1;
@@ -58,6 +58,7 @@ const result = await withKey(
58
58
  transport: f?.transport,
59
59
  gates: f?.gates,
60
60
  durability: f?.durability,
61
+ save: f?.save,
61
62
  diagnostics: f?.diagnostics,
62
63
  error: r.error
63
64
  }) + '\n'
@@ -31,7 +31,8 @@ for (const k of Object.keys(opts))
31
31
  'churn',
32
32
  'churn-at',
33
33
  'compress',
34
- 'strict-chrome'
34
+ 'strict-chrome',
35
+ 'slow-every'
35
36
  ].includes(k)
36
37
  )
37
38
  throw Error('Unknown option --' + k);
@@ -127,7 +128,8 @@ function makeWorker(index) {
127
128
  room,
128
129
  index,
129
130
  workers: workerCount,
130
- compress: opts.compress !== 'off'
131
+ compress: opts.compress !== 'off',
132
+ slowEvery: Number(opts['slow-every'] ?? 0)
131
133
  }
132
134
  });
133
135
  let sequence = 0;
@@ -474,7 +476,7 @@ try {
474
476
  );
475
477
  loop.disable();
476
478
  const histograms = {};
477
- for (const key of ['first', 'welcome', 'ack', 'gaps', 'loops']) {
479
+ for (const key of ['first', 'welcome', 'ack', 'slowAck', 'gaps', 'loops']) {
478
480
  const h = new Histogram();
479
481
  for (const f of finals) h.merge(f.histograms[key]);
480
482
  histograms[key] = h.json();
@@ -518,6 +520,8 @@ try {
518
520
  firstStateMs: histograms.first,
519
521
  welcomeMs: histograms.welcome,
520
522
  inputAckMs: histograms.ack,
523
+ // With --slow-every: the slow players' own acknowledgements, kept out of everybody else's.
524
+ slowInputAckMs: histograms.slowAck,
521
525
  receiveGapMs: histograms.gaps,
522
526
  inputLoopMs: histograms.loops,
523
527
  ackSkipped: sum('ackSkipped'),
@@ -541,6 +545,14 @@ try {
541
545
  wireDownBytes: sum('wireDownBytes'),
542
546
  compressedSockets: sum('compressed')
543
547
  },
548
+ // --slow-every K: every Kth simulated player reads for one second in five (so every Gate holds some). `healthy` is everybody else: what the
549
+ // slow ones cost them is the difference from a run without any.
550
+ slowPlayers: players.filter((p) => p.slow).length,
551
+ healthy: (() => {
552
+ const a = players.filter((p) => !p.slow && !p.retired), of = (key) => a.map((p) => p[key]).filter(Number.isFinite).sort((x, y) => x - y);
553
+ const hz = of('deliveredHz'), gap = of('maxStateGapMs');
554
+ return { players: a.length, deliveredHz: { min: hz[0] ?? null, median: hz[Math.floor(hz.length / 2)] ?? null }, maxStateGapMs: { median: gap[Math.floor(gap.length / 2)] ?? null, max: gap.at(-1) ?? null } };
555
+ })(),
544
556
  // Samples in which a Chrome player was not a connected replica of this room.
545
557
  chromeTrouble,
546
558
  // Whether the server agreed to permessage-deflate on these sockets, as a
@@ -48,6 +48,64 @@ node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
48
48
  It opens one request to the probe for every five players, each held open for the step, adds up what they
49
49
  measured, and writes `report.json` in the shape `crowd-load.mjs` writes, so `crowd-summary.mjs` reads both.
50
50
 
51
+ ## Slow players among them
52
+
53
+ `--slow-every 10` makes every tenth player a slow one: it takes what it is sent at six frames a second, in order,
54
+ as a connection that carries no more does, and says which tick it holds. The report then has `healthy` (everybody
55
+ else) beside `slow`: what a slow player costs the players on the same Gate is the difference from the same step
56
+ without any. A Worker cannot slow a socket's reads, so this is a slow player and not a slow socket; for a socket
57
+ that stops reading, `crowd-load.mjs --slow-every 10` from a computer pauses every tenth player's socket for four
58
+ seconds in five.
59
+
60
+ ## How fast Cloudflare runs, against your computer
61
+
62
+ ```sh
63
+ node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
64
+ --probe https://homie-crowd-probe.<account>.workers.dev --key $KEY --calibrate reports/calibration.json
65
+ ```
66
+
67
+ A Worker's clock stands still while its code runs, so nothing timed on a computer says by itself what the same
68
+ work costs on Cloudflare, and a Worker cannot time it either. This runs one fixed piece of work
69
+ (`reference-work.mjs`) in the probe, ten rounds a request, with the stopwatch on your computer (a request that
70
+ does the work less one that does none, the quickest of several), and the same work on your computer, and writes
71
+ the ratio. `crowd-summary.mjs` prints it under a run's tables.
72
+
73
+ ## A whole trial
74
+
75
+ `run-trial.sh` in this folder runs a plan of steps against a deployed studio: each from your computer and from
76
+ inside Cloudflare, the in-Cloudflare steps three times each, one with slow players, the room's own figures beside
77
+ every step, the calibration before and after, and one `summary.md`. It deploys nothing.
78
+
79
+ ```sh
80
+ cd <your studio>
81
+ URL=https://<studio> WORKER=<its worker's name> PROBE_URL=https://homie-crowd-probe.<account>.workers.dev PROBE_KEY=$KEY \
82
+ bash node_modules/@homie-rocks/studio/lib/crowd-probe/run-trial.sh
83
+ ```
84
+
85
+ `PLAN=$'300 cloud 3\n600 cloud 3'` chooses the steps ("players mode runs"; the modes are in the script).
86
+
87
+ ## What a room carries, and how to read a trial
88
+
89
+ One room's rules run in one object on one thread. With the crowd-circuit fixture (light rules, 20 ticks a second)
90
+ that was 300 players with margin on Cloudflare, 600 at the edge, and 800 to 1,000 only by luck: the same step gave
91
+ 10 frames a second or 20, minutes apart. A game with heavier rules carries fewer.
92
+
93
+ - Run the size you want and twice that size, three times each, at a busy hour. A size is carried when every run
94
+ is clean and twice the size is not falling apart. One clean run proves nothing: at its edge a room is clean or
95
+ not by what else its machine is doing.
96
+ - Read **a Gate's round trip to the room** ("Gate rtt"), **the share of worlds over 200 ms old** and **"server
97
+ late"** (inputs that reached the rules after their tick). They are measured from outside the room.
98
+ - Do not read the room's word about itself. On Cloudflare a Worker's clock stands still while its code runs and
99
+ reads a timer's due time when the timer fires, so a room that is seconds behind counts no late ticks. For the
100
+ same reason a probe that runs late reports rates that read high; the report's delivered frames a second are
101
+ counted against the room's ticks, not a clock.
102
+ - Cloudflare's count of the room's processor time is not in a `wrangler tail` (it reads under 1% for a room that
103
+ is out of time). It is in the GraphQL analytics API (`durableObjectsPeriodicGroups`), which needs an API token
104
+ that may read account analytics.
105
+ - `node packages/studio/test/crowd-room-cost.mjs` in the Homie repository says what a tick of the room costs on a
106
+ computer, divided into rules, input and worlds. With the calibration above it compares one game with another;
107
+ it does not predict where a room falls.
108
+
51
109
  ## Remove
52
110
 
53
111
  ```sh
@@ -90,6 +90,9 @@ export function crowdClient({ index, game, send, tallies, period = 50, lead = 3
90
90
  const span = c.lastAt > c.firstAt ? c.lastAt - c.firstAt : 0;
91
91
  return { i: index, seat: c.seat, firstStateMs: c.first, welcomeMs: c.welcomed, closed: c.closed, lead: c.lead, frames: c.frames, maxStateGapMs: c.maxGap,
92
92
  tickHz: span ? (c.lastTick - c.firstTick) * 1000 / span : null, deliveredHz: span ? (c.frames - 1) * 1000 / span : null,
93
+ // Frames received for each tick the room advanced: 1 is every frame. It needs no clock, which matters where
94
+ // the caller's clock is a Worker's (it reads a timer's due time, and runs slow when the Worker runs late).
95
+ framesPerTick: c.lastTick > c.firstTick ? (c.frames - 1) / (c.lastTick - c.firstTick) : null,
93
96
  downBytes: c.down, upBytes: c.up, inputs: c.inputs, reports: c.reports, late: c.late, undecodable: c.undecodable, ownMissing: c.ownMissing, errors: c.errors.slice(0, 8), fresh: c.at ? true : false };
94
97
  },
95
98
  };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A fixed piece of work, the same everywhere it runs: what a room's thread spends its time on, in miniature (parsing
3
+ * players' small messages, building and reading plain objects, arithmetic on floats, writing a block of text).
4
+ *
5
+ * A Worker cannot time a piece of its own work (its clock stands still while its code runs, and reads a timer's due
6
+ * time when the timer fires), so nothing measured on a laptop says by itself what a room costs on Cloudflare. This
7
+ * runs on both: the probe does it inside Cloudflare while `crowd-cloud.mjs --calibrate` holds the stopwatch on the
8
+ * request, and the same call is timed on the computer that asked. The ratio turns `test/crowd-room-cost.mjs`'s
9
+ * milliseconds into Cloudflare's.
10
+ *
11
+ * It returns a number that depends on every step, so no engine may skip the work, and the same number everywhere.
12
+ */
13
+ export function referenceWork(rounds = 40) {
14
+ let check = 0, seed = 20261011;
15
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
16
+ for (let round = 0; round < rounds; round += 1) {
17
+ // A tick's inputs: five hundred small messages parsed, held to numbers, queued by seat.
18
+ const queues = new Map();
19
+ for (let i = 0; i < 500; i += 1) {
20
+ const text = JSON.stringify({ t: 'in', e: 3914181188, k: 3000 + round, s: [[0, i % 2 ? 127 : -127, Math.round(random() * 70 - 35)]], r: i & 7, g: 2997 + round });
21
+ const m = JSON.parse(text), seat = i * 2;
22
+ const entry = { at: m.k >>> 0, values: { ax: Math.max(-127, Math.min(127, m.s[0][1] | 0)), ay: Math.max(-127, Math.min(127, m.s[0][2] | 0)) } };
23
+ let q = queues.get(seat); if (!q) { q = []; queues.set(seat, q); }
24
+ q.push(entry); check = (check + entry.values.ax + entry.values.ay + text.length) | 0;
25
+ }
26
+ // A tick of a thousand bodies: frozen vectors made and read, a little float arithmetic each.
27
+ const bodies = [];
28
+ for (let i = 0; i < 1000; i += 1) {
29
+ const q = queues.get(i), held = q ? q[q.length - 1].values : { ax: 0, ay: 0 };
30
+ const vel = Object.freeze({ x: Math.fround(held.ax / 127 * 6), y: Math.fround(held.ay / 127 * 6), z: 0 });
31
+ const pos = Object.freeze({ x: Math.fround(random() * 16 - 8 + vel.x * 0.05), y: Math.fround(random() * 120 - 60 + vel.y * 0.05), z: 0 });
32
+ const speed = Math.hypot(vel.x, vel.y);
33
+ bodies.push([`r${i}`, 0, round & 0xffff, [pos.x, pos.y], [vel.x, vel.y], [speed > 0 ? vel.x / speed : 1, speed > 0 ? vel.y / speed : 0], 0, [round, pos.x > 0], [], i, 0]);
34
+ check = (check + Math.round(pos.x * 100) + Math.round(pos.y * 100)) | 0;
35
+ }
36
+ // The world as it leaves: a block of numbers, and the room's checkpoint as text.
37
+ const block = new Float32Array(bodies.length * 6);
38
+ for (const [i, b] of bodies.entries()) block.set([b[3][0], b[3][1], b[4][0], b[4][1], b[5][0], b[5][1]], i * 6);
39
+ const bytes = new Uint8Array(block.buffer); let sum = 0;
40
+ for (let i = 0; i < bytes.length; i += 97) sum += bytes[i];
41
+ const text = JSON.stringify(bodies);
42
+ check = (check + sum + text.length + JSON.parse(text).length) | 0;
43
+ }
44
+ return check;
45
+ }