@homie-rocks/studio 0.14.4 → 0.15.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/README.md CHANGED
@@ -80,7 +80,7 @@ its D1; nothing is stored by hand. studio.json `homie.directory: false` keeps a
80
80
 
81
81
  Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
82
82
  featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
83
- joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
83
+ joinable and watchable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
84
84
  page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
85
85
  pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
86
86
  live rooms, credits (the original and its licence for a port; "Remix of <game> by <studio>", linked, for a
@@ -97,13 +97,20 @@ game's landing still. Every HTML answer is `no-transform`, and the site's pages
97
97
  site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
98
98
  `not-a-homie`, at the site's root as under the game.
99
99
 
100
+ Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<room>` (a Watch button sits beside
101
+ Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
102
+ with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
103
+ player's camera and HUD from `net.viewSeat` (Gem Rush and Ember Vale do; a game that does not is watched as its
104
+ overview), and game.json `"watch": "overview"` or `false` keeps hidden hands hidden. Private and invite-only games
105
+ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
106
+
100
107
  ## Upgrading a studio
101
108
 
102
109
  A studio pins one version, so nothing changes until it asks. To take a newer one:
103
110
 
104
111
  ```sh
105
- npx -y @homie-rocks/studio@0.14.4 upgrade # the plan; changes nothing
106
- npx -y @homie-rocks/studio@0.14.4 upgrade --apply # after the person agrees
112
+ npx -y @homie-rocks/studio@0.15.0 upgrade # the plan; changes nothing
113
+ npx -y @homie-rocks/studio@0.15.0 upgrade --apply # after the person agrees
107
114
  npm install && npm run build
108
115
  ```
109
116
 
@@ -234,7 +241,7 @@ device; the owner's own passkey account (`homie-studio players owner`) counts as
234
241
  ## Setup status
235
242
 
236
243
  ```sh
237
- npx -y @homie-rocks/studio@0.14.4 setup status --connector yes # before a studio exists
244
+ npx -y @homie-rocks/studio@0.15.0 setup status --connector yes # before a studio exists
238
245
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
239
246
  ```
240
247
 
@@ -411,7 +418,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
411
418
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
412
419
  | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
413
420
  | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
414
- | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
421
+ | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
415
422
  | `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
416
423
  | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
417
424
  | `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
package/lib/build.mjs CHANGED
@@ -230,6 +230,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
230
230
  ...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
231
231
  // game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
232
232
  ...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
233
+ // game.json `"watch"` (NETPLAY.md section 16): watchers see any player's view (the default), only the whole room
234
+ // ("overview": hidden hands or roles), or nothing (false: no watch door).
235
+ ...(g.watch === 'overview' ? { watch: 'overview' } : g.watch === false || g.watch === 'off' ? { watch: 'off' } : {}),
233
236
  // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
234
237
  license: licenseOf(g.license),
235
238
  ...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
package/lib/scaffold.mjs CHANGED
@@ -169,7 +169,7 @@ studio's pinned copy, never a registry lookup of the bare name.
169
169
  (\`node_modules/@homie-rocks/studio/site/SITE.md\` says all of it):
170
170
 
171
171
  - **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
172
- Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
172
+ Rooms (every public room playing now, joinable and watchable) and Posts. A section with nothing in it has no tab, and its
173
173
  page is not found.
174
174
  - **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
175
175
  (games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
@@ -200,6 +200,14 @@ studio's pinned copy, never a registry lookup of the bare name.
200
200
  scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
201
201
  device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
202
202
  a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
203
+ - **Watch any player.** Every live room can be watched at \`/<id>/watch?room=<room>\` (each room on the Rooms page
204
+ and the landing has a Watch button): the game itself, drawn by the watcher's own browser, which never takes a
205
+ seat, with a strip of the players to switch between (a tap, keys 1-9, A for Auto, O for the whole room). Point the
206
+ camera and the HUD at \`net.viewSeat\` (your own seat when playing; the followed player when watching; null: the
207
+ overview camera; \`room.viewBody()\` in a port), and call \`net.spotlight(seat)\` on a hit, a kill or a goal so
208
+ Auto cuts to it (\`NETPLAY.md\` section 16). A game that never reads \`viewSeat\` is watched as its overview. A
209
+ game with hidden hands or roles says game.json \`"watch": "overview"\` (the whole room only) or \`false\` (no
210
+ watch door); a private or invite-only game is watched only by those it lets in.
203
211
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
204
212
  - A game's id is its URL (\`/<id>/\`); keep it once published.
205
213
  - **Remixing.** A public game shares its source at \`/games/<id>/source.json\`, with who made it (the studio, the
@@ -407,6 +407,33 @@
407
407
  "## Beta": "9d02638207a26e73"
408
408
  }
409
409
  }
410
+ },
411
+ "0.15.0": {
412
+ "files": {
413
+ "CLAUDE.md": "ddf2b2a0376202f6",
414
+ "README.md": "684b3cca8001673c",
415
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
416
+ "games/README.md": "5ad32fe80c61f117",
417
+ "music/README.md": "b8a828de9311bd28",
418
+ "videos/README.md": "70614388cbb203c7",
419
+ "posts/README.md": "37841aae476bd540",
420
+ "site/README.md": "ca131cd0472464fb",
421
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
422
+ },
423
+ "sections": {
424
+ "AGENTS.md": {
425
+ "(intro)": "214aa46eb3a895d5",
426
+ "## Layout": "f14d641159480e6c",
427
+ "## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
428
+ "## The site": "ab922bb6bde23293",
429
+ "## Making games": "a8a893eeada33bf5",
430
+ "## The Game Codex and progress": "0738ef719cef3a4c",
431
+ "## Running live games (the back office)": "f0566f07cff04113",
432
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
433
+ "## Rules": "969318425c99f5a5",
434
+ "## Beta": "9d02638207a26e73"
435
+ }
436
+ }
410
437
  }
411
438
  }
412
439
  }
@@ -1,10 +1,20 @@
1
- # Homie netplay contract, v1 (revision 4)
1
+ # Homie netplay contract, v1 (revision 5)
2
2
 
3
- Status: **v1, revision 4** (2026-10-01, `@homie-rocks/studio` 0.13.0). The wire version is
4
- `v: 1`. Everything revisions 2 to 4 added is either an optional field, a new message type,
3
+ Status: **v1, revision 5** (2026-10-01, `@homie-rocks/studio` 0.15.0). The wire version is
4
+ `v: 1`. Everything revisions 2 to 5 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 5 added** (a v1 game that knows none of it is watched as its overview):
9
+ - **Watching** (section 16): a watcher is a screen that came to watch a live room from any
10
+ player's view. It never takes a seat, hosts only a room no player can, and the game draws
11
+ the followed player's camera and HUD from `net.viewSeat`. Auto follows the action
12
+ (`net.spotlight(seat)`, else the leader of the `scores` probe).
13
+ - New optional fields `hello.watch`, `welcome.watch` and `peer.watch`, a relay → client
14
+ `watch` frame, the final refusal `watch-off`, and `counts.watchers` in the watch feed.
15
+ - game.json `"watch"`: `"overview"` (hidden hands or roles: the whole room only) or `false`.
16
+ - `PALETTE`: the contract's 12 player colours, which a peer's `colour` indexes.
17
+
8
18
  **What revision 4 added** (a v1 game that knows none of it keeps playing; its shell shows
9
19
  the notices and the banner):
10
20
  - **The owner's controls** (section 15): the studio's owner kicks a player out of a room
@@ -91,12 +101,15 @@ code.
91
101
  |---|---|---|---|---|---|
92
102
  | `host` | One browser per room | yes | if seated (a seatless host is possible) | `snap`, `state`, `ckpt`, `round`, `roster`, `ev` | `in`, `ev`, `join`, `leave` |
93
103
  | `replica` | Every other seated browser | no | yes | `in`, `ev` | `snap`, `state`, `ev`, `round`, `roster`, `join`, `leave`, `seat` |
94
- | `screen` | A spectator (a TV or laptop with `?screen=1`, or a visitor waiting for a seat) | no | no | `ev` (2/s, 512 B) | the same as a replica |
104
+ | `screen` | A spectator (a TV or laptop with `?screen=1`, a watcher from the game's watch door, or a visitor waiting for a seat) | no | no | `ev` (2/s, 512 B) | the same as a replica |
95
105
 
96
106
  - **Role and seat are separate facts.**
97
107
  - `net.isHost` answers: do I run the rules?
98
108
  - `net.seat !== null` answers: do I have a body?
99
109
  - A screen can be elected host when nobody else can host.
110
+ - **A watcher** (section 16) is a screen that never takes a seat: not at its hello, and not when
111
+ one frees up. A visitor who wanted to play and found the room full is the other kind of screen:
112
+ it takes the next free seat.
100
113
  - **Offline is not a fourth role.** With no shell (a plain file, a Vite dev server),
101
114
  the helper reports `role: 'host'`, `seat: null` and `offline: true`, and every send
102
115
  is a no-op.
@@ -113,7 +126,7 @@ code.
113
126
  | `token` | The capability that resumes a seat | Returned in `welcome` and never shown to others. The shell keeps it in `sessionStorage`. |
114
127
  | `id` | One socket | Used for routing, event addressing and the host reference. It changes on every reconnect. |
115
128
  | `name` | Display name | Typed by the guest, so untrusted: render it with `textContent` only. |
116
- | `colour` | `seat % 12` | An index into a 12-colour palette. |
129
+ | `colour` | `seat % 12` | An index into the contract's 12 colours, `PALETTE` (the watch page's strip draws them; a game that colours its players by seat matches it). |
117
130
 
118
131
  - **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
119
132
  - **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
@@ -224,7 +237,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
224
237
 
225
238
  | `t` | Sent by | Fields | What the relay does |
226
239
  |---|---|---|---|
227
- | `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?` | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full` or `too-many`. |
240
+ | `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?`, `watch?` (revision 5) | 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). |
228
241
  | `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. |
229
242
  | `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. |
