@homie-rocks/studio 0.24.3 → 0.24.5

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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * THE PERSON'S OWN AI (@homie-rocks/studio 0.24.4): Cloudflare's Clef decision model on this computer, through Ollama.
3
+ * The same typed questions the studio's Workers AI answers, at /v1/systemone, free and offline:
4
+ *
5
+ * homie-studio dev finds it and hands it to the local Worker (HOMIE_LOCAL_AI), so AI guides, room chat's review
6
+ * and a game's own decisions (net.decide) need no Cloudflare while building
7
+ * agents sit --brain local / the MCP's agent_sit { brain: "local" }
8
+ * a guide's seat that thinks on this computer every few seconds, with no AI turn per decision
9
+ * homie-studio doctor says what is there, and what one sentence gets the rest
10
+ *
11
+ * It never downloads a model. `ollama pull clef-flash` is about 11 GB (clef, the 27B, about 18 GB): the person says yes
12
+ * first, every time (the studio skill asks and names the size). It needs Ollama 0.35.1 or later (the first with Clef).
13
+ */
14
+
15
+ export const OLLAMA_BASE = 'http://127.0.0.1:11434';
16
+ export const OLLAMA_MIN = '0.35.1';
17
+ /** The models Homie uses, smallest first, and what each costs the disk (ollama.com/library, 2026-10-02). */
18
+ export const LOCAL_MODELS = Object.freeze([
19
+ Object.freeze({ name: 'clef-flash', size: 'about 11 GB', params: '9B' }),
20
+ Object.freeze({ name: 'clef', size: 'about 18 GB', params: '27B' }),
21
+ ]);
22
+
23
+ const newer = (a, b) => {
24
+ const pa = String(a).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
25
+ const pb = String(b).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
26
+ for (let i = 0; i < 3; i += 1) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
27
+ return true;
28
+ };
29
+
30
+ /** Only a loopback address: Homie never sends a game's state to another machine's Ollama. */
31
+ export const loopbackBase = (raw) => {
32
+ const s = String(raw ?? '').trim().replace(/\/+$/, '');
33
+ return /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(s) ? s : null;
34
+ };
35
+
36
+ /**
37
+ * Is Clef on this computer? `{ ok, base, model, version }`, or `{ ok: false, why, say, version?, base }` with why one of
38
+ * no-ollama, old-ollama, no-model. `HOMIE_OLLAMA` names another loopback port. Two quick local requests, nothing else.
39
+ */
40
+ export async function detectLocalAi({ env = process.env, fetchFn = globalThis.fetch, ms = 1500 } = {}) {
41
+ const base = loopbackBase(env.HOMIE_OLLAMA) ?? OLLAMA_BASE;
42
+ const get = async (path) => {
43
+ const res = await fetchFn(`${base}${path}`, { signal: AbortSignal.timeout(ms) });
44
+ if (!res.ok) throw new Error(`${path} answered ${res.status}`);
45
+ return res.json();
46
+ };
47
+ let version = null;
48
+ try { version = String((await get('/api/version'))?.version ?? ''); } catch {
49
+ return { ok: false, base, why: 'no-ollama', say: `Ollama is not running on this computer (${base}). With it and clef-flash, AI guides, chat review and game decisions run here for free while building; without it they play from the game's script under dev.` };
50
+ }
51
+ if (!newer(version, OLLAMA_MIN)) return { ok: false, base, version, why: 'old-ollama', say: `Ollama ${version} is here; Clef needs ${OLLAMA_MIN} or later. Updating Ollama is the person's to do (its own app updates itself, or ollama.com/download).` };
52
+ let names = [];
53
+ try { names = ((await get('/api/tags'))?.models ?? []).map((m) => String(m?.name ?? m?.model ?? '')).filter(Boolean); } catch { names = []; }
54
+ for (const want of LOCAL_MODELS) {
55
+ const hit = names.find((n) => n === want.name || n.startsWith(`${want.name}:`));
56
+ if (hit) return { ok: true, base, version, model: hit };
57
+ }
58
+ const flash = LOCAL_MODELS[0];
59
+ return {
60
+ ok: false, base, version, why: 'no-model', models: names.length,
61
+ say: `Ollama ${version} is here without Clef. Downloading it is the person's choice: ask first. \`ollama pull ${flash.name}\` downloads ${flash.size} (the ${flash.params} model; ${LOCAL_MODELS[1].name} is ${LOCAL_MODELS[1].size}). Then run dev again.`,
62
+ };
63
+ }
64
+
65
+ /** The Worker variables that hand a found model to `wrangler dev` (`--var` pairs), or none. */
66
+ export const localAiVars = (found) => (found?.ok ? ['--var', `HOMIE_LOCAL_AI:${found.base}`, '--var', `HOMIE_LOCAL_AI_MODEL:${found.model}`] : []);
package/lib/mcp-tools.mjs CHANGED
@@ -33,6 +33,7 @@ import { STUDIO_VERSION } from './version.mjs';
33
33
  import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
34
34
  import { pinnedVersion } from './upgrade.mjs';
35
35
  import { AgentSeat } from './agent-seat.mjs';
36
+ import { detectLocalAi } from './local-ai.mjs';
36
37
  import { LAB_PORT, runningLab } from './lab.mjs';
37
38
  import { pictureFor } from './pictures.mjs';
38
39
  import { ART_UI, artToolDefs } from './art-tools.mjs';
