@homie-rocks/studio 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,32 @@
1
- # Homie netplay contract, v1 (revision 5)
1
+ # Homie netplay contract, v1 (revision 6)
2
2
 
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,
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
+
8
30
  **What revision 5 added** (a v1 game that knows none of it is watched as its overview):
9
31
  - **Watching** (section 16): a watcher is a screen that came to watch a live room from any
10
32
  player's view. It never takes a seat, hosts only a room no player can, and the game draws
@@ -237,14 +259,16 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
237
259
 
238
260
  | `t` | Sent by | Fields | What the relay does |
239
261
  |---|---|---|---|
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). |
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`. |
241
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. |
242
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. |
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`. |
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`. |
244
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. |
245
267
  | `ckpt` | host | `k`, `st`, `d`, `c?` | Stores it in memory. It is saved to storage at most every 30 s. Not forwarded. |
246
- | `round` | host | `round` | Stores it, forwards it, and tells the shell's watchers. |
247
- | `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. |
248
272
  | `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
249
273
  | `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
250
274
 
@@ -252,7 +276,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
252
276
 
253
277
  | `t` | To | Fields |
254
278
  |---|---|---|
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) |
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`) |
256
280
  | `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
257
281
  | `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
258
282
  | `host` | everyone else, on a change | `host`, `why` |
@@ -267,30 +291,34 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
267
291
  | `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
268
292
  | `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
269
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?` |
270
296
 
271
297
  **Error codes.**
272
298
  - Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
273
299
  `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).
300
+ `watch-off` (a watcher of a game that cannot be watched; section 16), `agent-pass`,
301
+ `agents-off` and `agents-unsupported` (section 17).
275
302
  The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
276
303
  every knock is refused at `hello`, and the shell stops the frame (section 15).
277
- - Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
278
- 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.)
279
306
 
280
307
  A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
281
308
 
282
309
  Record types:
283
310
 
284
311
  ```ts
285
- Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true }
312
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts }
286
313
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
287
- RoundResult = { slot, seat: number|null, name, score, bot, place }
288
- 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' }
289
317
  ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c and ckpt.c
290
318
  ```
291
319
 
292
320
  **The shell's watch socket** (`/<game>/__watch?room=&b=`) gets
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 }`
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 }`
294
322
  about once a second and on every change, and the owner's controls as they happen
295
323
  (section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
296
324
  only for the browser they are about (its room key `b`), `kicked { until, message }` and
@@ -309,7 +337,8 @@ held out of it, hears so at once.
309
337
  game's `scores` probe).
310
338
 
311
339
  **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.
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`.
313
342
 
314
343
  The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
315
344
  so `ev.origin` is `"null"`.
@@ -675,7 +704,7 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
675
704
 
676
705
  The studio's owner runs their live rooms from the studio's back office (`/_studio/office`,
677
706
  the owner's overlay in their own game, `homie-studio office`, and the Homie MCP's owner
678
- tools). The room's relay enforces five controls:
707
+ tools). The room's relay enforces these controls:
679
708
 
680
709
  | `op` | Arguments | What the relay does |
681
710
  |---|---|---|
@@ -684,6 +713,8 @@ tools). The room's relay enforces five controls:
684
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. |
685
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. |
686
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. |
687
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. |
688
719
 
689
720
  **Signed by the studio, checked by the room.** A control reaches the relay only from the
@@ -813,3 +844,162 @@ watching the room too (the same room key), and a closed room closes for watchers
813
844
  ping every 2 s (a request, billed 20:1). Like a big screen, a watcher alone keeps a room's object
814
845
  awake; the room's host of last resort is the watcher's browser, running the bots.
815
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.