@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/README.md CHANGED
@@ -6,7 +6,7 @@ contract (every browser renders, strangers meet in public rooms, bots fill seats
6
6
  restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
7
7
  to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
8
8
 
9
- Most people never run this by hand: the Homie plugin for Claude Code and Codex does,
9
+ Most people never run this by hand: the Homie plugin for Claude Code, Codex and Grok does,
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
@@ -15,6 +15,7 @@ cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
+ npx homie-studio shoot crown-thief --url http://127.0.0.1:8787 # frames on a stepped clock (works without a GPU) and a two-client seat smoke check
18
19
  npx homie-studio perf crown-thief --url http://127.0.0.1:8787 # how fast it runs: a computer and an emulated phone, host and replica
19
20
  npx homie-studio lab crown-thief # the Game Lab: how a move feels, New beside Today, frame by frame
20
21
  npx homie-studio style board crown-thief # its look as decisions: three directions drawn by the engine (free)
@@ -42,7 +43,7 @@ npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" #
42
43
  ```
43
44
 
44
45
  The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
45
- the live demo, make / remix / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
46
+ the live demo, make / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
46
47
  playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
47
48
  their provider is set up. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and
48
49
  input shape. Long work (npm install, a check, a deploy) runs in the background and reports through the progress
@@ -99,10 +100,11 @@ featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every
99
100
  joinable and watchable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
100
101
  page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
101
102
  pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
102
- live rooms, credits (the original and its licence for a port; "Remix of <game> by <studio>", linked, for a
103
- remix), and "Make a game like this". A game's shared source (`/games/<id>/source.json`) says who made it and the
104
- licence its owner picked in game.json `"license"`: `"remix-with-credit"` (the default), `"remix-freely"` or
105
- `"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
103
+ live rooms and credits (the original and its licence for a port; "Based on <game> by <studio>", linked, for a
104
+ game that was made from another studio's). game.json `"license": "MIT"` (an SPDX identifier) is the licence the game
105
+ names for itself, said in its credits; a game that names none says nothing. No game's source is served: games build on
106
+ each other through parts ("Game parts", below). studio.json `"site": { "order": ["<id>", …] }` is the
107
+ order of the games in every list, the directory's manifest included. Every page ends with "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
106
108
  files. `site/SITE.md` says all of it.
107
109
 
108
110
  The play page never opens blank (0.26.0): from its first paint an arrival card shows the game's own look (its title,
@@ -118,7 +120,7 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
118
120
 
119
121
  Search engines and AI agents read a studio correctly (0.27.0): every generated page carries schema.org JSON-LD
120
122
  (`worker/schema.mjs`: the studio's `Organization` and `WebSite` on Home, a full `VideoGame` on each landing with its
121
- players, platforms, licence, remix original, trailer, dates and a free-to-play offer whose add-ons are what the shop
123
+ players, platforms, licence, trailer, dates and a free-to-play offer whose add-ons are what the shop
122
124
  really sells, `MusicRecording`, `VideoObject`, `BlogPosting`, `ItemList` and `BreadcrumbList`; never a rating), and the
123
125
  site makes `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt` from its public catalogue
124
126
  (`worker/discover.mjs`). `lib/schema-check.mjs` checks every block against schema.org's own vocabulary and what Google
@@ -155,6 +157,29 @@ shows how it differs from the template. `--apply` also pins this version in pack
155
157
  `homie.studio`. When the template's words change, `node scripts/studio-template-history.mjs` records them (a test
156
158
  fails until it has).
157
159
 
160
+ ### Remix was retired
161
+
162
+ Handing over a whole game (`game remix`, the `game_remix` tool, a game's `/games/<id>/source.json`, the office's
163
+ Remixable switch, the "Make a game like this" band) is gone. Games build on each other through **parts**: pieces of a
164
+ game its studio chooses to share, each with its own licence ("Game parts", below). For a studio that upgrades:
165
+
166
+ - **Old settings still build.** A game.json that carries `"remix"`, `"share"`, `landing.make`, or a `"license"` that
167
+ is one of the old words (`"remix-with-credit"`, `"remix-freely"`, `"no-remix"`, or an object with `"kind"`) builds
168
+ as before. The keys do nothing, and the build says so in one note that lists them; delete them when convenient. An
169
+ SPDX identifier in `"license"` (`"MIT"`, or `{ "spdx": "MIT" }`) is still the licence the game names on its page.
170
+ - **A game that was a remix keeps its credit.** game.json `"remixOf"` is still read: the landing and the credits page
171
+ say "Based on <game> by <studio>", linked to the original. That credit is owed, so leave the key where it is. The
172
+ files such a game carried keep saying whose they were in its `assets/RIGHTS.md`.
173
+ - **No source is served.** The build writes no `source.json` or `assets.json` and takes out the ones an older build
174
+ left. `/games/<id>/source.json` answers 410 with `{ "retired": "remix", "see": "/parts/" }`, and
175
+ `/.well-known/homie-studio.json` no longer has `remix`, `source` or `remixOf`. The first deploy after the upgrade
176
+ asks the directory to read a listed studio again, once, so it drops the old offer.
177
+ - **Nothing is dropped from the database.** The office's `office_games.remix` column stays in D1 with whatever it
178
+ held; nothing reads or writes it.
179
+ - **Asset records.** `license.remix` on an entry of `assets/manifest.json` is ignored and no longer written, and
180
+ RIGHTS.md has no "A remix" line. Whether a file may be handed on in a shared part is still its licence's.
181
+ - `homie-studio game remix` answers with a sentence that points to parts; `office launch --remixable` is ignored.
182
+
158
183
  ## Rooms of up to 32
159
184
 
160
185
  Every room of a game has the seats its netplay manifest names: game.json's `netplay`
@@ -164,6 +189,24 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
164
189
  the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
165
190
  on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
166
191
 
192
+ ## A game's netplay manifest
193
+
194
+ game.json `"netplay"` says more than the room's size. All of it is optional, and `homie-studio build`
195
+ writes it to the catalogue for the site's rooms (`netplay/NETPLAY.md` sections 22 to 24):
196
+
197
+ | Field | What it does |
198
+ |---|---|
199
+ | `maxPlayers` | Seats in a room, up to 32. |
200
+ | `version` | The game's revision (a word or a number). Bump it when a change makes an already-open tab unable to play with a new one: a room then runs one build at a time, strangers are matched within the live build, and an old tab is told to reload at its round's break. |
201
+ | `stallMs` | How long a host may send no snapshot before the room is handed to another browser: 1500 (the default) to 10000. A heavy 3D game whose frames hitch on a slow phone raises it; the helper also sends a heartbeat for up to 4 s while a host's frames are stuck. |
202
+ | `params` | Names of the play page's query parameters to hand the game (`/<id>/play?seed=42`), beside `debug` and `q`, which always are. The game reads them as `net.params`. |
203
+
204
+ The game's frame is sandboxed without `allow-same-origin` (it must never read the site's storage or
205
+ the owner's session), so `localStorage` throws inside it: a setting or a personal best goes in
206
+ `net.prefs` (the play page keeps 16 KB a game), and progress that must last in saves, below.
207
+ `net.link` says whether the browser is in its room, reconnecting, or playing alone because the room
208
+ never answered; `guardGestures()` stops a long press on a phone from selecting text in a touch game.
209
+
167
210
  ## Player accounts and cloud saves
168
211
 
169
212
  A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
@@ -228,7 +271,7 @@ it):
228
271
  - **Close** a room: everyone is sent out with a thank-you; nobody gets in until it opens.
