@homie-rocks/studio 0.30.1 → 0.31.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
package/lib/routes.mjs ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * THE ROUTES IN A STUDIO'S WRANGLER CONFIG: WHICH ARE THE STUDIO'S, AND WHICH ARE NOT ITS TO TOUCH.
3
+ *
4
+ * A studio on a custom domain shares that domain's Cloudflare zone with whatever else its owner runs there, and a
5
+ * zone's Worker routes are one list for all of it. Three things went wrong with that:
6
+ *
7
+ * 1. `deploy` writes wrangler.jsonc again from studio.json, and it used to write it without `routes`. A studio whose
8
+ * owner had added the exact-host route its domain needed lost it at the next deploy, and the domain went back to
9
+ * whichever Worker the zone's wildcard route names.
10
+ * 2. A zone that already has a wildcard route (`*example.com/*`, the router of another site) answers the studio's
11
+ * hostname FIRST: a route is matched before a custom domain. The studio deployed fine and its own domain showed
12
+ * somebody else's "not found". Nothing said why, or that one exact-host route fixes it.
13
+ * 3. A config that itself names a catch-all (`*` + `/*`) hands every hostname of the zone to the studio's Worker
14
+ * the moment it deploys: another site on the zone stops answering.
15
+ *
16
+ * So: the routes the owner wrote for the studio's OWN hostnames (a custom domain, an exact host) are kept across
17
+ * every rewrite; a wildcard or catch-all route is never deployed by the studio unless studio.json says the zone is
18
+ * the studio's (`cloudflare.allowWildcardRoutes: true`); and a deploy whose domain answers as something else says
19
+ * which route is needed. Nothing here calls Cloudflare: a zone's other routes belong to other Workers, and the
20
+ * studio never lists, changes or removes one.
21
+ */
22
+ import { readFileSync } from 'node:fs';
23
+ import { configPath } from './studio.mjs';
24
+
25
+ /** wrangler.jsonc as an object (whole-line comments left out, as the toolkit writes it), or null. */
26
+ export function readConfig(root) {
27
+ try { return JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
28
+ }
29
+
30
+ /** Wrangler takes a route as a string or an object, in `routes` or the older single `route`: one shape for all. */
31
+ export function routesOf(config) {
32
+ const raw = [...(Array.isArray(config?.routes) ? config.routes : []), ...(config?.route ? [config.route] : [])];
33
+ return raw.map((r) => (typeof r === 'string' ? { pattern: r } : r)).filter((r) => r && typeof r.pattern === 'string' && r.pattern.trim());
34
+ }
35
+
36
+ /** The hostname part of a route's pattern ("play.example.com/*" gives "play.example.com"; a scheme is left out). */
37
+ export function routeHost(route) {
38
+ return String(route?.pattern ?? '').trim().replace(/^[a-z*]+:\/\//i, '').split('/')[0].toLowerCase();
39
+ }
40
+
41
+ /**
42
+ * What kind of route it is:
43
+ * 'custom-domain' `custom_domain: true`: Cloudflare makes the DNS record and the certificate for that one host
44
+ * 'exact-host' one hostname, any path ("play.example.com/*"): only that host's requests
45
+ * 'catch-all' every hostname of the zone ("*" + "/*")
46
+ * 'wildcard' a `*` in the hostname ("*example.com/*", "*.example.com/*"): many hosts, maybe not the studio's
47
+ */
48
+ export function routeKind(route) {
49
+ if (route?.custom_domain === true) return 'custom-domain';
50
+ const host = routeHost(route);
51
+ if (host === '*' || host === '') return 'catch-all';
52
+ return host.includes('*') ? 'wildcard' : 'exact-host';
53
+ }
54
+
55
+ /** Whether a route's hostname pattern covers a hostname ("*example.com" covers "play.example.com" and "example.com"). */
56
+ export function routeCovers(route, hostname) {
57
+ const pattern = routeHost(route);
58
+ if (!pattern) return false;
59
+ const re = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
60
+ return re.test(String(hostname ?? '').toLowerCase());
61
+ }
62
+
63
+ /**
64
+ * The config's routes, sorted into the studio's own (kept across every rewrite of wrangler.jsonc, exactly as written)
65
+ * and the wide ones (a wildcard or a catch-all).
66
+ */
67
+ export function sortRoutes(config) {
68
+ const own = [];
69
+ const wide = [];
70
+ for (const r of routesOf(config)) (['custom-domain', 'exact-host'].includes(routeKind(r)) ? own : wide).push(r);
71
+ return { own, wide };
72
+ }
73
+
74
+ /**
75
+ * The routes a rewrite of wrangler.jsonc carries over from the one on disk: the studio's own, and the wide ones
76
+ * only when studio.json says the whole zone is the studio's. Null when there are none, so a studio without routes
77
+ * gets byte for byte the config it always got.
78
+ */
79
+ export function keptRoutes(root, studio = null) {
80
+ const { own, wide } = sortRoutes(readConfig(root));
81
+ const kept = [...own, ...(studio?.cloudflare?.allowWildcardRoutes === true ? wide : [])];
82
+ return kept.length ? kept : null;
83
+ }
84
+
85
+ /**
86
+ * Why a deploy of this config would reach past the studio, or null. A wide route in the studio's own config makes
87
+ * its Worker answer for hostnames the studio does not have: said before anything is created or deployed.
88
+ */
89
+ export function wideRouteRefusal(config, studio = null) {
90
+ if (studio?.cloudflare?.allowWildcardRoutes === true) return null;
91
+ const { own, wide } = sortRoutes(config);
92
+ if (!wide.length) return null;
93
+ const names = wide.map((r) => `"${r.pattern}"`).join(', ');
94
+ const all = wide.some((r) => routeKind(r) === 'catch-all');
95
+ const keep = own.length ? ` Its own route${own.length === 1 ? '' : 's'} (${own.map((r) => `"${r.pattern}"`).join(', ')}) stay${own.length === 1 ? 's' : ''}, and every deploy keeps ${own.length === 1 ? 'it' : 'them'}.` : '';
96
+ return {
97
+ needs: 'cloudflare-routes', routes: wide.map((r) => r.pattern),
98
+ why: `wrangler.jsonc gives this studio's Worker the route${wide.length === 1 ? '' : 's'} ${names}: ${all ? 'a catch-all, which answers for EVERY hostname of the zone' : 'a wildcard, which answers for more hostnames than the studio\'s own'}. Deploying it would take those hostnames away from whatever serves them now (another site on the same domain stops answering), so nothing was deployed or changed on Cloudflare. Take ${wide.length === 1 ? 'that route' : 'those routes'} out of "routes" in wrangler.jsonc: a studio needs only its own hostname, as { "pattern": "<its hostname>", "custom_domain": true } or { "pattern": "<its hostname>/*", "zone_name": "<the domain>" }.${keep} If the whole zone really is this studio's, say so in studio.json ("cloudflare": { "allowWildcardRoutes": true }) and deploy again.`,
99
+ };
100
+ }
101
+
102
+ /** The apex a hostname most likely belongs to ("play.example.com" gives "example.com"): a suggestion, never a lookup. */
103
+ export function likelyZone(hostname) {
104
+ const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
105
+ return parts.slice(-2).join('.');
106
+ }
107
+
108
+ /** The exact-host route that makes a zone's Worker routes send one hostname to this studio's Worker. */
109
+ export function exactRouteFor(hostname, zone = likelyZone(hostname)) {
110
+ return { pattern: `${String(hostname).toLowerCase()}/*`, zone_name: zone };
111
+ }
112
+
113
+ /**
114
+ * The studio's custom domain answered, but not as this studio. With no exact-host route for that hostname in the
115
+ * config, the likeliest reason is another Worker's route on the zone that covers it (a wildcard, or a catch-all):
116
+ * a route is matched before a custom domain. Says which route is needed and that the other one is left alone; with
117
+ * the exact-host route already there, says to wait and what to look at instead.
118
+ */
119
+ export function shadowedDomain(config, hostname, answer = {}) {
120
+ const host = String(hostname ?? '').toLowerCase();
121
+ const own = sortRoutes(config).own;
122
+ const exact = own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host);
123
+ const said = answer.status ? `answered ${answer.status}${answer.said ? ` ("${answer.said}")` : ''}` : 'answered';
124
+ if (exact) {
125
+ return { shadowed: false, why: `https://${host} ${said}, not as this studio, although wrangler.jsonc has its exact-host route ("${exact.pattern}"). A new route can take a minute to apply; if it stays like this, look at the zone's Workers Routes in the Cloudflare dashboard for a more specific route on that hostname.` };
126
+ }
127
+ const route = exactRouteFor(host);
128
+ return {
129
+ shadowed: true, route,
130
+ why: `https://${host} ${said}, not as this studio: something else on the domain answers that hostname first. The usual reason is another Worker's route on the same zone that covers it (a wildcard like "*${route.zone_name}/*", or a catch-all): Cloudflare matches a route before a custom domain. The studio does not touch that route (it is not the studio's). Add the studio's own exact-host route to "routes" in wrangler.jsonc, which wins over a wildcard for this one hostname, and deploy again: ${JSON.stringify(route)} (zone_name is the domain as it is named in Cloudflare). Every deploy keeps it.`,
131
+ };
132
+ }
package/lib/scaffold.mjs CHANGED
@@ -36,6 +36,11 @@ import { themeFile } from './site.mjs';
36
36
  export const COMPAT_DATE = '2026-06-01';
37
37
  /** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
38
38
  export const WRANGLER_VERSION = '4.145.0';
39
+ /**
40
+ * The TypeScript a new studio asks for, so `build --types` (and an editor) has a compiler: the studio's own
41
+ * devDependency, never one of the toolkit's (a studio that never checks types pays nothing for it at runtime).
42
+ */
43
+ export const TYPESCRIPT_VERSION = '5.9.3';
39
44
 
40
45
  export function slugify(name) {
41
46
  return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
@@ -58,7 +63,7 @@ export function slugify(name) {
58
63
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
59
64
  * the binding names and keep it linked.
60
65
  */
61
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false }) {
66
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
62
67
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
63
68
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
64
69
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -73,6 +78,10 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
73
78
  compatibility_flags: ['global_fetch_strictly_public'],
74
79
  workers_dev: true,
75
80
  preview_urls: true,
81
+ // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
82
+ // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
83
+ // (lib/routes.mjs). A studio without routes gets no key at all.
84
+ ...(routes?.length ? { routes } : {}),
76
85
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
77
86
  durable_objects: { bindings: rooms },
78
87
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
@@ -132,7 +141,23 @@ studio's pinned copy, never a registry lookup of the bare name.
132
141
  how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
133
142
  owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
134
143
  plugin's \`port\` skill does the whole job.
135
- - \`npm run build\` — bundle every game into \`site/dist\`.
144
+ - \`npm run build\` — bundle every game into \`site/dist\`. It builds in a folder of its own and puts the result
145
+ in place only when all of it is there: a game that does not build fails the command (a non-zero exit) and
146
+ leaves \`site/dist\` exactly as it was, and nothing running from \`site/dist\` loses its files. Each game's line
147
+ says \`changed\`, \`unchanged\` or \`new\` (against the build before) and its build hash;
148
+ \`site/dist/_site/build.json\` keeps the same, and the live site says each game's hash in
149
+ \`/.well-known/homie-studio.json\` (\`games[].build.hash\`), so "is the live game the one I built?" is one
150
+ comparison. A game's bundle is named by its content (\`assets/main-<HASH>.js\`; its built \`index.html\` names
151
+ it, so write \`./assets/main.js\` in the game's own \`index.html\` as always), and \`await import('./later')\` in
152
+ a game becomes a file of its own that loads when asked for: a 3D game can show its first screen before the
153
+ rest of its code arrives.
154
+ - \`npx --no-install homie-studio build --types\` — the same, after checking the games' TypeScript (the build
155
+ itself only strips types; a type error ships otherwise). It uses the studio's own \`typescript\`
156
+ (\`npm install --save-dev typescript\` if this studio has none), reads a game's own \`tsconfig.json\` when it
157
+ has one, and a type error in a game's files stops the build.
158
+ - \`npx --no-install homie-studio preview <id>\` — one built game's files at an address on this computer
159
+ (\`http://127.0.0.1:8788/\`), nothing else: no Wrangler, no rooms, no database. For a capture, a screenshot or
160
+ a perf script; the game plays offline with its bots. Ctrl-C stops it.
136
161
  - \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
137
162
  Durable Object, D1): open the printed address in two browsers and they share a room.
138
163
  Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
@@ -199,14 +224,18 @@ studio's pinned copy, never a registry lookup of the bare name.
199
224
  page is not found.
200
225
  - **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
201
226
  (games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
202
- - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
203
- (\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
204
- with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
205
- live rooms, how to play, credits, and "Make a game like this". Give it words in game.json's \`landing\` block
227
+ - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own files in
228
+ \`games/<id>/hero/\`: \`wide.jpg\` (16:9, 1280×720 or larger) and \`tall.jpg\` (9:16, 720×1280, what an upright
229
+ phone gets) for a still, and \`wide.mp4\` and \`tall.mp4\` in the same shapes for footage (a silent loop of 8 to
230
+ 15 s, under 3 MB each); else a trailer in \`videos/\` with \`for.game\`; else its cover with slow motion. Any
231
+ size is shown, cropped to fill; one file may be 25 MiB at most. Then the pitch, a big Play button into a public room, phone / computer / TV with the join code,
232
+ live rooms, how to play and credits. Give it words in game.json's \`landing\` block
206
233
  (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
207
234
  - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
208
235
  page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
209
- \`site/theme.css\` (\`site/README.md\`). A game whose picture is white or cream gets a light landing with
236
+ \`site/theme.css\` (\`site/README.md\`). **The order of the games** everywhere (Home, the Games page, the cards,
237
+ the directory's manifest) is studio.json \`"site": { "order": ["<id>", "<id>"] }\`: those first, as named, the
238
+ rest by id; \`"site": { "featured": "<id>" }\` picks Home's hero. A game whose picture is white or cream gets a light landing with
210
239
  game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
211
240
  - **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
212
241
  a cover of its own shows the music manifest's \`cover\`, else its game's still.
@@ -243,14 +272,47 @@ studio's pinned copy, never a registry lookup of the bare name.
243
272
  Auto cuts to it (\`NETPLAY.md\` section 16). A game that never reads \`viewSeat\` is watched as its overview. A
244
273
  game with hidden hands or roles says game.json \`"watch": "overview"\` (the whole room only) or \`false\` (no
245
274
  watch door); a private or invite-only game is watched only by those it lets in.
275
+ - **A touch game guards its own page.** The play page refuses text selection, the long-press callout and page
276
+ gestures around the game's frame, but a long press INSIDE the game (a HUD label, a button's text, the canvas) is
277
+ the game's own: on a phone it raises copy/paste and the thumb's touch is lost. Call
278
+ \`guardGestures({ touch: 'canvas, [data-action]' })\` from \`@homie-rocks/studio/netplay\` once, early: no
279
+ selection, no callout, no pan or pinch on the gameplay surfaces; text fields, links and ordinary buttons keep the
280
+ browser's own behaviour (\`NETPLAY.md\` section 24). Try it with a real thumb on a real phone: emulated touch
281
+ does not raise the callout.
282
+ - **The frame has no storage of its own.** The game runs in a sandboxed frame without \`allow-same-origin\` (a
283
+ stranger's game must never read this site's storage or the owner's session), so \`localStorage\` throws inside it.
284
+ A setting or a personal best goes in \`net.prefs\` (\`await net.prefs.get('quality', 'high')\`,
285
+ \`net.prefs.set('quality', 'low')\`: the play page keeps 16 KB a game); progress that must last goes in saves.
286
+ The play page's \`?debug\` and \`?q=…\` reach the game as \`net.params\`, with any names game.json
287
+ \`"netplay": { "params": ["seed"] }\` declares (\`NETPLAY.md\` section 24).
288
+ - **game.json \`"netplay"\`** also takes \`"version"\` (the game's revision: bump it when a change makes an
289
+ already-open tab unable to play with a new one; rooms then run one build at a time and the old tab is told to
290
+ reload) and \`"stallMs"\` (1500 to 10000: how long a host may send no snapshot before the room is handed on, for
291
+ a heavy 3D game). \`NETPLAY.md\` sections 22 and 23.
246
292
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
247
293
  - A game's id is its URL (\`/<id>/\`); keep it once published.
248
- - **Remixing.** A public game shares its source at \`/games/<id>/source.json\`, with who made it (the studio, the
249
- game, its page) and the licence its owner picks in game.json \`"license"\`: \`"remix-with-credit"\` (the
250
- default), \`"remix-freely"\` or \`"no-remix"\`, and \`{ "kind": "…", "spdx": "MIT" }\` to name a licence too.
251
- \`npx --no-install homie-studio game remix <source.json> --id <new id>\` brings another studio's game in as a new
252
- game here: its game.json \`remixOf\` says "Remix of <game> by <studio>" with a link back, and its landing and
253
- credits show it; keep that credit. A game whose licence says no remix is refused.
294
+ - **A game's own licence.** game.json \`"license": "MIT"\` (any SPDX identifier) is said on the game's page; a game
295
+ that names none says nothing. It is a statement about the game, not an offer of its source: no game is handed
296
+ over whole. A game.json that still has \`"remix"\`, \`"share"\`, \`landing.make\` or one of the old licence words
297
+ builds as before with one note (remix was retired; delete them). A game that has \`"remixOf"\` was made from another
298
+ studio's game: keep it, it is the credit its landing and credits show ("Based on <game> by <studio>").
299
+ - **A 3D game's models.** \`createModels()\` warns in development about a model over 1,500 triangles or 300 KB,
300
+ which is a small prop's budget. A game whose models are bigger on purpose says so once in its game.json:
301
+ \`"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }\` (per model; \`texturePx\` and \`materials\`
302
+ too). A game copied from a 3D starter has \`"assets": "library"\` there, which only mattered when it was copied:
303
+ replace it with the object.
304
+ - **Parts: how games build on each other.** A part is a piece of a game its studio chose to share (a creature, a
305
+ level generator, a camera, a bot brain, an audio pack), never the whole game. **Before writing a system from
306
+ scratch, look for one**: the \`@homie-rocks/*\` packages (npm) have the general mechanism (camera, input, audio,
307
+ effects), and \`parts_find\` searches the pieces other studios shared; packages are not parts. \`part_add\` with
308
+ \`"<studio site>/<part id>"\` fetches one exact version, checks every file, copies it into \`parts/_vendor/\` where
309
+ the studio owns and tunes it (its \`tuning.json\` survives a newer version), credits it in the game, and lets npm
310
+ install the packages it builds on; the game imports it in one line, \`import … from '@parts/<studio site>/<part
311
+ id>'\`. Write in the game's CODEX (Built from) what came from where. A reusable piece of this studio's own game
312
+ becomes a part with \`part_new\` (it is lifted out and the game keeps working). **A part is private until the
313
+ person asks to share it** (\`part_share\`, with an SPDX licence they picked and who to credit); it is live after
314
+ the next deploy. Without the chat tools, the same jobs are \`npx --no-install homie-studio parts
315
+ find|add|new|share\`. The design is \`node_modules/@homie-rocks/studio/parts/PARTS.md\`.
254
316
  - **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
255
317
  a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
256
318
  \`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
@@ -301,8 +363,7 @@ studio's pinned copy, never a registry lookup of the bare name.
301
363
  lean, look-at, crowds; every number a Game Lab tunable); \`game new <id> --from hero-rush-3d\` starts one. The
302
364
  plugin's \`animate\` skill has the rest.
303
365
  - **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
304
- generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
305
- checked by SHA-256, and a grey placeholder for the rest.
366
+ generated); \`publish\` refuses an asset with no licence record.
306
367
 
307
368
  ## Running live games (the back office)
308
369
 
@@ -310,12 +371,12 @@ studio's pinned copy, never a registry lookup of the bare name.
310
371
  the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
311
372
  sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
312
373
  the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
313
- room; and per game: launch state, Remixable, players per room and invites.
374
+ room; and per game: launch state, players per room and invites.
314
375
  - **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
315
376
  in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
316
377
  as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
317
378
  the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
318
- from its first deploy with \`"launch": "private"\` in its game.json. **Remixable** publishes or withdraws its source.
379
+ from its first deploy with \`"launch": "private"\` in its game.json.
319
380
  - The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
320
381
  Mute, Kick; Announce). Nobody else's page has it.
321
382
  - **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
@@ -422,11 +483,16 @@ chat's card follows the work from then on) and, for a studio still being set up,
422
483
  studio.json as \`cloudflare.domain\`.
423
484
  - A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
424
485
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
486
+ - This studio is this folder. A studio in a folder beside it (another folder with its own \`studio.json\`) is
487
+ somebody else's work: never read it or copy from it unless the person asks.
425
488
  - Optional in studio.json: \`"protect"\` (globs of files the owner wants to look at before any change, such as
426
489
  \`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
427
490
  in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
428
- the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
429
- you meant to do and why; do not retry it unless the person asks.
491
+ the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
492
+ the same calls until the person's own \`proceed <code>\`, once the person has trusted them; in Grok Build nothing
493
+ holds them yet. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
494
+ holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
495
+ meant to do and why; do not retry it unless the person asks.
430
496
 
431
497
  ## Telling Homie
432
498
 
@@ -480,7 +546,7 @@ That line is all the session is given. The brief (what the person asked for, in
480
546
 
481
547
  1. \`npm install\` (once per session).
482
548
  2. \`npx --no-install homie-studio handoff hb_…\` prints the brief and the steps for this kind of build (a new
483
- studio's first session, a new game, a port, a remix or a change). It takes the build once, so the chat's card
549
+ studio's first session, a new game, a port or a change). It takes the build once, so the chat's card
484
550
  follows the work, and for a studio still being set up it checks in from this repository.
485
551
  3. If it says this session's network does not reach homie.rocks and the Homie connector's tools are in this session,
486
552
  call \`build_progress\` with \`{ "build": "hb_…" }\`: its answer carries the same brief. Otherwise tell the person
@@ -549,7 +615,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
549
615
  | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
550
616
  | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
551
617
  | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
552
- | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). |
618
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
553
619
  | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). A \`robots.txt\`, \`sitemap.xml\` or \`llms.txt\` here replaces the one the site makes. |
554
620
 
555
621
  \`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
@@ -615,7 +681,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
615
681
  private: true,
616
682
  type: 'module',
617
683
  scripts: { dev: 'homie-studio dev', build: 'homie-studio build', deploy: 'homie-studio deploy', check: 'homie-studio check', studio: 'homie-studio' },
618
- devDependencies: { '@homie-rocks/studio': packageSpec(homie), wrangler: WRANGLER_VERSION },
684
+ devDependencies: { '@homie-rocks/studio': packageSpec(homie), typescript: TYPESCRIPT_VERSION, wrangler: WRANGLER_VERSION },
619
685
  // What Cloudflare's "Deploy to Cloudflare" form says about each setting (package.json `cloudflare.bindings`).
620
686
  cloudflare: {
621
687
  label: 'Homie studio',