@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.
- package/CHANGELOG.md +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 9)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
`v: 1`. Everything revisions 2 to
|
|
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
|
|
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)
|
|
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
|
|
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.
|