hayao 0.2.0 → 0.4.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 (83) hide show
  1. package/README.md +123 -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 +65 -4
  5. package/dist/app/game.d.ts +59 -10
  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 +39 -1
  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 +63 -0
  21. package/dist/audio/theory.d.ts +64 -0
  22. package/dist/audio/zzfx.d.ts +14 -0
  23. package/dist/content/campaign.d.ts +69 -0
  24. package/dist/content/generate.d.ts +78 -0
  25. package/dist/content/level.d.ts +93 -0
  26. package/dist/content/worldgraph.d.ts +73 -0
  27. package/dist/core/clock.d.ts +1 -1
  28. package/dist/core/dmath.d.ts +2 -0
  29. package/dist/core/projection.d.ts +36 -0
  30. package/dist/core/rng.d.ts +9 -0
  31. package/dist/hayao.global.js +200 -0
  32. package/dist/index.d.ts +39 -1
  33. package/dist/index.js +6558 -686
  34. package/dist/index.js.map +4 -4
  35. package/dist/index.min.js +200 -0
  36. package/dist/input/actions.d.ts +60 -6
  37. package/dist/input/gamepad.d.ts +101 -0
  38. package/dist/input/source.d.ts +133 -1
  39. package/dist/logic/coroutine.d.ts +68 -0
  40. package/dist/mcp.js +31225 -0
  41. package/dist/rasterize-worker-lite.mjs +13 -0
  42. package/dist/render/canvas.d.ts +6 -5
  43. package/dist/render/canvas2d-core.d.ts +9 -0
  44. package/dist/render/commands.d.ts +82 -2
  45. package/dist/render/paint.d.ts +41 -0
  46. package/dist/render/renderer.d.ts +47 -0
  47. package/dist/render/svg.d.ts +5 -1
  48. package/dist/render/svgString.d.ts +6 -2
  49. package/dist/render/webgl.d.ts +176 -0
  50. package/dist/scene/cameraController.d.ts +42 -0
  51. package/dist/scene/iso.d.ts +73 -0
  52. package/dist/scene/node.d.ts +98 -6
  53. package/dist/scene/nodes.d.ts +48 -0
  54. package/dist/scene/parallax.d.ts +15 -0
  55. package/dist/scene/particles.d.ts +19 -0
  56. package/dist/scene/verletChain.d.ts +76 -0
  57. package/dist/studio/mcpMain.d.ts +1 -0
  58. package/dist/studio/mcpServer.d.ts +2 -0
  59. package/dist/studio/record.d.ts +54 -0
  60. package/dist/studio/run.d.ts +78 -0
  61. package/dist/studio/session.d.ts +80 -0
  62. package/dist/studio/timeline.d.ts +35 -0
  63. package/dist/studio/vitePlugin.d.ts +6 -0
  64. package/dist/studio-plugin.js +228 -0
  65. package/dist/ui/overlay.d.ts +6 -0
  66. package/dist/ui/touch.d.ts +51 -0
  67. package/dist/verify/audioFilmstrip.d.ts +39 -0
  68. package/dist/verify/dom.d.ts +26 -0
  69. package/dist/verify/ethnography.d.ts +67 -0
  70. package/dist/verify/gates.d.ts +160 -0
  71. package/dist/verify/layout.d.ts +30 -3
  72. package/dist/verify/ramp.d.ts +40 -0
  73. package/dist/world.d.ts +109 -8
  74. package/dist-studio/assets/index-C7tty_Wo.js +109 -0
  75. package/dist-studio/assets/index-CM3tjRQo.css +1 -0
  76. package/dist-studio/index.html +18 -0
  77. package/docs/API.md +300 -23
  78. package/docs/CONVENTIONS.md +511 -0
  79. package/docs/EMBED.md +90 -0
  80. package/docs/QUICKSTART.md +141 -0
  81. package/docs/STUDIO.md +97 -0
  82. package/docs/VERIFICATION.md +256 -0
  83. package/package.json +40 -6