230
243
  | `ev` | anyone | `k`, `d`, `to?` | **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`. |
@@ -239,7 +252,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
239
252
 
240
253
  | `t` | To | Fields |
241
254
  |---|---|---|
242
- | `welcome` | the new client | `v`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?` |
255
+ | `welcome` | the new client | `v`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?`, `watch?: { follow, why? }` (a watcher only) |
243
256
  | `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
244
257
  | `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
245
258
  | `host` | everyone else, on a change | `host`, `why` |
@@ -253,10 +266,12 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
253
266
  | `error` | the offender | `code`, `message`, `of?`, `until?` |
254
267
  | `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
255
268
  | `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
269
+ | `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
256
270
 
257
271
  **Error codes.**
258
272
  - Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
259
- `too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15).
273
+ `too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15),
274
+ `watch-off` (a watcher of a game that cannot be watched; section 16).
260
275
  The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
261
276
  every knock is refused at `hello`, and the shell stops the frame (section 15).
262
277
  - Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
@@ -267,7 +282,7 @@ A socket that is kicked for silence is closed with code 4000, and the helper rec
267
282
  Record types:
268
283
 
269
284
  ```ts
270
- Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true }
285
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true }
271
286
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
272
287
  RoundResult = { slot, seat: number|null, name, score, bot, place }
273
288
  Slot = { slot, seat: number|null, name, bot }
@@ -275,7 +290,7 @@ ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c a
275
290
  ```
