@homie-rocks/studio 0.14.4 → 0.16.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.
@@ -1,10 +1,42 @@
1
- # Homie netplay contract, v1 (revision 4)
1
+ # Homie netplay contract, v1 (revision 6)
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 6** (2026-10-01, `@homie-rocks/studio` 0.16.0). The wire version is
4
+ `v: 1`. Everything revisions 2 to 6 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 6 added** (a v1 game that knows none of it plays on every server; its AI
9
+ is labelled by name, its people are capped, and its play page shows no vote):
10
+ - **Servers and agent seats** (section 17): a server is a named, lasting pool of rooms with
11
+ a policy (`open`, `humans-only`, `hybrid`, `beginner`). The Worker hands every room its
12
+ policy; a hybrid server keeps its top seats for AI. An **agent** is an AI that sits with
13
+ a pass the Worker verified: always named `<label> · AI`, marked `peer.agent`, never on a
14
+ humans-only server, never alone in a room, and the relay labels the host's roster and
15
+ results so they cannot hide one.
16
+ - **The skill dial**: five levels (`SKILLS`: Rookie, Steady, Fair, Strong, Maxed), each
17
+ `{ reactionMs, aimNoise, aggression, positioning }`. The party votes the room's level; a
18
+ game's bots read it with `net.skillOf(slot)`.
19
+ - New optional fields `hello.rev`, `hello.caps`, `hello.agent`, `welcome.policy`,
20
+ `welcome.vote`, `welcome.agent`, `peer.agent`, `slot.agent`, `result.agent`; new frames
21
+ `caps` and `vote` (client → relay) and `policy` and `vote` (relay → client); final
22
+ refusals `agent-pass`, `agents-off` and `agents-unsupported`, and the reported
23
+ `agents-alone`; speech modes (`lines`, `off`); the owner's `policy` and `level` controls.
24
+ - The helper: `net.policy`, `net.skill`, `net.skillOf(slot)`, `net.vote(n)`,
25
+ `net.openVote()`, `on('policy')`, `on('vote')`, `net.agents()`, `net.isAgent(seat)`,
26
+ `net.hushed` (the play page's Quiet AI), `createNetplay({ caps })`, `SKILLS`,
27
+ `skillPreset`, `aiName`, `stripAi`; the `Roster`'s AI seats; `NETPLAY_REVISION` and the
28
+ build mark `homie-netplay-rev:6`.
29
+
30
+ **What revision 5 added** (a v1 game that knows none of it is watched as its overview):
31
+ - **Watching** (section 16): a watcher is a screen that came to watch a live room from any
32
+ player's view. It never takes a seat, hosts only a room no player can, and the game draws
33
+ the followed player's camera and HUD from `net.viewSeat`. Auto follows the action
34
+ (`net.spotlight(seat)`, else the leader of the `scores` probe).
35
+ - New optional fields `hello.watch`, `welcome.watch` and `peer.watch`, a relay → client
36
+ `watch` frame, the final refusal `watch-off`, and `counts.watchers` in the watch feed.
37
+ - game.json `"watch"`: `"overview"` (hidden hands or roles: the whole room only) or `false`.
38
+ - `PALETTE`: the contract's 12 player colours, which a peer's `colour` indexes.
39
+
8
40
  **What revision 4 added** (a v1 game that knows none of it keeps playing; its shell shows
9
41
  the notices and the banner):
10
42
  - **The owner's controls** (section 15): the studio's owner kicks a player out of a room
@@ -91,12 +123,15 @@ code.
91
123
  |---|---|---|---|---|---|
92
124
  | `host` | One browser per room | yes | if seated (a seatless host is possible) | `snap`, `state`, `ckpt`, `round`, `roster`, `ev` | `in`, `ev`, `join`, `leave` |
93
125
  | `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 |
126
+ | `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
127
 
96
128
  - **Role and seat are separate facts.**
97
129
  - `net.isHost` answers: do I run the rules?
98
130
  - `net.seat !== null` answers: do I have a body?
99
131
  - A screen can be elected host when nobody else can host.
132
+ - **A watcher** (section 16) is a screen that never takes a seat: not at its hello, and not when
133
+ one frees up. A visitor who wanted to play and found the room full is the other kind of screen:
134
+ it takes the next free seat.
100
135
  - **Offline is not a fourth role.** With no shell (a plain file, a Vite dev server),
101
136
  the helper reports `role: 'host'`, `seat: null` and `offline: true`, and every send
102
137
  is a no-op.
@@ -113,7 +148,7 @@ code.
113
148
  | `token` | The capability that resumes a seat | Returned in `welcome` and never shown to others. The shell keeps it in `sessionStorage`. |
114
149
  | `id` | One socket | Used for routing, event addressing and the host reference. It changes on every reconnect. |
115
150
  | `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. |
151
+ | `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
152
 
118
153
  - **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
119
154
  - **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
@@ -224,14 +259,16 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
224
259
 
225
260
  | `t` | Sent by | Fields | What the relay does |
226
261
  |---|---|---|---|
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`. |
262
+ | `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`. |
228
263
  | `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
264
  | `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
- | `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`. |
265
+ | `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`. |
231
266
  | `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. |
232
267
  | `ckpt` | host | `k`, `st`, `d`, `c?` | Stores it in memory. It is saved to storage at most every 30 s. Not forwarded. |
233
- | `round` | host | `round` | Stores it, forwards it, and tells the shell's watchers. |
234
- | `roster` | host | `slots` | Stores it, forwards it, and tells the shell's watchers. |
268
+ | `round` | host | `round` | Labels its `results` (section 17), stores it, forwards it, and tells the shell's watchers. |
269
+ | `roster` | host | `slots` | Labels it (section 17: a host cannot hide an AI), stores it, forwards it, and tells the shell's watchers. |
270
+ | `caps` | host (revision 6) | `caps: ('skill'\|'agents')[]` | What the host's game does with servers: its bots read the dial, and it moves AI bodies. Stored as the room's (the play page offers the vote; a lite agent may sit) and in the watch feed. |
271
+ | `vote` | a seated person, or the host (revision 6) | `of: 'skill'`, `n?: 1..levelMax`, `open?: true`, `reason?` | `open` starts the party's vote on the dial (a player may open one every 2 minutes); `n` casts or changes this seat's vote. Agents and watchers never vote. The shell's watch socket may send it too, counted as the seat of the client with the same browser key. |
235
272
  | `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
236
273
  | `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
237
274
 
@@ -239,7 +276,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
239
276
 
240
277
  | `t` | To | Fields |
241
278
  |---|---|---|
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?` |
279
+ | `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`) |
243
280
  | `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
244
281
  | `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
245
282
  | `host` | everyone else, on a change | `host`, `why` |
@@ -253,29 +290,35 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
253
290
  | `error` | the offender | `code`, `message`, `of?`, `until?` |
254
291
  | `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
255
292
  | `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
293
+ | `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
294
+ | `policy` | everyone (revision 6) | `policy` (with `skill`, and `by: 'vote'\|'owner'` once the dial was set): on any change, a vote's result, the owner's control, a newer server policy |
295
+ | `vote` | everyone and the shell's watch sockets (revision 6) | `of`, `id`, `open`, `until`, `options` (1..levelMax), `counts`, `voters`, `of_total`, `result?: { level, name, votes, why }`, `reason?` |
256
296
 
257
297
  **Error codes.**
258
298
  - Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
259
- `too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15).
299
+ `too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15),
300
+ `watch-off` (a watcher of a game that cannot be watched; section 16), `agent-pass`,
301
+ `agents-off` and `agents-unsupported` (section 17).
260
302
  The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