229
272
  - Per game: **players per room**, the **launch state** (`private`: only the owner;
230
273
  `invite`: an invite-only beta, where each invite link or code lets a browser in;
231
- `public`: anyone, listed), and **remixable** (publish or withdraw the game's source).
274
+ `public`: anyone, listed).
232
275
  A game that is not public is in no list and not in the directory manifest, so the
233
276
  directory drops it the next time it reads the studio. A new game is private from its
234
277
  first deploy with `"launch": "private"` in its game.json.
@@ -442,6 +485,23 @@ one token out: about 0.1 of the 10,000 free neurons a day; `lib/brain-probe.mjs`
442
485
  Workers Paid (error 5035) or retired (5007) is named, with what to do: pick another model, or, the person's money,
443
486
  Workers Paid. A spent daily allowance (3036) is said as that, not as a broken model.
444
487
 
488
+ A missing connector never blocks (0.30.2): a session that can run commands makes, deploys and lists a studio with
489
+ this toolkit alone (`new`, `deploy`, `publish`), and the connector row says so. With `--client codex` or
490
+ `--client grok` (in Codex the toolkit also reads the app from its environment) two things more:
491
+
492
+ - **The connector's name.** The Homie plugin's connector is the MCP server `homie`. When the app's own
493
+ configuration (`config.toml` in its home folder, or `.codex/` or `.grok/` in this folder) already has another
494
+ server of that name, the plugin's does not load. The row says so and gives the command that adds Homie's connector
495
+ under its own name (`codex mcp add homie-rocks --url https://homie.rocks/mcp`). It reads only whether that entry is
496
+ an address or a command; the command itself is never read out.
497
+ - **Homie's holds**: whether the plugin's hooks ran in this app in the last ten minutes, from the dated mark
498
+ they leave in `.cache/homie-studio/holds/` of the home folder. Off means nothing is held: Codex runs no plugin's hooks
499
+ until the person trusts them in `/hooks`, and the row says so; Grok Build 1.0.41 runs no plugin's hooks at all, so
500
+ there the row is off until a Grok that does.
501
+
502
+ `homie-studio demo` answers without a network too (0.30.2): where it cannot reach the arcade it names the arcade's
503
+ standing first pick, with `reached: false`.
504
+
445
505
  ## The Game Codex
446
506
 
