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.
- package/README.md +123 -24
- package/bin/create-hayao.mjs +380 -0
- package/bin/hayao-mcp-cli.mjs +11 -0
- package/dist/app/browser.d.ts +65 -4
- package/dist/app/game.d.ts +59 -10
- package/dist/app/tuning.d.ts +68 -0
- package/dist/art/palette.d.ts +41 -0
- package/dist/audio/adaptive.d.ts +58 -0
- package/dist/audio/album.d.ts +16 -0
- package/dist/audio/analysis.d.ts +59 -0
- package/dist/audio/audio.d.ts +39 -1
- package/dist/audio/chord.d.ts +17 -0
- package/dist/audio/genres.d.ts +11 -0
- package/dist/audio/lint.d.ts +29 -0
- package/dist/audio/match.d.ts +38 -0
- package/dist/audio/music.d.ts +88 -0
- package/dist/audio/pcm.d.ts +54 -0
- package/dist/audio/quality.d.ts +28 -0
- package/dist/audio/reverb.d.ts +15 -0
- package/dist/audio/synth.d.ts +63 -0
- package/dist/audio/theory.d.ts +64 -0
- package/dist/audio/zzfx.d.ts +14 -0
- package/dist/content/campaign.d.ts +69 -0
- package/dist/content/generate.d.ts +78 -0
- package/dist/content/level.d.ts +93 -0
- package/dist/content/worldgraph.d.ts +73 -0
- package/dist/core/clock.d.ts +1 -1
- package/dist/core/dmath.d.ts +2 -0
- package/dist/core/projection.d.ts +36 -0
- package/dist/core/rng.d.ts +9 -0
- package/dist/hayao.global.js +200 -0
- package/dist/index.d.ts +39 -1
- package/dist/index.js +6558 -686
- package/dist/index.js.map +4 -4
- package/dist/index.min.js +200 -0
- package/dist/input/actions.d.ts +60 -6
- package/dist/input/gamepad.d.ts +101 -0
- package/dist/input/source.d.ts +133 -1
- package/dist/logic/coroutine.d.ts +68 -0
- package/dist/mcp.js +31225 -0
- package/dist/rasterize-worker-lite.mjs +13 -0
- package/dist/render/canvas.d.ts +6 -5
- package/dist/render/canvas2d-core.d.ts +9 -0
- package/dist/render/commands.d.ts +82 -2
- package/dist/render/paint.d.ts +41 -0
- package/dist/render/renderer.d.ts +47 -0
- package/dist/render/svg.d.ts +5 -1
- package/dist/render/svgString.d.ts +6 -2
- package/dist/render/webgl.d.ts +176 -0
- package/dist/scene/cameraController.d.ts +42 -0
- package/dist/scene/iso.d.ts +73 -0
- package/dist/scene/node.d.ts +98 -6
- package/dist/scene/nodes.d.ts +48 -0
- package/dist/scene/parallax.d.ts +15 -0
- package/dist/scene/particles.d.ts +19 -0
- package/dist/scene/verletChain.d.ts +76 -0
- package/dist/studio/mcpMain.d.ts +1 -0
- package/dist/studio/mcpServer.d.ts +2 -0
- package/dist/studio/record.d.ts +54 -0
- package/dist/studio/run.d.ts +78 -0
- package/dist/studio/session.d.ts +80 -0
- package/dist/studio/timeline.d.ts +35 -0
- package/dist/studio/vitePlugin.d.ts +6 -0
- package/dist/studio-plugin.js +228 -0
- package/dist/ui/overlay.d.ts +6 -0
- package/dist/ui/touch.d.ts +51 -0
- package/dist/verify/audioFilmstrip.d.ts +39 -0
- package/dist/verify/dom.d.ts +26 -0
- package/dist/verify/ethnography.d.ts +67 -0
- package/dist/verify/gates.d.ts +160 -0
- package/dist/verify/layout.d.ts +30 -3
- package/dist/verify/ramp.d.ts +40 -0
- package/dist/world.d.ts +109 -8
- package/dist-studio/assets/index-C7tty_Wo.js +109 -0
- package/dist-studio/assets/index-CM3tjRQo.css +1 -0
- package/dist-studio/index.html +18 -0
- package/docs/API.md +300 -23
- package/docs/CONVENTIONS.md +511 -0
- package/docs/EMBED.md +90 -0
- package/docs/QUICKSTART.md +141 -0
- package/docs/STUDIO.md +97 -0
- package/docs/VERIFICATION.md +256 -0
- 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.
|
|
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
|
-
"
|
|
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/
|
|
55
|
-
"
|
|
56
|
-
"
|
|
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",
|