276
291
 
277
292
  **The shell's watch socket** (`/<game>/__watch?room=&b=`) gets
278
- `{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
293
+ `{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, watchers, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
279
294
  about once a second and on every change, and the owner's controls as they happen
280
295
  (section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
281
296
  only for the browser they are about (its room key `b`), `kicked { until, message }` and
@@ -289,6 +304,12 @@ held out of it, hears so at once.
289
304
  `room-closed`, and `until` and `message` for the last two);
290
305
  - `pick` (with `seat`): the game's `net.pickPlayer(seat)`, a player clicked. Only the studio
291
306
  owner's page does anything with it (section 15).
307
+ - `view` (a watcher; section 16): `seat`, `following`, `follows` (the game draws the followed
308
+ player), `canFollow`, `whyNot`; and `scores` (`[{ seat, score }]`, once a second, from the
309
+ game's `scores` probe).
310
+
311
+ **Parent page → game frame** (revision 5): `postMessage { t: 'homie-watch', follow: seat | 'auto' | null }`.
312
+ The helper takes it only from its own parent window, and only as a watcher.
292
313
 
293
314
  The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
294
315
  so `ev.origin` is `"null"`.
@@ -486,6 +507,7 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
486
507
  - use separate storage keys, or a SQLite-backed object, if a save can pass 128 KiB.
487
508
  7. **Choose the device** with the short-edge rule (≤ 540 px is a phone; `?hand=`
488
509
  overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
510
+ `/<game>/watch?room=` makes a watcher, with a strip of the players (section 16).
489
511
  8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
490
512
  results, and with `?debug=1` the debug strip.
491
513
  9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
@@ -508,7 +530,9 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
508
530
  4. **When seated:** call `net.input([...avatar, ...intent], held)` every frame. Adopt
509
531
  your body on `control` with `reset`. While `!net.owned`, predict (section 8).
510
532
  5. **Render others** from `net.sample()` and your own body locally. A spectator gets an
511
- overview camera.
533
+ overview camera. **Draw from `net.viewSeat`** (section 16): your own seat when you play,
534
+ the followed player's body, camera and HUD when you watch, the overview when it is `null`;
535
+ call `net.spotlight(seat)` on a hit or a goal, and expose `scores` for Auto and the strip.
512
536
  6. **Draw your own HUD**: clock (`round.endsAt − net.now()`), scores, results.
513
537
  7. **Touch controls on phones:** a drag in the lower-left moves you. On desktop,
514
538
  WASD and the arrow keys.
@@ -616,9 +640,9 @@ function frame(dt) {
616
640
  if (net.snapshotDue()) net.snapshot(fastState(), tick);
617
641
  net.state('world', slowWorld()); // sent only when it changed
618
642
  } else net.input([q(me.x), q(me.y), q(me.vx), q(me.vy), stick.x, stick.y], heldButtons());
619
- render(hosting ? authoritative : net.sample());
643
+ render(hosting ? authoritative : net.sample(), net.viewSeat); // camera + HUD: whose view (section 16)
620
644
  }
621
- net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount });
645
+ net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount, scores: () => scoresBySeat() });
622
646
  ```
623
647
 
624
648
  ```sh
