@homie-rocks/studio 0.30.2 → 0.31.1

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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -1,10 +1,35 @@
1
- # Homie netplay contract, v1 (revision 8)
1
+ # Homie netplay contract, v1 (revision 9)
2
2
 
3
- Status: **v1, revision 8** (2026-10-03, `@homie-rocks/studio` 0.23.0). The wire version is
4
- `v: 1`. Everything revisions 2 to 8 added is either an optional field, a new message type,
3
+ Status: **v1, revision 9** (2026-10-06, `@homie-rocks/studio` 0.31.0). The wire
4
+ version is `v: 1`. Everything revisions 2 to 9 added is either an optional field, a new message type,
5
5
  a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
6
6
  not know. A change to the contract bumps `v` and keeps v1 working.
7
7
 
8
+ **What revision 9 added** (a game that knows none of it plays as before; a revision-8 helper and a
9
+ revision-8 relay ignore every new field and frame):
10
+ - **The link, said out loud** (section 22): `net.link` and `on('link')` (`online`, `reconnecting`,
11
+ `alone`, `closed`), a "Reconnecting…" line over the game, `net.reconnects`. A page that gave up
12
+ waiting for its room no longer plays alone in silence. `connectOpenMaxMs`, and `connectClock: 'game'`
13
+ with `net.start()` for a game that boots for seconds.
14
+ - **A host that hitches keeps its room** (section 22): a heartbeat snapshot from a timer (`snap.hb`),
15
+ for four seconds; game.json `netplay.stallMs` (1500 to 10000) sets the relay's stall time.
16
+ - **Game revisions** (section 23): game.json `netplay.version`. A room runs one build at a time,
17
+ the Lobby matches within the live build, an older tab is told to reload (`stale`). `features`:
18
+ per-peer capability words the relay keeps (`peer.feat`).
19
+ - **The page around the frame** (section 24): `net.prefs` (a few settings the play page keeps,
20
+ because the frame has no storage), `net.params` (the address's switches), `net.shell` (where the
21
+ page's own controls sit over the game), `guardGestures()` for a touch game.
22
+ - **Whose body** (section 25): `peer.occ` (which stay in a seat), `Roster.claim(…, occ)`,
23
+ the `admit` callback, and createRoom's takeover and adopt callbacks for a room revived from its
24
+ checkpoint and for a host that reconnects as host.
25
+ - **Places** (section 26): a tie policy for results. **The relay's log** (section 27).
26
+ - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `createRoom({ fallback })`,
27
+ `room.standing`, `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
28
+ readers for `net.prefs` (section 24).
29
+ - New optional fields `hello.ver`, `hello.feat`, `welcome.ver`, `welcome.stale`, `welcome.stall`,
30
+ `peer.occ`, `peer.ver`, `peer.feat`, `snap.hb`; a relay → client `stale` frame; the final refusal
31
+ `stale` and the reported `room-stale`; `NETPLAY_REVISION` 9 and the build mark `homie-netplay-rev:9`.
32
+
8
33
  **What revision 8 added** (a game that knows none of it has room chat anyway: the play page's
9
34
  panel, the float and the big screen's corner need no game code):
10
35
  - **Room chat** (section 19): anyone in a room (a player, a watcher, the big screen's page, a
@@ -174,7 +199,8 @@ code.
174
199
  it takes the next free seat.
175
200
  - **Offline is not a fourth role.** With no shell (a plain file, a Vite dev server),
176
201
  the helper reports `role: 'host'`, `seat: null` and `offline: true`, and every send
177
- is a no-op.
202
+ is a no-op. A page whose room never answered does the same, and says so: `net.link` is
203
+ `'alone'` (section 22).
178
204
  - **Roles change at any time.** A game must handle all three cases:
179
205
  - its first role;
180
206
  - promotion (a replica becomes host);
@@ -247,7 +273,7 @@ it.
247
273
  |---|---|
248
274
  | No host | The first `canHost` hello becomes host. |
249
275
  | The host closes or says `bye` | Sends `leave`, then elects a replacement. |
250
- | **Stall:** the host has sent no snapshot for **1.5 s** while others are present | Demotes it (`role replica, why host-stalled`) and elects another. |
276
+ | **Stall:** the host has sent no snapshot for **1.5 s** (the game's own `netplay.stallMs`, 1.5 to 10 s; section 22) while others are present | Demotes it (`role replica, why host-stalled`) and elects another. A host whose frames hitch sends a heartbeat meanwhile, for 4 s. |
251
277
  | **Frozen host at a newcomer's hello:** the host is hidden, or has sent nothing for 2.5 s | Makes the newcomer host at once. The old host gets `role replica`. |
252
278
  | **Hidden tab** | The helper sends a checkpoint, then `yield`. `ping {hid: true}` has the same effect. |
253
279
  | **Silent socket:** nothing received for **10 s** | Closes it (a slept phone, a half-open link). The seat's body turns into a bot. The helper pings every 2 s. |
@@ -271,7 +297,9 @@ it.
271
297
  - A hidden tab reconnects when it becomes visible again.
272
298
 
273
299
  **Empty rooms and deploys.**
274
- - An empty room forgets everything after 60 s, and clears its storage.
300
+ - An empty room forgets everything after 60 s, and clears its storage. A visitor who arrives
301
+ before that is the host of the SAME round, restored from the checkpoint (section 25: the
302
+ departed players' bodies go back to bots, and the newcomer takes one over).
275
303
  - If the relay restarts with sockets connected, as a Worker deploy does, every browser
276
304
  reconnects with its token and gets the **same seat**. The seat that was host gets 3 s
277
305
  to reclaim the role.
@@ -299,8 +327,8 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
299
327
 
300
328
  | `t` | Sent by | Fields | What the relay does |
301
329
  |---|---|---|---|
302
- | `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?`, `watch?` (revision 5), `rev?: 6`, `caps?: ('skill'\|'agents')[]`, `agent?: { hands, role }` (revision 6) | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full`, `too-many` or `watch-off`. A `watch: true` hello is a watcher (section 16). An agent's (section 17) is seated in a seat kept for AI and named `<label> · AI`, or refused with `agent-pass`, `agents-off`, `agents-unsupported` or `agents-alone`. |
303
- | `snap` | host | `k`, `st`, `d`, `c?` (control table) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
330
+ | `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?`, `watch?` (revision 5), `rev?: 6`, `caps?: ('skill'\|'agents')[]`, `agent?: { hands, role }` (revision 6), `ver?` (the game's revision) and `feat?: string[]` (revision 9, sections 23) | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full`, `too-many` or `watch-off`. A `watch: true` hello is a watcher (section 16). An agent's (section 17) is seated in a seat kept for AI and named `<label> · AI`, or refused with `agent-pass`, `agents-off`, `agents-unsupported` or `agents-alone`. |
331
+ | `snap` | host | `k`, `st`, `d`, `c?` (control table), `hb?: 1` (revision 9: a heartbeat, the last state again) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
304
332
  | `in` | seated non-host | `q`, `a`, `h`, `p?`, `r` (the reset epoch the sender has adopted) | Forwards to the host only, stamped `from: <seat>`. Presses are clamped to integers 0–8, at most 16 keys. |
305
333
  | `ev` | anyone | `k`, `d`, `to?` | **Speech** (section 17): with the room's speech `lines`, every `chat…` is dropped (anyone's, a host relaying one too); with `off`, every speech kind; from an agent, one line every 4 s and 8 a minute. **From a muted player** (section 15): a speech kind (`say…`, `chat…`, `emote…`) is dropped. **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
306
334
  | `state` | host | `k` (≤ 64 characters), `d` (`null` deletes) | Stores the key in memory, forwards it to all others, and includes the whole map in every `welcome` and in `role` to a new host. |
@@ -317,12 +345,13 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
317
345
 
318
346
  | `t` | To | Fields |
319
347
  |---|---|---|
320
- | `welcome` | the new client | `v`, `rev`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'\|'host-person'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?`, `watch?: { follow, why? }` (a watcher only), `policy` (with `skill`), `vote?`, `agent?` (an agent's own; a hands-`host` agent's carries no `snap` or `ckpt`, and an empty `state`) |
348
+ | `welcome` | the new client | `v`, `rev`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'\|'host-person'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?`, `watch?: { follow, why? }` (a watcher only), `policy` (with `skill`), `vote?`, `agent?` (an agent's own; a hands-`host` agent's carries no `snap` or `ckpt`, and an empty `state`), `ver?` (the build the room runs), `stale?: { ver }` (a newer build is live) and `stall` (the room's stall time, ms) (revision 9) |
321
349
  | `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
322
350
  | `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
