@homie-rocks/studio 0.44.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ ---
2
+ name: game
3
+ description: Make or change a rules-plus-view multiplayer web game inside a Homie studio — the server runs the rules, each browser predicts its own movement and renders the game, strangers meet in public rooms, bots fill empty seats, rounds end and restart — prove it with two browsers finishing a round, and give it an epic landing page (a full-bleed hero from its own footage or art, the pitch, Play, phone / computer / TV, live rooms, how to play, credits). Use when someone in a Homie studio (a folder with studio.json) asks for a new game, a change to a game, a game whose own AI decides (opponents picking tactics, an NPC's reaction, a director's call, a turn's move), or a better page for a game ("make my game's landing page epic").
4
+ compatibility: Node 22 and Chrome. The studio's pinned Wrangler runs the dev site; the GitHub CLI (gh) opens a pull request when the studio publishes that way; Ollama with clef-flash, optional, answers a game's AI decisions on this computer under dev (downloaded only after the person's yes).
5
+ metadata:
6
+ providers: cloudflare github ollama
7
+ ---
8
+
9
+ Read the installed toolkit's `node_modules/@homie-rocks/studio/guides/game/{RULES,REWRITE}.md`
10
+ before authoring: these match its runtime even when this plugin is older. Toolkit
11
+ check/build report stale installed plugin copies and the update step. For live
12
+ cover, doors and platforms, use declared entity `collider` state and movement's
13
+ `ctx.world.sweep/support/overlaps`; test prediction, loss, rejoin and restore.
14
+
15
+
16
+ **Apps:** For a business, venue, cause or customer app, follow the `app` skill: `apps/<id>/app.json`, one morphing screen, roles and parts. Reuse these engines and workflows; do not impose game rounds, scores, bots, a game demo or page navigation. The app check proves shared actions and reconnect; app stores use the same standalone command.
17
+
18
+
19
+ # Make a game
20
+
21
+ You are in a Homie studio when the folder (or one above it) has `studio.json`. If there
22
+ is none, use the `studio-setup` skill first.
23
+
24
+ **Asked to rewrite an existing browser-hosted game for the server? Read
25
+ [REWRITE.md](REWRITE.md) in full before editing.** Preserve the renderer, feel,
26
+ other games and every existing test file, including its import paths. If an old
27
+ `rules.ts` exports tested helpers, keep those exports when adding defineRules;
28
+ do not redirect the tests to a new helper file. Run the original test command.
29
+
30
+ For a request involving a larger room or map, read RULES.md's **Spatial delivery**
31
+ section. 0.44.0 can reduce what a server player receives with `room.view.radiusM`;
32
+ Gates and public admission above 32 seats are the next slice. Choose a radius
33
+ only when it fits the game. No rules rewrite or new profile is needed.
34
+
35
+ ## Start it
36
+
37
+ - **Look for pieces before writing them.** Games build on each other by sharing parts: a creature from one game,
38
+ a level from another, a bot brain from a third. For each system the game needs (camera, movement, bots, pickups,
39
+ effects, sound, UI, environment), before writing it from scratch:
40
+ - check the `@homie-rocks/*` packages (npm) for the general mechanism: camera, input, audio, effects;
41
+ - run `parts_find` for pieces other studios shared, and bring one in with `part_add`.
42
+
43
+ Record both in the game's CODEX (Built from) and its credits: what came from where, and what you wrote here and
44
+ why. After building a piece another game could use, offer to make it a part (`part_new`) and, only if the person
45
+ asks, to share it (`part_share`). The `parts` skill has the method.
46
+ - **A new studio's first game:** follow `studio-setup`, choose sensible defaults from the request,
47
+ write the plan and build it. The live demo is optional; never require a detour or an interview.
48
+ - **A game with a Game Codex** (`games/<id>/CODEX.md`): read it first; it is the plan. Every decision that
49
+ changes it goes into it in the same change, with a dated line under Latest, and the page is redrawn
50
+ (`npx --no-install homie-studio codex <id>`). A big change to a game without one: plan it first.
51
+ - **Other studios** beside this one (a neighbouring folder with its own `studio.json`) are other people's
52
+ work: never read their games or copy from them, not even as a model for this one, unless the person asks.
53
+ What you may read is this studio and the toolkit in its `node_modules/@homie-rocks/studio/`.
54
+ - **New game:** call the Homie MCP tool `game_make` (id, name) for the exact command
55
+ and rules (without the connector, go on: the command is here and the rules are this skill's), then run
56
+ `npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"`
57
+ (`--from gem-rush-3d` for a 3D game: three.js with free library models; `--from hero-rush-3d` for a 3D game with
58
+ animated characters that run, jump and swing). The id becomes the game's
59
+ address (`/<id>/`); lowercase, digits, hyphens.
60
+ - **Make an existing single-player web game multiplayer:** use the `port` skill (it
61
+ grades the port, brings the game in, and proves it with the owner tests).
62
+ - **Build on another studio's game:** through parts, never the whole game (remix was
63
+ retired; `game remix` answers with a sentence that says so). `parts_find` searches the pieces
64
+ other studios shared (a creature, a level, a bot brain) and `part_add` brings one in with its
65
+ licence and credit: the `parts` skill has the method. When someone asks to copy or remix a
66
+ whole game, say that plainly and offer to make a game of their own like it, with parts where
67
+ some fit.
68
+ - **A game's own licence** is game.json `"license"`: an SPDX identifier (`"MIT"`), said on its
69
+ page; a game that names none says nothing. A game.json with `"remixOf"` was made from another
70
+ studio's game: keep the key, whatever else changes (its landing says "Based on <game> by
71
+ <studio>").
72
+
73
+ ## Write and change rules plus view
74
+
75
+ **Read [RULES.md](RULES.md) before writing code.** All five starters use this contract.
76
+ Work in few, large edits: read each source file once, decide its changes, then
77
+ make every change it needs in ONE file_edit with an edits list. Build after each
78
+ coherent change across rules, movement and view; use diagnostics to guide the next edit.
79
+
80
+ New games use `game.json` `"entry": "src/view.ts"`, `"room": { "host": "server" }`,
81
+ and `players.max` at most 32. HTML loads `<script type="module"
82
+ src="./assets/main.js"></script>`, not the source-file URL. Choose seats, tick rate (20 by default), dimensions and
83
+ starter yourself from the request; never ask the person an engineering question.
84
+ `coin-dash` is the smallest reference; `gem-rush` adds bumps; `ember-vale` adds
85
+ companions, decisions and character saves; the two 3D starters add models and jumps.
86
+ A starter is a starting point: implement the requested mechanic, not a renamed gem collector.
87
+ The build refuses a game with neither rules plus view nor a browser-hosted netplay contract;
88
+ a plain browser bundle is not a multiplayer game.
89
+
90
+ - **Truth: `src/rules.ts`.** Default-export `defineRules({ contract: 2, ... })`.
91
+ Declare every lasting value in entity `fields`, movement `motion`, or room `shared`,
92
+ and payloads in `shapes`. An entity handler changes only `self`; room handlers alone
93
+ change shared state. Affect others with declared events, delivered on a later tick.
94
+ Validate commands against current turn, cooldown, distance and ownership in rules.
95
+ IDs are opaque `f.ref()` strings; compare them, never parse or sort them as numbers.
96
+ - **Movement: `src/move.ts`.** Export `move = defineMove(...)`, import it in rules.
97
+ The server and local prediction run the same step. Read only body pose/motion,
98
+ input, public tune, map plus declared live colliders and `ctx` clock/math. Sweep against the world, normalize
99
+ diagonals, and declare the normal running `maxSpeed`. Put jumps, dashes and knocks
100
+ here, with rule decisions encoded in `motion`. Do not add `body.move: 'owner'`:
101
+ authoritative movement already predicts immediately and keeps the wall solid.
102
+ - **View: `src/view.ts`.** `import type rules from './rules'` and
103
+ `openRoom<typeof rules>()`; send `room.input` and `room.command`, draw `room.me`,
104
+ `room.each`, `room.shared`, `room.round` and `room.roster`. Handle a null own body.
105
+ Camera, models, DOM, audio and particles stay here. The local pose is predicted;
106
+ other poses are interpolated. A button or swing can animate on press, but damage,
107
+ pickups and scores wait for the server. No second physics loop or local score authority.
108
+ - **Determinism and budget.** Use `world.tick`, `world.dt`, `world.ticks`, `world.after`,
109
+ `world.random` and `world.math` (movement: `ctx`). No clocks, timers, async, network,
110
+ `Math.random`, transcendental `Math` functions, `**`, changing module state or
111
+ load-time computation in rules. Bounded local queries, bounded collections and
112
+ declared fields keep work under the deterministic budget. Read all build messages
113
+ and repair the named handler; never evade the guard or raise budgets to hide a bug.
114
+ - **Levels:** declare `map: './map'` and put bounds, solids and named spots in
115
+ `map/main.json`. Draw from the same map. In 3D rules use metres, z up and feet
116
+ positions; Three.js draws `(x,z,y)`. Height tiles are terrain; boxes are solid
117
+ platforms. Decorative hills or meshes do not collide unless represented in the map.
118
+ - **Rounds and arrivals:** use automatic rounds or `rounds.seconds: 0` plus a room
119
+ handler that calls `world.round.end()` for turn games. Never give a quiz or word
120
+ game fake movement just to satisfy a test. Initialize late arrivals in `on.arrive`
121
+ as well as existing bodies in `onRoom.roundStart`. `think` returns inputs, not commands;
122
+ bot actions go through the same rules as a person's. Show the actual goal and result.
123
+ - **Companions:** `guide.view` returns declared `shapes.view`; `guide.floor` chooses
124
+ a vocabulary goal without a model; `think` follows `self.goal` and calls
125
+ `world.goalDone`. Write `agents.json` goals, lines and asks in the game's voice.
126
+ Draw `room.askButtons(id)` and call `room.ask`. Server policies reserve companion
127
+ seats; input never grants a role. Use `world.level`, `world.guideLevel`,
128
+ `world.guideSeats` and `world.kids`. Preserve visible AI labels.
129
+ - **Game decisions:** declare `asks` with state, questions and a synchronous floor;
130
+ call `world.ask` per beat or turn and handle `on.answer` once (`ai`, `local`, or
131
+ `floor`). No promise/await or `net.decide` in rules. The floor must play well alone.
132
+ - **Hosting and saving:** server hosting is the default. Browser hosting is for
133
+ offline play, local experiments or private friends games, using the same rules;
134
+ it is not a fix for a failed build. Room state saves automatically and restores
135
+ as a whole, but the room ends 60 seconds after its last person leaves. Character
136
+ progress uses the separate saves API below. No secrets in entity/shared state:
137
+ every replica receives it. A prop disguise is a visual mechanic, not secure hidden
138
+ information. Private per-seat information and bigger rooms belong to milestone 2.
139
+ - **Changing a rules game:** read its codex, declarations, move and view; change the
140
+ responsible half, preserve unrelated play, and rebuild. The build/state hashes
141
+ manage compatibility automatically; do not hand-pack snapshots or bump netplay.version.
142
+ Recheck the changed mechanic, a late join and a reload in two browsers.
143
+ - **Changing an old-style game:** ordinary requested edits keep it working untouched
144
+ in its existing architecture. When its owner asks to move rules to the server,
145
+ follow [REWRITE.md](REWRITE.md). There is no automatic converter.
146
+ - Phones get touch, computers get keys; typed names and chat are text only.
147
+ Keep native DOM buttons outside `guardGestures`' touch-drag selector; otherwise bind
148
+ touch pointers explicitly. Verify actual touchscreen taps, not only `.click()`.
149
+ Keep controls clear of the shell's pills (`room.net.shell`). Watchers use
150
+ `room.net.viewSeat` for their camera and HUD, and `room.net.spotlight` for action.
151
+ `watch: 'overview'` or `false` changes the offered UI, not secrecy of snapshots.
152
+
153
+ Make it the requested game, one milestone at a time. Read each file, make coherent
154
+ edits, build, act on the diagnostics and test the actual play before declaring it done.
155
+
156
+ - **Room chat and speech bubbles** (NETPLAY.md section 19; `chat/CHAT.md`). Every game has room chat on its
157
+ play page with no code: reactions that float up every screen, quick lines, and typing where the rules allow.
158
+ Give it the game's own voice in `game.json` `"chat"`: `"lines"` (up to 12 quick lines, short and kind:
159
+ `{ "gg": "Good game!", "gem": "Grab that gem!" }`), up to 3 extra `"emoji"` (`{ "gem": "💎" }`), and the
160
+ defaults that suit its players (`"mode": "lines"` for a game for children; the owner can change any of it).
161
+ Draw what players say over their characters with the port kit, after the name labels:
162
+
163
+ ```ts
164
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; } });
165
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
166
+ net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id)));
167
+ paintBubbles(ctx, bubbles.place(speakers.map((p) => ({ key: p.seat, x: p.labelX, y: p.labelTop, self: p.mine }))));
168
+ ```
169
+ A game's own chat keys (a quick-line wheel): `net.sayLine('gg')`, `net.react('fire')`. Never draw a name or a
170
+ message as markup. **Where chat sits:** the Chat pill rides the room button's band (`screen.share` below; a
171
+ round icon on a phone), and the strip of new lines shows for a moment at the bottom left. If the game's HUD or
172
+ controls are there, put the strip where it has room in `game.json` `"screen": { "chat": { "at": "top-right",
173
+ "y": 110 } }` (a corner, `top-center` or `bottom-center`; `x` / `y` in pixels; per device: `desk`, `phone`,
174
+ `sideways`, `tv`), or keep new lines in the Chat sheet with `"lines": "sheet-only"` (the pill counts them).
175
+ Never turn typing off to make room: place the strip instead (`chat/CHAT.md` has every field).
176
+ - Update `game.json` `name` and `blurb`, and the `<title>`.
177
+
178
+ ## Progress that lasts: cloud saves
179
+
180
+ When the plan says progress persists across sessions or devices (the codex's "Progress that lasts", or the person
181
+ asks for a character, levels, unlocks, a collection, hardcore), wire in saves. Never keep it in the room: a room
182
+ forgets everything 60 s after its last player leaves.
183
+
184
+ - `game.json`: `"saves": true`. A new game of that kind can start from the starter:
185
+ `npx --no-install homie-studio game new <id> --from ember-vale --name "<Name>"` (a hero that lasts, lifetime
186
+ stats, a hardcore mode with a hall of the fallen, on the same rooms as any game).
187
+ - In the game: `import { createSaves } from '@homie-rocks/studio/saves'`; `const saves = createSaves({ game: '<id>' })`.
188
+ Load on arrival (`await saves.get('hero')`), save when it changes (`saves.set('hero', hero)`, at most about once a
189
+ second), and reload on `saves.on('player', ...)` (the player signed in on this device). Keep one character in ONE
190
+ key. Lifetime numbers: `saves.stats.add({ kills: 1 })`. Hardcore: `saves.fall({ character, summary, wipe: true })`.
191
+ - Rules emit declared effects; the view checks the effect's entity ID against `room.me?.id` before
192
+ changing its own character save. These browser-written saves are player-owned progress, not a
193
+ server-verified economy or authority for purchases. Room durability is separate and automatic.
194
+ - A character's name is the save's, not the room's: the room knows a person by their account's name or a two-word
195
+ handle. Draw the character's name over its body and in the ranking (a declared command requests it; rules validate and keep it in the player entity), except on a kids server, where the others stay handles. Ember Vale does it.
196
+ - Show who is playing (`saves.player.name`, guest or signed in) and a small "Keep my progress" button that calls
197
+ `saves.signIn()`; the play page shows its passkey sheet. Pressing Play never needs an account.
198
+ - Prove it: build, `npm run dev`, open `http://localhost:8787/<id>/play` (passkeys need `localhost`, not
199
+ `127.0.0.1`), play, reload: the progress is still there. The whole guide is
200
+ `node_modules/@homie-rocks/studio/saves/SAVES.md` (the limits, offline and conflicts, what is stored, privacy).
201
+
202
+ ## Prove it
203
+
204
+ Open a progress feed for every build, titled with what it does (the codex's milestone); the codex
205
+ page's Build status tab and Claude Code's status line follow it by themselves (in Codex and Grok Build
206
+ the codex page is the view: `npx --no-install homie-studio codex <id> --open`). When the person is
207
+ following along in the Claude app (or anywhere they cannot see your terminal), share it and show it:
208
+
209
+ ```sh
210
+ npx --no-install homie-studio progress start <id> --share --title "<what this build does>" [--budget <dollars>]
211
+ npx --no-install homie-studio progress stage plan done --note "<the plan in one line>"
212
+ ```
213
+
214
+ It prints a build id: call the Homie MCP tool `build_progress` with it once, and where the app
215
+ draws cards (the Claude app) the card follows the build by itself (stages, each check going green, a
216
+ preview, spend, Stop). The commands below report into it. If a command answers `stopped`, the person pressed Stop:
217
+ end there and ask before starting again.
218
+
219
+ **Started from the Claude app** (a Claude Code session whose prompt came from a "Build it"
220
+ card, naming a build `hb_...` and maybe a setup `hs_...`): the chat already opened the build, so
221
+ take it instead of starting one, then work on a branch and publish as a pull request. The
222
+ prompt names the studio's repository: first check this session is in it
223
+ (`git remote get-url origin`). If it is in another one (`homie-rocks/homie` is Homie's
224
+ engine and template, never a studio), stop and say so; never attach from it.
225
+
226
+ ```sh
227
+ npm install # the studio's pinned toolkit, from registry.npmjs.org
228
+ npx --no-install homie-studio setup attach hs_... # only when the prompt names a setup: once, first
229
+ npx --no-install homie-studio progress attach hb_... # this session takes the chat's build (once)
230
+ npx --no-install homie-studio chrome install # Linux without Chrome: Chrome for Testing, once
231
+ # ... make the game, build, dev, check (below) ...
232
+ npx --no-install homie-studio progress change "<what the change does, one line>"
233
+ git switch -c <short-branch> && git add -A && git commit -m "<what it does>" && git push -u origin HEAD
234
+ gh pr create --fill
235
+ npx --no-install homie-studio progress pr --url <the pull request's address>
236
+ ```
237
+
238
+ The card's Publish button opens the pull request for the person; their merge in GitHub is
239
+ the approval. Workers Builds deploys the branch as a Preview (run `check --url <the Preview
240
+ URL>` when the pull request shows it, and pass it as `progress pr --preview`) and `main`
241
+ after the merge; the card says Live by itself. Never merge the pull request yourself. If an
242
+ attach fails, quote the toolkit's message as it is: it names the directory's status and its
243
+ own words, or the connection error and whether this machine's proxy was used. Never guess at
244
+ the cause. Only when it says the network proxy refused homie.rocks, pass on the setting it
245
+ names; then go on, since the build works with its local feed. On Linux without a GPU,
246
+ `check` measures seats, rooms and rounds; its frame rate is SwiftShader's, not a person's:
247
+ say so rather than calling the game slow.
248
+
249
+ ```sh
250
+ npm run build # fix every error it names (a game that does not build fails it)
251
+ npx --no-install homie-studio build --types # the same, with the TypeScript checked first: run it before a deploy
252
+ npm run dev # in the background: http://127.0.0.1:8787/<id>/play
253
+ npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 --shots ./.checks
254
+ npx --no-install homie-studio shoot <id> --preview # pictures of the built game on a stepped clock, no site needed (a 3D game where there is no GPU)
255
+ npx --no-install homie-studio preview <id> # only that game's built files at an address (alone, offline): for a capture script
256
+ ```
257
+
258
+ `npx --no-install homie-studio dev --timestamps` puts a time on every line the site prints (room
259
+ sockets and errors always have one): use it to lay a connection loss beside a check's report.
260
+ `shoot <id> --url http://127.0.0.1:8787` adds a two-client seat smoke check in a private room.
261
+ Where there is no terminal, the same commands go through the `studio_run` tool, by their words.
262
+
263
+ `check` passes only when two fresh browsers (a computer and a phone) press Play, land
264
+ in the same room, and both see a round finish with both of them in the results. Look
265
+ at the screenshots it saves. Never say a game works without a passing `check`.
266
+ Start `npm run dev` as a background task your app keeps alive (Claude Code: the Bash
267
+ tool's `run_in_background`), and when you are done with it: stop it with `npx --no-install homie-studio dev --stop`, which stops exactly this studio's dev server (and its Wrangler) and nothing else. Never `pkill`, `killall` or `lsof ... | xargs kill` by name or port: other projects on this machine may run their own `wrangler dev`, and a pattern stops theirs too.
268
+ Before giving a live preview link, fetch it and confirm its server will outlive the
269
+ task. A terminal session may end with a headless agent. If you stopped the server
270
+ or cannot keep it alive, say the game is built and give `npm run dev` and the local
271
+ path; do not describe a stopped preview as running.
272
+ In a headless authoring task, always include the start command and game path in
273
+ the handoff: a successful curl proves the link works now, not after the agent exits.
274
+
275
+ Then make it good, not just working:
276
+
277
+ - **Sound**: the `sound` skill makes the game's effects and a synthesized theme for free and wires
278
+ them in (`sound.play('coin')` where it happens, in every browser). A silent game is not finished.
279
+ - **Look**: first the decisions (the `style` skill: render style, palette, light, camera, fonts, budgets; automatic
280
+ from the person's words, drawn in the codex), then the models (the `models` skill: the engine and the free CC0
281
+ starter library first, the person's own files with their licence, generated props and characters only on their own
282
+ fal account under a budget), the characters' rigs and clips (the `animate` skill), then the `art` skill's cover from a real frame and, with a budget, painted backdrops and
283
+ textures. "Make the look better" goes through `style` and `models` before any painting.
284
+ - **Match the brief's tone**: a cozy, calm or gentle brief is not a fight. Score together (in `gem-rush-3d`,
285
+ game.json `"scoring": "together"`: one total the room fills, no places), make contact gentle or none, and give the
286
+ bots friendly names; keep rivals, rankings and knocks for briefs that ask for competition.
287
+ - **A place, not a board**: a 3D game's play area reads as somewhere. Give it a heart the theme names (a den, a
288
+ campfire, a well, a market stall) built in the game's style, a few solid features players move round, and
289
+ something to do within a few steps of any spot, so a phone's close view is never bare ground. Look at the computer
290
+ and phone frames before you call it done.
291
+ - **Models in code**: a three.js game loads every model through `@homie-rocks/studio/assets` (`createModels()`,
292
+ `instance(url)`, `placeholder(size)`): it refuses unsafe or oversized files and decodes the phone-sized format
293
+ `assets add` writes. Its development warnings use a small prop's budget (1,500 triangles, 300 KB a model); a game
294
+ whose models are bigger on purpose sets its own once in game.json, `"assets": { "budgets": { "triangles": 8000,
295
+ "bytes": 1500000 } }`, instead of living with a warning per model. Code a round does not need at once
296
+ (a later level, an editor) can load later: `await import('./level-2')` becomes a file of its own in the build. Read colours and fonts from the game's `style.json` instead of hard-coding them, so the
297
+ locked palette reaches the world and the HUD. `games/<id>/assets/manifest.json` records every model's origin and
298
+ licence; keep it true (`assets add` and `assets remove`, never a hand-copied .glb), and `assets check <id>`
299
+ before a deploy. The `gem-rush-3d` starter (`game new <id> --from gem-rush-3d`) is Gem Rush in 3D with library
300
+ models: start a 3D game from it. With characters that move (people, heroes, fighters, creatures), start from
301
+ `hero-rush-3d` instead: animated CC0 heroes through `@homie-rocks/studio/animate` (idle, walk, run, jump, a swing,
302
+ hits, a cheer), one shared clip library per skeleton, a jump and a swing tuned in the Game Lab. The `animate` skill
303
+ has rigs, clips, retargeting and feel.
304
+ - **Playtest**: the `playtest` skill plays it on a computer and a phone held both ways, measures the
305
+ first ten seconds, the look, the UI, the real sound and a round, and runs the owner tests.
306
+ Read the pictures and save an honest local verdict; use a fresh outside reviewer when requested.
307
+ Fix the most important finding and repeat the affected checks.
308
+ - **Speed**: when it stutters, loads slowly or a phone struggles, the `perf` skill measures it (frame
309
+ times, CPU per frame on player browsers, time to playable, downloads, memory, netplay; server
310
+ tick work is a separate measurement) and
311
+ keeps a change only when it is faster beyond the noise and two browsers still finish a round.
312
+ - **Feel**: when a move feels floaty, stiff, weak or unclear ("the jump", "the hit", "the drift"), the `lab` skill
313
+ builds a Game Lab for it: one take in the new build beside the last commit, frame by frame, with named phases,
314
+ graphs and sliders that write kept values into the game's `tunables.json`.
315
+
316
+ Then give it its landing (below) and a working local result. Publish with `npm run
317
+ deploy` and `studio_publish` (see `publish`) when publishing is in the owner's
318
+ requested scope, then check again on the live site. A build-only request needs no
319
+ Cloudflare setup or release question. Honor a local-only or no-deploy instruction.
320
+
321
+ ## Its landing page: `/<id>/`
322
+
323
+ Every game gets a landing page from the studio template, made from the game's own files
324
+ (`node_modules/@homie-rocks/studio/site/SITE.md` has every field): a full-bleed hero, the pitch, a big
325
+ Play button that drops a visitor into a public room, how to play on a phone, a computer and a TV (with
326
+ the join code), the live rooms, how to play and credits. It is as good as what you give it. "Make the landing
327
+ page epic" means all of this, in this order:
328
+
329
+ 1. **Footage in the hero.** The biggest single difference. Capture the game running with the `video`
330
+ skill (`capture <slug> --game <id> --url http://127.0.0.1:8787 --seconds 30`), pick 8 to 12 s where a
331
+ lot happens, and cut two silent loops into `games/<id>/hero/`: `wide.mp4` (16:9) and `tall.mp4`
332
+ (9:16, the phone's). Under 3 MB each, so a phone starts it at once:
333
+
334
+ ```sh
335
+ C=videos/<slug>/work/capture/capture.mp4
336
+ ffmpeg -y -ss <start> -t 10 -i $C -an -vf "scale=1600:-2,fps=30" -c:v libx264 -crf 27 -preset slow -pix_fmt yuv420p -movflags +faststart games/<id>/hero/wide.mp4
337
+ ffmpeg -y -ss <start> -t 10 -i $C -an -vf "crop=ih*9/16:ih,scale=720:-2,fps=30" -c:v libx264 -crf 27 -preset slow -pix_fmt yuv420p -movflags +faststart games/<id>/hero/tall.mp4
338
+ ffmpeg -y -ss <start+2> -i $C -frames:v 1 -q:v 3 games/<id>/hero/wide.jpg
339
+ ```
340
+
341
+ Look at them (`ffprobe`, and a frame or two): the game, not a menu, a QR code or a black frame. A
342
+ finished trailer in `videos/` with `"for": { "game": "<id>" }` is used when there is no `hero/`.
343
+ No footage at all: the cover (the `art` skill's `cover`, from a real frame) moves slowly instead.
344
+ 2. **The words**, in game.json's `landing` block: `pitch` (one line a stranger gets at once), `about`
345
+ (a short paragraph), `howToPlay` (three to five short lines), `controls` for `phone`, `computer`
346
+ (and `tv` if it differs), `players` (what a player is called: `{ "one": "pilot", "many": "pilots" }`),
347
+ `hero.alt` (what the footage shows, for a screen reader), `hero.focus` (`"50% 35%"` keeps the action
348
+ in frame on a phone), `hero.tint` (0 to 80: more when the art is bright and the title hard to read).
349
+ A game with a `style.json` gets its landing in that palette by itself (its paper, ink and accents,
350
+ light or dark as its paper is), so a bright game is not shown on the studio's dark page; leave
351
+ `scheme` and `theme` out unless the person wants something else. A game without one whose picture is
352
+ white or cream (a light arena) takes `"scheme": "light"`: its landing is drawn light, where the studio's
353
+ dark tint would turn the picture grey. `hero/wide.jpg` is also the game's picture on every card and in
354
+ the directory, and the play page's arrival card while the game loads, so pick a frame that reads small.
355
+ game.json `"genre"` (a word, or up to three: `["Racing", "Party"]`) and pictures of real play in
356
+ `games/<id>/screenshots/` (at most eight) go on the landing and into its structured data for search
357
+ engines; say what the game is, never invent a rating or a review.
358
+ 3. **Credits**: `landing.credits` names who made what (`[{ "role": "Music", "name": "..." }]`). A port
359
+ keeps its `credits.json` (the original, its author and licence, every part inside); a game that has
360
+ game.json `remixOf` keeps it; never drop either.
361
+ 4. **The look**: the studio's `site/theme.json` colours; `landing.theme` gives this game its own
362
+ `accent` and `glow` on its page, when two games of one studio should not look alike.
363
+ 5. **A band of its own**, when the game has something to say that the template does not (a soundtrack, a
364
+ mode, a season): `site/partials/game-<id>.html`, a short section in the page's own classes
365
+ (`<p class="kicker">`, `<h2>`, `<p class="lead">`, `<a class="ghost">`).
366
+
367
+ **The play page's first seconds**: from the first paint it shows the game's arrival card (its title, pitch,
368
+ hero still, a progress line and `landing.controls` for the device) until the game says it is playable, never a
369
+ blank screen (`SITE.md`, "The play page"). Its words and picture are the landing's, so give the landing its
370
+ `pitch`, `controls` and a hero still. A game that keeps loading after the room's first state (models,
371
+ textures, a baked world) passes `arrival: 'game'` to `openRoom`, calls `net.loading(p, 'the heroes')`
372
+ while it loads and `net.playable()` once its world and the player's own body are drawn, so nobody sees
373
+ stand-ins (NETPLAY.md section 21); the starters do. `perf` measures it: `load.look` (the first meaningful
374
+ frame), `load.playable` (control-ready: seated, a body, the card gone) and `load.ready` (the game's own
375
+ `net.playable()`), with the arrival mode beside them. `check` says seated, ready (the card lifted) and connected
376
+ apart: a seat is not ready, and a finished round is not an uninterrupted one.
377
+
378
+ **The play page's room button** (Invite, Big screen, the room code) sits top right, with the Chat pill
379
+ beside it. If the game draws a score, a timer or a bar there, move them in game.json: `"screen": { "share":
380
+ { "desk": "bottom-left", "phone": { "at": "top-left", "y": 56 } } }` (a corner or `top-center`, per device:
381
+ `desk`, `phone`, `sideways`; `x` / `y` move it in, in pixels; `"label": false` keeps both small icons). Room
382
+ chat's strip of new lines moves with `"screen": { "chat": … }` (above). Look at `/<id>/play` on a computer and
383
+ a phone, both ways up, while a round is on and somebody says something in chat.
384
+
385
+ Then `npm run build`, `npm run dev`, and look at `http://127.0.0.1:8787/<id>/` as a stranger would:
386
+ a computer (1440 wide) and a phone (390 wide, and turned sideways), from the top, scrolling to the end.
387
+ The `playtest` skill takes the screenshots. The hero reads at a glance, Play is above the fold on a phone,
388
+ nothing is cut off or runs off the side. Fix what you see; build again.
389
+
390
+ A whole landing of the studio's own (`site/pages/<id>/index.html`) replaces the generated one: only when
391
+ the person asks for a hand-made page, and start from the generated one's HTML so Play, the TV road and
392
+ "Made with Homie" stay.
@@ -0,0 +1,27 @@
1
+ import {readFileSync,readdirSync,existsSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {homedir} from 'node:os';
4
+ import {STUDIO_VERSION} from './version.mjs';
5
+ const json=file=>{try{return JSON.parse(readFileSync(file,'utf8'));}catch{return null;}};
6
+ const older=(a,b)=>{const x=a.split('.').map(Number),y=b.split('.').map(Number);for(let i=0;i<3;i++){if(x[i]!==y[i])return x[i]<y[i];}return false;};
7
+ export function authoringGuidance({home=homedir(),version=STUDIO_VERSION}={}) {
8
+ const installed=[];
9
+ const claude=json(join(home,'.claude/plugins/installed_plugins.json'));
10
+ for(const row of claude?.plugins?.['homie@homie']??[]) {
11
+ const v=row.version??json(join(row.installPath??'','.claude-plugin/plugin.json'))?.version;
12
+ if(v)installed.push({client:'Claude Code',version:v,update:'claude plugin update homie@homie'});
13
+ }
14
+ // Codex's plugin cache is versioned. Report older copies explicitly, including
15
+ // one still loaded by a long-lived session after another copy was installed.
16
+ const cache=join(home,'.codex/plugins/cache/homie/homie');
17
+ if(existsSync(cache))for(const entry of readdirSync(cache)) {
18
+ const v=json(join(cache,entry,'.codex-plugin/plugin.json'))?.version;
19
+ if(v)installed.push({client:'Codex',version:v,update:'Run codex plugin marketplace upgrade homie && codex plugin add homie@homie, then start a new session'});
20
+ }
21
+ const guides='node_modules/@homie-rocks/studio/guides/game/';
22
+ return {version,guides,warning:installed.filter(p=>/^\d+\.\d+\.\d+$/.test(p.version)&&older(p.version,version)).map(p=>`Homie ${p.client} plugin ${p.version} is older than toolkit ${version}. Its guidance may be stale. Read ${guides}{SKILL,RULES,REWRITE}.md. ${p.update}.`)};
23
+ }
24
+ export function authoringWords(options) {
25
+ const g=authoringGuidance(options);
26
+ return [`Author against toolkit ${g.version}: ${g.guides}{SKILL,RULES,REWRITE}.md`,...g.warning];
27
+ }
@@ -236,7 +236,8 @@ function play(c, companions, allow) {
236
236
  const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
237
237
  const steering = new Map();
238
238
  let moveTick = 0;
239
- const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
239
+ let moveGeometry = c.map;
240
+ const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, geometry: () => moveGeometry, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
240
241
  /**
241
242
  * An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
242
243
  * the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
@@ -344,9 +345,9 @@ function play(c, companions, allow) {
344
345
  const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
345
346
  A.tickNow();
346
347
  units += A.core.stats.tickUnits;
347
- for (const [name, tick, input, before, after] of movesToCheck.splice(0)) {
348
+ for (const [name, tick, input, before, after, geometry] of movesToCheck.splice(0)) {
348
349
  const kind = c.kinds.find(k => k.name === name);
349
- moveTick = tick;
350
+ moveTick = tick; moveGeometry = geometry ?? c.map;
350
351
  const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
351
352
  error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
352
353
  units += result.used;
@@ -69,7 +69,8 @@ export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
69
69
  export const WORLD_METHODS = ['ticks', 'random', 'send', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'spot', 'spots'];
70
70
  export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
71
71
  const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
72
- const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MATH_METHODS]);
72
+ export const MOVE_METHODS = ['support', 'overlaps'];
73
+ const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
73
74
  const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
74
75
  /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
75
76
  const CONVERTING = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', '<', '>', '<=', '>=']);
package/lib/scaffold.mjs CHANGED
@@ -68,14 +68,14 @@ export function slugify(name) {
68
68
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
69
69
  * the binding names and keep it linked.
70
70
  */
71
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
71
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
72
72
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
73
73
  : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
74
74
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
75
75
  const config = {
76
76
  $schema: `${at.schema}/wrangler/config-schema.json`,
77
77
  name: worker,
78
- main: at.main,
78
+ main: main ?? at.main,
79
79
  compatibility_date: COMPAT_DATE,
80
80
  // The site's own fetches (its directory claim, "played this week") go out as any browser's would, so a studio
81
81
  // whose directory is on the same zone (a house studio on *.homie.rocks, or two Workers on one workers.dev
@@ -122,6 +122,13 @@ the Table/Lobby Durable Objects, all on Cloudflare's free Workers plan), built
122
122
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
123
123
  directory lists its games; homie.rocks does not host them.
124
124
 
125
+ ## Version-matched authoring guides
126
+
127
+ Before authoring or rewriting a game, read \`node_modules/@homie-rocks/studio/guides/game/SKILL.md\`,
128
+ \`RULES.md\` and \`REWRITE.md\` in that same folder. These ship with the installed toolkit;
129
+ use them when an installed plugin describes an older API. \`homie-studio check\` reports older
130
+ plugin copies and the update step. After upgrading the plugin, start a new session.
131
+
125
132
  ## Layout
126
133
 
127
134
  | Path | What it is |
@@ -2426,6 +2426,48 @@
2426
2426
  }
2427
2427
  }
2428
2428
  }
2429
+ },
2430
+ "0.44.1": {
2431
+ "files": {
2432
+ "CLAUDE.md": "ddf2b2a0376202f6",
2433
+ "README.md": "684b3cca8001673c",
2434
+ "HANDOFF.md": "29337e2ace95b2e7",
2435
+ "games/README.md": "5ad32fe80c61f117",
2436
+ "music/README.md": "a3dc042dadd39df8",
2437
+ "videos/README.md": "16babc3437d5545b",
2438
+ "posts/README.md": "37841aae476bd540",
2439
+ "site/README.md": "732894273a32dc3f",
2440
+ "site/src/worker.mjs": "34444a6ca4b14437"
2441
+ },
2442
+ "sections": {
2443
+ "AGENTS.md": {
2444
+ "(intro)": "214aa46eb3a895d5",
2445
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2446
+ "## Layout": "f3a280b8c96cd068",
2447
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2448
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2449
+ "## The site": "0cb8dd9a6f8a0096",
2450
+ "## Making games": "9e44559de535d842",
2451
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2452
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2453
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2454
+ "## Servers and AI seats": "d0028361fe567add",
2455
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2456
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2457
+ "## Rules": "2e6b29afb85dcf7e",
2458
+ "## Telling Homie": "80b46fdd9e578db8",
2459
+ "## Beta": "9d02638207a26e73"
2460
+ }
2461
+ },
2462
+ "named": {
2463
+ "Homie Arcade": {
2464
+ "sections": {
2465
+ "AGENTS.md": {
2466
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2467
+ }
2468
+ }
2469
+ }
2470
+ }
2429
2471
  }
2430
2472
  }
2431
2473
  }
@@ -1,23 +1,56 @@
1
+ import {createHash} from 'node:crypto';
1
2
  import {refreshWorkerConfig} from './worker-config.mjs';
2
3
  import {pathToFileURL} from 'node:url';
3
- import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
4
- import {join,relative} from 'node:path';
4
+ import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
5
+ import {join,relative,basename,sep} from 'node:path';
5
6
  import {readConfig} from './routes.mjs';
7
+ import {workerDir} from './studio.mjs';
6
8
  import {paidReleases} from './parts-upload.mjs';
7
9
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
8
10
  export async function buildWorker(root,esbuild) {
9
11
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
10
12
  const out=join(root,'site/src/runtime');
11
- refreshWorkerConfig(root);
13
+
12
14
  const alias={...(readConfig(root)?.alias??{})};
13
15
  const toolModule=join(root,'site/src/tools/index.mjs');
14
16
  const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
15
17
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
16
18
  const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
17
- rmSync(out,{recursive:true,force:true});
19
+ // A watcher may already have read the previous entry while this build is
20
+ // publishing. Keep its entire graph available. Publish immutable modules first,
21
+ // then switch the single entry atomically; never remove a running graph.
22
+ const generation=createHash('sha256');
23
+ for(const file of result.outputFiles)generation.update(basename(file.path)).update(file.contents);
24
+ const id=generation.digest('hex').slice(0,24),dir=join(out,id);
18
25
  mkdirSync(out,{recursive:true});
19
- for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
20
- const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
26
+ if(!existsSync(dir)) {
27
+ const stage=mkdtempSync(join(out,'.generation-'));
28
+ try {
29
+ for(const file of result.outputFiles)writeFileSync(join(stage,basename(file.path)),file.contents);
30
+ try {renameSync(stage,dir);} catch(error) {if(!existsSync(dir))throw error;}
31
+ } finally {rmSync(stage,{recursive:true,force:true});}
32
+ }
33
+ const entry=`export * from './${id}/worker.js';\nexport {default} from './${id}/worker.js';\n`;
34
+ const target=join(out,'worker.js');
35
+ if(!existsSync(target)||readFileSync(target,'utf8')!==entry) {
36
+ const temp=join(out,`.worker-${process.pid}.tmp`);
37
+ writeFileSync(temp,entry);renameSync(temp,target);
38
+ }
39
+ // Wrangler's module root is the immutable generation, so old generations are
40
+ // retained locally for in-flight reloads but are never added to a deployment.
41
+ refreshWorkerConfig(root,{main:relative(workerDir(root),join(dir,'worker.js'))});
42
+ // Dev sometimes watches a copy with routes/remote AI removed. Publish its
43
+ // entry too, keeping its local bindings and its module root at one generation.
44
+ const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
45
+ if(existsSync(local)) {
46
+ const config=JSON.parse(readFileSync(local,'utf8'));
47
+ if(typeof config.main==='string' && config.main.startsWith(out+sep)) {
48
+ config.main=join(dir,'worker.js');
49
+ const text=JSON.stringify(config,null,2)+'\n';
50
+ if(readFileSync(local,'utf8')!==text) {const temp=`${local}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,local);}
51
+ }
52
+ }
53
+ const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,join(dir,basename(path))),bytes:value.bytes,imports:value.imports}));
21
54
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
22
55
  return sizes;
23
56
  }
@@ -1,4 +1,4 @@
1
- import {readFileSync,writeFileSync,existsSync} from 'node:fs';
1
+ import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
4
4
  import {wranglerConfig} from './scaffold.mjs';
@@ -14,7 +14,7 @@ function merge(base,custom) {
14
14
  export function updateWorkerConfig(root,generated) {
15
15
  const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
16
16
  const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
17
- if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(file,text);
17
+ if(!existsSync(file)||readFileSync(file,'utf8')!==text) {const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,file);}
18
18
  }
19
19
  export function refreshWorkerConfig(root,overrides={}) {
20
20
  const studio=readStudio(root),cf=studio.cloudflare;