261
303
  every knock is refused at `hello`, and the shell stops the frame (section 15).
262
- - Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
263
- comes back slowly.)
304
+ - Reported only: `too-large`, `rate`, `state-full`, `vote`. (`flood` closes the socket; the helper
305
+ comes back slowly. `agents-alone` closes an agent's socket; it knocks again after 30 s.)
264
306
 
265
307
  A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
266
308
 
267
309
  Record types:
268
310
 
269
311
  ```ts
270
- Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true }
312
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts }
271
313
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
272
- RoundResult = { slot, seat: number|null, name, score, bot, place }
273
- Slot = { slot, seat: number|null, name, bot }
314
+ RoundResult = { slot, seat: number|null, name, score, bot, place, agent?: true }
315
+ Slot = { slot, seat: number|null, name, bot, agent?: { seat: number|null, role, hands } }
316
+ AgentFacts = { pass, role: 'party'|'guide'|'player', hands: 'self'|'host', by: 'studio'|'guest'|'service' }
274
317
  ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c and ckpt.c
275
318
  ```
276
319
 
277
320
  **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 }`
321
+ `{ t:'net', rev, room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, agents, screens, waiting, watchers, humans, bots, ai, maxPlayers, humanSeats}, policy, vote?, caps, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
279
322
  about once a second and on every change, and the owner's controls as they happen