@@ -1050,17 +1051,24 @@ export function toolDefs(ctx, avail = {}) {
1050
1051
  {
1051
1052
  name: 'agent_sit', title: 'Sit in a game as an AI guide',
1052
1053
  description: 'Take a guide\'s seat in a live room of one of the studio\'s games as an AI ("Claude · AI": always marked AI, never on a humans-only server, only in a room with people in it). The game\'s own bot code moves the body every frame; you choose its goal and, when the server lets its AI talk, one of the game\'s own lines (agents.json; never free text). Returns your seat, what you see (view), the asks players made of you and your choices. Then agent_look and agent_do every 20 to 40 seconds, and agent_stand when done. Uses the site running here (preview_run) or the live site; with no pass, makes a one-day guide pass with the owner\'s office key and revokes it when you stand.',
1053
- inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), ...STUDIO_ARG }, required: ['game'] },
1054
+ inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), brain: { type: 'string', enum: ['local'], description: 'Optional: "local" lets Cloudflare\'s Clef decision model on this computer (Ollama with clef-flash) choose every few seconds instead of you, free; agent_look shows what it chose and agent_do still overrides it. Never downloads a model: if Ollama has no clef-flash this says what the download costs, and the person decides.' }, ...STUDIO_ARG }, required: ['game'] },
1054
1055
  annotations: { title: 'Sit in a game as an AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1055
1056
  run: async (a) => {
1056
1057
  const root = ctx.root(a.studio);
1057
1058
  if (ctx.seat && !ctx.seat.closed) await ctx.seat.stand();
1058
1059
  const site = a.url ? String(a.url) : devOf(root)?.url ?? siteUrl(root);
1059
1060
  if (!site) return fail('the studio is not running here and has no live site: preview_run first, or studio_deploy');
1060
- const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : 'Claude', root });
1061
+ let local = null;
1062
+ if (a.brain === 'local') {
1063
+ const found = await detectLocalAi();
1064
+ if (!found.ok) return fail(`No local brain: ${found.say}`, { kind: 'agent', ok: false, error: found.why });
1065
+ local = found;
1066
+ }
1067
+ const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : local ? 'Clef' : 'Claude', root, brain: local ? 'local' : null, local });
1061
1068
  const r = await seat.sit();
1062
1069
  if (!r.ok) return fail(`No seat: ${r.why}`, { kind: 'agent', ok: false, error: r.error ?? null });
1063
1070
  ctx.seat = seat;
1071
+ if (local) return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}), thinking with ${local.model} on this computer every few seconds (free; nothing sent to Cloudflare). agent_look shows its last decisions; agent_do overrides; agent_stand ends it.`, { kind: 'agent', ...r });
1064
1072
  return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). ${r.talking ? 'This server lets its AI talk: lines from look.choices.lines, at most one every 8 s.' : 'This server\'s AI does not talk: choose goals only (say null).'} Look again with agent_look; act with agent_do { goal, args, say?, sayArgs? }.\n${JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1).slice(0, 6000)}`, { kind: 'agent', ...r });
1065
1073
  },
1066
1074
  },
package/lib/scaffold.mjs CHANGED
@@ -221,10 +221,16 @@ studio's pinned copy, never a registry lookup of the bare name.
221
221
  - Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
222
222
  - Phones and computers: touch controls on phones only, keys on computers; keep the
223
223
  centre of the screen clear during play.