323
351
  | `host` | everyone else, on a change | `host`, `why` |
324
352
  | `join` / `leave` | everyone else | `peer` / `id`, `seat`, `why: 'closed'\|'bye'\|'replaced'\|'silent'\|'flood'` |
325
- | `snap` | everyone but the host | `from`, `k`, `st`, `d`, `c?` |
353
+ | `snap` | everyone but the host | `from`, `k`, `st`, `d`, `c?`, `hb?: 1` |
354
+ | `stale` | a client on an older build than the live one (revision 9) | `ver` (the live build): reload when it suits (section 23) |
326
355
  | `in` | the host | `from`, `q`, `a`, `h`, `p?`, `r?` |
327
356
  | `ev` | as routed | `from`, `k`, `d`, and `id` on events to the host |
328
357
  | `state` | everyone but the host | `k`, `d` |
@@ -340,10 +369,13 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
340
369
  - Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
341
370
  `too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15),
342
371
  `watch-off` (a watcher of a game that cannot be watched; section 16), `agent-pass`,
343
- `agents-off` and `agents-unsupported` (section 17).
372
+ `agents-off` and `agents-unsupported` (section 17), `stale` (revision 9, with `ver`: this
373
+ tab runs an older build than the room; a helper before revision 9 keeps knocking and is
374
+ refused each time, section 23).
344
375
  The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
345
376
  every knock is refused at `hello`, and the shell stops the frame (section 15).
346
- - Reported only: `too-large`, `rate`, `state-full`, `vote`. (`flood` closes the socket; the helper
377
+ - Reported only: `too-large`, `rate`, `state-full`, `vote`, `room-stale` (revision 9: the room still
378
+ runs another build; the socket is closed and the helper knocks again until it is let in). (`flood` closes the socket; the helper
347
379
  comes back slowly. `agents-alone` closes an agent's socket; it knocks again after 30 s.)
348
380
 
349
381
  A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
@@ -351,7 +383,10 @@ A socket that is kicked for silence is closed with code 4000, and the helper rec
351
383
  Record types:
352
384
 
353
385
  ```ts
354
- Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts, badge?: string }
386
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts, badge?: string,
387
+ occ?: number, ver?: string, feat?: string[] }
388
+ (revision 9. occ: which stay in the seat this is: a new number whenever the seat changes hands, the same
389
+ across that browser's reconnects (section 25). ver: the peer's game revision. feat: what its build can do.)
355
390
  (badge: a word the studio's Worker verified from what the player's account owns, such as a supporter's
356
391
  "Supporter"; a hello can never set it; never on a kids server, never for an AI. @homie-rocks/studio 0.24.0)