280
323
  (section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
281
324
  only for the browser they are about (its room key `b`), `kicked { until, message }` and
@@ -289,6 +332,13 @@ held out of it, hears so at once.
289
332
  `room-closed`, and `until` and `message` for the last two);
290
333
  - `pick` (with `seat`): the game's `net.pickPlayer(seat)`, a player clicked. Only the studio
291
334
  owner's page does anything with it (section 15).
335
+ - `view` (a watcher; section 16): `seat`, `following`, `follows` (the game draws the followed
336
+ player), `canFollow`, `whyNot`; and `scores` (`[{ seat, score }]`, once a second, from the
337
+ game's `scores` probe).
338
+
339
+ **Parent page → game frame** (revision 5): `postMessage { t: 'homie-watch', follow: seat | 'auto' | null }`.
340
+ The helper takes it only from its own parent window, and only as a watcher. Revision 6 adds
341
+ `{ t: 'homie-hush', on }` (the play page's Quiet AI) and the frame's `policy` and `vote`.
292
342
 
293
343
  The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
294
344
  so `ev.origin` is `"null"`.
@@ -486,6 +536,7 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
486
536
  - use separate storage keys, or a SQLite-backed object, if a save can pass 128 KiB.
487
537
  7. **Choose the device** with the short-edge rule (≤ 540 px is a phone; `?hand=`
488
538
  overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
539
+ `/<game>/watch?room=` makes a watcher, with a strip of the players (section 16).
489
540
  8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
490
541
  results, and with `?debug=1` the debug strip.
491
542
  9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
@@ -508,7 +559,9 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
508
559
  4. **When seated:** call `net.input([...avatar, ...intent], held)` every frame. Adopt
509
560
  your body on `control` with `reset`. While `!net.owned`, predict (section 8).
510
561
  5. **Render others** from `net.sample()` and your own body locally. A spectator gets an
511
- overview camera.
562
+ overview camera. **Draw from `net.viewSeat`** (section 16): your own seat when you play,
563
+ the followed player's body, camera and HUD when you watch, the overview when it is `null`;
564
+ call `net.spotlight(seat)` on a hit or a goal, and expose `scores` for Auto and the strip.
512
565
  6. **Draw your own HUD**: clock (`round.endsAt − net.now()`), scores, results.
513
566
  7. **Touch controls on phones:** a drag in the lower-left moves you. On desktop,
514
567
  WASD and the arrow keys.
@@ -616,9 +669,9 @@ function frame(dt) {
616
669
  if (net.snapshotDue()) net.snapshot(fastState(), tick);
617
670
  net.state('world', slowWorld()); // sent only when it changed
618
671
  } 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());
672
+ render(hosting ? authoritative : net.sample(), net.viewSeat); // camera + HUD: whose view (section 16)
620
673
  }
621
- net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount });
674
+ net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount, scores: () => scoresBySeat() });
622
675
  ```
623
676
 
624
677
  ```sh
@@ -651,7 +704,7 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
651
704
 
652
705
  The studio's owner runs their live rooms from the studio's back office (`/_studio/office`,
653
706
  the owner's overlay in their own game, `homie-studio office`, and the Homie MCP's owner
654
- tools). The room's relay enforces five controls:
707
+ tools). The room's relay enforces these controls:
655
708
 
656
709
  | `op` | Arguments | What the relay does |