224
- - The play page's small room button (Invite, Big screen, the room code) sits top right. If the game's
225
- scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
226
- device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
227
- a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
224
+ - The play page's small room button (Invite, Big screen, the room code) sits top right, with the Chat pill
225
+ beside it (a round icon on a phone, as the room button is). If the game's scoreboard or a bar is there, move
226
+ both in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per device (\`desk\`, \`phone\`,
227
+ \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep them small icons
228
+ (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Room chat's strip of new lines shows for a moment at the
229
+ bottom left: if the game's HUD or controls are there, \`"screen": { "chat": … }\` puts it where the game has
230
+ room (\`at\`: a corner, \`top-center\` or \`bottom-center\`; \`x\` / \`y\`; per device, and \`tv\` for the big
231
+ screen), or \`"lines": "sheet-only"\` keeps new lines in the Chat sheet, the pill counting them, so a game with
232
+ a busy HUD keeps typed chat (\`node_modules/@homie-rocks/studio/chat/CHAT.md\`). Look at both on a phone and a
233
+ computer while a round is on and somebody says something.
228
234
  - **Watch any player.** Every live room can be watched at \`/<id>/watch?room=<room>\` (each room on the Rooms page
229
235
  and the landing has a Watch button): the game itself, drawn by the watcher's own browser, which never takes a
230
236
  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
@@ -342,6 +348,13 @@ studio's pinned copy, never a registry lookup of the bare name.
342
348
  it only ASKS. The AI never types: it picks a goal and a line id; the relay drops anything else. With no AI, over
343
349
  budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
344
350
  Claude in a guide's seat.
351
+ - **Clef (0.24.4):** Workers AI's guides think with Cloudflare's decision model \`@cf/cloudflare/clef-flash\` (it
352
+ picks among the vocabulary's goals, values and lines; it never writes text; about 9 neurons a decision).
353
+ \`agents try <id> --view <file> [--ask …] [--model …]\` shows what the brain would decide in one moment. Under
354
+ \`npm run dev\`, Clef on this computer (Ollama with \`clef-flash\`) runs the guides, chat review and game
355
+ decisions for free when it is here; **never download it without the person's yes** (\`ollama pull clef-flash\` is
356
+ about 11 GB). A game whose game.json says \`"decide": true\` may ask \`net.decide(state, questions, { floor })\`
357
+ on the host (\`NETPLAY.md\` section 20): per beat or per turn, never per frame.
345
358
 
346
359
  ## Selling things (the shop)
347
360
 
package/lib/servers.mjs CHANGED
@@ -18,8 +18,14 @@
18
18
  * homie-studio agents brain key [--remove] the owner's own AI key (owner-key brains), from a page on this
19
19
  * computer only: it goes straight to the Worker secret
20
20
  * HOMIE_BRAIN_KEY, never through a chat, a file or a log
21
- * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
22
- * sit in a guide's seat from this terminal (the local MCP's agent_sit)
21
+ * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude] [--brain local]
22
+ * sit in a guide's seat from this terminal (the local MCP's agent_sit);
23
+ * --brain local: Clef on this computer (Ollama) decides, free
24
+ * homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>,…] --from <seat>] [--model <id>]
25
+ * [--mode workers-ai|owner-key|script] [--quiet] [--avoid <seat>,…]
26
+ * what the studio's guide brain would decide in that moment, with
27
+ * no seat taken: the decision, the brain's own pick, how sure, how
28
+ * long and what it cost (spent from the guides' day)
23
29
  *
24
30
  * The proof of ownership is the studio's own Cloudflare login, as for the office: a ten-minute office key is minted
25
31
  * and dropped around each call. Making a server, a widening change, a pass and a room's level happen at once. A
@@ -128,6 +134,29 @@ export async function agentsBrain(root, game, server, mode, { url, budget } = {}
128
134
  return askedFor(root, url, r, 'agents brain');
129
135
  }
130
136
 
137
+ /**
138
+ * `homie-studio agents try`: the studio's guide brain on one moment (a view file, asks), from its live Worker (or
139
+ * `homie-studio dev`). The answer is the office's (worker/office.mjs agentsTry).
140
+ */
141
+ export async function agentsTry(root, game, { url, view, ask, from, model, mode, quiet, avoid, players, readFile } = {}) {
142
+ const usage = 'usage: homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>,…] --from <seat>] [--model <id>] [--mode workers-ai|owner-key|script] [--quiet] [--avoid <seat>,…]';
143
+ if (!GAME.test(String(game ?? '')) || typeof view !== 'string') return { ok: false, command: 'agents try', why: usage };
144
+ let v;
145
+ try { v = JSON.parse(readFile(view)); } catch (error) { return { ok: false, command: 'agents try', why: `--view is a JSON file of the game state a guide sees (${String(error?.message ?? error).slice(0, 80)})` }; }
146
+ const seats = (x) => (x === undefined || x === true ? undefined : String(x).split(',').map(Number).filter((n) => Number.isInteger(n) && n >= 0));
147
+ const asks = [];
148
+ if (typeof ask === 'string') {
149
+ const [k, rest = ''] = ask.split(':');
150
+ const args = Object.fromEntries(rest.split(',').filter(Boolean).map((kv) => { const [a, val = ''] = kv.split('='); return [a, /^\d+$/.test(val) ? Number(val) : val]; }));
151
+ asks.push({ k, args, from: Number.isInteger(Number(from)) ? Number(from) : 0 });
152
+ }
153
+ const r = await withKey(root, url, (call) => call('/_studio/api/agents/try', {
154
+ game, view: v, asks, ...(typeof model === 'string' ? { model } : {}), ...(typeof mode === 'string' ? { mode } : {}),
155
+ ...(quiet ? { quiet: true } : {}), ...(seats(avoid) ? { avoid: seats(avoid) } : {}), ...(seats(players) ? { players: seats(players) } : {}),
156
+ }));
157
+ return r.ok ? { ok: true, command: 'agents try', ...r } : { ok: false, command: 'agents try', why: r.message ?? r.why };
158
+ }
159
+
131
160
  /**
132
161
  * `homie-studio agents brain key`: the owner's own AI provider key (owner-key brains: claude-haiku-4-5), typed once
133
162
  * into a page on this computer (127.0.0.1, one use, ten minutes) and handed straight to `wrangler secret put
@@ -1117,6 +1117,84 @@
1117
1117
  }
1118
1118
  }
1119
1119
  }