@@ -0,0 +1,141 @@
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. `justReleased` mirrors it.
123
+ - **Query ACTION names, not key codes** — `isDown('jump')`, never
124
+ `isDown('Space')`. In the browser, an undeclared name warns once in the
125
+ console instead of silently never firing.
126
+ - **Pointer coordinates are design-space** — `input.axis('pointer.x'/'pointer.y')`
127
+ already un-letterboxed; mouse buttons are bindable actions
128
+ (`mouse.left/right/middle`). See CONVENTIONS §Pointer & continuous input.
129
+ - **`splash: false`** in `defineGame` skips the built-in loading splash (e.g.
130
+ for embeds that paint their own cover).
131
+ - **The browser build is scriptable** — `?capture=1` exposes `window.__hayao`
132
+ (pump/probe/hash/shot), and `handle.tick()` single-steps the real loop; the
133
+ loop keeps running in hidden tabs (never patch rAF yourself). See
134
+ VERIFICATION §Channel 2.
135
+
136
+ ## Where to go next
137
+
138
+ - [API.md](API.md) — the full, greppable public surface (every export + signature).
139
+ - [VERIFICATION.md](VERIFICATION.md) — the two verification channels; how to prove a game correct.
140
+ - [CONVENTIONS.md](CONVENTIONS.md) — how larger games are structured (pure logic modules, solver proofs, house style).
141
+ - [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.
@@ -0,0 +1,256 @@
1
+ # Verification — how an AI proves a hayao game works
2
+
3
+ The whole engine is shaped so you can verify a game **without a browser and
4
+ without playing it**. Build the harness *before* the content; unverified content
5
+ is presumed broken (in-head verification has a documented failure rate).
6
+
7
+ Two channels, in order:
8
+
9
+ 1. **Numeric channel** — headless `step()`, probes, the solver, and determinism
10
+ checks. Proves *behavior* and *beatability*. This is where correctness lives.
11
+ 2. **Visual channel** — a headless SVG screenshot. Judges *looks* only — palette,
12
+ layout, legibility. Never correctness.
13
+
14
+ Everything below runs in Node under Vitest (`npm test`) or the CI script
15
+ (`npm run verify`).
16
+
17
+ ---
18
+
19
+ ## Channel 1a — scripted playthrough (any game)
20
+
21
+ Drive a world with an input log and assert on its probe. No DOM.
22
+
23
+ ```ts
24
+ import { createWorld } from '@hayao';
25
+ import { myGame } from './game';
26
+
27
+ const world = createWorld(myGame); // a live, deterministic World
28
+ world.step(['right']); // one fixed step, 'right' held
29
+ world.step([]); // release (edges need a rising edge)
30
+ world.step(['confirm']);
31
+ expect(world.probe().score).toBe(1); // assert on game state, not pixels
32
+ ```
33
+
34
+ Helpers: `scriptedPlaythrough(world, [hold(['right'], 30), wait(10)])` returns a
35
+ probe after each segment; `pump(world, n, actions)` advances n steps. Hold keys
36
+ for several frames — a 1-frame synthetic tap is treated as a real short tap.
37
+
38
+ ## Channel 1b — winnability solver (puzzles / turn-based)
39
+
40
+ Keep the rules in a pure module implementing `Puzzle<State, Move>` (no engine
41
+ imports), then prove every level beatable by search:
42
+
43
+ ```ts
44
+ import { solve, assertSolvable, type Puzzle } from '@hayao';
45
+
46
+ const puzzle: Puzzle<State, Move> = {
47
+ initial: (level) => …,
48
+ moves: (s) => […],
49
+ apply: (s, m) => …, // fold in a DETERMINISTIC opponent here if any
50
+ isWin: (s) => …,
51
+ isDead: (s) => …, // optional: prune lost states
52
+ key: (s) => canonicalString(s),
53
+ };
54
+
55
+ const r = assertSolvable(puzzle, { level: 0, maxDepth: 80 }); // throws if unwinnable
56
+ console.log(r.depth, r.nodes); // shortest solution length + search cost
57
+ ```
58
+
59
+ A deterministic opponent means winnability is single-agent BFS — no adversarial
60
+ branching. `solve` returns the shortest `path`, which you can replay through the
61
+ real game (Channel 1a) to prove the *view* agrees with the *logic*.
62
+
63
+ ## Channel 1c — determinism (the regression net)
64
+
65
+ Same seed + same input log must produce bit-identical state hashes. If not, some
66
+ nondeterminism leaked in (Math.random, Date.now, Set iteration, unordered map).
67
+
68
+ ```ts
69
+ import { assertDeterministic, assertSnapshotStable } from '@hayao';
70
+
71
+ assertDeterministic(() => createWorld(myGame), inputLog); // runs twice, compares every step's hash
72
+ assertSnapshotStable(() => createWorld(myGame), inputLog, 20); // snapshot→restore reproduces the tail exactly
73
+ ```
74
+
75
+ `world.hash()` is the structural hash; `world.snapshot()` / `world.restore()` are
76
+ save/undo/time-travel. Mark pure-view nodes `cosmetic = true` so transient display
77
+ (move counters, particles, tweened positions) never pollutes the canonical hash.
78
+
79
+ ## Channel 1d — the golden replay corpus (portfolio-wide refactor net)
80
+
81
+ Every example pins its full scripted run's final `world.hash()` in a committed
82
+ `examples/<slug>/golden.json`, checked by `t.golden(label, hash)` in its
83
+ verify suite. This turns "did my engine refactor change ANY game's behavior?"
84
+ into one command: `npm run verify` — a pure refactor leaves every golden hash
85
+ untouched; any divergence names the game and the run that changed.
86
+
87
+ ```ts
88
+ // in examples/<slug>/verify.ts, after the full run:
89
+ t.golden('full run', world.hash());
90
+ ```
91
+
92
+ **Re-record policy.** Golden divergence is a failing gate, on purpose. When a
93
+ behavior change is *intentional* (tuning, new content, a deliberate engine
94
+ change), re-record and say so:
95
+
96
+ 1. `UPDATE_GOLDEN=1 npm run verify` — rewrites the affected `golden.json`.
97
+ 2. Commit the new hashes IN THE SAME COMMIT as the change, with a line in the
98
+ message naming which games' behavior changed and why.
99
+
100
+ Never re-record to silence a divergence you can't explain — an unexplained
101
+ golden change IS the bug the corpus exists to catch.
102
+
103
+ ## Channel 2 — the visual screenshot (looks only)
104
+
105
+ ```ts
106
+ import { HeadlessRenderer, createWorld } from '@hayao';
107
+
108
+ const world = createWorld(myGame);
109
+ world.step([]);
110
+ const r = new HeadlessRenderer({ width: 1280, height: 720, background: '#f3ecdb' });
111
+ r.draw(world.render());
112
+ writeFileSync('shots/title.svg', r.toSVGString()); // a real vector screenshot, in Node
113
+ ```
114
+
115
+ Because rendering is SVG, the screenshot is **resolution-independent and text is
116
+ crisp** — no canvas fuzz, no GPU. You rarely need a browser at all, because the
117
+ DOM is already inspectable and the sim already runs in Node — but when a tool
118
+ must drive the real browser build, two seams exist:
119
+
120
+ **`?capture=1` → `window.__hayao`** (`src/verify/capture.ts`). Appending
121
+ `?capture=1` to a game's URL skips the splash and installs a scripted-session
122
+ API on `window`:
123
+
124
+ | Member | What it does |
125
+ |---|---|
126
+ | `pump(frames, actions?)` | Step exactly `frames` fixed steps with `actions` held (wall-clock loop suspended), then return `probe()` |
127
+ | `probe()` | The game's probe snapshot |
128
+ | `hash()` | `world.hash()` — the determinism hash |
129
+ | `shot()` | Current frame as an SVG string (a vector screenshot) |
130
+ | `save(path)` | POST that SVG to the dev server's `/__shot` endpoint; resolves `true` on success |
131
+ | `key(type, code)` | Dispatch a synthetic `keydown`/`keyup` by key code |
132
+ | `world` | The live `World` — full access when the shorthands aren't enough |
133
+
134
+ `probe` is whatever the game's `defineGame({ probe })` returns — expose exactly
135
+ the state a scripted session will assert on.
136
+
137
+ **`handle.tick(dtMs?)`** — the `runBrowser` handle can drive one frame by hand:
138
+ sample the pointer and extra sources, advance by `dtMs` (default one fixed
139
+ step) with the currently-held actions, render. It is the SAME code path the
140
+ wall-clock loop runs, so tool-driven frame stepping is the real loop, not a
141
+ simulation of it. The wall-clock loop keeps running independently — pause the
142
+ shell or hold via `RunOptions.isHeld` to make `tick()` the only driver.
143
+
144
+ **Hidden tabs and iframes keep ticking.** Browsers throttle rAF to death in
145
+ hidden tabs/iframes; the engine now owns the fallback — when `document.hidden`,
146
+ the loop pumps via `setTimeout(16)` and swaps back to native rAF on
147
+ visibility. Games and harnesses must **NOT** patch the global
148
+ `requestAnimationFrame` anymore: per-game patches double-fire and race the
149
+ engine's scheduler (the 2026-07 triage traced real bugs to exactly that).
150
+
151
+ For MOTION, use the filmstrip instead of a single frame: it samples a whole
152
+ run into one SVG contact sheet, so pacing, readability under load, and
153
+ layering during play are judgeable from a file.
154
+
155
+ ```ts
156
+ import { renderFilmstrip } from '@hayao';
157
+ t.artifact('run-filmstrip.svg', renderFilmstrip(createWorld(myGame), inputFrames, {
158
+ width: myGame.width, height: myGame.height, background: myGame.background, panels: 12,
159
+ }));
160
+ // → shots/<slug>/run-filmstrip.svg — open it and actually look.
161
+ ```
162
+
163
+ ## Channel 3 — feel probes (quality proxies)
164
+
165
+ Correctness channels prove the game is *not broken*; they say nothing about
166
+ whether it is *good*. Feel probes give quality a measurable proxy: compute
167
+ metrics from a per-frame probe timeline of the winning run, and gate them on
168
+ windows YOU tune per game. A window is a design decision recorded as a test —
169
+ retuning that wrecks pacing fails loudly instead of silently.
170
+
171
+ ```ts
172
+ import { recordTimeline, firstFrame, inputDensity, longestLull, changeFrames, isMonotonic } from '@hayao';
173
+
174
+ const tl = recordTimeline(createWorld(myGame), winningLog);
175
+ firstFrame(tl, (p) => p.kills > 0); // time-to-first-meaningful-action
176
+ inputDensity(winningLog); // engagement: waiting ↔ mashing
177
+ longestLull(changeFrames(tl, 'score'), tl.length); // dead-air detector
178
+ isMonotonic(depths, 'up', slack); // difficulty ramps with breathers
179
+ ```
180
+
181
+ What to gate is genre truth: a stealth game gates its hide/move alternation,
182
+ an incremental its unlock cadence, a puzzle its solve-depth curve. Two rules:
183
+ (1) derive windows from a run you have actually judged as feeling right —
184
+ never invent them; (2) keep them generous enough that tuning breathes, tight
185
+ enough that pacing regressions fail. These are proxies, not proof of fun —
186
+ the filmstrip and a human/AI look stay in the loop.
187
+
188
+ ## Channel 4 — feel gates (the professional floor, made checkable)
189
+
190
+ Channel 3 measures *pacing* from a timeline; Channel 4 gates the small set of feel
191
+ **fundamentals** that separate amateur from professional and that are genuinely
192
+ mechanical, not matters of taste. Each returns human-readable issues (empty = pass),
193
+ exactly like `layoutIssues` — so wiring one in is one line. This is the direct
194
+ answer to "green tests, dead game": turn more of *feel* green.
195
+
196
+ ```ts
197
+ import { forgivenessIssues, graceWindowIssues, feedbackIssues,
198
+ salienceIssues, telegraphIssues, cameraIssues, lookAheadIssues } from '@hayao';
199
+ ```
200
+
201
+ 1. **Forgiveness.** `forgivenessIssues(cfg)` audits the grace windows (coyote,
202
+ jump-buffer, corner-nudge) against a floor; `graceWindowIssues(label, W, accepts)`
203
+ *behaviourally* proves a window accepts an input `0..W` frames late and refuses
204
+ `W+1` — the exact edge FUN law 5 demands. (updrift proves a 4-frame coyote window
205
+ by walking a rig off a lip and jumping D frames later.)
206
+ 2. **Feedback completeness.** Declare a `FeedbackContract` (event → channels +
207
+ shake/hit-stop), then `feedbackIssues(contract, events)` proves every significant
208
+ event answers on ≥ 2 senses within the frame, with juice bounded (min to read,
209
+ max to not nauseate).
210
+ 3. **Readability.** `salienceIssues(render(), avatarFill, background)` proves the
211
+ avatar out-contrasts both the background and the median scenery fill (WCAG
212
+ luminance, from pure draw data). `telegraphIssues(timeline, minFrames)` proves
213
+ every threat activation is preceded by ≥ `minFrames` of telegraph — reactive play
214
+ needs danger to announce itself.
215
+ 4. **Camera lawfulness.** `cameraIssues(samples)` proves the follow never snaps
216
+ (bounded speed) or jerks (bounded acceleration); `lookAheadIssues(cam, target)`
217
+ proves it leads the motion rather than trailing. Sample the *follow* position
218
+ (exclude screen shake), and drop the pre-start frame (no camera exists yet).
219
+
220
+ The full four-gate suite wired on a real game lives in
221
+ [`examples/updrift/verify.ts`](../examples/updrift/verify.ts); the authoring side is
222
+ [docs/JUICE.md](JUICE.md). Gate on the fundamentals; the *soul* above the floor
223
+ stays authored (a director, not a proof) — but the floor no amateur clears is now
224
+ machine-enforced.
225
+
226
+ **Portfolio floor (`npm run feel`).** A game declares its contract once —
227
+ `export const feel: FeelSpec` in `game.ts` (avatar fill → salience; controller
228
+ config → forgiveness; feedback contract → feedback; `scrolls` → camera). The audit
229
+ runs every gate each spec enables across all games and exits non-zero on any
230
+ failure; it is part of `npm run verify`, so the floor rises for ALL output, not one
231
+ game. Declare only what is honestly true — a false contract is worse than none.
232
+
233
+ ## Channel 5 — the vision judge (`npm run judge`)
234
+
235
+ The gates prove the floor mechanically; they can't see that a scene is *empty* or
236
+ *flat*. `npm run judge` renders each game headlessly to PNG (SVG → `@resvg/resvg-js`,
237
+ no browser) so a multimodal model LOOKS at the pixels and scores them against
238
+ [JUDGE.md](JUDGE.md) — then fixes what a human would wince at, staying cosmetic (the
239
+ golden hash unchanged). It's the one judge that can't be a pure function, which is
240
+ why an AI-first engine — deterministic sim, headless render — is uniquely built to
241
+ run it in a loop. Drive it with the `/judge` skill.
242
+
243
+ ---
244
+
245
+ ## The gate (`npm run verify`)
246
+
247
+ The CI verifier proves every example winnable, replays a solution through the
248
+ real game, and checks determinism — exiting non-zero on any failure. Wire your
249
+ game's puzzle + game into `scripts/verify.ts` and it becomes a pipeline gate.
250
+
251
+ ## Invariants
252
+
253
+ - All randomness flows through `world.rng`. No `Math.random()` in `src/` or games.
254
+ - No wall-clock in the sim (`Date.now`, `performance.now`, argless `new Date`).
255
+ - Fixed tree order; ordered collections for logic (no `Set`/object-key order).
256
+ - Every shipped level: a solver proof OR a scripted playthrough. No exceptions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hayao",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "An AI-first game engine: a deterministic, headless-native simulation kernel with a Godot-style scene tree, pluggable renderers (SVG/Canvas/headless), and a built-in verification harness — designed so an LLM can author, test, and prove correct a whole game without ever opening a browser.",
6
6
  "keywords": [
@@ -26,15 +26,31 @@
26
26
  "main": "./dist/index.js",
27
27
  "module": "./dist/index.js",
28
28
  "types": "./dist/index.d.ts",
29
+ "bin": {
30
+ "create-hayao": "./bin/create-hayao.mjs",
31
+ "hayao-mcp": "./bin/hayao-mcp-cli.mjs"
32
+ },
29
33
  "exports": {
30
34
  ".": {
31
35
  "types": "./dist/index.d.ts",
32
36
  "import": "./dist/index.js"
37
+ },
38
+ "./studio": {
39
+ "types": "./dist/studio/vitePlugin.d.ts",
40
+ "import": "./dist/studio-plugin.js"
33
41
  }
34
42
  },
35
43
  "files": [
36
44
  "dist",
37
- "docs/API.md"
45
+ "dist-studio",
46
+ "bin/create-hayao.mjs",
47
+ "bin/hayao-mcp-cli.mjs",
48
+ "docs/QUICKSTART.md",
49
+ "docs/API.md",
50
+ "docs/VERIFICATION.md",
51
+ "docs/CONVENTIONS.md",
52
+ "docs/EMBED.md",
53
+ "docs/STUDIO.md"
38
54
  ],
39
55
  "engines": {
40
56
  "node": ">=18"
@@ -45,19 +61,37 @@
45
61
  "scripts": {
46
62
  "dev": "vite",
47
63
  "build": "tsc --noEmit && vite build",
48
- "build:lib": "rm -rf dist && esbuild src/index.ts --bundle --format=esm --platform=neutral --target=es2022 --outfile=dist/index.js --sourcemap && tsc -p tsconfig.build.json",
64
+ "build:lib": "rm -rf dist && esbuild src/index.ts --bundle --format=esm --platform=neutral --target=es2022 --outfile=dist/index.js --sourcemap && esbuild src/index.ts --bundle --minify --format=esm --platform=neutral --target=es2022 --outfile=dist/index.min.js && esbuild src/index.ts --bundle --minify --format=iife --global-name=hayao --platform=neutral --target=es2022 --outfile=dist/hayao.global.js && esbuild src/studio/mcpMain.ts --bundle --format=esm --platform=node --target=es2022 --outfile=dist/mcp.js --external:@resvg/resvg-js --external:hayao && esbuild src/studio/vitePlugin.ts --bundle --format=esm --platform=node --target=es2022 --outfile=dist/studio-plugin.js --external:vite && cp scripts/rasterize-worker-lite.mjs dist/rasterize-worker-lite.mjs && tsc -p tsconfig.build.json",
65
+ "build:studio": "vite build -c vite.studio.config.ts",
49
66
  "check": "tsc --noEmit",
50
67
  "test": "vitest run",
51
68
  "test:watch": "vitest",
52
69
  "api": "tsx scripts/api-digest.ts",
53
70
  "invariants": "tsx scripts/invariants.ts",
54
- "verify": "tsx scripts/invariants.ts && tsx scripts/verify.ts",
55
- "prepublishOnly": "npm run check && npm test && npm run build:lib",
56
- "relay": "node scripts/relay.mjs"
71
+ "verify": "tsx scripts/verify-all.ts",
72
+ "eval": "tsx scripts/eval.ts",
73
+ "create": "node bin/create-hayao.mjs",
74
+ "prepublishOnly": "npm run check && npm test && npm run build:lib && npm run build:studio",
75
+ "relay": "node scripts/relay.mjs",
76
+ "palette": "tsx scripts/palette-audit.ts",
77
+ "audio": "tsx scripts/audio-render.ts",
78
+ "feel": "tsx scripts/feel.ts",
79
+ "judge": "tsx scripts/judge.ts",
80
+ "thumbs": "tsx scripts/site-thumbs.ts"
57
81
  },
58
82
  "devDependencies": {
83
+ "@modelcontextprotocol/sdk": "^1.29.0",
84
+ "@resvg/resvg-js": "^2.6.2",
59
85
  "@types/node": "^22.10.0",
86
+ "@types/qrcode": "^1.5.6",
87
+ "@types/react": "^19.2.17",
88
+ "@types/react-dom": "^19.2.3",
60
89
  "esbuild": "^0.28.0",
90
+ "happy-dom": "^20.10.6",
91
+ "leva": "^0.10.1",
92
+ "qrcode": "^1.5.4",
93
+ "react": "^19.2.7",
94
+ "react-dom": "^19.2.7",
61
95
  "tsx": "^4.19.0",
62
96
  "typescript": "^5.7.0",
63
97
  "vite": "^6.0.0",