657
710
  |---|---|---|
@@ -660,6 +713,8 @@ tools). The room's relay enforces five controls:
660
713
  | `announce` | `text` (one line, at most 280 characters; empty takes it down), `seconds` (5–3600, default 30) | Everyone and every watching shell gets `announce { id, text, at, until, from: 'studio' }`; a joiner gets it in `welcome.announce` until it ends. |
661
714
  | `close` | `minutes`, or `seconds` (10 s up to a day), `message?`, or `reopen: true` | Every socket gets `error room-closed { until, message }` and is closed; the room forgets its play as an empty room does (section 4); watchers get `closed`. Until `until` every `hello` is refused with `room-closed`, and the Lobby sends nobody there. `reopen` opens it at once. |
662
715
  | `seats` | `max` | The room's seats while it runs (the owner's "players per room"). Players already seated above it keep their seat; nobody new is seated there. Never above the room's own cap (the game's manifest). |
716
+ | `policy` | `pol` (revision 6) | The server changed (section 17): the room takes its new policy at once. When agents are no longer allowed (humans-only), the current round finishes first, by the same rules as `regate`, and then every agent gets `error agents-off { message: 'This server is humans-only now.' }`. |
717
+ | `level` | `level` (1–5; revision 6) | The owner sets this room's dial now (`room_level`), as a vote's result does. |
663
718
  | `regate` | `allow` (holder kinds: `o` the owner, `i` an invite, `p` a player account), `notice?`, `message?`; no `allow` calls a waiting one off | A game's launch state narrowed (private, or an invite-only beta). The current round finishes first: the `notice` goes up as an announcement, and 5 s after the host's `round` with `phase: 'over'` (at the latest 20 s after its `endsAt`, or 30 s when no round is running; never later than 15 minutes) every socket whose ticket names no allowed holder gets `error room-closed { message }` (no `until`: they come back through the game's door if they have access) and is closed; its seat is freed and the others play on. A room with nobody to send out does nothing. The studio's Worker sends it to every live room the game's Lobby knows; a room that opens in the same moment reads its game's launch state once on its first heartbeat and re-gates itself the same way. |
664
719
 
665
720
  **Signed by the studio, checked by the room.** A control reaches the relay only from the
@@ -707,3 +762,244 @@ line across the top until it ends, and tells a muted player so.
707
762
  **Launch states** are the site's, not the relay's: a game that is private or an invite-only
708
763
  beta gives each browser it lets in a signed ticket (`t`) for its frame and sockets, and the
709
764
  Worker refuses a socket without one before it reaches the room.
765
+
766
+ ## 16. Watching (revision 5)
767
+
768
+ A **watcher** watches a live room from any player's view. Nothing is streamed: the watcher's
769
+ own browser loads the game and receives what a replica receives (snapshots, keyed state, events
770
+ sent to everyone), and draws it with the followed player's camera and HUD. Every existing game
771
+ is watchable on day one as its spectator overview; a game that draws `net.viewSeat` lets the
772
+ watcher switch between the players.
773
+
774
+ **Who a watcher is (the relay).**
775
+ - The shell's watch door (`/<game>/watch?room=<room>&follow=<seat>|auto|overview`) boots the
776
+ game with `HOMIE_NET = { …, want: 'screen', watch: true, follow, watchPolicy }`, and the
777
+ socket's address carries `w=1`. A revision-5 helper also says `watch: true` in its `hello`.
778
+ Either one makes the socket a watcher:
779
+ - it **never takes a seat**, at its hello or later, whatever its hello asks for;
780
+ - it **hosts only a room no player can host**: election ranks every visible, responsive
781
+ player above it, and a watcher never deposes a frozen host at its hello;
782
+ - its peer record carries `watch: true`, and the watch feed counts it (`counts.watchers`);
783
+ - for every cap it is a screen: `ev` at 2/s and 512 B, one of the room's `+16` sockets.
784
+ - `welcome.watch = { follow, why? }` says whether it may follow one player's view, and a
785
+ `watch { follow, why }` frame says so when that changes. `why` is `overview` (the game shows
786
+ watchers the whole room only) or `seated-here` (this browser holds a seat in this room).
787
+ - A game whose game.json says `"watch": false` has no watch door: the Worker answers it with a
788
+ page that says so and refuses a `w=1` socket (403), and the relay refuses a watching hello
789
+ with `error watch-off` (final). The Worker passes the game's rule to the room with every
790
+ socket (`wp=follow|overview|off`).
791
+
792
+ **What the game does (the helper).**
793
+
794
+ | Call | Meaning |
795
+ |---|---|
796
+ | `net.watching` | This browser watches. |
797
+ | `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. |
798
+ | `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`. |
799
+ | `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. |
800
+ | `net.spotlight(seat)` | Something happened to this player (a hit, a kill, a goal). A no-op except on a watcher in Auto. |
801
+ | `net.players()` | The seated players in seat order (key 1 is the first). |
802
+ | `net.following`, `net.canFollow` | What was asked; whether this watcher may choose. |
803
+ | `net.watchedSeat` | `viewSeat` without claiming the camera follows: for an overlay that only marks the followed player (the port's HUD). |
804
+
805
+ - **Auto** shows a player for at least 3.5 s, then cuts to the newest spotlight. With no action,
806
+ every 12 s it looks at the leader of the game's `scores` probe
807
+ (`net.expose({ scores: () => [{ seat, score }] })`; a tie keeps who is shown), else the next
808
+ player in seat order. Nobody seated: the overview.
809
+ - **Keys** inside the frame, for a watcher who clicked into the game: 1–9 the n-th player in
810
+ seat order, A Auto, O or 0 the whole room, ← → the previous or next player
811
+ (`createNetplay({ watchKeys: false })` turns them off). The watch page answers the same keys.
812
+ - The helper tells its page `view` and `scores` (section 5) and takes `homie-watch` from its
813
+ own parent window only.
814
+ - **Draw the followed player as that player's own browser frames them**: their body under the
815
+ camera, their score or health in the HUD, their row marked as "you" is (with their name, never
816
+ "You"). Their own body is interpolated like every other one; nothing of yours moves.
817
+
818
+ **Hidden information.** A watcher receives what any replica receives. What is private goes to
819
+ one seat (`net.send(kind, data, seat)`), so a watcher following that seat never has it. A game
820
+ whose shared state itself shows a hand or a role (cards, a traitor game) says game.json
821
+ `"watch": "overview"`: its watchers draw the whole room only and the strip only names the
822
+ players. `"watch": false` removes the watch door. Neither is a wall a modified browser cannot
823
+ climb, and neither needs to be: anyone may take a seat in a public room and receive the same
824
+ stream as a player. The rule decides what is offered and drawn.
825
+
826
+ **A second tab is not a peek.** A browser that holds a seat in a room watches that room in the
827
+ overview only (`seated-here`), for as long as its seat is there: the relay compares the
828
+ browser's room key (`b`, section 15), so a second tab cannot look over an opponent's shoulder
829
+ with a click. Another device is another watcher, as it would be another player.
830
+
831
+ **Launch states and the owner's controls.** The watch door is the play door: a private game or
832
+ an invite-only beta is watched only by whoever may play it, and their ticket rides in the
833
+ frame's and the sockets' addresses as it does for Play. A kick holds that browser out of
834
+ watching the room too (the same room key), and a closed room closes for watchers.
835
+
836
+ **Old games and old relays.**
837
+ - A game with a helper from before revision 5 is booted as a screen: it draws its overview, the
838
+ Worker's `w=1` makes the relay treat it as a watcher, and the page lists the players without
839
+ letting the watcher choose.
840
+ - A revision-5 helper on an older relay is a plain screen (no relay ever seated a screen); with
841
+ no `welcome.watch`, following is the page's to allow.
842
+
843
+ **Cost.** A watcher is one more socket on the fan-out (outgoing messages are not billed) and one
844
+ ping every 2 s (a request, billed 20:1). Like a big screen, a watcher alone keeps a room's object
845
+ awake; the room's host of last resort is the watcher's browser, running the bots.
846
+
847
+ ## 17. Servers, policies and agent seats (revision 6)
848
+
849
+ A **server** is a named, lasting pool of rooms for one game, with its own policy, door and
850
+ Lobby pool. Strangers are matched only inside one server, never across. Every game's
851
+ `pub-N` rooms are its implicit server `public` (Quick play), so every old link and room keeps
852
+ working; a server's rooms are `s-<id>-<n>`; a named `?room=` takes the public server's rules.
853
+ Servers, their members and agent passes live in the studio's own D1 (migration
854
+ `0006_studio_servers.sql`); a game.json `"servers": [{ id, name, policy, … }]` seeds them (a
855
+ D1 row of the same id wins). The site's pages are `site/SITE.md`'s; this section is the room's.
856
+
857
+ **Policies.**
858
+
859
+ | `kind` | People | AI |
860
+ |---|---|---|
861
+ | `open` | anyone (the server's door decides) | an AI with a pass may sit, always marked AI |
862
+ | `humans-only` | anyone | none: every agent is refused (the Worker's 403, and the relay's `agents-off`). The game's filler bots are off (`bots: 'off'`) unless the owner turns practice bots on. |
863
+ | `hybrid` | the seats below the reserve | `aiSeats` seats in every room are AI companions: the top seats, `[max − N, max)` |
864
+ | `beginner` | new accounts (the door) | `guides` (and any `aiSeats`) seats are AI guides; speech is quick lines only; `kids` adds handles only, the dial at most 3, aggression at most 0.3 |
865
+
866
+ **The policy reaches the room from the Worker.** For every socket the Worker composes
867
+ `pol = { v, at, server, kind, aiSeats, guides, bots, level, levelMax, speech, kids, brain }`
868
+ from the server, the game and the office (AI seats and guides together are at most the seats
869
+ less one), and passes it to the room's Table, which applies it when `at` is newer than the
870
+ room's own. The owner's change reaches live rooms as a signed `policy` control (section 15).
871
+ Every client hears `welcome.policy` and `policy` frames, with `skill`: the room's dial now.
872
+
873
+ **Seats.** `seatFor(token, kind)`: a person takes a seat in `[0, max − reserve)`, an agent in
874
+ `[max − reserve, max)` (with no reserve, any seat, from the top). A token's own seat is kept
875
+ whatever the range (a person seated above a new cap keeps it). A seventh person in a hybrid
876
+ room of 8 with 2 AI seats waits as a spectator and is seated when a person's seat frees.
877
+
878
+ **Agents.** An AI sits like a phone: the same socket, with a ticket whose holder is
879
+ `a-<pass>`. The Worker verifies the pass (not revoked, not ended, for this game and server, a
880
+ server that lets AI in; a game that is not public opens only to the owner's own passes) and
881
+ hands the room the agent's facts; only that makes a socket an agent (a hello that says
882
+ `agent` with none is refused `agent-pass`). An agent:
883
+ - is named `<label> · AI` (the exact mark ` · AI`), and its peer carries `agent: { pass, role,
884
+ hands, by }`. A person's typed name never ends in an AI or bot mark (the relay strips it;
885
+ account names refuse it);
886
+ - **hands `self`**: runs the game itself (its frame is the game, booted with `HOMIE_NET.agent`)
887
+ and plays its seat like a person. It hosts only a room no person can host, and a person who
888
+ can host takes the rules back at their hello (`why: 'host-person'`);
889
+ - **hands `host`**: has no game client. The host's own bot code moves its body (a `Roster`
890
+ slot that stays a bot, `agent.seat` naming it). It never hosts, and it gets the **lite
891
+ feed**: `join`/`leave`, `roster`, `round`, `policy`, `vote` and `ev` addressed to its seat,
892
+ never `snap`, `ckpt` or `state`. It sits only where the host's game declared
893
+ `caps: ['agents']`, else `agents-unsupported`;
894
+ - **never keeps a room alive**: with no person seated for 60 s, every agent gets
895
+ `agents-alone` and is closed, and none may come in until a person is seated (the helper
896
+ waits 30 s before it knocks again). Agents are never players for the Lobby or for a room's
897
+ forgetting: `counts.players` is people, `counts.agents` and `counts.ai` are apart;
898
+ - says at most one line every 4 s and 8 a minute (`stats.agentDrops` counts the rest).
899
+
900
+ **Labels.** The relay rewrites the host's `roster` and every round's `results` before anyone
901
+ sees them: a slot or row whose seat an agent holds gets `agent` and the agent's name; a slot
902
+ with no seat is a bot; a slot naming a seat nobody holds is a bot; a person's slot never
903
+ carries `agent` or an AI mark. A modified host cannot present an AI as a person.
904
+
905
+ **Speech** (`policy.speech`): `game` (as the game does it), `lines` (every `chat…` is dropped,
906
+ from people too: quick lines `say:…` and emotes pass), `off` (every speech kind). The play
907
+ page's **Quiet AI** hides AI speech on that browser only: the helper drops `ev` speech from an
908
+ agent's seat when `net.hushed`.
909
+
910
+ **The skill dial.** One shape for every game: `{ level 1..5, name, reactionMs, aimNoise 0..1,
911
+ aggression 0..1, positioning 0 back..1 front }`.
912
+
913
+ | level | name | reactionMs | aimNoise | aggression | positioning | |
914
+ |---|---|---|---|---|---|---|
915
+ | 1 | Rookie | 650 | 0.55 | 0.10 | 0.10 | stays at the back, misses a lot |
916
+ | 2 | Steady | 420 | 0.30 | 0.30 | 0.35 | helps, never steals the show |
917
+ | 3 | Fair | 250 | 0.15 | 0.50 | 0.50 | plays like a regular |
918
+ | 4 | Strong | 170 | 0.07 | 0.70 | 0.75 | keeps up with good players |
919
+ | 5 | Maxed | 110 | 0.02 | 0.90 | 0.95 | front-line tank, rarely misses |
920
+
921
+ Fair is what the port kit's bots always were (250 ms, an aim error of 0.12 rad = 0.15 × 0.8).
922
+ Kids caps the level at 3 and aggression at 0.3. The room's dial is the party's vote (or the
923
+ owner's `level`), else the server's `level`; a guide plays at the server's level.
924
+
925
+ **The party's vote.** Opened by itself at a party's first live round when the room has AI seats
926
+ and its game reads the dial (once per room per 30 minutes), by any seated person from the play
927
+ page's chip (once per 2 minutes), or by the game (`net.openVote('skill', 'dungeon')`). It is
928
+ open 15 s, or until every seated person voted (a lone player's tap decides at once). The result
929
+ is the **median of the seated people's votes, rounded down** (a tie is settled kindly), capped
930
+ at the server's `levelMax`, and it applies at once (`policy`, and the vote's `result`). The
931
+ shell draws the card; a game that draws its own says game.json `"agents": { "vote": "game" }`
932
+ (`false`: no vote).
933
+
934
+ **What the game does (the helper).**
935
+
936
+ ```ts
937
+ import { createNetplay, type Skill } from '@homie-rocks/studio/netplay';
938
+ // 'skill': this game's bots read the dial; 'agents': its host moves an AI's body (its Roster passes p.agent).
939
+ const net = createNetplay({ game: 'gem-rush', maxPlayers: 8, caps: ['skill', 'agents'], checkpoint });
940
+ const roster = new Roster({ min: 3, max: 8, botName, policy: () => net.policy }); // keeps the AI seats
941
+ net.on('join', (p) => { if (p.seat !== null) roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null); });
942
+
943
+ /** What each bot last noticed: it re-reads the world only every skill.reactionMs. */
944
+ const sight = new Map<number, { at: number; tx: number; ty: number }>();
945
+ function stepBots(dt: number): void {
946
+ const taken = new Set<number>();
947
+ for (const b of bodies.values()) {
948
+ if (!b.bot) continue;
949
+ const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
950
+ let eye = sight.get(b.slot);
951
+ if (!eye || net.now() - eye.at >= s.reactionMs) { // REACTION TIME (a miss costs it again)
952
+ const g = pickGem(b, taken, s); // POSITIONING: 0 leaves the hot zone, 1 fights for it
953
+ const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1
954
+ eye = { at: net.now(), tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
955
+ sight.set(b.slot, eye);
956
+ if (g) taken.add(g.id);
957
+ }
958
+ const rival = nearestBody(b, KNOCK_RANGE); // AGGRESSION: bumps a rival in reach
959
+ if (rival && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
960
+ steer(b, eye.tx, eye.ty, BOT_SPEED, dt);
961
+ }
962
+ }
963
+ ```
964
+
965
+ | Call | Meaning |
966
+ |---|---|
967
+ | `net.policy` | The room's policy (`DEFAULT_POLICY` before the relay says one: open, Fair). |
968
+ | `net.skill`, `net.skillOf(slot)` | The room's dial; the dial a bot in that slot plays at. Reading either declares `caps: ['skill']` (the host tells the room once), so the play page offers the vote. |
969
+ | `net.on('policy', p)`, `net.on('vote', v)` | The policy or dial changed; the vote opened, moved or closed (`net.voteState`). |
970
+ | `net.vote(n)`, `net.openVote('skill', reason?)` | A seated person's vote; open one. |
971
+ | `net.agents()`, `net.isAgent(seat)` | The AI in the room. |
972
+ | `net.hushed` | Quiet AI on this browser (the play page sets it). |
973
+ | `aiName(label)`, `stripAi(name)`, `AI_MARK`, `SKILLS`, `skillPreset(n, kids?)` | Names and the dial. |
974
+ | `Roster({ …, policy })` | Keeps `aiSeats + guides` slots marked `agent` (a person never takes one); `claim(seat, name, agent?)` (hands `host`: the slot stays a bot, `agent.seat` set; hands `self`: claimed like a person's); `release(seat)` gives an agent's slot back as a seat kept for AI; `bots: 'off'` adds no filler. |
975
+
976
+ `createRoom` (the port kit) does all of it: its roster keeps the AI seats, its join passes
977
+ `p.agent`, it declares `caps: ['agents']`, and `room.skillOf(body)` is the dial. `BotBrain`
978
+ takes `skill: () => room.skillOf(body)` and maps it: `reactionMs`, an aim error of
979
+ `aimNoise × 0.8` rad, and a commitment of `2500 × (1.3 − 0.6 × aggression)` ms; `port/skill.ts`
980
+ adds `jitter(s, maxPx)`, `engages(s, dt)` and `standoff(s, near, far)`. A ported game built on
981
+ `BotBrain` gets the dial with a rebuild.
982
+
983
+ **Old games and old relays.**
984
+ - A game built with a helper before revision 6 plays on every server: doors, the human seat
985
+ cap, speech modes and AI labels in names and in the shell work; it keeps no AI seats and its
986
+ bots do not read the dial, a hands-`host` agent is refused (`agents-unsupported`), a
987
+ hands-`self` agent is labelled by its name. Its play page shows no vote. `homie-studio
988
+ build` writes the revision a build speaks into the catalogue (the helper's mark
989
+ `homie-netplay-rev:<n>`), and the office says "This build predates servers" until it is
990
+ rebuilt.
991
+ - A revision-6 helper on an older relay has `DEFAULT_POLICY` (open, Fair) and no AI seats.
992
+
993
+ **Kids and safety.** The AI never types: in this revision an agent is a seat and a body, and
994
+ its speech is rate-limited and filtered like anyone's (quick lines only on a beginner server).
995
+ Every AI is marked AI by the relay, not trusted to the host. The owner's controls work on AI:
996
+ mute (its speech is dropped), kick (held out by its pass; its seat goes back to a seat kept for
997
+ AI), announce. Nothing about a person beyond a player id the studio already has is stored; no
998
+ age is ever asked.
999
+
1000
+ **Cost.** An agent is one more socket, like a phone. Agents never keep a room alive (60 s
1001
+ alone at most). A lite agent gets no snapshots, so it costs a few frames a minute.
1002
+
1003
+ **Revision 7 (a later version)** adds an AI's brain: hands in the host (`agent:view`,
1004
+ `agent:do`), lines only from the game's own `agents.json` vocabulary (`say:<lineId>`, checked
1005
+ by the relay), and guides that answer the party. Nothing in revision 6 needs to change for it.