1120
+ },
1121
+ "0.24.4": {
1122
+ "files": {
1123
+ "CLAUDE.md": "ddf2b2a0376202f6",
1124
+ "README.md": "684b3cca8001673c",
1125
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1126
+ "games/README.md": "5ad32fe80c61f117",
1127
+ "music/README.md": "a3dc042dadd39df8",
1128
+ "videos/README.md": "16babc3437d5545b",
1129
+ "posts/README.md": "37841aae476bd540",
1130
+ "site/README.md": "ca131cd0472464fb",
1131
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1132
+ },
1133
+ "sections": {
1134
+ "AGENTS.md": {
1135
+ "(intro)": "214aa46eb3a895d5",
1136
+ "## Layout": "8baf49e4291e7e88",
1137
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1138
+ "## The site": "ab922bb6bde23293",
1139
+ "## Making games": "a8a893eeada33bf5",
1140
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1141
+ "## Art direction and models": "124772c374de7390",
1142
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1143
+ "## Servers and AI seats": "98d08705087a33ec",
1144
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1145
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1146
+ "## Rules": "7e8458adc1b81ece",
1147
+ "## Beta": "9d02638207a26e73"
1148
+ }
1149
+ },
1150
+ "named": {
1151
+ "Homie Arcade": {
1152
+ "sections": {
1153
+ "AGENTS.md": {
1154
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ },
1160
+ "0.24.5": {
1161
+ "files": {
1162
+ "CLAUDE.md": "ddf2b2a0376202f6",
1163
+ "README.md": "684b3cca8001673c",
1164
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1165
+ "games/README.md": "5ad32fe80c61f117",
1166
+ "music/README.md": "a3dc042dadd39df8",
1167
+ "videos/README.md": "16babc3437d5545b",
1168
+ "posts/README.md": "37841aae476bd540",
1169
+ "site/README.md": "ca131cd0472464fb",
1170
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1171
+ },
1172
+ "sections": {
1173
+ "AGENTS.md": {
1174
+ "(intro)": "214aa46eb3a895d5",
1175
+ "## Layout": "8baf49e4291e7e88",
1176
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1177
+ "## The site": "ab922bb6bde23293",
1178
+ "## Making games": "abf2f0d59341512d",
1179
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1180
+ "## Art direction and models": "124772c374de7390",
1181
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1182
+ "## Servers and AI seats": "98d08705087a33ec",
1183
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1184
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1185
+ "## Rules": "7e8458adc1b81ece",
1186
+ "## Beta": "9d02638207a26e73"
1187
+ }
1188
+ },
1189
+ "named": {
1190
+ "Homie Arcade": {
1191
+ "sections": {
1192
+ "AGENTS.md": {
1193
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1120
1198
  }
1121
1199
  }
1122
1200
  }
@@ -309,6 +309,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
309
309
  | `roster` | host | `slots` | Labels it (section 17: a host cannot hide an AI), stores it, forwards it, and tells the shell's watchers. |
310
310
  | `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. |
311
311
  | `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. |
312
+ | `decide` | host (0.24.4) | `n`, `state`, `questions` | A game's own decision (section 20): checked, paced (one every 3 s and 20 a minute per room), handed to the Table's decision model; answered with `decided` to that socket only. |
312
313
  | `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
313
314
  | `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
314
315
 
@@ -328,6 +329,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
328
329
  | `round` / `roster` | everyone but the host | `round` / `slots` |
329
330
  | `pong` | the pinger | `c`, `st` |
330
331
  | `error` | the offender | `code`, `message`, `of?`, `until?` |
332
+ | `decided` | the host that asked (0.24.4) | `n`, `ok`, and `by`, `picks`, `p`, `ms`; or `why` (`off`, `no-ai`, `budget`, `pace` and `retryMs`, `busy`, `bad`, `slow`, `error`, `not-host`) |
331
333
  | `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
332
334
  | `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
333
335
  | `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
@@ -396,6 +398,7 @@ so `ev.origin` is `"null"`.
396
398
  | `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
397
399
  | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
398
400
  | `ping` | — | 256 B | every 2 s | 8 |
401
+ | `decide` | < 2 KB | 6 KB (state 2 KB) | per beat (section 20) | 2; and per room one every 3 s, 20 a minute |
399
402
 
400
403
  **Sockets.**
401
404
  - At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
@@ -1057,12 +1060,29 @@ brain never runs in the frame loop, and it can only choose: goals and lines are
1057
1060
  game's own vocabulary, never free text.
1058
1061
 
1059
1062
  **Where a brain runs.** In the room's own Table, on the studio owner's Worker (a *house
1060
- guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default
1061
- `@cf/meta/llama-3.1-8b-instruct-fp8-fast`), or the owner's own key (Worker secret
1062
- `HOMIE_BRAIN_KEY`, `claude-haiku-4-5` through the official `@anthropic-ai/sdk`). Or in the
1063
- owner's own AI through the local MCP (`agent_sit`, about 30 s a decision). With none of them,
1064
- over the day's budget, or between decisions: the game's scripted floor (`decide`). homie.rocks
1065
- runs nothing and stores nothing.
1063
+ guide*): Workers AI through the `AI` binding (model var `HOMIE_BRAIN_MODEL`, default since
1064
+ 0.24.4 `@cf/cloudflare/clef-flash`, Cloudflare's **Clef** decision model; `@cf/cloudflare/clef`,
1065
+ the 27B, or a chat model such as `@cf/meta/llama-3.1-8b-instruct-fp8-fast` also work), or the
1066
+ owner's own key (Worker secret `HOMIE_BRAIN_KEY`, `claude-haiku-4-5` through the official
1067
+ `@anthropic-ai/sdk`). Under `homie-studio dev` with no binding: Clef on the person's own computer
1068
+ (Ollama with `clef-flash`, which dev finds; free). Or in the owner's own AI through the local MCP
1069
+ (`agent_sit`, about 30 s a decision), or a seat that thinks with Clef on the owner's computer
1070
+ (`agent_sit { brain: "local" }`). With none of them, over the day's budget, or between decisions:
1071
+ the game's scripted floor (`decide`). homie.rocks runs nothing and stores nothing.
1072
+
1073
+ **Clef asks the decision as the questions it is.** A decision model never writes text: it takes a
1074
+ state and typed questions and returns a probability for every allowed answer, in one pass. A guide's
1075
+ decision is a Choice of goal (only goals whose every argument has a value to take), a Choice per set
1076
+ of argument values (one question for a goal's quest and a line's quest, so what the guide does and
1077
+ says agree; a seat that said "no thanks" and the guide's own seat are never options), a Choice of
1078
+ line or none (only when the guide may speak), and, with no ask open, a yes/no "anything to say at
1079
+ all?" that a line also needs. The newest ask is in the goal question itself, with the answer
1080
+ agents.json gives it. The state is the sanitized view, the open asks, who said no thanks, the
1081
+ guide's own last lines and who is new to it. The answers are composed back into one decision, which
1082
+ parseDecision and every fixed rule still check. Measured 2026-10-03 on 64 recorded Ember Vale
1083
+ moments (`homie-studio agents try`, a throwaway studio): Clef answered 36 of 40 open asks itself
1084
+ (Llama 3.1 8B: 0 of 40), blind judges preferred its decisions in 51 of 64 (Llama in none), model time
1085
+ p50 259 ms, p90 433 ms (Llama 303 / 364 ms).
1066
1086
 
1067
1087
  ### The vocabulary: `games/<id>/agents.json`
1068
1088
 
@@ -1161,14 +1181,16 @@ with a pass (the owner's own Claude) takes a guide's seat from a house guide (`a
1161
1181
  | Pace | At least 3 s between AI calls per guide, at most 10 a minute; one alarm per room at a time, at most one every 3 s. Never `setInterval`. |
1162
1182
  | Prompt | System: the persona, the goals, the lines, the asks (about 400 tokens, stable). User: the view (sanitized: no key that names a person, an account, a ticket, an address or an age; nothing that looks like a secret; seats, never names), the asks, and the last three party lines as ids and arguments (free text only on a `speech: game` server: quoted, 120 characters, labelled as data). |
1163
1183
  | Output | One JSON object `{ goal, args, say, sayArgs }` (a JSON schema where the provider takes one). Anything else (prose, a code fence, an unknown id, a wrong or extra argument, a player who said no thanks) is no decision: the scripted floor answers instead. |
1164
- | Fixed rules | A person's ask is answered the way agents.json says (its `goal`): a model's other choice is overruled, and the asked-for goal is carried through until it is done (or 60 s) with no model call meanwhile but for a new ask. A line at most every 8 s. "No thanks" holds the guide off that player for 10 minutes. No goal aims at a player. |
1184
+ | Fixed rules | A person's ask is answered the way agents.json says (its `goal`, with the values the person asked for): a model's other goal, or the asked goal with another value, is overruled, and the asked-for goal is carried through until it is done (or 60 s) with no model call meanwhile but for a new ask. A line at most every 8 s. "No thanks" holds the guide off that player for 10 minutes. No goal aims at a player. |
1165
1185
  | Budget | A day, for the whole studio (meta `brain_budget`): 8,000 Workers AI neurons (the free allocation is 10,000 an account), $1 of the owner's key. Counted in `stats_daily` (`brain-calls`, `brain-neurons`, `brain-microdollars`), written at most once a minute. Over it: the scripted floor until 00:00 UTC. |
1166
- | Log | The last 50 decisions per room (seat, goal, line, provider, time, why), in memory, in the office. |
1186
+ | Log | The last 50 decisions per room (seat, goal, line, provider, time, why; the model, Clef's probability for the goal and line, and the brain's own pick when a rule overruled it), in memory, in the office. |
1187
+ | Try | `homie-studio agents try <game> --view <file> [--ask …] [--model …]` (office API `/_studio/api/agents/try`): the same brain and rules on one moment, no seat taken, spent from the same day. |
1167
1188
 
1168
1189
  **Cost.** A house guide costs no request of its own beyond the alarm: one per room per due
1169
- decision, at most one every 3 s. A decision is about 4.3 Workers AI neurons
1170
- (`@cf/meta/llama-3.1-8b-instruct-fp8-fast`: 700 tokens in, 40 out), or $0.0009 of the owner's
1171
- key.
1190
+ decision, at most one every 3 s. A Clef decision is about 9 Workers AI neurons (clef-flash: about
1191
+ 1,000 tokens in at $0.09 a million, nothing for output), so the default day of 8,000 is about 900
1192
+ decisions; a Llama decision is about 4.1 (700 tokens in, 40 out); the owner's key about $0.0009.
1193
+ Under dev, Clef on the person's own computer costs nothing.
1172
1194
 
1173
1195
  **Kids and safety.** The AI never types: it picks ids, the game renders the creator's text.
1174
1196
  A beginner server's chat is quick lines only; on a kids server names are handles and no free
@@ -1270,7 +1292,7 @@ and reach every screen at once.
1270
1292
  | Review | |
1271
1293
  |---|---|
1272
1294
  | Budget | A day, for the whole studio (meta `chat_budget`): 2,000 neurons by default. With the AI guides' 8,000 that is the 10,000 Workers AI gives an account free a day. A short message measured about 2.3 neurons (clef-flash: $0.09 per million input tokens, no output charge; the question and its answers are most of the input), so about 850 reviewed messages a day. Counted in `stats_daily` (`chat-reviews`, `chat-neurons`). |
1273
- | No review | No `AI` binding (`homie-studio dev` without `--remote-ai`), the day's budget used, the model failing or slower than 1.5 s, more than 8 messages waiting: the floor alone decides, the message goes out, and the office counts it. |
1295
+ | No review | No `AI` binding (`homie-studio dev` without `--remote-ai`, unless Clef is on the person's own computer: then it reviews there, free), the day's budget used, the model failing or slower than 1.5 s, more than 8 messages waiting: the floor alone decides, the message goes out, and the office counts it. |
1274
1296
  | The owner | The studio's owner is never reviewed or held by slow mode (their lines are marked `owner`). |
1275
1297
 
1276
1298
  ### The owner's tools
@@ -1318,16 +1340,20 @@ ticker.
1318
1340
  ### What the page does (worker/chat-page.mjs)
1319
1341
 
1320
1342
  - **Play:** a Chat pill in the room button's band (game.json `screen.share` already keeps that
1321
- corner clear of the game's HUD), with a count of unread lines; its sheet: the room's last
1343
+ corner clear of the game's HUD; a round icon on a phone and beside a room button kept an icon,
1344
+ and on a computer wherever its word would reach the middle third of the top), with a count of
1345
+ unread lines; its sheet: the room's last
1322
1346
  minutes, a row of reactions (2.5 rem circles that pop), the quick lines, typing where the rules
1323
1347
  allow it (and why not where they do not: "Sign in to type here. Emoji are open to everyone."),
1324
1348
  Report on a line, and two switches kept in this browser: **Show my messages over my character**
1325
- and **Show chat on this screen** (off: no ticker, no float, no bubbles in this game). New lines
1326
- show for a moment where the status chip sits (game.json `"screen": { "chat": "bottom-right" }`
1327
- moves them; `false` keeps only the pill's count).
1349
+ and **Show chat on this screen** (off: no strip, no float, no bubbles in this game). New lines
1350
+ show for a moment where the status chip sits; game.json `"screen": { "chat": … }` moves that
1351
+ strip per device (`at`, `x` / `y`, `desk`, `phone`, `sideways`, `tv`) or keeps new lines in the
1352
+ sheet (`"lines": "sheet-only"`, or `false`: only the pill's count). chat/CHAT.md has the fields.
1328
1353
  - **Float:** every reaction rises up every screen in the room, as on the television: at most six
1329
1354
  a second and eighteen at once, 2.4 s each with a little sway. The sender's own floats at once.
1330
- - **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use, no typing.
1355
+ - **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use (or game.json
1356
+ `screen.chat`'s corner), no typing.
1331
1357
  - **Watch:** a Chat button in the band; its panel over the stage (a bottom sheet on a phone).
1332
1358
 
1333
1359
  **Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
@@ -1344,3 +1370,55 @@ about 2.3 neurons a typed line, inside the free allocation by default.
1344
1370
  **Old games and old relays.** A game built before revision 8 has the panel, the float, the ticker
1345
1371
  and the TV corner; it draws no bubbles and its own UI cannot send. A revision-8 helper on an older
1346
1372
  relay never hears a `line` and has `chatRules` null.
1373
+
1374
+ ---
1375
+
1376
+ ## 20. A game's own decisions (0.24.4)
1377
+
1378
+ Clef answers in about a tenth of a second and never writes text, so a game can ask it what to do
1379
+ next about its own state: a tactic for its opponents, an NPC's reaction from a fixed set, a
1380
+ director's call (a wave now? push harder?), a turn-based move. The host asks; the room's own
1381
+ Durable Object asks the studio's Workers AI (or Clef on the person's own computer under `dev`);
1382
+ the answer comes back as option ids, yes or no, and numbers. Nothing a player reads comes from a
1383
+ model.
1384
+
1385
+ ```ts
1386
+ const d = await net.decide(
1387
+ { heroes: [{ hp: 40 }, { hp: 90 }], slimes: { count: 9 } }, // the game's state (sanitized; seats, never names)
1388
+ {
1389
+ tactic: { type: 'choice', instructions: 'How should the slimes hunt?', criteria: { chase: 'Rush the nearest hero', surround: 'Close in from every side' } },
1390
+ wave: { type: 'noul', instructions: 'Should a wave come now?' },
1391
+ pressure: { type: 'score', instructions: 'How hard should the vale push?', criteria: ['A breather', 'Steady', 'Fierce'] },
1392
+ },
1393
+ { floor: () => ({ tactic: 'chase', wave: false, pressure: 1 }) }, // the game's own answer, synchronous
1394
+ );
1395
+ // d.by: 'ai' | 'local' | 'floor'; d.picks: { tactic: 'surround', wave: false, pressure: 1.4 }; d.p: probabilities
1396
+ ```
1397
+
1398
+ | | |
1399
+ |---|---|
1400
+ | Opt-in | game.json `"decide": true` (the build carries it into games.json; deploy binds Workers AI for it). Without it the room answers `off` and the floor plays. |
1401
+ | Questions | 1 to 8: Choice (2 to 26 option ids), yes/no (`noul`), Score (2 to 10 levels, lowest first; the pick is the probability-weighted level). Instructions and descriptions at most 160 characters. The state at most 2 KB after the same sanitizing as a guide's view. |
1402
+ | Frames | Host to relay `{ t: 'decide', n, state, questions }`; relay to that host only `{ t: 'decided', n, ok, by, picks, p, ms }` or `{ t: 'decided', n, ok: false, why }` (`off`, `no-ai`, `budget`, `pace` with `retryMs`, `busy`, `bad`, `slow`, `error`, `not-host`). |
1403
+ | Pace | One ask every 3 s and 20 a minute per room, two at a time; the helper waits 2.5 s (at most 5) and then answers from the floor. After `off`, `no-ai` or `budget` it asks again only after a minute. |
1404
+ | Budget | The AI brains' day (meta `brain_budget`, 8,000 neurons by default): decisions and guides share it, counted as `brain-calls` source `decide`. About 4 neurons for three short questions. |
1405
+ | Office | Each room's decisions: how many, who answered, the model's median time, the neurons. Never a state. |
1406
+
1407
+ **What rate is real.** Measured 2026-10-03 on a throwaway studio (Ember Vale's slimes' director,
1408
+ three questions every 6 s): the host's round trip p50 160 to 253 ms, p90 347 to 497 ms; the model's
1409
+ time in the room p50 115 ms; about 4.1 neurons a decision. So never per frame; per second is fast
1410
+ enough but one room asking every second would spend about 15,000 neurons an hour, more than the free
1411
+ allocation's whole day; **per beat (every 5 to 10 s), per turn, or on an event** is the rate that fits
1412
+ the free plan (a room asking every 6 s: about 2,500 neurons an hour). Clef on the person's own
1413
+ computer costs nothing; its time depends on the computer.
1414
+
1415
+ **Kids and the dial.** A decision moves the game's own world (its opponents, its director), never a
1416
+ person, and never the party's skill dial: the bots' level stays the room's. On a kids server the game
1417
+ keeps its decisions gentle (Ember Vale's slimes never gang up on the most hurt hero there, and the vale
1418
+ never pushes past steady). The state follows a view's rules: game state and seats, never a name, an
1419
+ account or typed text.
1420
+
1421
+ **Ember Vale** (the starter, opt-in: its game.json ships `"decide": false`): every 6 s of a live night
1422
+ the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
1423
+ whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
1424
+ chose it. The floor is the vale as it always played.
@@ -402,6 +402,8 @@ export interface NetStats {
402
402
  reconnects: number;
403
403
  promotions: number;
404
404
  round: number | null;
405
+ /** Section 20: the host's decisions: asked, answered by the studio's model ('ai'), the person's own Ollama ('local'), or the floor; median ms. */
406
+ decides?: { asked: number; ai: number; local: number; floor: number; msP50: number | null };
405
407
  }
406
408
 
407
409
  export interface Vec { x: number; y: number; z?: number }
@@ -497,6 +499,29 @@ export interface NetHandlers<S, A, C> {
497
499
  held: (h: ChatHeld) => void;
498
500
  }
499
501
 
502
+ /**
503
+ * A GAME'S OWN DECISIONS (section 20): typed questions about the game's state that the studio's decision model (Clef)
504
+ * answers with a probability for every option, never with text. A Choice picks one option id (2 to 26), a yes/no
505
+ * (`noul`) is true or false, a Score is a level (2 to 10, lowest first; the answer is the probability-weighted level).
506
+ */
507
+ export type DecideQuestion =
508
+ | { type: 'choice'; instructions: string; criteria: Record<string, string | null> }
509
+ | { type: 'noul'; instructions: string; criteria?: { true?: string; false?: string } }
510
+ | { type: 'score'; instructions: string; criteria: string[] };
511
+ export type DecidePicks = Record<string, string | boolean | number>;
512
+ /**
513
+ * What `decide` resolves to, always: `by` 'ai' (the studio's Workers AI) or 'local' (the person's own Ollama under dev),
514
+ * or 'floor' (the game's own answer: no AI, not opted in, over budget, paced, slow, not the host); `why` says which.
515
+ * `p`: the model's probability for each option (a choice), `{ yes }` (a yes/no), or each level (a score).
516
+ */
517
+ export interface Decided { by: 'ai' | 'local' | 'floor'; picks: DecidePicks; p?: Record<string, Record<string, number>>; ms: number; why?: string }
518
+ export interface DecideOptions {
519
+ /** The game's own answer, synchronous: used whenever the model's is not there in time. Missing picks fall back to it one by one. */
520
+ floor?: (state: unknown) => DecidePicks;
521
+ /** How long to wait for the model (default 2500 ms; at most 5000). */
522
+ ms?: number;
523
+ }
524
+
500
525
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
501
526
  readonly role: Role;
502
527
  readonly isHost: boolean;
@@ -640,6 +665,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
640
665
  /** Interpolation pair for rendering remote entities. */
641
666
  sample(delayMs?: number): Sample<S> | null;
642
667
  latest(): Snapshot<S> | null;
668
+ /**
669
+ * Host: ask the studio's decision model (Clef) a few typed questions about the game's state, for a game whose
670
+ * game.json says "decide": true. Always resolves: with the model's picks, or the floor's when it cannot answer in
671
+ * time (no AI, over the day's budget, at most one ask every 3 s a room, a replica, offline). Never per frame: per
672
+ * beat (every few seconds), and the game keeps playing while it waits.
673
+ */
674
+ decide(state: unknown, questions: Record<string, DecideQuestion>, opts?: DecideOptions): Promise<Decided>;
643
675
  stats(): NetStats;
644
676
  expose(probes: Probes): void;
645
677
  close(): void;
@@ -1069,6 +1101,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1069
1101
  /** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
1070
1102
  const inSent: number[] = [];
1071
1103
  let inSlowUntil = 0;
1104
+ /** Section 20: decisions waiting for the room's answer (by request id), and how long the room said it is off. */
1105
+ const deciding = new Map<string, { done: (d: Decided) => void; timer: ReturnType<typeof setTimeout>; at: number; floor: () => Decided }>();
1106
+ let decideSeq = 0;
1107
+ let decideOffUntil = 0;
1108
+ let decideNextAt = 0;
1109
+ const decideStats = { asked: 0, ai: 0, local: 0, floor: 0, ms: [] as number[] };
1072
1110
  let idleSkipped = 0;
1073
1111
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
1074
1112
  let closedWhy: string | null = null;
@@ -1460,6 +1498,29 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1460
1498
  resolveView('policy');
1461
1499
  return;
1462
1500
  }
1501
+ case 'decided': {
1502
+ const n = String(m['n'] ?? '');
1503
+ const w = deciding.get(n);
1504
+ if (!w) return;
1505
+ deciding.delete(n);
1506
+ clearTimeout(w.timer);
1507
+ const ms = Math.round(wall() - w.at);
1508
+ if (m['ok'] === true && m['picks'] && typeof m['picks'] === 'object') {
1509
+ const base = w.floor();
1510
+ const by = m['by'] === 'local' ? 'local' : 'ai';
1511
+ decideStats[by] += 1;
1512
+ decideStats.ms.push(ms); if (decideStats.ms.length > 40) decideStats.ms.shift();
1513
+ w.done({ by, picks: { ...base.picks, ...(m['picks'] as DecidePicks) }, p: (m['p'] as Decided['p']) ?? {}, ms });
1514
+ return;
1515
+ }
1516
+ const why = String(m['why'] ?? 'error');
1517
+ // Not opted in, no AI or out of budget: ask again only after a minute. Paced: at the room's next slot.
1518
+ if (why === 'off' || why === 'no-ai' || why === 'budget') decideOffUntil = wall() + 60_000;
1519
+ if (why === 'pace' && Number.isFinite(Number(m['retryMs']))) decideNextAt = wall() + Number(m['retryMs']);
1520
+ decideStats.floor += 1;
1521
+ w.done({ ...w.floor(), why, ms });
1522
+ return;
1523
+ }
1463
1524
  case 'error': {
1464
1525
  const code = String(m['code'] ?? '');
1465
1526
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
@@ -1748,6 +1809,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1748
1809
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1749
1810
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
1750
1811
  peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
1812
+ decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
1751
1813
  };
1752
1814
  }
1753
1815
  function notifyStats(force: boolean): void {
@@ -2185,6 +2247,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2185
2247
  return { a: newest, b: newest, alpha: 1, renderT, starved: 0 };
2186
2248
  },
2187
2249
  latest() { return buf.length ? buf[buf.length - 1] as Snapshot<S> : null; },
2250
+ decide(state, questions, o = {}) {
2251
+ const t0 = wall();
2252
+ const floor = (): Decided => {
2253
+ let picks: DecidePicks = {};
2254
+ try { picks = o.floor ? o.floor(state) ?? {} : {}; } catch { picks = {}; }
2255
+ return { by: 'floor', picks, ms: Math.round(wall() - t0) };
2256
+ };
2257
+ decideStats.asked += 1;
2258
+ const now0 = wall();
2259
+ const why = role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
2260
+ if (why) { decideStats.floor += 1; return Promise.resolve({ ...floor(), why }); }
2261
+ decideNextAt = now0 + 3000;
2262
+ const n = `d${(decideSeq += 1).toString(36)}`;
2263
+ return new Promise<Decided>((done) => {
2264
+ const ms = Math.max(200, Math.min(5000, Number(o.ms) || 2500));
2265
+ const timer = setTimeout(() => { if (deciding.delete(n)) { decideStats.floor += 1; done({ ...floor(), why: 'slow' }); } }, ms);
2266
+ deciding.set(n, { done, timer, at: t0, floor });
2267
+ if (!raw({ t: 'decide', n, state, questions })) { deciding.delete(n); clearTimeout(timer); decideStats.floor += 1; done({ ...floor(), why: 'offline' }); }
2268
+ });
2269
+ },
2188
2270
  stats: statsNow,
2189
2271
  expose(p) { probes = { ...probes, ...p }; },
2190
2272
  close() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.24.3",
3
+ "version": "0.24.5",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -169,6 +169,10 @@ where the game has room, per device:
169
169
  - A button at the bottom left sends the "finding a room" chip to the bottom right.
170
170
  - On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
171
171
  phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
172
+ - Room chat's Chat pill sits in the same band: a round icon on a phone (either way up) and beside a button kept an
173
+ icon; on a computer it says "Chat" unless the word would take the band off the screen or into the middle third of
174
+ its width, where games keep a clock or a title. Its strip of new lines (bottom left by default) moves with
175
+ `"screen": { "chat": … }`, per device, or stays in the Chat sheet (`"lines": "sheet-only"`): chat/CHAT.md.
172
176
 
173
177
  Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
174
178
  score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
@@ -7,6 +7,7 @@
7
7
  "entry": "src/main.ts",
8
8
  "netplay": { "v": 1, "public": true, "movement": "owner" },
9
9
  "saves": true,
10
+ "decide": false,
10
11
  "landing": {
11
12
  "pitch": "Your hero keeps every level. The night does not.",
12
13
  "howToPlay": [