357
392
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
@@ -379,10 +414,15 @@ held out of it, hears so at once.
379
414
  - `view` (a watcher; section 16): `seat`, `following`, `follows` (the game draws the followed
380
415
  player), `canFollow`, `whyNot`; and `scores` (`[{ seat, score }]`, once a second, from the
381
416
  game's `scores` probe).
417
+ - Revision 9: `link` (`state`, `prev`, `why`, `hosting`; section 22), `stale` (`ver`, `mine`,
418
+ `final`; section 23), `prefs` (`op: 'all'|'set'|'del'`, `n`, `k?`, `v?`; section 24) and `ready`
419
+ (`mode`, `ms`, `lateMs?`: the game's own `net.playable()`; section 21).
382
420
 
383
421
  **Parent page → game frame** (revision 5): `postMessage { t: 'homie-watch', follow: seat | 'auto' | null }`.
384
422
  The helper takes it only from its own parent window, and only as a watcher. Revision 6 adds
385
423
  `{ t: 'homie-hush', on }` (the play page's Quiet AI) and the frame's `policy` and `vote`.
424
+ Revision 9 adds `{ t: 'homie-prefs', n, ok, all?, kept?, why? }` (the answer to a `prefs` call) and
425
+ `{ t: 'homie-shell', device, orientation, width, height, rects }` (section 24).
386
426
 
387
427
  The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
388
428
  so `ev.origin` is `"null"`.
@@ -541,7 +581,8 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
541
581
  2. Overlay the snapshot.
542
582
  3. Read keyed state with `net.stateOf()`.
543
583
  4. Take `round` if it is newer.
544
- 5. Call `roster.reconcile(peers)`, then `reset()` every claimed seat.
584
+ 5. Call `roster.reconcile(peers)` (pass each peer's `occ`), then `reset()` every claimed seat
585
+ (section 25: a claim made here is a takeover like a fresh join's).
545
586
  6. Set your own body to your local one.
546
587
  7. Call `net.roster()` and continue.
547
588
 
@@ -557,7 +598,9 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
557
598
  1. **Seat every visitor at once**, phone and desktop, with no `gather`.
558
599
  2. **Boot the game frame immediately.** Use the sandboxed, opaque-origin frame, and
559
600
  inject `window.HOMIE_NET = { v: 1, url, room, token?, name?, device, want }` ahead of
560
- every module.
601
+ every module (revision 9: `ver`, `params` and `prefs` too, sections 23 and 24). The page and
602
+ the frame refuse text selection, the long-press callout and page gestures
603
+ (`user-select: none`, `-webkit-touch-callout: none`, `touch-action: none`).
561
604
  3. **Serve the game's own files** beside the shell (`/<game>/__game/`), with `HOMIE_NET`
562
605
  in its `index.html`.
563
606
  4. **Answer the arcade knock.** A game made with Homie's arcade controls asks
@@ -1446,7 +1489,7 @@ playing · 2 AI", "Loading the game…", "Joining Room 4…", the game's own "Lo
1446
1489
  60%") and the controls for this device (game.json `landing.controls`). Then it fades and the
1447
1490
  game has the screen. Its colours are the game's palette (style.json), else its landing's, else
1448
1491
  the studio's (`worker/arrival.mjs`). Nothing here crosses the relay: it is between the game's frame
1449
- and its page, so the wire stays revision 8.
1492
+ and its page, so the wire did not change for it.
1450
1493
 
1451
1494
  | From the helper to its page | When |
1452
1495
  |---|---|
@@ -1468,3 +1511,496 @@ and its page, so the wire stays revision 8.
1468
1511
  - **Measured** by `homie-studio perf` (`load.look`: the card's first frame, the first meaningful
1469
1512
  frame; `load.playable`: seated, the body drawn and the card lifted), from the page's own marks
1470
1513
  `homie:look` and `homie:playable` and `window.__shell.arrival`.
1514
+ - **A late `net.playable()` under `arrival: 'auto'` is a mistake, and is said** (revision 9). A game
1515
+ that calls `net.playable()` when its character has loaded, but never said `arrival: 'game'`, has
1516
+ already had its card lifted by the helper (seated, the room's state in): the card left before the
1517
+ game was ready, and a probe measured a stand-in as playable. The helper warns once in the
1518
+ console when the call comes 250 ms or more after the automatic arrival, and says both times:
1519
+ inside the frame `window.__homieNet.arrival` (and `net.arrivalInfo`) is
1520
+ `{ mode, by, playableMs, explicitMs, lateMs }` (ms since the helper was made; `explicitMs`: the
1521
+ game's own word, null when it never gave one; `lateMs`: how long after the automatic arrival
1522
+ that was, null when it was not late); on the play page `window.__shell.arrival` has `mode`,
1523
+ `by`, `explicitMs` (the page's clock) and `lateMs`, and `window.__shell.ready` the message
1524
+ itself. The fix is one option: `createNetplay({ arrival: 'game' })`, or in a port
1525
+ `createRoom({ netplay: { arrival: 'game' } })`.
1526
+
1527
+ ## 22. The link, said out loud (revision 9)
1528
+
1529
+ A page stands in one of six places with its room, and the helper says which:
1530
+
1531
+ | `net.link` | What it means | `net.offline` | `net.connected` |
1532
+ |---|---|---|---|
1533
+ | `connecting` | No welcome yet. | false | false |
1534
+ | `online` | In the room. | false | true |
1535
+ | `reconnecting` | It was in the room and its socket went. It knocks again with its token (250 ms, 500 ms, 1 s, 2 s, then every 4 s); its seat is held 60 s and a bot drives its body meanwhile. **Bounded**: after `reconnectMaxMs` (default 20000, 5000 to 300000) it is `alone`. | false | false |
1536
+ | `alone` | The room never answered in time (`why: 'relay-timeout'`), or it was in the room and the room did not come back (`why: 'reconnect-timeout'`). This page plays by itself as an offline host: no seat, its own bots, nobody else. It still knocks, and joins when it is answered (a `role` with `why: 'reconnected'`). | **true** | false |
1537
+ | `offline` | There is no room at all: a plain file, a dev server with no shell. | true | false |
1538
+ | `closed` | Stopped for good; `net.closedWhy` says why (`replaced`, `room-full`, `kicked`, `stale`…). | as it was | false |
1539
+
1540
+ - **`net.offline` means "not in a room"**, not "no network": it is true for `alone` and `offline`.
1541
+ **`net.connected`** is true only while the socket is up and welcomed. The state between them
1542
+ (`!net.offline && !net.connected`) is `reconnecting`.
1543
+ - **`on('link', (e) => …)`** fires on every change with `{ state, prev, why, hosting, downMs }`.
1544
+ `hosting` says this page is running the rules right now. Two pages can both say so for a
1545
+ moment: a host that was cut off keeps simulating until its welcome tells it the room moved on,
1546
+ and a page that is `alone` hosts its own private round. Before revision 9 neither said
1547
+ anything, which is how two browsers of one room came to report `hosting: true` with different
1548
+ rosters. A game that must not score, save or award anything while it is cut off checks
1549
+ `net.link === 'online'`.
1550
+ - **The line over the game.** One line at the top of the game's own document says where the
1551
+ page stands whenever that is not "playing in the room". It says what is true, so "Reconnecting…"
1552
+ is only ever shown to a page that was connected:
1553
+
1554
+ | When | `data-homie-link` | The line |
1555
+ |---|---|---|
1556
+ | `reconnecting` for more than 0.7 s | `reconnecting` | Reconnecting… |
1557
+ | `alone`, never answered | `alone` | Playing on your own · still looking for the room… |
1558
+ | `alone`, a room still on the old build (`room-stale`) | `alone` | Playing on your own · this room opens when its players have the new version |
1559
+ | `alone`, the room did not come back | `alone` | Playing on your own · the room dropped, still trying… |
1560
+ | `online`, every seat taken (`net.full`) | `full` | This room is full · watching until a seat is free |
1561
+ | `online`, the game's own sentence (`net.line(text)`; `createRoom`'s `fallback` uses it) | `seat` | for example: Playing on your own until the next round |
1562
+ | `closed`: `room-full`, `too-many` | `closed` | This room is full. (with "· playing on your own" once it does) |
1563
+ | `closed`: `replaced`, `kicked`, `room-closed`, `version` | `closed` | This game is open in another tab. / This room is closed. / This game needs a reload to play online. |
1564
+ | `closed`: `stale`, or a newer build is live | `stale` | This game was updated. Tap to reload. |
1565
+
1566
+ Its default style has no specificity (`:where(…)`), so any rule the game writes for
1567
+ `[data-homie-link]` restyles it. `createNetplay({ linkOverlay: false })` turns it off: listen
1568
+ to `link` and draw your own. The play page's chip says the same in a word ("reconnecting",
1569
+ "playing on your own: looking for the room", "room full"), and a room with no place left at all
1570
+ gets the page's notice with **Play in another room**. A game that draws its own "Reconnecting…"
1571
+ tests `net.link === 'reconnecting'`, not `!net.connected`: that is also true of a page still
1572
+ joining and of one that stopped for good.
1573
+ - **Every state ends somewhere a player can act, in bounded time.** `connecting` ends in `online`
1574
+ or, after `connectOpenMaxMs`, in `alone`. `reconnecting` ends in `online` or, after
1575
+ `reconnectMaxMs`, in `alone` (before this bound a replica whose room never came back showed a
1576
+ frozen round and "Reconnecting…" for good). Going `alone` from a room is a `role` event (`role:
1577
+ 'host'`, `why: 'reconnect-timeout'`), also for a page that was the room's host: its seat went
1578
+ with the room. `createRoom` keeps the round such a host was running (its own body and score,
1579
+ everybody else's body a bot's); a game on raw `createNetplay` starts a round of its own, as it
1580
+ does for `relay-timeout`. `closed` is said in words and the play page offers the way out.
1581
+ - **`net.full`** is true when this browser asked to play and every seat is taken: it is in the
1582
+ room with no seat (`seat === null`, role `screen`) and the relay seats it when one frees up (a
1583
+ `role` event with `why: 'seated'`). Section 28 says what a game does meanwhile.
1584
+ - **`net.reconnects`** is how many times the helper has scheduled another knock (the same number
1585
+ as `stats().reconnects`, which was always there; `net.reconnects` and
1586
+ `window.__homieNet.reconnects` are new). 0 means the connection was never interrupted, which is
1587
+ a different fact from "the round finished". `stats().drops` counts interruptions of a link that
1588
+ was up.
1589
+
1590
+ **The wait for the welcome.** A page with a live socket listens for the room's welcome for
1591
+ `connectOpenMaxMs` (default 10000, 2000 to 120000) of time it could actually listen, then plays
1592
+ `alone`. A heavy 3D game that boots under load for longer than that used to play a private round
1593
+ for a few seconds first. Two ways out, neither changes the default:
1594
+
1595
+ ```ts
1596
+ createNetplay({ connectOpenMaxMs: 30_000 }); // wait longer
1597
+ const net = createNetplay({ connectClock: 'game' }); // or: the wait starts when the game says
1598
+ await loadEverything();
1599
+ net.start(); // booted: the clock runs from here
1600
+ ```
1601
+
1602
+ With `connectClock: 'game'` the socket still opens at once and a welcome that arrives early is
1603
+ used at once; only the giving-up waits for `net.start()` (or for 60 s, so a game that never says
1604
+ is not left without a role).
1605
+
1606
+ **A page that was blocked is not a dead socket.** The helper drops a socket that delivered
1607
+ nothing for `staleMs` (6 s) and knocks again. A heavy boot blocks the page for longer than that,
1608
+ and when it wakes its ping timer runs before the frames queued behind it: a healthy socket was
1609
+ dropped and every long hitch became a reconnect. A ping that itself runs more than 3.5 s late now
1610
+ gives the socket 2.5 s to be heard from first; one that is really dead is still dropped.
1611
+
1612
+ **A host that hitches.** The relay hands the room on when its host has sent no snapshot for the
1613
+ stall time while others are present. That is right for a frozen tab and wrong for a game whose
1614
+ frames stopped for a second (a level loading, shaders compiling): a pair of slow 3D clients swapped
1615
+ the host four to seven times in thirty seconds.
1616
+
1617
+ - **The heartbeat (helper).** When the game has called `net.snapshot()` at least once and then
1618
+ sends nothing for `heartbeatMs` (default 500, 0 turns it off) while others are present, a timer
1619
+ sends the last state again with a fresh time stamp and `hb: 1`. Replicas hold the picture,
1620
+ which is what the host is showing too. It stops 4 s after the game's last real snapshot, so a
1621
+ game that is really frozen is still replaced (4 s plus the stall time later). A hidden tab
1622
+ never beats: it yields at once, as before. A JavaScript frame that blocks the whole page for
1623
+ seconds blocks the timer too; that case is what `stallMs` is for.
1624
+ - **The stall time (game.json).** `"netplay": { "stallMs": 4000 }` sets it for every room of the
1625
+ game, held to 1500 to 10000 ms (`STALL`); `homie-studio build` writes it to the catalogue and
1626
+ says when it had to hold a value to the bounds. The welcome carries the room's (`stall`).
1627
+
1628
+ ## 23. Game revisions (revision 9)
1629
+
1630
+ A deploy that changes what the snapshot means (a new field, a smaller arena, a new rule) meets
1631
+ tabs that were open before it. Without a revision, an old tab and a new one share a room and one
1632
+ reads the other's snapshot with the wrong code.
1633
+
1634
+ **`"netplay": { "version": "7" }`** in game.json names the game's revision: 1 to 32 of A-Z a-z 0-9
1635
+ `.` `_` `-` (a number works). Bump it with every change an already-open tab cannot play with.
1636
+ `homie-studio build` writes it to the catalogue; the Worker stamps every page it serves with it
1637
+ (`HOMIE_NET.ver`, and `gv=` in the socket's address) and tells every room which revision is live.
1638
+
1639
+ - **A room runs one build at a time.** Its build is its first visitor's, until nobody on that
1640
+ build is connected.
1641
+ - **Strangers on different builds never meet**: the Lobby matches a visitor only with rooms of
1642
+ the live build (`/join?ver=`), and opens a new room otherwise. Tabs of the old build keep
1643
+ playing together in the room they have.
1644
+ - **An old tab is told.** An old tab in its own room gets `stale { ver }` (in its welcome, or
1645
+ as a frame when a visitor on the live build knocks at its room): `on('stale', …)`, `net.stale`,
1646
+ and a line "A new version is ready. Tap to reload." for ten seconds. The play page loads the
1647
+ game's frame again at the round's break (at once on a big screen). A game that would rather
1648
+ choose the moment calls `location.reload()` in its `stale` handler; the frame reloads to the
1649
+ live build and its seat token brings its seat back.
1650
+ - **An old tab that knocks at the live build's room is refused**: `error stale` (final; the helper
1651
+ shows "This game was updated. Tap to reload." and the play page reloads the frame, at most twice
1652
+ a minute).
1653
+ - **A tab of the live build that knocks at a room still on the old build waits**: `error
1654
+ room-stale` (not final). The room's players are told to reload; when the last of them has left,
1655
+ the next knock is let in, and the room **starts fresh**: its checkpoint, snapshot, round, roster
1656
+ and keyed state are dropped, because the new build must not read what the old one wrote. Seats
1657
+ keep their tokens and names.
1658
+ - **It is ignorable.** A helper from before revision 9 sends no `ver`; the socket's address
1659
+ carries its page's build anyway, so it is held to the same rule (it does not know `stale` is
1660
+ final and keeps knocking; every knock is refused, and after its wait it plays alone). A relay
1661
+ from before revision 9 ignores `ver`, and the helper then plays as it always did. A game that
1662
+ names no version is one pool, as before. A relay nobody tells which build is live (no Worker in
1663
+ front) uses the hello's `ver`: two builds still never share a room, and nobody is called stale.
1664
+
1665
+ **What a revision cannot see.** Until a game's bundle has a content-hashed
1666
+ name, the edge may serve the previous `main.js` for up to a minute after a deploy while the page
1667
+ around it is already stamped with the new version. A tab loaded in that minute says it is the new
1668
+ build and runs the old code. Revisions keep builds apart by what the page was served with, not by
1669
+ what the browser cached.
1670
+
1671
+ ### Rolling out a change to gameplay
1672
+
1673
+ 1. **Is an open tab still able to play with a new one?** A new snapshot field old code ignores, a
1674
+ new sound, a tuning number the host owns: yes. A changed meaning (arena bounds, a body's packed
1675
+ fields, a rule both sides simulate, a reordered `pack()`): no.
1676
+ 2. **No: bump `netplay.version`.** Nothing else is needed. Old rooms finish among themselves, new
1677
+ visitors get new rooms, old tabs reload at their round's break. Test it the way it happens:
1678
+ leave a tab open on the old build, deploy, open a second tab by the room's link, and watch the
1679
+ first be told and the second wait and then get a fresh round. A host-migration test between two
1680
+ tabs of the same build proves nothing about this.
1681
+ 3. **Yes, but only when everyone has the new build: use `features`.** Do not bump the version.
1682
+
1683
+ ```ts
1684
+ const net = createNetplay({ features: ['powerups'] });
1685
+ // host, whenever it matters (cheap: it reads the peers the relay gave it)
1686
+ const on = net.allHave('powerups'); // every seated player's build says so
1687
+ net.state('rules', { powerups: on }); // keyed state: every replica and the next host read it
1688
+ ```
1689
+
1690
+ `features` are short words (up to 8, each a-z 0-9 `-`, 24 characters) in the hello. The relay
1691
+ keeps them with the peer and hands them to everyone in `peers`, in a welcome and in the `role`
1692
+ that promotes a new host. That answers the three things a hand-rolled acknowledgement gets
1693
+ wrong:
1694
+ - **Host migration.** A new host never held the acknowledgements the old one collected. With
1695
+ `features` there is nothing to hand over: `net.featuresOf(seat)` and `net.allHave(word)` read
1696
+ the peers the relay just gave it.
1697
+ - **Connection-id reuse.** A socket id changes on every reconnect and a seat number is reused
1698
+ by the next visitor. `peer.feat` belongs to the socket that is in the seat now; nothing is
1699
+ keyed by an id you stored earlier.
1700
+ - **Late legacy arrivals.** A build that says nothing has no features, so `allHave` turns false
1701
+ the moment an old tab is seated; the host turns the feature off (or leaves it on for the
1702
+ players who have it, if the rule is the host's alone) and says so in keyed state.
1703
+ 4. **If you keep your own capability state anyway** (a per-player choice, a handshake with data):
1704
+ key it by seat **and** `peer.occ` (section 25), never by connection id; put it in the checkpoint
1705
+ and in keyed state so a new host and a late joiner both have it; treat "unknown" as "legacy";
1706
+ and when you are promoted, drop every entry whose `occ` is not in `net.peers` any more.
1707
+
1708
+ ## 24. The page around the frame (revision 9)
1709
+
1710
+ The game runs in a sandboxed frame **without `allow-same-origin`**: an opaque origin. That is
1711
+ deliberate and stays. A studio's site also holds its owner's session, its players' accounts and
1712
+ its shop; a game is code a stranger may have written (a port, a part from another studio), and with
1713
+ `allow-same-origin` it could read all of that. The price is that the frame has no storage of its
1714
+ own (`localStorage`, `sessionStorage`, cookies and IndexedDB throw a `SecurityError`), cannot read
1715
+ the page's address, and cannot see what the page draws over it. Revision 9 gives the game each of
1716
+ those through the helper.
1717
+
1718
+ **`net.prefs`: a few settings that last.** Quality, volume, a personal best, the last name typed.
1719
+
1720
+ **The trap, and the fix: a setting that is not there must never reach your game as `null`.**
1721
+ `null * x` is `0` and `Number(null)` is `0`, so a volume that was never set, or was kept as `null`,
1722
+ plays as silence with no error. Read a number, a switch or a word with the typed readers: they
1723
+ answer with your fallback for a key never set, a `null`, a wrong type, a `NaN`, and a read before
1724
+ the prefs arrived.
1725
+
1726
+ ```ts
1727
+ await net.prefs.ready; // once, before the first read
1728
+ const volume = net.prefs.number('volume', 0.8, { min: 0, max: 1 }); // never null, never NaN, never out of range
1729
+ const muted = net.prefs.boolean('muted', false); // true or false, never 0 or 'false'
1730
+ const quality = net.prefs.string('quality', 'high', { oneOf: ['low', 'high'] });
1731
+ net.prefs.set('volume', 0.4); // resolves true when kept
1732
+ ```
1733
+
1734
+ - `number(key, fallback, { min, max, integer })`, `boolean(key, fallback)` and
1735
+ `string(key, fallback, { oneOf })` read what has arrived, without waiting. The fallback is
1736
+ yours and required: one of the wrong type (`number('volume')`, a `NaN`) throws a `TypeError`.
1737
+ - **Before `ready`** the page has not answered yet (on the play page the prefs come by message):
1738
+ a typed reader and `peek` answer with the fallback and the console says so once, naming
1739
+ `await net.prefs.ready`. `net.prefs.loaded` says whether they have arrived.
1740
+ - **A wrong type is said once** per key in the console, with what was kept (a string where a
1741
+ number was asked for, a word that is not one of `oneOf`). A key that is simply not set says nothing.
1742
+ - **`set` refuses what JSON cannot keep**: `NaN`, `Infinity`, a function, a loop, `undefined`
1743
+ inside an object. JSON would write `null`, and the next visit would read that as the setting.
1744
+ It resolves false, warns once for the key, and what was kept stands. `set(key, null)` still
1745
+ removes. A `null` that an earlier build kept this way is read as nothing kept, by `get` too.
1746
+ - `get(key, fallback)`, `all()` and `peek(key, fallback)` remain for JSON values (an object, a
1747
+ list): they are not checked against the fallback's type.
1748
+
1749
+ ```ts
1750
+ const q = await net.prefs.get('layout', { hud: 'left' }); // always resolves, with the fallback if nothing is kept
1751
+ await net.prefs.ready; net.prefs.peek('best', 0); // after ready: read without waiting
1752
+ ```
1753
+
1754
+ - The play page keeps them in its own storage, one JSON object per game on this browser
1755
+ (`homie-prefs.<game>`): **16 KB a game, 32 keys, 64 characters a key**; a `set` over the cap is
1756
+ refused (false) and what was kept stands. Values are JSON.
1757
+ - `net.prefs.where` says where they are: `'page'` (the play page), `'local'` (this document's own
1758
+ storage: a plain file or a dev server, where it works) or `'memory'` (this visit only: a watch
1759
+ page, a play page from before revision 9, a private window).
1760
+ - They are a convenience, per browser, readable by the player. Never a secret, never progress that
1761
+ must not be lost: that is cloud saves (`../saves/SAVES.md`).
1762
+ - A single-player game ported with `localStorage` everywhere keeps working through the port kit's
1763
+ in-memory shim (`@homie-rocks/studio/port/early`), which forgets at the end of the visit; move
1764
+ what should last to `net.prefs`.
1765
+
1766
+ **`net.params`: the address's switches.** `/<id>/play?q=low&debug` reaches the game as
1767
+ `net.params` (`{ q: 'low', debug: '' }`) and `net.param('q', 'high')`.
1768
+
1769
+ - Passed always: `debug`, `q`, and the port kit's `touchdebug`, `cam`, `view`.
1770
+ - game.json `"netplay": { "params": ["seed", "lod"] }` adds up to 12 names of the game's own
1771
+ (lowercase a-z 0-9 `-` `_`).
1772
+ - Never passed, whatever a game declares: the page's own (`room`, `name`, `k`, `t`, `b`, `hand`,
1773
+ `screen`, `watch`…). A value is up to 48 of A-Z a-z 0-9 `_` `.` `~` `-`; anything else is not
1774
+ passed at all. A bare `?debug` is the empty string.
1775
+ - With no page (a plain file, a dev server) `net.params` is the document's own query.
1776
+
1777
+ **`net.shell`: where the page's own controls sit.** The room button, the server pill, the chat
1778
+ pill, the "3 playing" chip and the big screen's join card are drawn by the page, over the game. A
1779
+ HUD check that only measures inside the frame cannot see them, and a help line ended up under the
1780
+ chip. The page tells the frame, in the game's own CSS pixels:
1781
+
1782
+ ```ts
1783
+ net.on('shell', (s) => layoutHud(s)); // and net.shell, null before the page says
1784
+ // { device: 'phone', orientation: 'portrait', width: 390, height: 844,
1785
+ // rects: [{ id: 'room', x: 348, y: 8, w: 34, h: 34 }, { id: 'chip', x: 10, y: 800, w: 132, h: 26, fades: true }] }
1786
+ ```
1787
+
1788
+ - `id`: `room`, `server`, `chat`, `chip`, `join`, `banner`, `ticker`, `results`, `screen`, and the
1789
+ open sheets (`sheet`, `server-sheet`, `vote`). `fades`: it goes by itself after a few seconds and
1790
+ comes back when the room's facts change; keep text that must be read out of it anyway.
1791
+ - Said when the helper attaches, on a resize or a turn of the phone, and when a control moves
1792
+ (the room button shrinks to a dot after six seconds). Per device and orientation because the
1793
+ room button's place is (`screen.share`).
1794
+ - A test reads the same from `window.__homieNet.shell` inside the frame and `window.__shell.rects`
1795
+ on the page. To move the page's controls instead, see game.json `screen.share` and `screen.chat`.
1796
+
1797
+ **`guardGestures()`: a touch game's own page.** The play page refuses selection, the long-press
1798
+ callout and page gestures on itself and around the frame. Inside the game's document it is the
1799
+ game's turn: a long press on a HUD label or a button's text raises copy/paste on a phone and the
1800
+ thumb's touch is cancelled.
1801
+
1802
+ ```ts
1803
+ import { guardGestures } from '@homie-rocks/studio/netplay';
1804
+ const off = guardGestures({ touch: 'canvas, [data-action]' }); // once, early; off() undoes it
1805
+ ```
1806
+
1807
+ - Page text is unselectable, the callout and the context menu are suppressed, and iOS's page
1808
+ pinch is stopped.
1809
+ - Touches that start on a `touch` surface (default `canvas`) get `touch-action: none` and a
1810
+ non-passive `preventDefault`: no pan, no zoom, no double-tap zoom.
1811
+ - `input`, `textarea`, `select`, links, labels, `contenteditable` and anything inside
1812
+ `[data-selectable]` keep the browser's own behaviour, also inside a gameplay surface. An
1813
+ ordinary `<button>` outside a `touch` surface keeps its click.
1814
+ - It is opt-in: a text adventure wants selection. **Not verified on a physical phone**: the
1815
+ listeners and the stylesheet are tested, and emulated touch does not raise the callout, so try
1816
+ a real long press on a real iPhone and Android phone before calling it done.
1817
+
1818
+ ## 25. Whose body: seats that change hands (revision 9)
1819
+
1820
+ A seat number is not a person. The relay reuses a number when its player has been gone a minute
1821
+ (or sooner, in a full room), a host can be cut off while people come and go, and a round can
1822
+ outlive everyone in it. Three cases went wrong in real games; all three now run the same code as
1823
+ an ordinary join.
1824
+
1825
+ **`peer.occ`: which stay in the seat.** The relay numbers every stay: a new number whenever a seat
1826
+ is given to a new token, the same number across that browser's reloads and reconnects, kept
1827
+ through a deploy. `Roster.claim(seat, name, agent, occ)` and `Roster.reconcile(peers)` (pass
1828
+ `occ`) use it: the same stay keeps its body; a seat that changed hands gives the old body back to
1829
+ a bot and admits the newcomer like any arrival. `roster.occupants()` belongs in the checkpoint
1830
+ beside `roster.toJSON()`, and `Roster.from(slots, opts, occupants)` reads it back. With a relay
1831
+ that says no `occ`, a seat is taken to be the same player's, as before, and the helper still knows
1832
+ its own: `net.resumed` is true when its welcome gave back the seat its token named.
1833
+
1834
+ **The takeover callbacks cover every way a body changes hands** (`createRoom`):
1835
+
1836
+ | How | `onTakeover(body, info)` | `adopt(body)` |
1837
+ |---|---|---|
1838
+ | A person joins a running round | `{ why: 'join', own: false }` | on the joiner, when the host resets its body |
1839
+ | A visitor revives a room from its checkpoint | `{ why: 'restore', own: true }` for its own body, and for anyone claimed with it | on the new host, with the body it took |
1840
+ | A replica is promoted and someone arrived since the last checkpoint | `{ why: 'migrate' }` | on that player, by reset |
1841
+ | A host reconnects as host and someone arrived while it was away | `{ why: 'join' }` (the missed `join` is replayed) | on that player, by reset |
1842
+ | The same player comes back to the body they held | not called when the slot never left them; called with `back: true` when they left and returned (a reload) | as before |
1843
+
1844
+ `info.back` is true when the player held that body before. The default (no `onTakeover`) is still
1845
+ "score 0, where it stands".
1846
+
1847
+ **The restored room.** Everyone leaves; the relay keeps the round for 60 s; a new visitor arrives
1848
+ before that. It is welcomed as host with the checkpoint (`why: 'resumed'`), and the round goes on
1849
+ with the clock where it was. Before revision 9 the newcomer inherited a departed player's body
1850
+ without `onTakeover` (their score, with eleven seconds left) and without `adopt` (its local pose
1851
+ stayed at a default, so it could not move until the next round). Now `createRoom` hands the
1852
+ departed players' bodies back to bots, claims one for the newcomer through `admit`, calls
1853
+ `onTakeover` and then `adopt`. A player who comes back to their own room (their token) keeps
1854
+ their body and score, and is adopted onto it. A game on raw `createNetplay` does the same with
1855
+ `roster.reconcile(peers)` in its `role` handler: treat every slot in `claimed` as a takeover.
1856
+
1857
+ **The host that reconnects as host.** A host whose socket drops and comes back while nobody else
1858
+ could host gets a welcome, not a `role` (its role did not change), so its `role` handler never
1859
+ ran, and the relay never told it who came or went meanwhile. A player who arrived in that window
1860
+ had no body for the rest of the visit, and one who left kept a frozen one. The helper now says
1861
+ the difference as the events it would have been: `leave` (with `why: 'gone'`) and `join`, by seat,
1862
+ with a seat whose `occ` changed as both. Nothing to write: the handlers a game already has do it.
1863
+ The same change stopped `createRoom` calling `onTakeover` (and zeroing the score) for a player
1864
+ whose body never left them.
1865
+
1866
+ **`admit`: which body an arrival takes.** By default a newcomer takes the lowest bot's body. In an
1867
+ elimination round that can be a dead one while living bots stand by.
1868
+
1869
+ ```ts
1870
+ createRoom({
1871
+ admit: (candidates, who) => candidates.find((b) => b.alive) ?? null, // null: none will do
1872
+ });
1873
+ new Roster({ min, max, admit: (slots, who) => pickSlotId(slots) }); // raw: slots in, a slot id out
1874
+ ```
1875
+
1876
+ - Asked for every NEW arrival: a fresh join, a restored room's claims, a promoted host's
1877
+ reconcile, a missed join replayed. Return the body (the slot id, on `Roster`), `undefined` for the
1878
+ default, or `null` for "none of these": a new body is spawned while the room has space, and when
1879
+ it has none the default applies (a seated person always gets a body).
1880
+ - Never asked for a player returning to the body they held: an eliminated player who reloads gets
1881
+ their own dead body back, not a way into the round. `occ` is how the roster knows.
1882
+ - Never offered a seat kept for AI when a person arrives; an agent is offered the kept seats first
1883
+ (the reservation of section 17 holds).
1884
+ - Decide from the bodies' own state so the next host makes the same choice. The host still resets
1885
+ the body (`net.reset`), announces the roster and checkpoints, as for any claim.
1886
+
1887
+ **The late-join checklist.** A game is not done with joining until each of these is true in two
1888
+ real browsers:
1889
+
1890
+ 1. A joiner mid-round takes a bot's body, the right one (`admit`), is reset onto it and can move.
1891
+ 2. The host leaves; the other browser continues the same round; a joiner after that still works.
1892
+ 3. A joiner who reloads comes back to the same body (and the same score, if the game keeps it:
1893
+ `info.back`).
1894
+ 4. **Everyone leaves, and a new visitor enters before the room is forgotten (60 s).** The round is
1895
+ the same one; the visitor has a body it did not inherit silently (`onTakeover` ran, `adopt` ran),
1896
+ can move at once, and is ranked as a newcomer.
1897
+ 5. The same, but the visitor is one of the players who left (same tab, reloaded): their own body
1898
+ and score.
1899
+ 6. The host's network drops for a second while a third browser joins: after it reconnects, three
1900
+ people, three bodies, and nobody's score was reset.
1901
+ 7. A deploy with tabs open: section 23.
1902
+ 8. **A slow browser joins mid-round** (throttle one to "4x slowdown" and open the room's link
1903
+ while a round runs, then again during the results). Within a few seconds it is in one of the
1904
+ places section 28 names, and the screen says which: playing in the room on its own body,
1905
+ playing on its own with bots, or watching with a line that says until when. Never a body-less
1906
+ camera with nothing said, never "Reconnecting…" on a page that was never connected, and never
1907
+ a line left up after the link came back. `room.standing` (or `net.link`, `net.full` and your
1908
+ own roster on raw `createNetplay`) is what a test asserts.
1909
+
1910
+ ## 26. Places and ties (revision 9)
1911
+
1912
+ `createRoom` ranks a round's results by score. The order of the rows was always: higher score
1913
+ first, then **people before bots**, then **the lower slot**. Two bodies on 61 points were shown
1914
+ first and second with nothing saying why. The order is the tiebreaker and is unchanged; what
1915
+ place a tie gets is now the game's to say:
1916
+
1917
+ | `createRoom({ ties })` | 61, 61, 40, 12 | |
1918
+ |---|---|---|
1919
+ | `'order'` (default) | 1, 2, 3, 4 | What results always were: the order above is the place. Say so to players if you keep it ("ties go to the player, then the earlier seat"). |
1920
+ | `'shared'` | 1, 1, 3, 4 | Standard competition places: equal scores share one and the next is skipped. |
1921
+ | `'dense'` | 1, 1, 2, 3 | Equal scores share one and none is skipped. |
1922
+
1923
+ `placesOf(scores, ties)` is the same rule for a game that ranks its own rows (pass the scores in
1924
+ the order you show them). A game with a real tiebreaker (time, kills, who got there first) sorts
1925
+ by it before ranking and keeps `'order'`.
1926
+
1927
+ ## 27. The relay's log (revision 9)
1928
+
1929
+ A room says its lifecycle one JSON line at a time, each with `at` (ISO time), `game` and `room`:
1930
+ `hello`, `leave` (who, which seat, host or not, how many are left, and `via: 'error'` when the
1931
+ socket failed rather than closed), `elect`, `restored`, `stale`, `room-stale`, `build-changed`,
1932
+ and `failed`.
1933
+
1934
+ - **A departure is not a failure.** A browser that closes its tab, loses its network or is shut by
1935
+ a test harness ends its socket without a goodbye, and the runtime reports that on the socket as
1936
+ an error ("Network connection lost"). The Table handles the socket's `error` event as the
1937
+ departure it is: one `socket-gone` line with the client it was, then the room's own `leave`. A
1938
+ browser that goes away while its socket is still being opened is a `socket-gone` line from the
1939
+ Worker, not an uncaught error.
1940
+ - **A failure is said as one.** A room operation that throws is caught at the socket boundary and
1941
+ logged on the error stream as `failed` with what the room was doing (`op`, and the frame's type,
1942
+ never its contents), the client and how many are connected; the beat that throws is
1943
+ `tick-failed`; a socket error that is not a departure is `socket-error`; a room the Worker could
1944
+ not reach is `room-unreachable`. One bad frame does not end a seat.
1945
+ - **No line carries a credential**: not a seat token, a ticket, a browser key or an address.
1946
+ - `HOMIE_ROOM_LOG=0` (a Worker variable) keeps only the failures.
1947
+ - Not verified against the Workers runtime's own console in this revision: the boundaries are
1948
+ tested with stand-in sockets. If `wrangler dev` still prints an error with no room on it after a
1949
+ host is closed, it comes from somewhere these boundaries do not cover; the `leave` and
1950
+ `socket-gone` lines beside it say what the room was doing at that second.
1951
+
1952
+ ## 28. Seat or solo: a page with no body (after 0.31.0)
1953
+
1954
+ A page can be in its room and have nothing to play: `net.link === 'online'`, and no body. It
1955
+ happened three ways, and none of them said anything:
1956
+
1957
+ | How | What the page had | What happens now |
1958
+ |---|---|---|
1959
+ | The relay seated the player and the host had no body free (every body was somebody's: a server stopped keeping seats for AI mid-round, or the game has fewer bodies than the room has seats). The claim was tried once, at the join. | a seat, role `replica`, no body, for the rest of the visit | The host seats everybody who is waiting **whenever a body frees up and at every round start** (`createRoom`; the starters on raw `createNetplay` do the same in `seatWaiting()`). |
1960
+ | Every seat is taken. The relay lets the page in with no seat. | `seat === null`, role `screen`, nothing said | `net.full` is true and the line says so; the relay seats it when a seat frees up, as before. |
1961
+ | The page was reset by its host before a snapshot showed its body. | a body it never adopted (it could not move until the next round) | `createRoom` owes the adoption until a snapshot shows the body. |
1962
+
1963
+ **The recipe is an option, not prose.** `createRoom` decides what a body-less page does, after
1964
+ `seatWaitMs` (default 4000, 1000 to 30000) of time it could actually listen:
1965
+
1966
+ ```ts
1967
+ const room = createRoom({
1968
+ // …
1969
+ fallback: 'solo', // 'wait' (default) | 'solo' | 'spectate'
1970
+ seatWaitMs: 4000,
1971
+ onStanding: (s) => hud.say(s.line), // optional: the line over the game says it already
1972
+ });
1973
+ room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed', why, line }
1974
+ ```
1975
+
1976
+ | `fallback` | What the page does while the room has no body for it | The line |
1977
+ |---|---|---|
1978
+ | `'wait'` (default) | What a game did before: it waits. Now it says so, and the host seats it as above. | Waiting for a place in this round… |
1979
+ | `'solo'` | A private round with bots, here: `room.hosting` is true, `room.mine()` is its body, `room.solo` is true. Nothing of it is sent (no round, roster, snapshot or `room.send`). The moment the room has a body for it, the private round ends and `adopt` is called with the room's body. | Playing on your own until the next round (in a full room: This room is full · playing on your own until a seat is free) |
1980
+ | `'spectate'` | No body; `room.viewBody()` follows a person in the room until it has one. | Watching until the next round |
1981
+
1982
+ `room.standing` ties it to the link states of section 22, so a game (and a test) asks one thing:
1983
+
1984
+ | `standing.state` | `why` | `net.link` | Can the player play? |
1985
+ |---|---|---|---|
1986
+ | `joining` | `connecting`, `no-body`, `full`, `reconnecting` | `connecting`, or `online` inside `seatWaitMs` | not yet; a line after 0.7 s ("Joining the round…") |
1987
+ | `playing` | `seated`, `reconnecting` | `online` (or `reconnecting`, bounded) | yes, in the room |
1988
+ | `solo` | `alone`, `offline` (the link); `no-body`, `full` (the fallback) | `alone`, `offline`, or `online` | yes, on their own, with bots |
1989
+ | `watching` | `screen` (a watcher or a big screen, by choice); `no-body`, `full` (`spectate`) | `online` | no; the line says until when |
1990
+ | `waiting` | `no-body`, `full` (`wait`) | `online` | no; the line says so |
1991
+ | `closed` | `net.closedWhy` | `closed` | no; the line says why and the play page offers another room |
1992
+
1993
+ - **Solo is the game's own host code, run privately.** `onRoundStart` runs for the private round
1994
+ (with the room's round number, a clock of its own) and the game's rules move its bots, exactly
1995
+ as offline. A score made there stays there: the room's body starts as the host made it. Events
1996
+ from the real room still arrive on `room.on('event')` while solo; a game that plays sounds for
1997
+ them checks `room.solo`.
1998
+ - **The wait counts only time the page could listen**, like the wait for the welcome: a page
1999
+ blocked for six seconds by its own boot runs its timers before the snapshots queued behind
2000
+ them, and a body that is already on its way is not "missing".
2001
+ - **The default changes nothing a working game did.** A page that gets its body with its first
2002
+ snapshots never sees any of this; `'wait'` only adds the line and the host's second try.
2003
+ - **On raw `createNetplay`** there is no option: do what the starters do. Host: try the claim
2004
+ again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
2005
+ `net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
2006
+ player stands with `net.line('…')`.