447
507
  `games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
@@ -461,8 +521,8 @@ redraws itself whenever the feed changes and refreshes in the browser while a bu
461
521
  puts each game's codex on the site at `/_studio/codex/<id>/`, served only to the studio's owner (the same
462
522
  one-time sign-in as the stats page), never listed or indexed. Text is escaped and the page runs only its own
463
523
  script (the site allows it by its hash); pictures and fonts come only from inside the studio and are
464
- embedded, so it loads nothing but Google Fonts. CODEX.md is never in a game's remix source or among a
465
- static game's served files.
524
+ embedded, so it loads nothing but Google Fonts. CODEX.md is never among a static game's
525
+ served files.
466
526
 
467
527
  ## The Claude Code status line
468
528
 
@@ -502,7 +562,7 @@ one runs, a shared feed asks whether Stop was pressed; a stopped command closes
502
562
  browsers and says so, and nothing already built or deployed is undone.
503
563
 
504
564
  **From the Claude app.** The chat can open the build first (the Homie MCP tool `build_open`, or the "Build it"
505
- card on a make, port or remix) and hand the work to a Claude Code session with a prefilled prompt. The session
565
+ card on a make or a port) and hand the work to a Claude Code session with a prefilled prompt. The session
506
566
  takes the build once, with no key in the prompt, and the card follows it; the change goes out as a pull request
507
567
  the person merges with one tap:
508
568
 
@@ -547,12 +607,99 @@ hours so the Claude app can show it: the feed only (plain bounded text, pictures
547
607
  never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
548
608
  feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
549
609
 
550
- ## The site's address
610
+ ## Running it here: `homie-studio dev`
611
+
612
+ ```sh
613
+ npx homie-studio dev [--port 8787] [--timestamps] # the whole site on this computer, until it is stopped
614
+ npx homie-studio dev --stop # this studio's dev server only
615
+ ```
616
+
617
+ Every command needs Node.js 22 or newer and says so at once on an older one, before it does anything.
618
+
619
+ **Rooms here are always local.** A studio that is live on its own domain has routes in `wrangler.jsonc`, and Wrangler
620
+ serves a config with a route as that production hostname: games would be handed a room socket on the live site. So
621
+ `dev` runs Wrangler from a copy of the config without `routes` (in `.wrangler/`; `wrangler.jsonc` is never changed),
622
+ then reads one game's page back and checks that the room socket it hands out is this computer's own address, port
623
+ included. If it is not (a `dev.host` in the config, say), `dev` stops and says so. No wrapper script is needed for a
624
+ route-free preview.
625
+
626
+ **A game added while it runs** is picked up. `dev` serves what `build` wrote: a new game folder that is not built is
627
+ named with the command, and once it is built, a running site that still answers 404 for it is restarted by `dev`
628
+ itself, on the same address.
629
+
630
+ **Stopping, and stale state.** `dev --stop` stops exactly the processes this studio's dev server registered, and
631
+ nothing else. A server stopped another way (a closed terminal, a kill by process id or by port) leaves its
632
+ registration behind; `dev` and `dev --stop` both notice, remove it and say it was stale, stop a local runtime left on
633
+ the port when it runs from this studio's folder, and name anything else on the port without touching it. Never stop
634
+ a dev server with `pkill` or by port: another project's would go too.
635
+
636
+ The registration is one file, `<folder of wrangler.jsonc>/.wrangler/homie-dev.json`, and it is the whole contract:
637
+
638
+ ```json
639
+ { "pid": 4242, "child": 4243, "port": 8787, "at": "2026-10-06T12:00:00.000Z" }
640
+ ```
641
+
642
+ `pid` is the process that owns the server, `child` the Wrangler it started (optional), `port` the local port, `at`
643
+ when it started. `dev --stop` and the MCP tools `preview_run` / `preview_stop` read only this. A process it names is
644
+ signalled only while it is still this studio's: alive, and its command line names the studio's folder, or its working
645
+ directory is inside it, or it is still the kind of program its key says (`pid`: homie-studio; `child`: wrangler or
646
+ workerd). So a studio's own wrapper script that writes this file, and is started from the studio's folder, is stopped
647
+ by `dev --stop` like the built-in one; it should delete the file when it ends.
648
+
649
+ **Room lines carry a time.** Wrangler prints a room socket opening (`GET /<game>/__net 101`) and a runtime error such
650
+ as `Network connection lost` with no time on them. `dev` puts this computer's clock in front of those lines (every
651
+ line with `--timestamps`), so they can be laid beside a check's own report. A connection-loss line is printed when a
652
+ browser or a host leaves abruptly, and also when a room operation really failed: the time is what tells them apart.
653
+
654
+ ## The site's address, its routes, and what a deploy says
551
655
 
552
656
  `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
553
657
  names the Cloudflare account, often after its owner. A custom domain goes in studio.json
554
658
  as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
555
659
 
660
+ **Routes.** `deploy` writes `wrangler.jsonc` again each time, and keeps the routes that are the studio's own exactly
661
+ as they were written: a custom domain (`{ "pattern": "play.example.com", "custom_domain": true }`) and an exact-host
662
+ route (`{ "pattern": "play.example.com/*", "zone_name": "example.com" }`). A zone's other routes are never listed,
663
+ changed or removed. When the zone already has a wildcard route on another Worker (`*example.com/*`), that Worker
664
+ answers the studio's hostname first; the deploy notices that its own domain answers as something else and prints the
665
+ exact-host route to add, once. A wildcard or catch-all route (`*/*`) in the studio's own config would hand every
666
+ hostname of the zone to the studio's Worker, so `deploy` refuses it before it asks Cloudflare anything; a studio that
667
+ really owns the whole zone says so in studio.json (`"cloudflare": { "allowWildcardRoutes": true }`).
668
+
669
+ **One deploy at a time.** A second `deploy` of the same studio is refused while one runs (`.studio/deploy.lock` names
670
+ its process and start time); a lock left by a deploy that was killed is taken over by the next one.
671
+
672
+ **Which games changed.** Each deploy prints, per game, `changed`, `unchanged` or `new` against the last deploy from
673
+ this computer, and the game's **build hash**: the one `homie-studio build` printed for it, that
674
+ `site/dist/_site/build.json` keeps, that `perf` names a run's build by, and that the live site answers with in
675
+ `/.well-known/homie-studio.json` (`games[].build.hash`). There is one hash, so a local build is matched to what is
676
+ live by comparing two strings. `build` says changed "since the last build here"; `deploy` says changed "since the
677
+ last deploy from this computer", from its own record in `.studio/local.json`. A deploy from another computer, or the first one, has nothing to compare with and says so.
678
+
679
+ **The directory.** Going online lists nothing. For a studio this computer already listed with `publish`, the first
680
+ deploy after an older toolkit's (one that still offered a game's whole source) asks the directory to read the studio
681
+ again, once, so it does not keep that offer. `publish` says how many publishes are left today when the directory gives the number (the
682
+ beta has a daily cap), and before it sends, how many this computer has sent today; a refusal for the cap is said as
683
+ that, with when it ends.
684
+
685
+ **This computer's network.** When Node.js cannot look a public hostname up (a browser on the same computer may still
686
+ open it), the command says "network preflight failed", not that the site or a game is broken, and offers the local
687
+ dev site instead.
688
+
689
+ ## A trailer: `homie-studio trailer`
690
+
691
+ ```sh
692
+ npx homie-studio trailer crown-thief --url http://127.0.0.1:8787 --seconds 45 --length 20 --title "CROWN THIEF"
693
+ ```
694
+
695
+ One command, into `videos/crown-thief-trailer/`: the game rendered frame by frame on a virtual clock (its
696
+ `requestAnimationFrame`, `performance.now`, `Date`, timers and CSS and Web animations all move 1/30 s a frame, so no
697
+ frame is held however heavy the game), its sound rebuilt from the game's own sound files and the log its sound player
698
+ keeps of what it played, the shots picked from that log, an end card, and 16:9, 1:1 and 9:16 files. The work is the
699
+ Homie plugin's video skill; this command finds its script and hands over (`--skills <folder>` when the plugin is not
700
+ beside the package). The skill's guide says what the virtual clock does not reach (the audio clock, media elements,
701
+ Web Workers, a live server) and what a game must do for its sound to be in the film.
702
+
556
703
  ## How fast it runs: `homie-studio perf`
