hayao 0.2.0 → 0.3.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 (69) hide show
  1. package/README.md +113 -24
  2. package/bin/create-hayao.mjs +380 -0
  3. package/bin/hayao-mcp-cli.mjs +11 -0
  4. package/dist/app/browser.d.ts +33 -2
  5. package/dist/app/game.d.ts +47 -2
  6. package/dist/app/tuning.d.ts +68 -0
  7. package/dist/art/palette.d.ts +41 -0
  8. package/dist/audio/adaptive.d.ts +58 -0
  9. package/dist/audio/album.d.ts +16 -0
  10. package/dist/audio/analysis.d.ts +59 -0
  11. package/dist/audio/audio.d.ts +21 -0
  12. package/dist/audio/chord.d.ts +17 -0
  13. package/dist/audio/genres.d.ts +11 -0
  14. package/dist/audio/lint.d.ts +29 -0
  15. package/dist/audio/match.d.ts +38 -0
  16. package/dist/audio/music.d.ts +88 -0
  17. package/dist/audio/pcm.d.ts +54 -0
  18. package/dist/audio/quality.d.ts +28 -0
  19. package/dist/audio/reverb.d.ts +15 -0
  20. package/dist/audio/synth.d.ts +56 -0
  21. package/dist/audio/theory.d.ts +64 -0
  22. package/dist/content/campaign.d.ts +69 -0
  23. package/dist/content/generate.d.ts +78 -0
  24. package/dist/content/level.d.ts +93 -0
  25. package/dist/content/worldgraph.d.ts +73 -0
  26. package/dist/core/dmath.d.ts +2 -0
  27. package/dist/hayao.global.js +15 -0
  28. package/dist/index.d.ts +30 -1
  29. package/dist/index.js +4293 -434
  30. package/dist/index.js.map +4 -4
  31. package/dist/index.min.js +15 -0
  32. package/dist/input/source.d.ts +52 -1
  33. package/dist/mcp.js +31225 -0
  34. package/dist/rasterize-worker-lite.mjs +13 -0
  35. package/dist/render/canvas.d.ts +6 -1
  36. package/dist/render/commands.d.ts +46 -0
  37. package/dist/render/paint.d.ts +33 -0
  38. package/dist/render/renderer.d.ts +22 -0
  39. package/dist/render/svg.d.ts +4 -1
  40. package/dist/render/svgString.d.ts +6 -2
  41. package/dist/scene/cameraController.d.ts +42 -0
  42. package/dist/scene/node.d.ts +17 -4
  43. package/dist/scene/nodes.d.ts +14 -0
  44. package/dist/scene/parallax.d.ts +15 -0
  45. package/dist/studio/mcpMain.d.ts +1 -0
  46. package/dist/studio/mcpServer.d.ts +2 -0
  47. package/dist/studio/record.d.ts +54 -0
  48. package/dist/studio/run.d.ts +78 -0
  49. package/dist/studio/session.d.ts +80 -0
  50. package/dist/studio/timeline.d.ts +35 -0
  51. package/dist/studio/vitePlugin.d.ts +6 -0
  52. package/dist/studio-plugin.js +228 -0
  53. package/dist/ui/overlay.d.ts +6 -0
  54. package/dist/verify/audioFilmstrip.d.ts +39 -0
  55. package/dist/verify/ethnography.d.ts +67 -0
  56. package/dist/verify/gates.d.ts +160 -0
  57. package/dist/verify/layout.d.ts +30 -3
  58. package/dist/verify/ramp.d.ts +40 -0
  59. package/dist/world.d.ts +38 -2
  60. package/dist-studio/assets/index-C7tty_Wo.js +109 -0
  61. package/dist-studio/assets/index-CM3tjRQo.css +1 -0
  62. package/dist-studio/index.html +18 -0
  63. package/docs/API.md +233 -9
  64. package/docs/CONVENTIONS.md +223 -0
  65. package/docs/EMBED.md +85 -0
  66. package/docs/QUICKSTART.md +129 -0
  67. package/docs/STUDIO.md +97 -0
  68. package/docs/VERIFICATION.md +226 -0
  69. package/package.json +39 -6