@@ -707,3 +731,85 @@ line across the top until it ends, and tells a muted player so.
707
731
  **Launch states** are the site's, not the relay's: a game that is private or an invite-only
708
732
  beta gives each browser it lets in a signed ticket (`t`) for its frame and sockets, and the
709
733
  Worker refuses a socket without one before it reaches the room.
734
+
735
+ ## 16. Watching (revision 5)
736
+
737
+ A **watcher** watches a live room from any player's view. Nothing is streamed: the watcher's
738
+ own browser loads the game and receives what a replica receives (snapshots, keyed state, events
739
+ sent to everyone), and draws it with the followed player's camera and HUD. Every existing game
740
+ is watchable on day one as its spectator overview; a game that draws `net.viewSeat` lets the
741
+ watcher switch between the players.
742
+
743
+ **Who a watcher is (the relay).**
744
+ - The shell's watch door (`/<game>/watch?room=<room>&follow=<seat>|auto|overview`) boots the
745
+ game with `HOMIE_NET = { …, want: 'screen', watch: true, follow, watchPolicy }`, and the
746
+ socket's address carries `w=1`. A revision-5 helper also says `watch: true` in its `hello`.
747
+ Either one makes the socket a watcher:
748
+ - it **never takes a seat**, at its hello or later, whatever its hello asks for;
749
+ - it **hosts only a room no player can host**: election ranks every visible, responsive
750
+ player above it, and a watcher never deposes a frozen host at its hello;
751
+ - its peer record carries `watch: true`, and the watch feed counts it (`counts.watchers`);
752
+ - for every cap it is a screen: `ev` at 2/s and 512 B, one of the room's `+16` sockets.
753
+ - `welcome.watch = { follow, why? }` says whether it may follow one player's view, and a
754
+ `watch { follow, why }` frame says so when that changes. `why` is `overview` (the game shows
755
+ watchers the whole room only) or `seated-here` (this browser holds a seat in this room).
756
+ - A game whose game.json says `"watch": false` has no watch door: the Worker answers it with a
757
+ page that says so and refuses a `w=1` socket (403), and the relay refuses a watching hello
758
+ with `error watch-off` (final). The Worker passes the game's rule to the room with every
759
+ socket (`wp=follow|overview|off`).
760
+
761
+ **What the game does (the helper).**
762
+
763
+ | Call | Meaning |
764
+ |---|---|
765
+ | `net.watching` | This browser watches. |
766
+ | `net.viewSeat` | Whose camera and HUD to draw: a player's own seat; the seat a watcher follows; `null` for the overview camera. Reading it (or listening for `view`) tells the watch page that this game draws the followed player. A game that never does is shown as its overview, and the page says so. |
767
+ | `net.on('view', ({ seat, following, prev, why }) => …)` | Whose view changed. `why`: `start`, `seat`, `asked`, `auto`, `left` (the followed player left; Auto takes over, and returns to them if they are back within 30 s), `back`, `policy`. |
768
+ | `net.follow(seat \| 'auto' \| null)` | Follow a seat, the action, or the whole room. The watch page's strip and keys call it through the frame; a game's own spectator UI may too. False when not allowed. |
769
+ | `net.spotlight(seat)` | Something happened to this player (a hit, a kill, a goal). A no-op except on a watcher in Auto. |
770
+ | `net.players()` | The seated players in seat order (key 1 is the first). |
771
+ | `net.following`, `net.canFollow` | What was asked; whether this watcher may choose. |
772
+ | `net.watchedSeat` | `viewSeat` without claiming the camera follows: for an overlay that only marks the followed player (the port's HUD). |
773
+
774
+ - **Auto** shows a player for at least 3.5 s, then cuts to the newest spotlight. With no action,
775
+ every 12 s it looks at the leader of the game's `scores` probe
776
+ (`net.expose({ scores: () => [{ seat, score }] })`; a tie keeps who is shown), else the next
777
+ player in seat order. Nobody seated: the overview.
778
+ - **Keys** inside the frame, for a watcher who clicked into the game: 1–9 the n-th player in
779
+ seat order, A Auto, O or 0 the whole room, ← → the previous or next player
780
+ (`createNetplay({ watchKeys: false })` turns them off). The watch page answers the same keys.
781
+ - The helper tells its page `view` and `scores` (section 5) and takes `homie-watch` from its
782
+ own parent window only.
783
+ - **Draw the followed player as that player's own browser frames them**: their body under the
784
+ camera, their score or health in the HUD, their row marked as "you" is (with their name, never
785
+ "You"). Their own body is interpolated like every other one; nothing of yours moves.
786
+
787
+ **Hidden information.** A watcher receives what any replica receives. What is private goes to
788
+ one seat (`net.send(kind, data, seat)`), so a watcher following that seat never has it. A game
789
+ whose shared state itself shows a hand or a role (cards, a traitor game) says game.json
790
+ `"watch": "overview"`: its watchers draw the whole room only and the strip only names the
791
+ players. `"watch": false` removes the watch door. Neither is a wall a modified browser cannot
792
+ climb, and neither needs to be: anyone may take a seat in a public room and receive the same
793
+ stream as a player. The rule decides what is offered and drawn.
794
+
795
+ **A second tab is not a peek.** A browser that holds a seat in a room watches that room in the
796
+ overview only (`seated-here`), for as long as its seat is there: the relay compares the
797
+ browser's room key (`b`, section 15), so a second tab cannot look over an opponent's shoulder
798
+ with a click. Another device is another watcher, as it would be another player.
799
+
800
+ **Launch states and the owner's controls.** The watch door is the play door: a private game or
801
+ an invite-only beta is watched only by whoever may play it, and their ticket rides in the
802
+ frame's and the sockets' addresses as it does for Play. A kick holds that browser out of
803
+ watching the room too (the same room key), and a closed room closes for watchers.
804
+
805
+ **Old games and old relays.**
806
+ - A game with a helper from before revision 5 is booted as a screen: it draws its overview, the
807
+ Worker's `w=1` makes the relay treat it as a watcher, and the page lists the players without
808
+ letting the watcher choose.
809
+ - A revision-5 helper on an older relay is a plain screen (no relay ever seated a screen); with
810
+ no `welcome.watch`, following is the page's to allow.
811
+
812
+ **Cost.** A watcher is one more socket on the fan-out (outgoing messages are not billed) and one
813
+ ping every 2 s (a request, billed 20:1). Like a big screen, a watcher alone keeps a room's object
814
+ awake; the room's host of last resort is the watcher's browser, running the bots.
815
+