557
704
 
558
705
  ```sh
@@ -568,15 +715,28 @@ on a fast computer is less than its rate). They play the same seeded presses thr
568
715
  window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
569
716
  and 50 ms), the game's JavaScript per frame (every requestAnimationFrame callback timed), the main thread per frame
570
717
  (Chrome's TaskDuration), time to the first meaningful frame (`load.look`: the play page's arrival card), to the
571
- game's first frame, to a seat and to playable, the game's files on the wire, the heap
572
- after a garbage collection, and netplay messages and kilobytes a second each way. `--profile` adds a CPU profile per
718
+ game's first frame, to a seat and to playable (control-ready: seated, a body reported, the loading cover gone) and,
719
+ beside it, `load.ready` (when the game itself called `net.playable()`) with the arrival mode (`load.arrival`: `auto`,
720
+ `game`, or `seat` for an older helper; under `auto`, `lateMs` is how long after the cover lifted the game said it was
721
+ ready, which is a game that should declare `arrival: 'game'`), the game's files on the wire, the heap
722
+ after a garbage collection, and netplay messages and kilobytes a second each way. Two more are measurements of the
723
+ running game, not estimates from its files: `load.prePlay` (every request the browser really made up to playable,
724
+ with the bytes that had arrived of any still in flight; the metric is `load.prePlayKb`), which is first-play traffic
725
+ and not the shipped payload that `perf sizes` and `assets check` total from the built folder; and `render` (the
726
+ renderer's own draw calls and triangles while playing, metrics `render.calls` and `render.triangles`), read when the
727
+ game exposes them on its port probe (`exposePort(net, { extra: { drawCalls: () => renderer.info.render.calls,
728
+ triangles: () => renderer.info.render.triangles } })`, as the 3D starters do; `PortExtra` in the port kit types these
729
+ and the other names the playtest reads: `alive`, `mode`, `loadout`, `touchHeld`). A game that exposes neither has no
730
+ `render` numbers: the run says the cost was not measured, never zero. `--profile` adds a CPU profile per
573
731
  browser in a window of its own (a `.cpuprofile` for Chrome DevTools) and its hottest functions, read through the
574
732
  game's source map when the build kept one (`homie-studio build --maps` keeps it in `.studio/maps/<id>/`, never in
575
733
  `site/dist`).
576
734
 
577
735
  The computer is shared, so every run waits for the 1-minute load to fall under 0.8 per core (`--max-load`) and
578
736
  records the load before and after; a run that started busy says `loaded` and `compare` leaves it out. A software
579
- renderer (SwiftShader) makes the run `blocked`: nothing on it is judged. `compare` calls a metric better only when a
737
+ renderer (SwiftShader) makes the run `blocked`: nothing on it is judged. So does an address this computer's Node.js
738
+ cannot look up (Node and a browser do not look names up the same way): "network preflight failed", with the local
739
+ site as the way round it, never "the site is down". `compare` calls a metric better only when a
580
740
  rank test says it is unlikely to be chance (p < 0.05), the 95% bootstrap interval of the change stays below zero, and
581
741
  it is at least 3% better; runs taken side by side with the same `--pair` label are judged as pairs (a signed-rank
582
742
  test on their ratios), so a computer that drifted busier cancels. Guards (frame time, main thread per frame, time to
@@ -590,6 +750,48 @@ Minified JavaScript gzips about as well as source text, and a bundle with three.
590
750
  source in strings, which no minifier touches; the line for such a file says `minified, 19% GLSL shader source in