@@ -0,0 +1,223 @@
1
+ # Conventions — how games are built in hayao.js
2
+
3
+ Every game in this repo follows these. They exist so that any game — by any
4
+ author, human or AI — inherits determinism, testability, and the house look for
5
+ free. They are the practical restatement of [ARCHITECTURE.md](ARCHITECTURE.md);
6
+ where the two disagree, ARCHITECTURE wins.
7
+
8
+ ---
9
+
10
+ ## Files & boundaries
11
+
12
+ - A game lives ONLY in `examples/<slug>/`: an `index.html` + a `game.ts` entry,
13
+ split into small modules once the entry approaches ~400 lines (pure sim logic
14
+ in one file, scene/rendering wiring in another). The vite config
15
+ auto-discovers `examples/<slug>/index.html` — a new folder needs zero config.
16
+ - Import ONLY from `'@hayao'` — never from `'@hayao/core/rng'`, `'../src/...'`,
17
+ or any deep path. The barrel (`src/index.ts`) is the entire public surface;
18
+ keeping imports there is what lets the internals move without breaking games,
19
+ and lets one grep of one file answer "what can I call?".
20
+ - Never modify `src/*`, `package.json`, `docs/ARCHITECTURE.md`, other examples,
21
+ or root config from within a game task.
22
+ - Register the game in the hub (`index.html`) so it appears in the example list.
23
+
24
+ ## Bootstrap: `defineGame()`
25
+
26
+ A game is a single `defineGame()` value — a plain, declarative description of
27
+ how to build the initial world, what the input map is, and (optionally) its
28
+ levels and probes. It contains **no host wiring**: the same value runs under
29
+ `runBrowser()` (rAF loop, real input, an `SvgRenderer`, audio, the UI shell) or
30
+ `runHeadless()` (no host; used by tests and `npm run verify`) unchanged.
31
+
32
+ ```ts
33
+ import { defineGame, World, Node2D, Sprite, DEFAULT_INPUT_MAP } from '@hayao';
34
+
35
+ export default defineGame({
36
+ title: 'Slug Rush',
37
+ inputMap: DEFAULT_INPUT_MAP, // action → key codes (never raw keys in logic)
38
+ build(world: World) { // populate the initial scene tree
39
+ const player = world.root.addChild(new Sprite({
40
+ name: 'player',
41
+ shape: { kind: 'circle', radius: 16 },
42
+ fill: '#2485a6', stroke: '#232228', strokeWidth: 3,
43
+ }));
44
+ player.onUpdate = (n, dt, ctx) => { // ctx = the host world (input/rng/clock/time)
45
+ const move = (ctx.input.isDown('right') ? 1 : 0) - (ctx.input.isDown('left') ? 1 : 0);
46
+ n.pos.x += move * 200 * dt; // dt is the FIXED step — deterministic
47
+ };
48
+ },
49
+ probe(world) { // compact JSON snapshot for the harness
50
+ return { ...world.probe(), x: world.root.find('player')?.pos.x };
51
+ },
52
+ });
53
+ ```
54
+
55
+ The engine, not game code, owns the clock, the rAF loop, focus, audio muting,
56
+ and the DOM shell. Do not re-implement any of that inside a game.
57
+
58
+ The third `onUpdate`/behavior argument is the **world context** (`input`, `rng`,
59
+ `clock`, `time`) — prefer it over closing over `world` from `build()`, so a node's
60
+ behavior is self-contained and liftable. For headless tests, `world.runSteps(n,
61
+ i => actions)` fast-forwards an exact step count; `world.advance(realMs)` is the
62
+ *realtime* driver and clamps big deltas to one frame budget (never use it to skip
63
+ ahead in a test).
64
+
65
+ ## Pointer & continuous input
66
+
67
+ Keyboard actions are the default and the only input that flows through the
68
+ deterministic log. For games driven by *where* the cursor is (slice, aim, drag,
69
+ point-and-click, placement), `runBrowser` wires a **`PointerSource`** that samples
70
+ the cursor into `world.input.axes` each step, in DESIGN space:
71
+ `ctx.input.axis('pointer.x' | 'pointer.y' | 'pointer.down')`. Need the mapping
72
+ yourself? `handle.toDesign(clientX, clientY)` (or `renderer.toDesign`) undoes the
73
+ letterbox once, so no game re-derives `getBoundingClientRect()` math.
74
+
75
+ Determinism caveat: pointer axes are live host samples — they are NOT in the string
76
+ input log or `world.hash()`, so a raw pointer trail does not replay the way key
77
+ actions do. For lockstep/replay, **quantize at the host edge**: map a tap to an
78
+ action (`input.press('fire')`) or snap position to a grid cell you feed as a
79
+ discrete move. Keep raw pointer floats out of the sim's canonical state.
80
+
81
+
82
+ ## The human-contact layer
83
+
84
+ The first real playtest of the campaign found every defect in the one layer
85
+ sim proofs cannot see. These rules close that gap; the machine-checkable ones
86
+ are enforced by `layoutIssues()` / `missingControlHints()` from `@hayao`,
87
+ which every verify suite must run on its representative screens.
88
+
89
+ - **Text is sacred.** A shape either fully CONTAINS a text's box (it is a
90
+ panel/scrim) or stays fully clear of it — partial overlap is a collision.
91
+ Background lattice (z ≤ 1: tile grids, felt) is exempt. HUDs drawn over
92
+ world geometry get a scrim panel, not hope.
93
+ - **First contact teaches.** Every action in the input map must be named by
94
+ some on-screen text on the first frame (`missingControlHints` = []). Games
95
+ with phases show a CONTEXTUAL coach line ("X has moved — F fires"), not one
96
+ static legend. Anything tactical gets an onboarding overlay: goal, threat,
97
+ verbs — a stranger must know what to do within 30 seconds.
98
+ - **Every entity kind is legible.** Distinct shape+outline per kind (items
99
+ must not share silhouettes with creatures) and a legend that names them
100
+ with their true glyphs.
101
+ - **Fiction is earned.** Ending copy may not introduce proper nouns the game
102
+ never showed; if the title names a thing in-fiction, the first screen says
103
+ so (the HUD naming "THE DUSKVEIL" is what earns "The Duskveil lifts").
104
+ - **No safe camp.** Every real-time game's verify includes a degenerate-
105
+ position probe: park at edges/corners and prove the threat still reaches.
106
+ Bots demonstrate the intended line; this proves there's no cheaper one.
107
+ - **The screen arrives intact.** Renderers letterbox (never stretch or crop);
108
+ sim clamps include sprite EXTENTS so nothing sinks out of view at the rim.
109
+
110
+ ## Pure-sim separation (turn-based / puzzle games: mandatory)
111
+
112
+ For anything grid- or turn-based, keep the **rules** in a module with NO scene
113
+ or render imports: state in, state out, deterministic. This is the module the
114
+ solver runs over (see [VERIFICATION.md](VERIFICATION.md)); the scene tree then
115
+ becomes a thin *projection* of that state. Real-time games instead put logic in
116
+ node `onUpdate`/behaviors, but the same rule holds: all state lives in the tree
117
+ or a pure module, never in a renderer or the DOM.
118
+
119
+ The same discipline applies to menus and settings: model them as a pure reducer
120
+ `(state, action) → { state, fx }`, with sound cues returned as data, then render
121
+ via the DOM shell. "Pure logic" is for anything with state transitions, not just
122
+ puzzles.
123
+
124
+ ## Determinism rules (the contract — restated from ARCHITECTURE §Determinism)
125
+
126
+ These are non-negotiable. Break one and `assertDeterministic` fails loudly,
127
+ which is the point.
128
+
129
+ 1. **All randomness flows through `Rng`.** Use `world.rng` (or a child from
130
+ `world.rng.split(key)`). `Math.random()` is banned in `src/` and in games.
131
+ 2. **No wall-clock reads in the sim.** Time comes from the `Clock`
132
+ (`world.time`, `world.frame`, and the fixed `dt` passed to `update`).
133
+ `Date.now()`, `performance.now()`, and argless `new Date()` are banned in
134
+ sim code.
135
+ 3. **The scene tree updates in a fixed order** — depth-first, child-index order.
136
+ Don't reorder children for logic reasons mid-step; don't depend on iteration
137
+ racing anything else.
138
+ 4. **Iterate collections in a stable order** — arrays or insertion-ordered
139
+ `Map`s. Never rely on `Set` iteration order or `Object` key order for logic.
140
+ (The state hash sorts object keys for you, but your *logic* must not branch
141
+ on unordered iteration.)
142
+ 5. **Floating point is fine** as long as 1–4 hold: same ops, same order, same
143
+ engine → same bits.
144
+
145
+ RNG state and clock state are part of `world.hash()`, `world.snapshot()`, and
146
+ saves, so a restored world resumes *identically* — that is what makes replay,
147
+ undo, and time-travel free.
148
+
149
+ ## House style (defaults — swap deliberately, not accidentally)
150
+
151
+ - **Code-as-art, zero binary assets.** A `Sprite` is a vector `Shape`
152
+ (`rect` / `circle` / `poly` / `path`) or a `glyph` (a Unicode character) —
153
+ never a bitmap. Unicode glyphs (♞ ♝ ⚔ ☠ ✿) are instant, legible art.
154
+ **Shape origins:** `rect`/`circle`/`glyph` are CENTER-anchored on the node's
155
+ `pos` (a rect draws from `-w/2,-h/2`); `poly`/`path` points are local to `pos`.
156
+ Thinking in corners? Pass `{ kind: 'rect', w, h, anchor: 'topLeft' }` — otherwise
157
+ a top-left mental model lands the box half-a-size off.
158
+ **Outlined text:** a `Text` node carries `Paint`, so `stroke`/`strokeWidth`
159
+ outline the glyph (the outline lays UNDER the fill, so it frames rather than
160
+ eats the letter) — the way to make a label read on both light and dark grounds.
161
+ Don't stack offset copies to fake a halo; one `stroke` does it.
162
+ - **Default palette is Kentō** (`import { KENTO, MEADOW, DUSK } from '@hayao'`).
163
+ It fuses the site's washi/sumi/ai/shu ink tokens with landscape hues loosely
164
+ drawn from Miyazaki-16 — eight named hues, each with a `Deep` tone for light
165
+ grounds and a bright tone for dark grounds, so `MEADOW` (light) and `DUSK`
166
+ (dark) share one identity. Pick hues by name (`KENTO.asagi`); reach for ~5–6
167
+ per game plus a ground. Alpha/tone variants via `withAlpha`/`mix` are welcome;
168
+ monospace/terminal defaults read as lazy and are discouraged. The engine stays
169
+ palette-agnostic — Kentō is a consistent starting point, never a restriction.
170
+ Every Kentō pairing is WCAG-AA verified: `npm run palette` is the gate.
171
+ - **Pickups and interactables carry a dark ink outline** (`stroke: KENTO.sumi`
172
+ on light, `KENTO.gofun` rim on dark), plus a glow/pulse for emphasis. An
173
+ accent-colored shape flat on the floor vanishes the moment two hues share a
174
+ family — contrast lives in the edge, not the fill. Judge contrast from a
175
+ rendered SVG, never from hex values.
176
+ - **Menus, titles, HUD, and settings are DOM overlays** (the ui/ shell), never
177
+ `Text` nodes drawn into the scene. Humans compare in-scene type to the DOM
178
+ around it and the scene loses. In-scene `Text` is for in-world labels only.
179
+ - **Strict layering** via the `z` field on nodes/commands: background → world →
180
+ actors → fx → HUD. Nothing gameplay-critical may be occluded. In-world `Text`
181
+ defaults to `z=0`, so a label placed ON a sprite (a tile value, a score over a
182
+ token) needs an explicit `z` ABOVE that sprite or it paints underneath and is
183
+ silently invisible. `layoutIssues` now flags this (`text … hidden behind …`).
184
+ - **Contrast is now headless-checkable.** Pass the page `background` to
185
+ `layoutIssues(commands, { background })` and it flags near-invisible fills and
186
+ sub-AA text from the hex alone — a first line of defense before the vision
187
+ judge. Still judge final contrast from a rendered SVG; the lint catches the
188
+ "dark shape on dark ground" class the pixel-blind proofs otherwise miss.
189
+
190
+ ## Definition of done
191
+
192
+ A game is done when ALL of the following hold. This is a checklist, not a vibe.
193
+
194
+ 1. **Typecheck clean** — `npm run check` reports zero errors. TypeScript strict
195
+ mode is a free correctness gate; use it.
196
+ 2. **Tests pass** — `npm test` green. Sim tests run headlessly in Node.
197
+ 3. **Every level is solver-proven or scripted-played** — each shipped level has
198
+ either a machine win-proof (`solve()`), or a scripted playthrough that
199
+ asserts the win condition on probes. In-head verification has a documented
200
+ failure rate (see [LESSONS.md](LESSONS.md)); it is not evidence.
201
+ 4. **Deterministic replay verified** — `assertDeterministic(game, seed,
202
+ inputLog)` passes: two runs of the same seed + input log produce an identical
203
+ `world.hash()` at every checkpoint.
204
+ 5. **The vision judge run and its high findings fixed** — `npm run judge <slug>`
205
+ renders the game to PNG; actually LOOK at it and score against
206
+ [JUDGE.md](JUDGE.md) (readability, depth, palette, juice, motion, chrome). Fix
207
+ every high-severity finding with cosmetic changes (the golden hash must stay
208
+ unchanged). "Passes the gates" is not "looks shipped"; this closes that gap.
209
+ 6. **A complete loop exists** — start → play → win/lose → restart, keyboard-only,
210
+ and `restart` fully rebuilds world state (no leaked nodes, no stale RNG).
211
+ 7. **The full run's hash is pinned** — `t.golden('full run', world.hash())` in
212
+ `verify.ts`, recorded via `UPDATE_GOLDEN=1 npm run verify`, `golden.json`
213
+ committed (VERIFICATION §Channel 1d).
214
+ 8. **Feel probes gate the genre's pacing** — at least two `recordTimeline`-based
215
+ metrics with tuned windows, plus a `renderFilmstrip` artifact reviewed for
216
+ motion/readability (VERIFICATION §Channel 3). Windows come from a run you
217
+ judged, not from thin air.
218
+ 9. **A feel spec is declared and its gates pass** — `export const feel: FeelSpec`
219
+ in `game.ts` (avatar fill for salience; controller config for forgiveness; a
220
+ feedback contract; `scrolls` for the camera gate). `npm run feel` runs every
221
+ gate the spec enables and is part of `npm run verify` — the professional FLOOR
222
+ is now a CI gate, not a hope (VERIFICATION §Channel 4). Declare only what is
223
+ honestly true; a false contract is worse than none.
package/docs/EMBED.md ADDED
@@ -0,0 +1,85 @@
1
+ # Embedding hayao (offline / single-file)
2
+
3
+ hayao's thesis is that an LLM can author a whole game as text. The natural
4
+ distribution for that is a **single self-contained HTML file** — no install, no
5
+ bundler, no CDN. That shape is what Claude Artifacts, js13k entries, itch.io HTML
6
+ uploads, and CodePen all want. This page has the two recipes.
7
+
8
+ `npm run build:lib` emits three library targets:
9
+
10
+ | File | Format | Use |
11
+ | --- | --- | --- |
12
+ | `dist/index.js` | ESM (readable, sourcemapped) | bundlers, `import` in apps |
13
+ | `dist/index.min.js` | ESM, minified | modern `<script type="module">` / inline import |
14
+ | `dist/hayao.global.js` | IIFE, minified, `window.hayao` | plain `<script src>` / paste-inline |
15
+
16
+ The minified builds are ~190 KB (~45 KB gzipped) and carry **no**
17
+ `sourceMappingURL` comment, so an inlined copy stays console-clean.
18
+
19
+ ## Recipe A — `<script src>` global (simplest)
20
+
21
+ The IIFE build exposes everything on `window.hayao`. No modules, no plumbing:
22
+
23
+ ```html
24
+ <div id="app"></div>
25
+ <script src="hayao.global.js"></script>
26
+ <script>
27
+ const { defineGame, runBrowser, Sprite, Text, Node, vec2 } = hayao;
28
+ const game = defineGame({
29
+ title: 'My Game',
30
+ build(world) {
31
+ const root = new Node({ name: 'root' });
32
+ root.addChild(new Text({ text: 'hello', pos: vec2(640, 360), align: 'center' }));
33
+ return root;
34
+ },
35
+ });
36
+ runBrowser(game, document.getElementById('app'));
37
+ </script>
38
+ ```
39
+
40
+ See [`examples/embed.html`](../examples/embed.html) for a runnable version
41
+ (pointer-follow demo). Serve the folder or open the file after `npm run build:lib`.
42
+
43
+ ## Recipe B — fully inline, single `.html` (truly offline)
44
+
45
+ For one file with **zero** external requests, paste the engine source directly
46
+ into a `<script>` tag instead of referencing it:
47
+
48
+ ```html
49
+ <div id="app"></div>
50
+ <script>
51
+ /* ⇩ paste the entire contents of dist/hayao.global.js here ⇩ */
52
+ "use strict";var hayao=(()=> …;
53
+ /* ⇧ end of pasted engine ⇧ */
54
+ </script>
55
+ <script>
56
+ const { defineGame, runBrowser } = hayao;
57
+ // …your game…
58
+ </script>
59
+ ```
60
+
61
+ That's the shape an LLM tool call can emit whole: one file, opens anywhere,
62
+ works on a plane. Because the minified builds drop the sourcemap comment, there
63
+ are no 404s or console warnings from the inlined copy.
64
+
65
+ ### ESM-inline variant
66
+
67
+ If you prefer modules, inline `dist/index.min.js` as a blob and dynamic-import it:
68
+
69
+ ```html
70
+ <script type="module">
71
+ const src = `/* paste dist/index.min.js here */`;
72
+ const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
73
+ const hayao = await import(url);
74
+ // …use hayao.defineGame / hayao.runBrowser…
75
+ </script>
76
+ ```
77
+
78
+ ## Notes
79
+
80
+ - **Assets.** If your game loads a webfont or atlas, use the `preload` +
81
+ `splash` lifecycle (see [API.md](API.md)) so there's no pop-in — the engine
82
+ holds a palette-guaranteed splash until preload resolves.
83
+ - **The one gap the engine can't close.** The window between the browser
84
+ *fetching* the file and the script *executing* is pre-engine by physics —
85
+ nothing hayao can draw yet. Recipe B shrinks it to a single local file.
@@ -0,0 +1,129 @@
1
+ # Quickstart — build a game from the `hayao` npm package
2
+
3
+ This is the consumer-facing guide: what to do after `npm i hayao`, with a
4
+ complete, runnable, headless-verifiable game in ~40 lines. For the full API
5
+ surface see [API.md](API.md); to browse finished games see
6
+ [hayao.dev/play](https://hayao.dev/play/).
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm i hayao
12
+ ```
13
+
14
+ **hayao is ESM-only.** Make sure your project is ES modules — set
15
+ `"type": "module"` in your `package.json` (or use an `.mjs` file). Importing
16
+ from a default CommonJS project fails with a cryptic
17
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`; that error means "switch to ESM," nothing more.
18
+
19
+ ## The one idea
20
+
21
+ A hayao game is a **pure, deterministic function of its inputs**. Rendering,
22
+ audio, and the browser are *observer plugins* that paint what the function
23
+ produces — they can never change its result. So you can run the whole game in
24
+ Node, assert on its state, and prove a level winnable, with no browser and no
25
+ pixels.
26
+
27
+ ## A complete game (`coins.ts`)
28
+
29
+ A 5×5 grid; walk onto coins to collect them. Canonical state lives in
30
+ `world.state` (plain JSON — it's what gets hashed). The scene tree is a
31
+ `cosmetic` *view* of that state and never pollutes the hash.
32
+
33
+ ```ts
34
+ import { defineGame, createWorld, drive, Node, Sprite, type World, type InputScript } from 'hayao';
35
+
36
+ const COLS = 5, ROWS = 5;
37
+ const COINS: [number, number][] = [[4, 0], [4, 4], [0, 4]];
38
+ interface State { x: number; y: number; coins: [number, number][]; collected: number; }
39
+
40
+ function tick(world: World) {
41
+ const s = world.state as unknown as State;
42
+ const i = world.input;
43
+ if (i.justPressed('right')) s.x = Math.min(COLS - 1, s.x + 1);
44
+ else if (i.justPressed('left')) s.x = Math.max(0, s.x - 1);
45
+ else if (i.justPressed('up')) s.y = Math.max(0, s.y - 1);
46
+ else if (i.justPressed('down')) s.y = Math.min(ROWS - 1, s.y + 1);
47
+ const before = s.coins.length;
48
+ s.coins = s.coins.filter(([cx, cy]) => !(cx === s.x && cy === s.y));
49
+ s.collected += before - s.coins.length;
50
+ }
51
+
52
+ export const game = defineGame({
53
+ title: 'Coin Grab', width: COLS * 32, height: ROWS * 32, seed: 1,
54
+ build(world: World): Node { // build MUST return the root Node
55
+ world.state = { x: 0, y: 0, coins: COINS.map(([x, y]) => [x, y]), collected: 0 } as unknown as Record<string, unknown>;
56
+ const root = new Node({ name: 'root' });
57
+ // A view sprite: shape is required (not `size`); mark it cosmetic so it stays out of hash().
58
+ const player = new Sprite({ name: 'player', shape: { kind: 'rect', w: 28, h: 28 }, fill: '#e8c34a' });
59
+ player.cosmetic = true;
60
+ player.onUpdate = (n) => { const s = world.state as unknown as State; n.pos = { x: s.x * 32 + 2, y: s.y * 32 + 2 }; };
61
+ root.addChild(player);
62
+ const brain = new Node({ name: 'brain' }); // a node that advances the sim each frame
63
+ brain.onUpdate = () => tick(world);
64
+ root.addChild(brain);
65
+ return root;
66
+ },
67
+ probe(world: World) { // expose exactly the state you'll assert on
68
+ const s = world.state as unknown as State;
69
+ return { x: s.x, y: s.y, collected: s.collected, remaining: s.coins.length, hash: world.hash() };
70
+ },
71
+ });
72
+ ```
73
+
74
+ ## Prove it works — headlessly
75
+
76
+ `createWorld(game)` runs the sim in Node. `drive(world, script, until?)` steps a
77
+ scripted input log, optionally stopping early when a predicate on `probe()` is
78
+ true. Then assert on `probe()` and `hash()`.
79
+
80
+ ```ts
81
+ // Input scripts are segments of { press, frames }. justPressed is a TRUE rising
82
+ // edge: holding the same action across adjacent frames fires ONCE, so separate
83
+ // repeated taps of the same action with a neutral (release) frame.
84
+ const tap = (dir: string): InputScript => [{ press: [dir], frames: 1 }, { frames: 1 }];
85
+ const script: InputScript = [
86
+ ...tap('right'), ...tap('right'), ...tap('right'), ...tap('right'),
87
+ ...tap('down'), ...tap('down'), ...tap('down'), ...tap('down'),
88
+ ...tap('left'), ...tap('left'), ...tap('left'), ...tap('left'),
89
+ ];
90
+
91
+ const world = createWorld(game);
92
+ const res = drive(world, script, (p) => p.remaining === 0);
93
+ const p = game.probe!(world);
94
+ console.assert(p.collected === 3 && p.remaining === 0, 'all coins collected');
95
+ console.assert(res.matched, 'stopped early on the win predicate');
96
+
97
+ // Determinism: the same script always produces the same hash.
98
+ const a = createWorld(game); drive(a, script);
99
+ const b = createWorld(game); drive(b, script);
100
+ console.assert(a.hash() === b.hash(), 'two runs hash identically');
101
+ ```
102
+
103
+ Run it:
104
+
105
+ ```sh
106
+ npx tsx coins.ts # or: node coins.mjs
107
+ ```
108
+
109
+ That's the whole loop an AI author uses: **define → step → assert on
110
+ state/hash**. A refactor that keeps the hash keeps behavior; a level a search
111
+ can reach is provably winnable — all without opening a browser.
112
+
113
+ ## Gotchas the type surface won't shout at you
114
+
115
+ - **ESM only** — `"type": "module"` (see Install).
116
+ - **`build(world)` must return the root `Node`.** Put canonical, hashed state in
117
+ `world.state` (plain JSON); never in module-level variables.
118
+ - **`Sprite` takes `{ shape: { kind: 'rect', w, h } }`**, not `{ size }`.
119
+ - **Mark pure-view nodes `cosmetic = true`** so transient display never enters
120
+ `world.hash()` — otherwise determinism checks will (correctly) fail.
121
+ - **`justPressed` is a one-frame rising edge** — insert a neutral frame between
122
+ repeated taps of the same action.
123
+
124
+ ## Where to go next
125
+
126
+ - [API.md](API.md) — the full, greppable public surface (every export + signature).
127
+ - [VERIFICATION.md](VERIFICATION.md) — the two verification channels; how to prove a game correct.
128
+ - [CONVENTIONS.md](CONVENTIONS.md) — how larger games are structured (pure logic modules, solver proofs, house style).
129
+ - [hayao.dev/play](https://hayao.dev/play/) — finished example games, each with source.
package/docs/STUDIO.md ADDED
@@ -0,0 +1,97 @@
1
+ # STUDIO.md — the human/AI playtest loop
2
+
3
+ Hayao proves almost everything by machine — winnability, determinism, ramp,
4
+ feel floors. The one channel no headless gate can close is **fun**, and that
5
+ takes a human. Studio is the instrument for that channel: it turns every human
6
+ playtest into a machine-legible, bit-exactly replayable artifact, and gives the
7
+ agent sanctioned verbs to act on it.
8
+
9
+ The design doctrine, in one line each:
10
+
11
+ - **Text is the source of truth.** The Studio UI and its knob panel are
12
+ observers; accepted values become code only when the agent edits the declared
13
+ defaults. `.studio/` is session data, never config.
14
+ - **Filesystem is the bus.** The browser posts artifacts to the dev server; the
15
+ agent reads the same files via the MCP sidecar. The two never hold a socket
16
+ to each other, so everything works across restarts and from cloud agents.
17
+ - **A session is a re-executable, not a recording.** `(seed, tuning, inputLog,
18
+ axesLog, knobEvents)` re-simulate the entire run in Node — any metric is
19
+ computable retroactively, any tick re-inspectable.
20
+ - **Telemetry describes, the human directs.** Metrics and reports feed the
21
+ human's decisions; nothing is auto-"fixed" from data alone.
22
+
23
+ ## Pieces
24
+
25
+ | Piece | Where | Role |
26
+ |---|---|---|
27
+ | Tuning knobs | `tuning:` on `defineGame` (see `src/app/tuning.ts`) | declared live-adjustable params; resolved values are hashed sim state read via `world.tune(key)` |
28
+ | `runStudio()` | game `main.ts` (instead of `runBrowser`) | records the session, applies `?seed=`/`?tuning=` URL overrides, exposes `window.__studio` (setKnob / annotate / flush) |
29
+ | Dev middleware | `hayaoStudio()` plugin in `vite.config.ts` | persists sessions + knob values under `.studio/`, serves `/__studio/state`, implements `/__shot` |
30
+ | MCP sidecar | `bin/hayao-mcp.ts`, registered in `.mcp.json` | the agent's verbs: `list_games`, `list_sessions`, `inspect_moment`, `get_knob_state`, `run_verify` |
31
+ | `/studio` skill | `.claude/skills/studio/` | the two loops: knob write-back and playtest reading |
32
+
33
+ ## The session artifact
34
+
35
+ `.studio/sessions/<id>.json` (`PlaytestSession` in `src/studio/session.ts`):
36
+ seed, initial tuning, per-frame action log, delta-encoded analog axes
37
+ (pointer is grid-quantized at the source so replay is exact), mid-play knob
38
+ events (replayed via rebuild-with-carryover at their exact frame), DOM screen
39
+ events (pause/overlay — menu time the sim can't see), wall-clock marks (tab
40
+ hidden ≠ hesitation), human annotations ("felt-bad @ frame N"), end reason,
41
+ and the git build ref. `playerId`/`consent` fields exist for future invited
42
+ playtesters; creator sessions leave them unset. **Production players are never
43
+ recorded** — this is a dev-server instrument only.
44
+
45
+ `replaySession(def, session, toFrame?)` reconstructs the world at any tick;
46
+ `assertSnapshotStable`-grade hash equality is covered in
47
+ `src/studio/session.test.ts`.
48
+
49
+ ## Scrub semantics (time travel in the play pane)
50
+
51
+ The timeline strip under pane A freezes the sim and drags to any recorded
52
+ frame — exact, not approximate: the engine restores the nearest periodic
53
+ snapshot (`SnapshotRing`, every 30 frames) and re-steps the session's own
54
+ recorded inputs (actions, axes, knob events) to the target. The ⌖ button shows
55
+ the live probe at the scrubbed frame. **Resuming after a rewind FORKS the
56
+ timeline**: the discarded future is truncated from the session (frames, axes,
57
+ knob/screen events, annotations past the fork), so the artifact always replays
58
+ as exactly what the player kept — a `scrub` screen event marks the fork frame.
59
+
60
+ ## Tape mode (watch any past session)
61
+
62
+ A game page opened with `?session=<id>` (`&at=<frame>` optional) becomes a
63
+ read-only player for that artifact: the exact starting world is rebuilt, the
64
+ whole recording is fast-replayed once to fill an adaptively-strided snapshot
65
+ ring, and the full tape is scrubbable end to end — playback steps recorded
66
+ frames at real time (elapsed-time accumulator, so throttled background timers
67
+ can't slow it). The Studio drawer's report links each moment ("longest pause @
68
+ frame N", your annotations, the quit frame) straight into the tape at that
69
+ frame. Knobs and annotation are disabled; nothing records.
70
+
71
+ ## Hot-swap semantics (play across code edits)
72
+
73
+ A game entry that passes `hot: import.meta.hot` to `runStudio` AND contains the
74
+ literal line `import.meta.hot?.accept();` keeps its live world across code
75
+ edits: on swap the old run snapshots into `hot.data`, the re-executed module
76
+ restores it (new module's tuning wins), and the session splits into segments —
77
+ the old one flushes as `hot-swap`, the new one records the restored snapshot as
78
+ its `startSnapshot`, so **every segment stays bit-exactly replayable** despite
79
+ the code change. Both lines are required: Vite marks HMR boundaries by
80
+ statically scanning module source, so an `accept()` call inside the engine
81
+ cannot mark your entry self-accepting — only the literal in your file can.
82
+
83
+ ## Knob-change semantics
84
+
85
+ Changing a knob mid-play is a **rebuild-with-carryover**: snapshot → new
86
+ tuning → restore → `def.attach?.(world)`. Behaviors are closures and die on
87
+ restore, so games whose nodes hold closures re-wire them in `attach` — the
88
+ same contract rollback netplay uses. Tuning values live in `world.hash()`, so
89
+ a knob change can never silently escape determinism checks.
90
+
91
+ ## Working on Studio itself
92
+
93
+ Browser-safe parts (`session.ts`, `record.ts`, `run.ts`) export through the
94
+ `@hayao` barrel; the Vite plugin and MCP sidecar are Node-only and must never
95
+ enter the barrel. Wall-clock use in `run.ts`/`record.ts` is sanctioned by the
96
+ invariants scanner alongside the browser drivers — timestamps go to session
97
+ artifacts (observer data), never into the sim.