@homie-rocks/studio 0.30.1 → 0.31.0

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