591
751
  strings`, and the JSON's `code` has every measure.
592
752
 
753
+ ## Game parts: how games build on each other
754
+
755
+ Games build on each other by sharing **parts**: a piece of a game that its studio chooses to share, so somebody
756
+ making another game can use it. A creature from one game, a level from another, a bot brain from a third. Without
757
+ parts, a creator's AI has to come up with everything on its own. [`parts/PARTS.md`](parts/PARTS.md) is the design;
758
+ the plugin's `parts` skill is the method.
759
+
760
+ Creators work in chat and never type a command: "what parts are there for a chase camera?", "add the Pickup field
761
+ part from owls.example/pickup-field to my game", "make my creature a part", "share it under CC BY". Four tools do
762
+ it, named like the asset tools: `parts_find`, `part_add`, `part_new` and `part_share`.
763
+
764
+ - **A part is a piece of a game, never the whole game**, in whatever form suits it: code, assets, data, a JSON
765
+ description, a tuned config, or a mix.
766
+ - **Agents look first.** `game_plan` and the game and plan skills tell an agent to check the `@homie-rocks/*`
767
+ packages for the general mechanism and `parts_find` for pieces other studios shared, before writing from
768
+ scratch, and to record in the game's CODEX (Built from) and its credits what came from where. `parts_find` reads
769
+ the hub's catalogue and the studio's own parts; when the hub cannot be reached it says so and still answers with
770
+ the studio's own, so an empty list never stands in for "could not look".
771
+ - **Packages are not parts.** `@homie-rocks/*` packages come from npm and are never in the catalogue. A part says
772
+ which packages it builds on, and npm does the rest: `npm ls` says whether the studio has one, `npm install`
773
+ installs what is missing after one line saying what and why, and npm's own output is relayed. There is no
774
+ resolver, no version ranges and no lockfile of Homie's.
775
+ - **Use.** `part_add` fetches one exact version (the latest shared unless named), checks every file's SHA-256 and
776
+ size before a byte is written, copies it into `parts/_vendor/<site>/<id>/` where the studio owns and tunes it,
777
+ records where it came from (`parts/origins.json`), and credits it in the game's `credits.json` with its licence,
778
+ who made it and the game it came from. The game imports it in one line,
779
+ `import { createPickupField } from '@parts/owls.example/pickup-field'` (one of the studio's own is
780
+ `@parts/<id>`). Adding it again brings the newer version: what changed is shown, `tuning.json` is kept, and a
781
+ file the studio edited is never replaced without saying so.
782
+ - **Share.** `part_new` lifts a piece out of a game (the game still builds and plays the same) and records the
783
+ game and studio it came from. A part is private until `part_share`, which needs an SPDX licence, an attribution
784
+ when the licence asks for credit, and known rights to every file. It is live after the studio's next deploy.
785
+ - **What the site serves** is `sharedPartsOf(studio)` and nothing else: `/.well-known/homie-parts.json`, each
786
+ shared version's content at `/parts/<id>/<version>/…` exactly as hashed (immutable, CORS open for GET), the
787
+ studio's own pages `/parts/` and `/parts/<id>/`, and a **Parts from this game** band on a game's landing. A
788
+ private part is never copied into the built site, and the Worker answers only what the built index names.
789
+ - **Publishing a game that uses parts**: `deploy --plan` shows each part's licence and attribution and says
790
+ plainly when licences cannot be combined; `publish` (and `studio_publish`, also with `before: true`) says the
791
+ same lines before the studio is listed. `parts add --no-install` brings a part in and installs nothing:
792
+ `package.json` is left as it is, and a package the part builds on that the studio lacks is named NOT INSTALLED
793
+ with the npm command that installs it.
794
+
593
795
  ## How it feels: the Game Lab (`homie-studio lab`)
594
796
 
595
797
  ```sh
@@ -642,7 +844,9 @@ npx homie-studio assets find "fox" --kind creature # the starter library
642
844
  npx homie-studio assets add crown-thief kenney-cube-pets/animal-fox --height 0.7 --card "Characters/Fox"
643
845
  npx homie-studio assets add crown-thief --file ./lantern.glb --license own --height 0.6
644
846
  npx homie-studio assets check crown-thief # budgets, the glTF-Validator, licences, staleness, big files
847
+ npx homie-studio assets use crown-thief old-tree unused # what the game does with it now: cast, prop, environment, unused
645
848
  npx homie-studio assets lineup crown-thief # true scale, silhouettes, palette drift: .studio/art/<id>/
849
+ # three lineups: the inventory (every recorded model), the cast (what is in play), the environment (the scenery)
646
850
  npx homie-studio assets rights crown-thief # games/<id>/assets/RIGHTS.md and the credits
647
851
  npx homie-studio assets redo crown-thief lantern # made again from its kept raw file, free
648
852
  npx homie-studio assets remove crown-thief lantern # the record and the copy the game ships
@@ -657,14 +861,40 @@ what remaking it would cost; nothing is remade by itself. `games/<id>/style.json
657
861
  and camera the game, its landing and its title cards draw with.
658
862
 
659
863
  Every model, texture and sky a game ships has an entry in `games/<id>/assets/manifest.json`: its route (procedural,
660
- library, generated, imported), each step that made it, its licence and what a remix gets, its measurements. From it:
661
- `RIGHTS.md`, the credits on the game's landing, `assets check`, the licence check `publish` makes, and
662
- `/games/<id>/assets.json` on the site, from which `game remix` fetches every model its licence lets a remix carry
663
- (checked by SHA-256; a grey placeholder of the same size for the rest). Models are refused when they load anything
864
+ library, generated, imported), each step that made it, its licence, its measurements. From it:
865
+ `RIGHTS.md`, the credits on the game's landing, `assets check` and the licence check `publish` makes. Models are refused when they load anything
664
866
  from an address or are over the size caps; `assets add` makes them phone-sized (meshopt geometry, WebP pictures, the
665
867
  pivot at the bottom centre, scaled to metres) with @gltf-transform, meshoptimizer and sharp; raw files stay in
666
868
  `art/<slug>/raw/`, which git ignores.
667
869
 
870
+ `assets check` is an **inventory estimate**, and says so in everything it prints: the recorded files' own triangles,
871
+ draw calls and pictures, each times its declared `placements`. It never runs the game, so geometry the code builds,
872
+ copies the code draws beyond `placements`, effects, and shadow and post passes are not in it; a pass is not a measured
873
+ frame (the playtest and perf guides measure the running game). What it does measure:
874
+
875
+ - **Standalone textures and skies** (a `texture` or `sky` record's own pictures): there, the file recorded (SHA-256),
876
+ decodable, within size (a note past 1,024 px, a problem past 2,048 px; a sky twice that), and their mipmapped memory
877
+ added once per unique picture. A row with nothing measured prints `??` and why, never `ok`. Shipped pictures under
878
+ `public/` that no record names are listed with their size, apart from pictures the folder keeps but never ships;
879
+ neither is in the sum.
880
+ - **Maps the game does not draw.** A model's normal and roughness maps are counted unless the manifest says the game
881
+ draws base colour only (`stylize()` with a toon, flat, pixel or hand-painted material model keeps nothing else):
882
+ `"inGame": { "maps": "base" }` on a record, or once at the top of `assets/manifest.json` for every model
883
+ (`"maps": "all"` on a record keeps its own). The check never guesses this from the code; without the declaration it
884
+ says how many MB are such maps.
885
+ - **The game's own model budget.** game.json `"assets": { "budgets": { "triangles": 4000, "bytes": 600000 } }` (the key
886
+ the loader's development warnings read) raises what a model may be in the check and in `assets add`, never past a
887
+ hard cap and never lowering a hero's. One key, read once: the check, `assets add` and the build (for the loader)
888
+ take the same numbers. A budget may also be set for one tier (`"hero": { "triangles": 12000 }`; tiers are hero,
889
+ npc, prop, signature, kit); the loader knows no tiers, so it is given the loosest one, and a number past a hard cap
890
+ is held to the cap by all three (the build says so).
891
+ - **Usage.** `assets use <game> <asset> unused` marks a file the game no longer draws: it stays in the inventory
892
+ lineup, leaves the cast and environment lineups and the scene sums, and still counts in the shipped payload until
893
+ `assets remove`. Unset, usage follows the asset's kind.
894
+ - **Shipped payload** (`totals.shippedPayloadMB`; `firstPlayMB` is its old name, kept for existing readers): every
895
+ file of the last build, gzipped. A conservative gate on what the game ships, not a trace of what a browser fetched
896
+ before the first round.
897
+
668
898
  In a three.js game, `@homie-rocks/studio/assets` is the one loader:
669
899
 
670
900
  ```ts
@@ -675,7 +905,8 @@ const fox = await models.instance('./models/fox.glb').catch(() => models.placeho
675
905
 
676
906
  It checks every file before three.js parses it (no external URIs, no buffer over the cap, only extensions three.js
677
907
  reads), decodes meshopt, clones skinned models properly, and says in development when a model is over its budget
678
- (`stats()`, `window.__homieModels`). `game new <id> --from gem-rush-3d` is Gem Rush in 3D, dressed from the library.
908
+ (`stats()`, `window.__homieModels`). The budget is a small prop's (1,500 triangles, 300 KB) unless the game sets its
909
+ own once, in game.json: `"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }` (per model). `game new <id> --from gem-rush-3d` is Gem Rush in 3D, dressed from the library.
679
910
  The local MCP's `style_explore`, `decision_set`, `assets_plan`, `assets_find`, `asset_add`, `asset_make`,
680
911
  `asset_check`, `asset_lineup` and `asset_rights` do the same with cards. Generated props (on the person's own fal
681
912
  account, under a budget, receipted) are the Homie plugin's `models` skill.
@@ -700,7 +931,14 @@ clip library `public/anims/<skeleton>.glb`, shared by every character with that
700
931
  has them, else retargeted at build time from the starter library's CC0 humanoid clips (KayKit's): every bone turns in
701
932
  the world the way the source's turned from its rest, after the two rest poses are lined up bone by bone, so a T-pose
702
933
  clip plays on an A-pose rig; a one-piece limb aims where the source's whole limb points. It is plain matrix math in
703
- Node (no renderer), so it runs in CI and a cloud session too. `assets check` holds characters to four influences a
934
+ Node (no renderer), so it runs in CI and a cloud session too. One clip library a skeleton: a second one is refused
935
+ when it is recorded (it used to hide the first), unless its record says `"rig": { "supplemental": true }`, in which case
936
+ its verbs join the character's coverage and previews and the game loads both
937
+ (`loadCharacter(models, url, { anims: [base, extra] })`). `anim plan` and `anim preview` also show how fast each
938
+ character's walk and run move its feet over the ground on its own rig (kept as `rig.gait`): animate's default
939
+ `runSpeed` of 4.2 m/s is not measured on your rig, and a clip retargeted onto a taller character covers more ground,
940
+ so start `tune: { runSpeed }` from that number. It is a guide from the foot bones, not a verified foot contact.
941
+ Removing a starter character keeps its pack's credit while any clip retargeted from it remains. `assets check` holds characters to four influences a
704
942
  vertex, the tier's bones, a clip library of 1 MB at 30 samples a second, every verb the game's `anim.clips` names,
705
943
  and the skinning a room costs on a phone (players times the heaviest character: 60,000 vertices and 1,200 bones a
706
944
  frame).
@@ -726,6 +964,71 @@ move), `anim_add`, `anim_preview` and `character_make` do the same with cards. A
726
964
  in the locked style, then Meshy's mesh and auto-rig on the person's own fal account, about US$1.44, priced and
727
965
  receipted) is the Homie plugin's `models` skill; rigs, clips and feel are its `animate` skill.
728
966
 
967
+ ## A model you can stand on (`homie-studio collision`)
968
+
969
+ Importing a model records what it looks like. It does not say where a figure may walk on it, so the first playable
970
+ version of a generated landmark is one figures fall through.
971
+
972
+ ```sh
973
+ npx homie-studio collision bake harbour-run lighthouse.glb --cell 0.25 # a height grid off its triangles, beside it
974
+ npx homie-studio collision check harbour-run # stale? proxies well formed? every route walked?
975
+ ```
976
+
977
+ `bake` writes `lighthouse.collision.json` (the grid sampled off the model's outside surface at the cell size you give,
978
+ the SHA-256 of the file and a checksum of its triangles) and `lighthouse.collision.svg`, the plan at true scale (40 px to
979
+ the metre). Three things in the JSON are yours, and a re-bake keeps them: `"proxies"` (boxes, cylinders and ramps for
980
+ what a figure walks around), `"mover"` (the body that must fit: radius, height, step, and the arrival radius the
981
+ game's bots really use) and `"routes"` (ways a bot should be able to walk, as lists of points). `check` fails, with a
982
+ row for each thing wrong, when the model's surface changed since the bake, a proxy is malformed, or a body of the
983
+ mover's size cannot walk a route: it says where it fell or what was in the way, and marks the spot on the plan.
984
+
985
+ The game loads the JSON with `unpackBake` and `bakeField` from `@homie-rocks/heightfield/MeshBake.js`, and calls
986
+ `bakeStale` against the mesh it loaded. The command needs `@homie-rocks/heightfield` installed in the studio (it says
987
+ so when it is not). Buildings with rooms and roofs, zone marks on every level and staged capture positions are that
988
+ package's `Levels.js`, `Route.js`, `Markers.js` and `Pose.js`: its README has them.
989
+
990
+ ## Bots that want different things (`@homie-rocks/studio/personas`)
991
+
992
+ ```ts
993
+ import { PERSONAS, createPersonaBrain, botStats, seededRng } from '@homie-rocks/studio/personas';
994
+
995
+ const who = createPersonaBrain(PERSONAS.bully, { rng: seededRng(roomSeed + slot), arena: { solid } });
996
+ const target = who.choose(self, candidates, rivals); // candidates: { x, y, kind, value, size? }
997
+ const push = who.steer(self, seek(self, target)); // turned off any wall ahead
998
+ ```
999
+
1000
+ One scorer (value against distance, with a target behind, a wall in the way, company near it and a bigger rival each
1001
+ costing some of the score) and five rows of weights: `glutton`, `bully`, `racer`, `scavenger`, `sneak`.
1002
+ `persona('sneak', { kinds: { beacon: 6 } })` extends one. `solid(x, y)` is the game's own wall test; line of sight
1003
+ and wall avoidance use nothing else. `who.chooser(...)` is the `choose` the port kit's `BotBrain.think` takes, so the
1004
+ room's skill dial still sets how late a bot reacts and how well it aims: a persona is what it wants.
1005
+ `who.stats()` / `botStats([...])` count what each bot picked (kinds, mean value, distance, facing, company, picks
1006
+ behind walls) and `personaSpread` is one number for how unlike each other they were: assert on those in the game's
1007
+ own test, because weights that look different often play the same. The only randomness is the `rng` you pass. The
1008
+ host owns the bots; nothing here is sent anywhere.
1009
+
1010
+ ## Power-ups whose timers survive a host change (`@homie-rocks/studio/powerups`)
1011
+
1012
+ ```ts
1013
+ import { createPowerups, stepPowerups, collectPowerups, resolveHit, biteRadius, collides, packPowerups, unpackPowerups } from '@homie-rocks/studio/powerups';
1014
+
1015
+ let power = createPowerups(SPOTS.length, roomSeed); // host
1016
+ ({ state: power } = stepPowerups(power, dtMs)); // host tick: timers, expiry, spawns
1017
+ ({ state: power, events } = collectPowerups(power, bodies, SPOTS)); // who touched what (lower slot on a tie)
1018
+ snapshot.power = packPowerups(power); // host: in the snapshot AND the checkpoint
1019
+ power = unpackPowerups(snapshot.power); // replicas, and a new host, once
1020
+ ```
1021
+
1022
+ Everything is one plain object and pure functions over it, so the rules are unit-tested and nothing reads a clock.
1023
+ The state holds how long each power-up has **left**, not a deadline, and the spawn sequence's seed: a browser that
1024
+ becomes host mid-timer carries on from the last snapshot and spawns the same next pickup. Three kinds are built in:
1025
+ Magnet (`biteRadius` is wider, `magnetPull` draws loose things toward the mouth), Phase (`collides(state, a, b)` is
1026
+ false) and Shield (`resolveHit` blocks one hit and returns the velocity to bounce the attacker with). Add a kind by
1027
+ adding a row to the rules, last (a kind travels as its index). `powerFlags` is a bitmask per body. It is the game's
1028
+ own snapshot data: the netplay wire protocol is unchanged, and a tab on an older build drops kinds it does not know.
1029
+ `powerDraws` fills instance matrices for three instanced meshes the game owns (gems, magnet rings, shield bubbles) and
1030
+ `hudPills` says what the HUD shows; both are optional and import no renderer.
1031
+
729
1032
  ## Checks on a computer without a GPU
730
1033
 
731
1034
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -734,6 +1037,40 @@ the home folder's `.cache/homie-studio` (from storage.googleapis.com, which a cl
734
1037
  renders with SwiftShader. `check` reports how fast each browser drew the game and on what renderer; on SwiftShader
735
1038
  it says the frame rate is not a person's, and judges only seats, rooms and rounds.
736
1039
 
1040
+ `check` reports three things apart: **seated** (a room, a seat, a role), **ready** (`readiness`: the play page's
1041
+ loading cover has lifted; the picture it calls `<browser>-playing.png` is taken only then, and a browser still
1042
+ covered is saved as `<browser>-loading.png`), and **connected** (`connection`, `reconnects`, `uninterrupted`: a
1043
+ round both browsers finished is `ok`, and whether one of them reconnected on the way is said beside it; a browser
1044
+ seen `reconnecting`, `alone`, `offline` or `closed` (the helper's link) is listed as `cutOff` and the connection is
1045
+ not called uninterrupted, whether or not a counter moved). `perf` leaves out a run whose browser ended its window cut
1046
+ off from the room. A site this computer's Node.js cannot look up does not stop `check` or `shoot` (their browsers may
1047
+ still open it); if the browsers cannot either, they report the same BLOCKED network preflight as `perf` and `port check`.
1048
+ Nothing listening on this computer's own address stops all four at once: "does not answer: start the site".
1049
+ `port check`'s big-screen row requires the join QR on an address a phone can reach and marks it not applicable on a
1050
+ loopback preview, where the page hides the join card on purpose.
1051
+
1052
+ ### Pictures on a stepped clock: `homie-studio shoot`
1053
+
1054
+ ```sh
1055
+ npx homie-studio shoot crown-thief --url http://127.0.0.1:8787 [--frames 60] [--fps 30] [--device computer|phone] [--hold KeyD] [--no-smoke] [--timeout 180]
1056
+ ```
1057
+
1058
+ For a 3D game where there is no GPU and the software renderer draws two frames a second. First a smoke check: two
1059
+ clients (a computer and a phone) open the play page in one private room (`?room=shoot-…`) and must both get a seat
1060
+ there, on different seats, with neither playing alone offline. Then the first client's clocks are frozen and stepped:
1061
+ `requestAnimationFrame`, `performance.now`, `Date`, `setTimeout`, `setInterval` and CSS/Web animations move by
1062
+ exactly 1/fps of a second per frame, in the page and in the game's frame, and each step is one picture
1063
+ (`.studio/shoot/<id>/frame-0001.png` …, with `shoot.json`: each frame's time, and the round phase, position and
1064
+ renderer counters when the game has a port probe). Not stepped, and said in the result: the room's socket and its
1065
+ server clock (other players arrive in real time), Web Audio's clock, `<video>`, workers. The frames show what was
1066
+ drawn; they are never a frame rate. Everything is bounded by `--timeout`; a run that hits it keeps the frames it has
1067
+ and says `partial`.
1068
+
1069
+ `shoot <id> --preview` needs no site running: it serves the built game itself (the light server `preview <id>` uses:
1070
+ no Wrangler, no rooms, the game alone and offline with its bots) and shoots that page. The smoke check is not run
1071
+ there (it needs rooms: use `dev` and `--url`). The result names the build the frames are of, by the hash `build`
1072
+ printed and the hashed bundle the page loaded.
1073
+
737
1074
  ## Another way to Cloudflare: Stripe Projects (a prototype, 0.24.3)
738
1075
 
739
1076
  `npx wrangler login` stays the way. For a creator with no Cloudflare account (and no ElevenLabs), Stripe Projects
@@ -795,7 +1132,9 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
795
1132
  | --- | --- |
796
1133
  | `bin/homie-studio.mjs` | The CLI. |
797
1134
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
798
- | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
1135
+ | `lib/build.mjs`, `lib/stage.mjs` | `build`: games bundled with esbuild (ES modules with code splitting, the bundle named by its content) into `site/dist`, `games.json` and `_site/build.json` (each game's build hash and whether it changed). Built in a folder of its own and put in place only when all of it is there; a game that does not build fails the command and leaves `site/dist` as it was (`site/SITE.md`, "The build"). |
1136
+ | `lib/typecheck.mjs` | `build --types`: the games' TypeScript checked with the studio's own `typescript` before anything is built. |
1137
+ | `lib/preview.mjs` | `preview <id>`: one built game's files at an address on this computer, with no Wrangler and no rooms. |
799
1138
  | `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket; `media move`: big media into it, checked by SHA-256 (every deploy runs it). |
800
1139
  | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
801
1140
  | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
package/assets/animate.ts CHANGED
@@ -49,7 +49,9 @@ const HERO_BUDGET = { triangles: 8000, texturePx: 1024, materials: 2, bytes: 153
49
49
  export const ANIM_TUNING = Object.freeze({
50
50
  fade: { value: 0.14, min: 0, max: 0.6, step: 0.01, unit: 's', group: 'Motion', note: 'How long one clip takes to blend into the next' },
51
51
  walkSpeed: { value: 1.5, min: 0.3, max: 6, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the walk clip\'s feet move at (feet slide when this is wrong)' },
52
- runSpeed: { value: 4.2, min: 1, max: 12, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the run clip\'s feet move at' },
52
+ // 4.2 m/s suits the starter library's chibi rigs. It is a default, NOT a reading of your rig: a clip retargeted onto
53
+ // a taller character covers more ground a cycle (`homie-studio anim plan` shows the speed measured on each rig).
54
+ runSpeed: { value: 4.2, min: 1, max: 12, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the run clip\'s feet move at (a default, not measured on your rig: anim plan shows the measured speed)' },
53
55
  runFrom: { value: 2.4, min: 0.5, max: 8, step: 0.05, unit: 'm/s', group: 'Motion', note: 'From this speed up, the walk becomes a run' },
54
56
  actSpeed: { value: 1.25, min: 0.5, max: 3, step: 0.05, group: 'Motion', note: 'How fast actions (attacks, casts) play: higher is snappier' },
55
57
  jumpStretch: { value: 0.16, min: 0, max: 0.5, step: 0.01, group: 'Motion', note: 'How tall and thin the body goes as it leaves the ground' },
@@ -64,8 +66,11 @@ export const ANIM_TUNING = Object.freeze({
64
66
  export type AnimTuning = { -readonly [K in keyof typeof ANIM_TUNING]?: number };
65
67
 
66
68
  export interface CharacterOptions {
67
- /** The clip library's address (default: the one the model names, beside it in ./anims/). */
68
- anims?: string | null;
69
+ /**
70
+ * The clip library's address (default: the one the model names, beside it in ./anims/); null for none; or a list of
71
+ * addresses when its skeleton has a supplemental library too (the base first).
72
+ */
73
+ anims?: string | string[] | null;
69
74
  /** Live numbers (lab.tunables(tuning)): any ANIM_TUNING key, read every frame. */
