@homie-rocks/studio 0.14.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/bin/homie-studio.mjs +1 -1
- package/lib/build.mjs +15 -4
- package/lib/mcp-tools.mjs +3 -2
- package/lib/scaffold.mjs +15 -1
- package/lib/studio.mjs +23 -3
- package/lib/template-history.json +54 -0
- package/netplay/NETPLAY.md +119 -13
- package/netplay/netplay.ts +291 -5
- package/package.json +1 -1
- package/port/hud.ts +10 -7
- package/port/room.ts +15 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +41 -5
- package/starters/ember-vale/src/main.ts +37 -5
- package/starters/gem-rush/src/main.ts +45 -15
- package/worker/index.mjs +83 -12
- package/worker/license.mjs +74 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/pages.mjs +417 -4
- package/worker/room.mjs +65 -6
- package/worker/site.mjs +50 -12
- package/worker/stats.mjs +4 -3
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -80,11 +80,13 @@ its D1; nothing is stored by hand. studio.json `homie.directory: false` keeps a
|
|
|
80
80
|
|
|
81
81
|
Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
|
|
82
82
|
featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
|
|
83
|
-
joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
|
|
83
|
+
joinable and watchable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
|
|
84
84
|
page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
|
|
85
85
|
pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
|
|
86
|
-
live rooms, credits (the original and its licence for a port
|
|
87
|
-
|
|
86
|
+
live rooms, credits (the original and its licence for a port; "Remix of <game> by <studio>", linked, for a
|
|
87
|
+
remix), and "Make a game like this". A game's shared source (`/games/<id>/source.json`) says who made it and the
|
|
88
|
+
licence its owner picked in game.json `"license"`: `"remix-with-credit"` (the default), `"remix-freely"` or
|
|
89
|
+
`"no-remix"`, which `game remix` refuses. Every page ends with "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
|
|
88
90
|
files. `site/SITE.md` says all of it.
|
|
89
91
|
|
|
90
92
|
The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
|
|
@@ -95,13 +97,20 @@ game's landing still. Every HTML answer is `no-transform`, and the site's pages
|
|
|
95
97
|
site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
|
|
96
98
|
`not-a-homie`, at the site's root as under the game.
|
|
97
99
|
|
|
100
|
+
Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<room>` (a Watch button sits beside
|
|
101
|
+
Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
|
|
102
|
+
with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
|
|
103
|
+
player's camera and HUD from `net.viewSeat` (Gem Rush and Ember Vale do; a game that does not is watched as its
|
|
104
|
+
overview), and game.json `"watch": "overview"` or `false` keeps hidden hands hidden. Private and invite-only games
|
|
105
|
+
are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
|
|
106
|
+
|
|
98
107
|
## Upgrading a studio
|
|
99
108
|
|
|
100
109
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
101
110
|
|
|
102
111
|
```sh
|
|
103
|
-
npx -y @homie-rocks/studio@0.
|
|
104
|
-
npx -y @homie-rocks/studio@0.
|
|
112
|
+
npx -y @homie-rocks/studio@0.15.0 upgrade # the plan; changes nothing
|
|
113
|
+
npx -y @homie-rocks/studio@0.15.0 upgrade --apply # after the person agrees
|
|
105
114
|
npm install && npm run build
|
|
106
115
|
```
|
|
107
116
|
|
|
@@ -232,7 +241,7 @@ device; the owner's own passkey account (`homie-studio players owner`) counts as
|
|
|
232
241
|
## Setup status
|
|
233
242
|
|
|
234
243
|
```sh
|
|
235
|
-
npx -y @homie-rocks/studio@0.
|
|
244
|
+
npx -y @homie-rocks/studio@0.15.0 setup status --connector yes # before a studio exists
|
|
236
245
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
237
246
|
```
|
|
238
247
|
|
|
@@ -409,7 +418,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
409
418
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
410
419
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
411
420
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
412
|
-
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
421
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
413
422
|
| `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
|
|
414
423
|
| `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
|
|
415
424
|
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -164,7 +164,7 @@ function print(result) {
|
|
|
164
164
|
lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
|
|
165
165
|
break;
|
|
166
166
|
case 'game remix':
|
|
167
|
-
lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
|
|
167
|
+
lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Its game.json credits the original: "${result.credit}"${result.page ? ` (${result.page})` : ''}, on its landing and in its credits. Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
|
|
168
168
|
break;
|
|
169
169
|
case 'game new':
|
|
170
170
|
lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
|
package/lib/build.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
|
|
|
33
33
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
34
34
|
import { SEAT_MAX } from '../worker/seats.mjs';
|
|
35
35
|
import { STUDIO_VERSION } from './version.mjs';
|
|
36
|
+
import { licenseOf, remixRow } from '../worker/license.mjs';
|
|
36
37
|
|
|
37
38
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
38
39
|
/** Never copied into a static game's served folder. */
|
|
@@ -64,8 +65,12 @@ function dirBytes(dir) {
|
|
|
64
65
|
return n;
|
|
65
66
|
}
|
|
66
67
|
const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
|
|
67
|
-
/**
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes; with who made
|
|
70
|
+
* it (`credit`: the studio and the game; the live site adds the page, worker/index.mjs) and its licence (game.json
|
|
71
|
+
* "license", worker/license.mjs).
|
|
72
|
+
*/
|
|
73
|
+
export function sourceOf(dir, id, { studio = null, game = null, license } = {}) {
|
|
69
74
|
const files = {};
|
|
70
75
|
let total = 0;
|
|
71
76
|
const walk = (rel) => {
|
|
@@ -82,7 +87,7 @@ export function sourceOf(dir, id) {
|
|
|
82
87
|
}
|
|
83
88
|
};
|
|
84
89
|
walk('');
|
|
85
|
-
return { v: 1, kind: 'homie-game-source', id, files };
|
|
90
|
+
return { v: 1, kind: 'homie-game-source', id, credit: { studio, game: game ?? id }, license: licenseOf(license), files };
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/*
|
|
@@ -193,7 +198,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
193
198
|
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
194
199
|
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
195
200
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
196
|
-
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
|
|
201
|
+
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
|
|
197
202
|
const main = join(out, 'assets', 'main.js');
|
|
198
203
|
const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
|
|
199
204
|
const seats = seatsFor(g, netplayOf(g, out));
|
|
@@ -225,6 +230,12 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
225
230
|
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
226
231
|
// game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
|
|
227
232
|
...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
|
|
233
|
+
// game.json `"watch"` (NETPLAY.md section 16): watchers see any player's view (the default), only the whole room
|
|
234
|
+
// ("overview": hidden hands or roles), or nothing (false: no watch door).
|
|
235
|
+
...(g.watch === 'overview' ? { watch: 'overview' } : g.watch === false || g.watch === 'off' ? { watch: 'off' } : {}),
|
|
236
|
+
// Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
|
|
237
|
+
license: licenseOf(g.license),
|
|
238
|
+
...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
|
|
228
239
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
229
240
|
};
|
|
230
241
|
});
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -684,7 +684,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
684
684
|
},
|
|
685
685
|
{
|
|
686
686
|
name: 'game_remix', title: 'Remix a shared game',
|
|
687
|
-
description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own
|
|
687
|
+
description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own. The original stays credited: game.json remixOf says "Remix of <game> by <studio>" with a link back, shown on the new game\'s landing and in its credits. A game whose owner\'s licence says no remix is refused.',
|
|
688
688
|
inputSchema: { type: 'object', properties: { game: str('The directory game: "<studio site>/<game id>" or its play URL'), id: str('The new game id in this studio'), name: str('Optional: its new name'), ...STUDIO_ARG }, required: ['game', 'id'] },
|
|
689
689
|
annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
|
|
690
690
|
run: async (a) => {
|
|
@@ -696,7 +696,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
696
696
|
const r = await cli(ctx, root, `game remix ${a.id}`, ['game', 'remix', source, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
|
|
697
697
|
if (!r.ended) return stillRunning(r.job, 'The remix');
|
|
698
698
|
if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
|
|
699
|
-
|
|
699
|
+
const credit = r.result?.credit ? ` Its game.json credits the original: "${r.result.credit}"${r.result.page ? ` (${r.result.page})` : ''}, on its landing and in its credits; keep it.` : '';
|
|
700
|
+
return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).${credit}`, { kind: 'game', ...r.result });
|
|
700
701
|
},
|
|
701
702
|
},
|
|
702
703
|
{
|
package/lib/scaffold.mjs
CHANGED
|
@@ -169,7 +169,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
169
169
|
(\`node_modules/@homie-rocks/studio/site/SITE.md\` says all of it):
|
|
170
170
|
|
|
171
171
|
- **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
|
|
172
|
-
Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
|
|
172
|
+
Rooms (every public room playing now, joinable and watchable) and Posts. A section with nothing in it has no tab, and its
|
|
173
173
|
page is not found.
|
|
174
174
|
- **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
|
|
175
175
|
(games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
|
|
@@ -200,8 +200,22 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
200
200
|
scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
|
|
201
201
|
device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
|
|
202
202
|
a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
|
|
203
|
+
- **Watch any player.** Every live room can be watched at \`/<id>/watch?room=<room>\` (each room on the Rooms page
|
|
204
|
+
and the landing has a Watch button): the game itself, drawn by the watcher's own browser, which never takes a
|
|
205
|
+
seat, with a strip of the players to switch between (a tap, keys 1-9, A for Auto, O for the whole room). Point the
|
|
206
|
+
camera and the HUD at \`net.viewSeat\` (your own seat when playing; the followed player when watching; null: the
|
|
207
|
+
overview camera; \`room.viewBody()\` in a port), and call \`net.spotlight(seat)\` on a hit, a kill or a goal so
|
|
208
|
+
Auto cuts to it (\`NETPLAY.md\` section 16). A game that never reads \`viewSeat\` is watched as its overview. A
|
|
209
|
+
game with hidden hands or roles says game.json \`"watch": "overview"\` (the whole room only) or \`false\` (no
|
|
210
|
+
watch door); a private or invite-only game is watched only by those it lets in.
|
|
203
211
|
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
204
212
|
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
213
|
+
- **Remixing.** A public game shares its source at \`/games/<id>/source.json\`, with who made it (the studio, the
|
|
214
|
+
game, its page) and the licence its owner picks in game.json \`"license"\`: \`"remix-with-credit"\` (the
|
|
215
|
+
default), \`"remix-freely"\` or \`"no-remix"\`, and \`{ "kind": "…", "spdx": "MIT" }\` to name a licence too.
|
|
216
|
+
\`npx --no-install homie-studio game remix <source.json> --id <new id>\` brings another studio's game in as a new
|
|
217
|
+
game here: its game.json \`remixOf\` says "Remix of <game> by <studio>" with a link back, and its landing and
|
|
218
|
+
credits show it; keep that credit. A game whose licence says no remix is refused.
|
|
205
219
|
- **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
|
|
206
220
|
a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
|
|
207
221
|
\`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
|
package/lib/studio.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
|
|
8
9
|
|
|
9
10
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
11
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -125,7 +126,11 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
|
125
126
|
return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String) };
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* `homie-studio game remix <source.json url> --id <new id>`: a shared game brought in as this studio's own. Its
|
|
131
|
+
* game.json `remixOf` credits the original ("Remix of <game> by <studio>", with a link back to its page), and its
|
|
132
|
+
* landing and credits show it. A game whose owner's licence says no remix is refused (worker/license.mjs).
|
|
133
|
+
*/
|
|
129
134
|
export async function remixGame(root, source, id, { name } = {}) {
|
|
130
135
|
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('give the new game an id: --id <lowercase-id>');
|
|
131
136
|
const dest = join(root, 'games', id);
|
|
@@ -137,6 +142,14 @@ export async function remixGame(root, source, id, { name } = {}) {
|
|
|
137
142
|
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
138
143
|
const body = await res.json();
|
|
139
144
|
if (body?.kind !== 'homie-game-source' || !body.files || typeof body.files !== 'object') throw new Error('that address is not a shared Homie game source');
|
|
145
|
+
// Who made it and what they allow: the source's credit and licence (a studio before 0.14.4 sends neither, so the
|
|
146
|
+
// default licence, and the page from the address).
|
|
147
|
+
const credit = body.credit && typeof body.credit === 'object' ? body.credit : {};
|
|
148
|
+
const license = licenseOf(body.license);
|
|
149
|
+
if (!remixAllowed(license)) {
|
|
150
|
+
const who = remixRow({ name: credit.game ?? body.id, studio: credit.studio });
|
|
151
|
+
throw new Error(`${who?.name ?? 'That game'}${who?.studio ? ` by ${who.studio}` : ''} is not open to remixing: its owner's licence says no remix. Make a game of your own like it instead.`);
|
|
152
|
+
}
|
|
140
153
|
const wrote = [];
|
|
141
154
|
for (const [rel, text] of Object.entries(body.files)) {
|
|
142
155
|
if (typeof text !== 'string' || rel.includes('..') || rel.startsWith('/') || !/^[A-Za-z0-9._/-]+$/.test(rel)) continue;
|
|
@@ -148,11 +161,18 @@ export async function remixGame(root, source, id, { name } = {}) {
|
|
|
148
161
|
if (!existsSync(join(dest, 'game.json'))) throw new Error('the shared source has no game.json');
|
|
149
162
|
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
150
163
|
const from = meta.id;
|
|
151
|
-
|
|
164
|
+
// The credit, in the remix's own game.json: the original's name, studio, page and licence, the line its landing and
|
|
165
|
+
// credits show, and, when the original was itself a remix, what that was a remix of.
|
|
166
|
+
const row = remixRow({ name: credit.game ?? meta.name ?? from, studio: credit.studio, page: httpsPage(credit.page) ?? pageOfSource(url) });
|
|
167
|
+
const before = remixRow(meta.remixOf);
|
|
168
|
+
meta.remixOf = {
|
|
169
|
+
credit: remixCredit(row), name: row?.name ?? null, studio: row?.studio ?? null, page: row?.page ?? null,
|
|
170
|
+
source: String(url), id: from, license, ...(before ? { of: before } : {}),
|
|
171
|
+
};
|
|
152
172
|
meta.id = id;
|
|
153
173
|
if (name) meta.name = String(name).slice(0, 60);
|
|
154
174
|
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
155
175
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
156
176
|
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
157
|
-
return { ok: true, command: 'game remix', id, from: String(url), files: wrote };
|
|
177
|
+
return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote };
|
|
158
178
|
}
|
|
@@ -380,6 +380,60 @@
|
|
|
380
380
|
"## Beta": "9d02638207a26e73"
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
|
+
},
|
|
384
|
+
"0.14.4": {
|
|
385
|
+
"files": {
|
|
386
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
387
|
+
"README.md": "684b3cca8001673c",
|
|
388
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
389
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
390
|
+
"music/README.md": "b8a828de9311bd28",
|
|
391
|
+
"videos/README.md": "70614388cbb203c7",
|
|
392
|
+
"posts/README.md": "37841aae476bd540",
|
|
393
|
+
"site/README.md": "ca131cd0472464fb",
|
|
394
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
395
|
+
},
|
|
396
|
+
"sections": {
|
|
397
|
+
"AGENTS.md": {
|
|
398
|
+
"(intro)": "214aa46eb3a895d5",
|
|
399
|
+
"## Layout": "f14d641159480e6c",
|
|
400
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
401
|
+
"## The site": "7c1618431bae6324",
|
|
402
|
+
"## Making games": "ce87c4dde1894c26",
|
|
403
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
404
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
405
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
406
|
+
"## Rules": "969318425c99f5a5",
|
|
407
|
+
"## Beta": "9d02638207a26e73"
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
"0.15.0": {
|
|
412
|
+
"files": {
|
|
413
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
414
|
+
"README.md": "684b3cca8001673c",
|
|
415
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
416
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
417
|
+
"music/README.md": "b8a828de9311bd28",
|
|
418
|
+
"videos/README.md": "70614388cbb203c7",
|
|
419
|
+
"posts/README.md": "37841aae476bd540",
|
|
420
|
+
"site/README.md": "ca131cd0472464fb",
|
|
421
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
422
|
+
},
|
|
423
|
+
"sections": {
|
|
424
|
+
"AGENTS.md": {
|
|
425
|
+
"(intro)": "214aa46eb3a895d5",
|
|
426
|
+
"## Layout": "f14d641159480e6c",
|
|
427
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
428
|
+
"## The site": "ab922bb6bde23293",
|
|
429
|
+
"## Making games": "a8a893eeada33bf5",
|
|
430
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
431
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
432
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
433
|
+
"## Rules": "969318425c99f5a5",
|
|
434
|
+
"## Beta": "9d02638207a26e73"
|
|
435
|
+
}
|
|
436
|
+
}
|
|
383
437
|
}
|
|
384
438
|
}
|
|
385
439
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 5)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
`v: 1`. Everything revisions 2 to
|
|
3
|
+
Status: **v1, revision 5** (2026-10-01, `@homie-rocks/studio` 0.15.0). The wire version is
|
|
4
|
+
`v: 1`. Everything revisions 2 to 5 added is either an optional field, a new message type,
|
|
5
5
|
a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
|
|
6
6
|
not know. A change to the contract bumps `v` and keeps v1 working.
|
|
7
7
|
|
|
8
|
+
**What revision 5 added** (a v1 game that knows none of it is watched as its overview):
|
|
9
|
+
- **Watching** (section 16): a watcher is a screen that came to watch a live room from any
|
|
10
|
+
player's view. It never takes a seat, hosts only a room no player can, and the game draws
|
|
11
|
+
the followed player's camera and HUD from `net.viewSeat`. Auto follows the action
|
|
12
|
+
(`net.spotlight(seat)`, else the leader of the `scores` probe).
|
|
13
|
+
- New optional fields `hello.watch`, `welcome.watch` and `peer.watch`, a relay → client
|
|
14
|
+
`watch` frame, the final refusal `watch-off`, and `counts.watchers` in the watch feed.
|
|
15
|
+
- game.json `"watch"`: `"overview"` (hidden hands or roles: the whole room only) or `false`.
|
|
16
|
+
- `PALETTE`: the contract's 12 player colours, which a peer's `colour` indexes.
|
|
17
|
+
|
|
8
18
|
**What revision 4 added** (a v1 game that knows none of it keeps playing; its shell shows
|
|
9
19
|
the notices and the banner):
|
|
10
20
|
- **The owner's controls** (section 15): the studio's owner kicks a player out of a room
|
|
@@ -91,12 +101,15 @@ code.
|
|
|
91
101
|
|---|---|---|---|---|---|
|
|
92
102
|
| `host` | One browser per room | yes | if seated (a seatless host is possible) | `snap`, `state`, `ckpt`, `round`, `roster`, `ev` | `in`, `ev`, `join`, `leave` |
|
|
93
103
|
| `replica` | Every other seated browser | no | yes | `in`, `ev` | `snap`, `state`, `ev`, `round`, `roster`, `join`, `leave`, `seat` |
|
|
94
|
-
| `screen` | A spectator (a TV or laptop with `?screen=1`, or a visitor waiting for a seat) | no | no | `ev` (2/s, 512 B) | the same as a replica |
|
|
104
|
+
| `screen` | A spectator (a TV or laptop with `?screen=1`, a watcher from the game's watch door, or a visitor waiting for a seat) | no | no | `ev` (2/s, 512 B) | the same as a replica |
|
|
95
105
|
|
|
96
106
|
- **Role and seat are separate facts.**
|
|
97
107
|
- `net.isHost` answers: do I run the rules?
|
|
98
108
|
- `net.seat !== null` answers: do I have a body?
|
|
99
109
|
- A screen can be elected host when nobody else can host.
|
|
110
|
+
- **A watcher** (section 16) is a screen that never takes a seat: not at its hello, and not when
|
|
111
|
+
one frees up. A visitor who wanted to play and found the room full is the other kind of screen:
|
|
112
|
+
it takes the next free seat.
|
|
100
113
|
- **Offline is not a fourth role.** With no shell (a plain file, a Vite dev server),
|
|
101
114
|
the helper reports `role: 'host'`, `seat: null` and `offline: true`, and every send
|
|
102
115
|
is a no-op.
|
|
@@ -113,7 +126,7 @@ code.
|
|
|
113
126
|
| `token` | The capability that resumes a seat | Returned in `welcome` and never shown to others. The shell keeps it in `sessionStorage`. |
|
|
114
127
|
| `id` | One socket | Used for routing, event addressing and the host reference. It changes on every reconnect. |
|
|
115
128
|
| `name` | Display name | Typed by the guest, so untrusted: render it with `textContent` only. |
|
|
116
|
-
| `colour` | `seat % 12` | An index into
|
|
129
|
+
| `colour` | `seat % 12` | An index into the contract's 12 colours, `PALETTE` (the watch page's strip draws them; a game that colours its players by seat matches it). |
|
|
117
130
|
|
|
118
131
|
- **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
|
|
119
132
|
- **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
|
|
@@ -224,7 +237,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
224
237
|
|
|
225
238
|
| `t` | Sent by | Fields | What the relay does |
|
|
226
239
|
|---|---|---|---|
|
|
227
|
-
| `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?` | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full` or `
|
|
240
|
+
| `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?`, `watch?` (revision 5) | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full`, `too-many` or `watch-off`. A `watch: true` hello is a watcher (section 16). |
|
|
228
241
|
| `snap` | host | `k`, `st`, `d`, `c?` (control table) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
|
|
229
242
|
| `in` | seated non-host | `q`, `a`, `h`, `p?`, `r` (the reset epoch the sender has adopted) | Forwards to the host only, stamped `from: <seat>`. Presses are clamped to integers 0–8, at most 16 keys. |
|
|
230
243
|
| `ev` | anyone | `k`, `d`, `to?` | **From a muted player** (section 15): a speech kind (`say…`, `chat…`, `emote…`) is dropped. **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
|
|
@@ -239,7 +252,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
239
252
|
|
|
240
253
|
| `t` | To | Fields |
|
|
241
254
|
|---|---|---|
|
|
242
|
-
| `welcome` | the new client | `v`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full
|
|
255
|
+
| `welcome` | the new client | `v`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?`, `watch?: { follow, why? }` (a watcher only) |
|
|
243
256
|
| `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
|
|
244
257
|
| `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
|
|
245
258
|
| `host` | everyone else, on a change | `host`, `why` |
|
|
@@ -253,10 +266,12 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
253
266
|
| `error` | the offender | `code`, `message`, `of?`, `until?` |
|
|
254
267
|
| `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
|
|
255
268
|
| `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
|
|
269
|
+
| `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
|
|
256
270
|
|
|
257
271
|
**Error codes.**
|
|
258
272
|
- Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
|
|
259
|
-
`too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15)
|
|
273
|
+
`too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15),
|
|
274
|
+
`watch-off` (a watcher of a game that cannot be watched; section 16).
|
|
260
275
|
The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
|
|
261
276
|
every knock is refused at `hello`, and the shell stops the frame (section 15).
|
|
262
277
|
- Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
|
|
@@ -267,7 +282,7 @@ A socket that is kicked for silence is closed with code 4000, and the helper rec
|
|
|
267
282
|
Record types:
|
|
268
283
|
|
|
269
284
|
```ts
|
|
270
|
-
Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true }
|
|
285
|
+
Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true }
|
|
271
286
|
RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
|
|
272
287
|
RoundResult = { slot, seat: number|null, name, score, bot, place }
|
|
273
288
|
Slot = { slot, seat: number|null, name, bot }
|
|
@@ -275,7 +290,7 @@ ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c a
|
|
|
275
290
|
```
|
|
276
291
|
|
|
277
292
|
**The shell's watch socket** (`/<game>/__watch?room=&b=`) gets
|
|
278
|
-
`{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
|
|
293
|
+
`{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, watchers, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
|
|
279
294
|
about once a second and on every change, and the owner's controls as they happen
|
|
280
295
|
(section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
|
|
281
296
|
only for the browser they are about (its room key `b`), `kicked { until, message }` and
|
|
@@ -289,6 +304,12 @@ held out of it, hears so at once.
|
|
|
289
304
|
`room-closed`, and `until` and `message` for the last two);
|
|
290
305
|
- `pick` (with `seat`): the game's `net.pickPlayer(seat)`, a player clicked. Only the studio
|
|
291
306
|
owner's page does anything with it (section 15).
|
|
307
|
+
- `view` (a watcher; section 16): `seat`, `following`, `follows` (the game draws the followed
|
|
308
|
+
player), `canFollow`, `whyNot`; and `scores` (`[{ seat, score }]`, once a second, from the
|
|
309
|
+
game's `scores` probe).
|
|
310
|
+
|
|
311
|
+
**Parent page → game frame** (revision 5): `postMessage { t: 'homie-watch', follow: seat | 'auto' | null }`.
|
|
312
|
+
The helper takes it only from its own parent window, and only as a watcher.
|
|
292
313
|
|
|
293
314
|
The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
|
|
294
315
|
so `ev.origin` is `"null"`.
|
|
@@ -486,6 +507,7 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
|
|
|
486
507
|
- use separate storage keys, or a SQLite-backed object, if a save can pass 128 KiB.
|
|
487
508
|
7. **Choose the device** with the short-edge rule (≤ 540 px is a phone; `?hand=`
|
|
488
509
|
overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
|
|
510
|
+
`/<game>/watch?room=` makes a watcher, with a strip of the players (section 16).
|
|
489
511
|
8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
|
|
490
512
|
results, and with `?debug=1` the debug strip.
|
|
491
513
|
9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
|
|
@@ -508,7 +530,9 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
|
|
|
508
530
|
4. **When seated:** call `net.input([...avatar, ...intent], held)` every frame. Adopt
|
|
509
531
|
your body on `control` with `reset`. While `!net.owned`, predict (section 8).
|
|
510
532
|
5. **Render others** from `net.sample()` and your own body locally. A spectator gets an
|
|
511
|
-
overview camera.
|
|
533
|
+
overview camera. **Draw from `net.viewSeat`** (section 16): your own seat when you play,
|
|
534
|
+
the followed player's body, camera and HUD when you watch, the overview when it is `null`;
|
|
535
|
+
call `net.spotlight(seat)` on a hit or a goal, and expose `scores` for Auto and the strip.
|
|
512
536
|
6. **Draw your own HUD**: clock (`round.endsAt − net.now()`), scores, results.
|
|
513
537
|
7. **Touch controls on phones:** a drag in the lower-left moves you. On desktop,
|
|
514
538
|
WASD and the arrow keys.
|
|
@@ -616,9 +640,9 @@ function frame(dt) {
|
|
|
616
640
|
if (net.snapshotDue()) net.snapshot(fastState(), tick);
|
|
617
641
|
net.state('world', slowWorld()); // sent only when it changed
|
|
618
642
|
} else net.input([q(me.x), q(me.y), q(me.vx), q(me.vy), stick.x, stick.y], heldButtons());
|
|
619
|
-
render(hosting ? authoritative : net.sample());
|
|
643
|
+
render(hosting ? authoritative : net.sample(), net.viewSeat); // camera + HUD: whose view (section 16)
|
|
620
644
|
}
|
|
621
|
-
net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount });
|
|
645
|
+
net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount, scores: () => scoresBySeat() });
|
|
622
646
|
```
|
|
623
647
|
|
|
624
648
|
```sh
|
|
@@ -707,3 +731,85 @@ line across the top until it ends, and tells a muted player so.
|
|
|
707
731
|
**Launch states** are the site's, not the relay's: a game that is private or an invite-only
|
|
708
732
|
beta gives each browser it lets in a signed ticket (`t`) for its frame and sockets, and the
|
|
709
733
|
Worker refuses a socket without one before it reaches the room.
|
|
734
|
+
|
|
735
|
+
## 16. Watching (revision 5)
|
|
736
|
+
|
|
737
|
+
A **watcher** watches a live room from any player's view. Nothing is streamed: the watcher's
|
|
738
|
+
own browser loads the game and receives what a replica receives (snapshots, keyed state, events
|
|
739
|
+
sent to everyone), and draws it with the followed player's camera and HUD. Every existing game
|
|
740
|
+
is watchable on day one as its spectator overview; a game that draws `net.viewSeat` lets the
|
|
741
|
+
watcher switch between the players.
|
|
742
|
+
|
|
743
|
+
**Who a watcher is (the relay).**
|
|
744
|
+
- The shell's watch door (`/<game>/watch?room=<room>&follow=<seat>|auto|overview`) boots the
|
|
745
|
+
game with `HOMIE_NET = { …, want: 'screen', watch: true, follow, watchPolicy }`, and the
|
|
746
|
+
socket's address carries `w=1`. A revision-5 helper also says `watch: true` in its `hello`.
|
|
747
|
+
Either one makes the socket a watcher:
|
|
748
|
+
- it **never takes a seat**, at its hello or later, whatever its hello asks for;
|
|
749
|
+
- it **hosts only a room no player can host**: election ranks every visible, responsive
|
|
750
|
+
player above it, and a watcher never deposes a frozen host at its hello;
|
|
751
|
+
- its peer record carries `watch: true`, and the watch feed counts it (`counts.watchers`);
|
|
752
|
+
- for every cap it is a screen: `ev` at 2/s and 512 B, one of the room's `+16` sockets.
|
|
753
|
+
- `welcome.watch = { follow, why? }` says whether it may follow one player's view, and a
|
|
754
|
+
`watch { follow, why }` frame says so when that changes. `why` is `overview` (the game shows
|
|
755
|
+
watchers the whole room only) or `seated-here` (this browser holds a seat in this room).
|
|
756
|
+
- A game whose game.json says `"watch": false` has no watch door: the Worker answers it with a
|
|
757
|
+
page that says so and refuses a `w=1` socket (403), and the relay refuses a watching hello
|
|
758
|
+
with `error watch-off` (final). The Worker passes the game's rule to the room with every
|
|
759
|
+
socket (`wp=follow|overview|off`).
|
|
760
|
+
|
|
761
|
+
**What the game does (the helper).**
|
|
762
|
+
|
|
763
|
+
| Call | Meaning |
|
|
764
|
+
|---|---|
|
|
765
|
+
| `net.watching` | This browser watches. |
|
|
766
|
+
| `net.viewSeat` | Whose camera and HUD to draw: a player's own seat; the seat a watcher follows; `null` for the overview camera. Reading it (or listening for `view`) tells the watch page that this game draws the followed player. A game that never does is shown as its overview, and the page says so. |
|
|
767
|
+
| `net.on('view', ({ seat, following, prev, why }) => …)` | Whose view changed. `why`: `start`, `seat`, `asked`, `auto`, `left` (the followed player left; Auto takes over, and returns to them if they are back within 30 s), `back`, `policy`. |
|
|
768
|
+
| `net.follow(seat \| 'auto' \| null)` | Follow a seat, the action, or the whole room. The watch page's strip and keys call it through the frame; a game's own spectator UI may too. False when not allowed. |
|
|
769
|
+
| `net.spotlight(seat)` | Something happened to this player (a hit, a kill, a goal). A no-op except on a watcher in Auto. |
|
|
770
|
+
| `net.players()` | The seated players in seat order (key 1 is the first). |
|
|
771
|
+
| `net.following`, `net.canFollow` | What was asked; whether this watcher may choose. |
|
|
772
|
+
| `net.watchedSeat` | `viewSeat` without claiming the camera follows: for an overlay that only marks the followed player (the port's HUD). |
|
|
773
|
+
|
|
774
|
+
- **Auto** shows a player for at least 3.5 s, then cuts to the newest spotlight. With no action,
|
|
775
|
+
every 12 s it looks at the leader of the game's `scores` probe
|
|
776
|
+
(`net.expose({ scores: () => [{ seat, score }] })`; a tie keeps who is shown), else the next
|
|
777
|
+
player in seat order. Nobody seated: the overview.
|
|
778
|
+
- **Keys** inside the frame, for a watcher who clicked into the game: 1–9 the n-th player in
|
|
779
|
+
seat order, A Auto, O or 0 the whole room, ← → the previous or next player
|
|
780
|
+
(`createNetplay({ watchKeys: false })` turns them off). The watch page answers the same keys.
|
|
781
|
+
- The helper tells its page `view` and `scores` (section 5) and takes `homie-watch` from its
|
|
782
|
+
own parent window only.
|
|
783
|
+
- **Draw the followed player as that player's own browser frames them**: their body under the
|
|
784
|
+
camera, their score or health in the HUD, their row marked as "you" is (with their name, never
|
|
785
|
+
"You"). Their own body is interpolated like every other one; nothing of yours moves.
|
|
786
|
+
|
|
787
|
+
**Hidden information.** A watcher receives what any replica receives. What is private goes to
|
|
788
|
+
one seat (`net.send(kind, data, seat)`), so a watcher following that seat never has it. A game
|
|
789
|
+
whose shared state itself shows a hand or a role (cards, a traitor game) says game.json
|
|
790
|
+
`"watch": "overview"`: its watchers draw the whole room only and the strip only names the
|
|
791
|
+
players. `"watch": false` removes the watch door. Neither is a wall a modified browser cannot
|
|
792
|
+
climb, and neither needs to be: anyone may take a seat in a public room and receive the same
|
|
793
|
+
stream as a player. The rule decides what is offered and drawn.
|
|
794
|
+
|
|
795
|
+
**A second tab is not a peek.** A browser that holds a seat in a room watches that room in the
|
|
796
|
+
overview only (`seated-here`), for as long as its seat is there: the relay compares the
|
|
797
|
+
browser's room key (`b`, section 15), so a second tab cannot look over an opponent's shoulder
|
|
798
|
+
with a click. Another device is another watcher, as it would be another player.
|
|
799
|
+
|
|
800
|
+
**Launch states and the owner's controls.** The watch door is the play door: a private game or
|
|
801
|
+
an invite-only beta is watched only by whoever may play it, and their ticket rides in the
|
|
802
|
+
frame's and the sockets' addresses as it does for Play. A kick holds that browser out of
|
|
803
|
+
watching the room too (the same room key), and a closed room closes for watchers.
|
|
804
|
+
|
|
805
|
+
**Old games and old relays.**
|
|
806
|
+
- A game with a helper from before revision 5 is booted as a screen: it draws its overview, the
|
|
807
|
+
Worker's `w=1` makes the relay treat it as a watcher, and the page lists the players without
|
|
808
|
+
letting the watcher choose.
|
|
809
|
+
- A revision-5 helper on an older relay is a plain screen (no relay ever seated a screen); with
|
|
810
|
+
no `welcome.watch`, following is the page's to allow.
|
|
811
|
+
|
|
812
|
+
**Cost.** A watcher is one more socket on the fan-out (outgoing messages are not billed) and one
|
|
813
|
+
ping every 2 s (a request, billed 20:1). Like a big screen, a watcher alone keeps a room's object
|
|
814
|
+
awake; the room's host of last resort is the watcher's browser, running the bots.
|
|
815
|
+
|