70
75
  tune?: AnimTuning;
71
76
  /** Bones that swing on springs (default: names with tail, ear, cape, hair, antenna). */
@@ -157,10 +162,15 @@ export async function loadCharacter(models: ModelSource, url: string, opts: Char
157
162
  const loaded = await models.load(url, { budget: HERO_BUDGET });
158
163
  const info = (loaded.scene.userData?.homie ?? {}) as { anims?: string; family?: string; sockets?: Record<string, string> };
159
164
  let clips = loaded.animations ?? [];
160
- const animsUrl = opts.anims === null ? null : (opts.anims ?? (info.anims ? new URL(info.anims, new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/')).href : null));
161
- if (animsUrl) {
162
- try { clips = [...(await models.load(animsUrl, { budget: { triangles: 0, bytes: 3 * 1024 * 1024 } })).animations, ...clips]; } catch (error) { if (!clips.length) console.warn(`[animate] ${url}: its clips did not load (${(error as Error).message})`); }
165
+ // `anims`: the clip library to use instead of the one the model names; null for none; or a LIST, for a skeleton
166
+ // with a supplemental library beside its own (the base first: where two hold a verb, the earlier one's clip plays).
167
+ const own = info.anims ? new URL(info.anims, new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/')).href : null;
168
+ const animsUrls = opts.anims === null ? [] : Array.isArray(opts.anims) ? opts.anims : [opts.anims ?? own].filter((u): u is string => Boolean(u));
169
+ const fromLibraries: AnimationClip[] = [];
170
+ for (const animsUrl of animsUrls) {
171
+ try { for (const c of (await models.load(animsUrl, { budget: { triangles: 0, bytes: 3 * 1024 * 1024 } })).animations) if (!fromLibraries.some((x) => x.name === c.name)) fromLibraries.push(c); } catch (error) { if (!clips.length && !fromLibraries.length) console.warn(`[animate] ${url}: its clips did not load (${(error as Error).message})`); }
163
172
  }
173
+ clips = [...fromLibraries, ...clips];
164
174
  const model = await models.instance(url, { budget: HERO_BUDGET });
165
175
  return createCharacter(model, clips, { ...opts, family: info.family ?? null, sockets: info.sockets ?? {} });
166
176
  }