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,511 @@
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`, plus `state`, `events`, `width`/`height`, `paused`,
60
+ `timeScale`, and `camera()`) — prefer it over closing over `world` from
61
+ `build()`, so a node's behavior is self-contained and liftable. Type the state
62
+ with `defineGame<TState>` and keep it in `world.state`/`ctx.state` — never in
63
+ module-level variables, which leak across `restart()` and hide from `hash()`
64
+ (see ENGINE.md §Typed state). For headless tests, `world.runSteps(n,
65
+ i => actions)` fast-forwards an exact step count; `world.advance(realMs)` is the
66
+ *realtime* driver and clamps big deltas to one frame budget (never use it to skip
67
+ ahead in a test).
68
+
69
+ ## Renderers
70
+
71
+ The engine ships four renderer backends, all consuming the same `DrawCommand[]`
72
+ display list. The sim never reads the renderer — renderer choice is purely a host
73
+ concern and has zero effect on `world.hash()` or determinism.
74
+
75
+ | Backend | `RunOptions.renderer` | Best for |
76
+ |---|---|---|
77
+ | `SvgRenderer` | `'svg'` (default) | Sparse scenes, DOM inspection, crisp resolution-independence |
78
+ | `Canvas2DRenderer` | `'canvas'` | High entity counts, particles, performance-first |
79
+ | `WebGL2Renderer` | `'webgl'` | Post-processing effects (bloom, pixelate, glitch, palette rotation) |
80
+ | `HeadlessRenderer` | — (Node.js only) | Tests, `npm run verify`, `toSVGString()` |
81
+
82
+ To select a renderer:
83
+
84
+ ```ts
85
+ import { runBrowser, WebGL2Renderer } from '@hayao';
86
+
87
+ // Built-in string shorthand:
88
+ runBrowser(def, mount, { renderer: 'webgl' });
89
+
90
+ // Or pass your own configured instance directly via the DOM:
91
+ const renderer = new WebGL2Renderer({ width, height, background,
92
+ postProcess: `#version 300 es
93
+ precision mediump float;
94
+ in vec2 v_uv;
95
+ uniform sampler2D u_scene;
96
+ out vec4 fragColor;
97
+ void main() {
98
+ // Vignette
99
+ vec2 uv = v_uv - 0.5;
100
+ float d = dot(uv, uv);
101
+ fragColor = texture(u_scene, v_uv) * (1.0 - d * 1.8);
102
+ }`,
103
+ });
104
+ ```
105
+
106
+ ### WebGL2Renderer post-processing
107
+
108
+ `WebGL2Renderer` rasterizes the full display list via Canvas2D (pixel-exact
109
+ parity with `Canvas2DRenderer`) then passes the frame through a GLSL fragment
110
+ shader. This gives you any full-screen effect in 5 lines of GLSL, without
111
+ touching the display list or the sim.
112
+
113
+ The fragment shader interface:
114
+ - `in vec2 v_uv` — 0..1 UV with (0,0) at top-left
115
+ - `uniform sampler2D u_scene` — the rasterized frame
116
+ - `out vec4 fragColor` — output pixel
117
+
118
+ Change the shader at runtime:
119
+ ```ts
120
+ // On game handle:
121
+ (handle.renderer as WebGL2Renderer).setPostProcess(shaderSrc);
122
+ (handle.renderer as WebGL2Renderer).clearPostProcess(); // back to passthrough
123
+ ```
124
+
125
+ **Verifiability**: because the renderer is a pure display-list consumer, the
126
+ `HeadlessRenderer` and `SvgRenderer` see the identical `DrawCommand[]` input
127
+ and remain the reference backends for all verify passes. Post-processing effects
128
+ are cosmetic — they are never part of the game's `world.hash()`.
129
+
130
+ ## Pointer & continuous input
131
+
132
+ Keyboard actions are the default and the only input that flows through the
133
+ deterministic log. For games driven by *where* the cursor is (slice, aim, drag,
134
+ point-and-click, placement), `runBrowser` wires a **`PointerSource`** that samples
135
+ the cursor into `world.input.axes` each step, in DESIGN space:
136
+ `ctx.input.axis('pointer.x' | 'pointer.y' | 'pointer.down')`. Need the mapping
137
+ yourself? `handle.toDesign(clientX, clientY)` (or `renderer.toDesign`) undoes the
138
+ letterbox once, so no game re-derives `getBoundingClientRect()` math.
139
+
140
+ Determinism caveat: pointer axes are live host samples — they are NOT in the string
141
+ input log or `world.hash()`, so a raw pointer trail does not replay the way key
142
+ actions do. For lockstep/replay, **quantize at the host edge**: map a tap to an
143
+ action (`input.press('fire')`) or snap position to a grid cell you feed as a
144
+ discrete move. Keep raw pointer floats out of the sim's canonical state.
145
+
146
+ **Replay-exact analog** (twin-stick aim, analog throttle): pass QUANTIZED axes as
147
+ `world.step(actions, axes)`'s second argument. Those enter `getState()` → hash and
148
+ the input log (`InputRecorder`/`frameAxes`), so `assertDeterministic` and netplay
149
+ reproduce them. Snap at the edge with `snapAxis(v, buckets)` / `quantizeAngle(rad,
150
+ buckets)`. Games that never pass axes keep their pinned hashes byte-for-byte.
151
+
152
+ **Mouse buttons are actions.** The browser driver wires PointerSource to the
153
+ KeyboardSource, so while a button is held the actions `mouse.left` /
154
+ `mouse.right` / `mouse.middle` are down — bindable in an inputMap
155
+ (`shield: ['mouse.right']`), queryable directly
156
+ (`input.justPressed('mouse.left')`), and replay-exact because they enter the
157
+ same deterministic actionsDown log as keys. The raw 0/1 axes `pointer.right`
158
+ / `pointer.middle` exist alongside `pointer.down` for analog-style reads. The
159
+ browser context menu is suppressed by default so right-click is usable as game
160
+ input (opt back in via `PointerSourceOptions.contextMenu`).
161
+
162
+ **Action names, not key codes.** `isDown`/`justPressed`/`justReleased` take
163
+ ACTION names from the input map (`'jump'`), never key codes (`'Space'`). Once
164
+ a host has declared its sources, querying an action no source can produce
165
+ logs a console.warn once per name — so `isDown('jmup')` fails loudly instead
166
+ of silently never firing. (`justReleased` exists and mirrors `justPressed`.)
167
+
168
+ **Sustained virtual input.** `press()` is a one-shot tap (cleared after one step).
169
+ For an on-screen control that models STATE (held joystick, hold-to-fire), use
170
+ `input.setHeld(action, on)` — it stays in the action set every step until released,
171
+ no re-press-per-frame.
172
+
173
+ **Multitouch.** `pointer.read()`/`sample()` describe the primary pointer (unchanged).
174
+ For two thumbs at once, `pointer.readAll()` returns every live touch with its stable
175
+ `id`. Or skip the raw layer: **`TouchControls`** (`ui/`, sibling to `Shell`) renders
176
+ floating sticks/buttons and drives the same action set via `setHeld` — a virtual
177
+ gamepad in one declaration. Anchor host-drawn UI to the played area with
178
+ `handle.viewport()` (letterbox rect + scale), never re-derived `getBoundingClientRect`.
179
+
180
+ **Proving the host layer.** Touch/pointer host code is the one layer sim proofs
181
+ can't see. `bootDom(def)` (`verify/dom`, under jsdom/happy-dom) boots the real
182
+ `runBrowser` wiring, fires synthetic touches, and steps so you assert on `probe()`.
183
+
184
+
185
+ ## Custom input sources (gamepad, MIDI, accelerometer, …)
186
+
187
+ The engine's input layer is open-ended: anything that implements `InputSource`
188
+ (`sample(input: InputState) + optional dispose()`) can be plugged in alongside
189
+ the built-in `KeyboardSource` and `PointerSource`.
190
+
191
+ ### Wiring a source into the step loop
192
+
193
+ Pass sources at start-up — or add them after the handle is returned:
194
+
195
+ ```ts
196
+ import { GamepadSource, DEFAULT_GAMEPAD_MAP } from '@hayao';
197
+
198
+ // Option A: at start-up
199
+ const handle = runBrowser(def, mount, {
200
+ sources: [new GamepadSource({ keyboard: handle.input })], // ← needs handle first
201
+ });
202
+
203
+ // Option B: post-creation (recommended for gamepad — wait for 'gamepadconnected')
204
+ const handle = runBrowser(def, mount);
205
+ window.addEventListener('gamepadconnected', () => {
206
+ const gamepad = new GamepadSource({ keyboard: handle.input });
207
+ handle.addSource(gamepad); // sampled every step; addSource returns a cleanup fn
208
+ });
209
+ ```
210
+
211
+ ### Axis naming convention
212
+
213
+ Use a dotted namespace prefixed by the source name. All hayao built-ins follow:
214
+
215
+ | Axis key | Source | Range | Notes |
216
+ |-------------------|--------------|--------|---------------------------------|
217
+ | `pointer.x` | PointerSource| design | Design-space x (letterbox-safe) |
218
+ | `pointer.y` | PointerSource| design | Design-space y |
219
+ | `pointer.down` | PointerSource| 0/1 | Primary pointer pressed |
220
+ | `gamepad.lx` | GamepadSource| ±1 | Left stick x (after deadzone) |
221
+ | `gamepad.ly` | GamepadSource| ±1 | Left stick y |
222
+ | `gamepad.rx` | GamepadSource| ±1 | Right stick x |
223
+ | `gamepad.ry` | GamepadSource| ±1 | Right stick y |
224
+ | `gamepad.lt` | GamepadSource| 0..1 | Left trigger |
225
+ | `gamepad.rt` | GamepadSource| 0..1 | Right trigger |
226
+ | `gamepad.dpad.x` | GamepadSource| −1/0/1 | D-pad horizontal |
227
+ | `gamepad.dpad.y` | GamepadSource| −1/0/1 | D-pad vertical (−1 = up) |
228
+
229
+ Custom sources: follow the same dotted convention (`mydev.axis1`, `midi.cc7`).
230
+
231
+ ### When to call sample()
232
+
233
+ The engine calls `sample(world.input)` once per fixed step, **before**
234
+ `world.advance()`. This mirrors `PointerSource.sample()` in the browser driver.
235
+ Never call `sample()` inside `onUpdate` or from sim code — axes are host-side.
236
+
237
+ ### Determinism rules for custom sources
238
+
239
+ 1. **Quantize at the host edge.** Use `snapAxis(v, buckets)` / `quantizeAngle`
240
+ before writing to `input.axes`. Unquantized floats differ across machines and
241
+ across frames when the device hasn't moved — quantized values are stable.
242
+ 2. **Axes are live by default** (NOT in `world.hash()` or the input log). This
243
+ is correct for most uses: the sim reads a "current" value, and replay is
244
+ driven by the recorded action log, not raw hardware.
245
+ 3. **For replay-exact analog** (twin-stick aim, analog throttle): pass the
246
+ quantized axes as the SECOND argument to `world.step(actions, axes)`. They
247
+ then enter `getState()` → hash and `InputRecorder` (same as pointer axes in
248
+ Studio sessions). Only do this if you need bit-exact replay of analog values.
249
+ 4. **For discrete input** (button press → action): call
250
+ `keyboard.setHeld(action, on)` — presses flow through `KeyboardSource.currentActions()`
251
+ into the SAME deterministic string log as keys and are covered by record/replay
252
+ and lockstep netplay at zero extra cost. This is what `GamepadSource` does for
253
+ its `buttonMap`. Prefer `setHeld` over `press()` for buttons: `press()` is a
254
+ one-shot tap (cleared after one step), `setHeld` models the held/released cycle.
255
+
256
+ ### GamepadSource
257
+
258
+ `GamepadSource` is the reference implementation of `InputSource`. It polls
259
+ `navigator.getGamepads()` per step and provides:
260
+
261
+ - **Analog sticks + triggers** as quantized axes (see table above).
262
+ - **D-pad + face buttons + start/select** as deterministic actions via a
263
+ `GamepadMap` (button index → action name). Defaults match `DEFAULT_INPUT_MAP`
264
+ so a gamepad works with the keyboard action set out of the box.
265
+ - **Deadzone** with circular magnitude clamping (default 0.12, matching typical
266
+ hardware drift). Output is remapped to fill the full ±1 range post-deadzone.
267
+ - **Configurable quantization** (`stickBuckets`, `triggerBuckets`).
268
+
269
+ ```ts
270
+ import { GamepadSource, DEFAULT_GAMEPAD_MAP } from '@hayao';
271
+
272
+ const gamepad = new GamepadSource({
273
+ keyboard: handle.input, // required for button→action routing
274
+ index: 0, // gamepad slot (default 0)
275
+ deadzone: 0.15, // override default 0.12
276
+ stickBuckets: 64, // coarser for a grid-based game
277
+ buttonMap: {
278
+ ...DEFAULT_GAMEPAD_MAP,
279
+ 6: 'dodge', // LT → dodge action
280
+ },
281
+ });
282
+ const removeGamepad = handle.addSource(gamepad);
283
+ // later: removeGamepad() to dispose and stop sampling
284
+ ```
285
+
286
+ Sim code reads analog axes the same way pointer input is read:
287
+
288
+ ```ts
289
+ player.onUpdate = (n, dt, ctx) => {
290
+ const lx = ctx.input.axis('gamepad.lx'); // −1..1 after deadzone + quantize
291
+ const ly = ctx.input.axis('gamepad.ly');
292
+ // Also check the fallback action for keyboard/dpad parity:
293
+ const dx = ctx.input.isDown('right') ? 1 : ctx.input.isDown('left') ? -1 : lx;
294
+ n.pos.x += dx * 200 * dt;
295
+ };
296
+ ```
297
+
298
+ ### Building your own InputSource
299
+
300
+ ```ts
301
+ import { InputSource, snapAxis, InputState } from '@hayao';
302
+
303
+ class AccelerometerSource implements InputSource {
304
+ private x = 0;
305
+ private y = 0;
306
+
307
+ constructor() {
308
+ window.addEventListener('devicemotion', (e) => {
309
+ this.x = e.accelerationIncludingGravity?.x ?? 0;
310
+ this.y = e.accelerationIncludingGravity?.y ?? 0;
311
+ });
312
+ }
313
+
314
+ sample(input: InputState): void {
315
+ // Normalize to ±1, quantize for determinism.
316
+ input.axes.set('accel.x', snapAxis(this.x / 9.8, 64, -1, 1));
317
+ input.axes.set('accel.y', snapAxis(this.y / 9.8, 64, -1, 1));
318
+ }
319
+ }
320
+
321
+ handle.addSource(new AccelerometerSource());
322
+ // Sim: ctx.input.axis('accel.x')
323
+ ```
324
+
325
+ ## Depth & 2.5D (isometric / overlap)
326
+
327
+ Painter's `z` is the one depth primitive: the renderer stable-sorts by it, ties
328
+ broken by tree order. For any game where sprites overlap (top-down, iso), derive
329
+ `z` from a depth axis instead of hand-setting it per mover:
330
+
331
+ - **`DepthSort({ key })`** — a container that assigns each child's `z` from an
332
+ accessor at draw time (positions are final for the frame). `key: (n) => n.pos.y`
333
+ for top-down overlap; `key: (n) => n.gx + n.gy` for an iso lattice. It removes
334
+ per-node depth bookkeeping from every overlap game.
335
+ - **`iso({ tileW, tileH, origin })`** — the grid↔screen projection (core, no
336
+ trig, hash-safe). `toScreen(gx, gy, elev?)` places a cell; `toGrid(sx, sy)`
337
+ inverts the ground plane so `pointer.x/y` → a tile coord. Use this for picking;
338
+ `physics/tilemap` is **orthogonal-only** and cannot pick on an iso map.
339
+ - **`IsoPrism({ tileW, tileH, height, fill })`** — a raised tile as three
340
+ auto-shaded faces (top + two sides), one node instead of three hand-rolled
341
+ polys. Pairs with `DepthSort` keyed on `gx + gy`.
342
+ - **`diamond` / `regularPoly` shapes** — sugar over `poly` so an iso tile or hex
343
+ reads as intent, not four re-typed corners.
344
+
345
+ ## The human-contact layer
346
+
347
+ The first real playtest of the campaign found every defect in the one layer
348
+ sim proofs cannot see. These rules close that gap; the machine-checkable ones
349
+ are enforced by `layoutIssues()` / `missingControlHints()` from `@hayao`,
350
+ which every verify suite must run on its representative screens.
351
+
352
+ - **Text is sacred.** A shape either fully CONTAINS a text's box (it is a
353
+ panel/scrim) or stays fully clear of it — partial overlap is a collision.
354
+ Background lattice (z ≤ 1: tile grids, felt) is exempt. HUDs drawn over
355
+ world geometry get a scrim panel, not hope.
356
+ - **First contact teaches.** Every action in the input map must be named by
357
+ some on-screen text on the first frame (`missingControlHints` = []). Games
358
+ with phases show a CONTEXTUAL coach line ("X has moved — F fires"), not one
359
+ static legend. Anything tactical gets an onboarding overlay: goal, threat,
360
+ verbs — a stranger must know what to do within 30 seconds.
361
+ - **Every entity kind is legible.** Distinct shape+outline per kind (items
362
+ must not share silhouettes with creatures) and a legend that names them
363
+ with their true glyphs.
364
+ - **Fiction is earned.** Ending copy may not introduce proper nouns the game
365
+ never showed; if the title names a thing in-fiction, the first screen says
366
+ so (the HUD naming "THE DUSKVEIL" is what earns "The Duskveil lifts").
367
+ - **No safe camp.** Every real-time game's verify includes a degenerate-
368
+ position probe: park at edges/corners and prove the threat still reaches.
369
+ Bots demonstrate the intended line; this proves there's no cheaper one.
370
+ - **The screen arrives intact.** Renderers letterbox (never stretch or crop);
371
+ sim clamps include sprite EXTENTS so nothing sinks out of view at the rim.
372
+
373
+ ## Pure-sim separation (turn-based / puzzle games: mandatory)
374
+
375
+ For anything grid- or turn-based, keep the **rules** in a module with NO scene
376
+ or render imports: state in, state out, deterministic. This is the module the
377
+ solver runs over (see [VERIFICATION.md](VERIFICATION.md)); the scene tree then
378
+ becomes a thin *projection* of that state. Real-time games instead put logic in
379
+ node `onUpdate`/behaviors, but the same rule holds: all state lives in the tree
380
+ or a pure module, never in a renderer or the DOM.
381
+
382
+ The same discipline applies to menus and settings: model them as a pure reducer
383
+ `(state, action) → { state, fx }`, with sound cues returned as data, then render
384
+ via the DOM shell. "Pure logic" is for anything with state transitions, not just
385
+ puzzles.
386
+
387
+ ## Determinism rules (the contract — restated from ARCHITECTURE §Determinism)
388
+
389
+ These are non-negotiable. Break one and `assertDeterministic` fails loudly,
390
+ which is the point.
391
+
392
+ 1. **All randomness flows through `Rng`.** Use `world.rng` (or a child from
393
+ `world.rng.split(key)`). `Math.random()` is banned in `src/` and in games.
394
+ 2. **No wall-clock reads in the sim.** Time comes from the `Clock`
395
+ (`world.time`, `world.frame`, and the fixed `dt` passed to `update`).
396
+ `Date.now()`, `performance.now()`, and argless `new Date()` are banned in
397
+ sim code.
398
+ 3. **The scene tree updates in a fixed order** — depth-first, child-index order.
399
+ Don't reorder children for logic reasons mid-step; don't depend on iteration
400
+ racing anything else.
401
+ 4. **Iterate collections in a stable order** — arrays or insertion-ordered
402
+ `Map`s. Never rely on `Set` iteration order or `Object` key order for logic.
403
+ (The state hash sorts object keys for you, but your *logic* must not branch
404
+ on unordered iteration.)
405
+ 5. **Floating point is fine** as long as 1–4 hold: same ops, same order, same
406
+ engine → same bits.
407
+
408
+ RNG state and clock state are part of `world.hash()`, `world.snapshot()`, and
409
+ saves, so a restored world resumes *identically* — that is what makes replay,
410
+ undo, and time-travel free.
411
+
412
+ **The dev guard.** Pass `guardDeterminism: true` in the world config and every
413
+ `step()` wraps `Math.random`/`Date.now` so a stray call inside the sim warns
414
+ ONCE with a stack hint (values still flow — nothing breaks mid-frame). It
415
+ never throws, so it is safe to leave on in tests and dev builds. It converts
416
+ "the replay diverged three hours later" into "here is the exact call site, now".
417
+
418
+ **Cosmetic randomness.** Rule 1 has two escape hatches for pure-view code so
419
+ decoration never perturbs the gameplay stream:
420
+
421
+ - `world.rng.split(key)` derives an independent child stream — give a cosmetic
422
+ node its own private `Rng` at construction (Particles and Shaker do this),
423
+ and its draws stop racing gameplay draws for the same numbers.
424
+ - `hashNoise(...values)` is *stateless* deterministic noise → `[0, 1)`: same
425
+ inputs, same output, no stream consumed — so it is safe to call from
426
+ `draw()` for per-entity variation (`hashNoise(i, ctx.frame)` for jitter,
427
+ phase offsets, hue nudges). Never call `rng.float()` from `draw()`.
428
+
429
+ **Catch-up steps.** `advance(realMs)` runs as many fixed steps as real time
430
+ demands, clamped at `clock.maxFrameMs` (default 250, with a warn-once when it
431
+ clamps). So the FIRST advance after a stall — tab restored, debugger resumed,
432
+ slow boot — can legitimately run multiple steps at once, and first-frame logic
433
+ that assumes "one advance = one step" (e.g. gravity applied before the floor
434
+ exists) sinks the player. Guard the logic, and in tests never fast-forward
435
+ with a big `advance(ms)` — use `runSteps(n)`, which has no realtime clamp.
436
+
437
+ ## House style (defaults — swap deliberately, not accidentally)
438
+
439
+ - **Code-as-art, zero binary assets.** A `Sprite` is a vector `Shape`
440
+ (`rect` / `circle` / `poly` / `path`) or a `glyph` (a Unicode character) —
441
+ never a bitmap. Unicode glyphs (♞ ♝ ⚔ ☠ ✿) are instant, legible art.
442
+ **Shape origins:** `rect`/`circle`/`glyph` are CENTER-anchored on the node's
443
+ `pos` (a rect draws from `-w/2,-h/2`); `poly`/`path` points are local to `pos`.
444
+ Thinking in corners? Pass `{ kind: 'rect', w, h, anchor: 'topLeft' }` — otherwise
445
+ a top-left mental model lands the box half-a-size off.
446
+ **Outlined text:** a `Text` node carries `Paint`, so `stroke`/`strokeWidth`
447
+ outline the glyph (the outline lays UNDER the fill, so it frames rather than
448
+ eats the letter) — the way to make a label read on both light and dark grounds.
449
+ Don't stack offset copies to fake a halo; one `stroke` does it.
450
+ - **Default palette is Kentō** (`import { KENTO, MEADOW, DUSK } from '@hayao'`).
451
+ It fuses the site's washi/sumi/ai/shu ink tokens with landscape hues loosely
452
+ drawn from Miyazaki-16 — eight named hues, each with a `Deep` tone for light
453
+ grounds and a bright tone for dark grounds, so `MEADOW` (light) and `DUSK`
454
+ (dark) share one identity. Pick hues by name (`KENTO.asagi`); reach for ~5–6
455
+ per game plus a ground. Alpha/tone variants via `withAlpha`/`mix` are welcome;
456
+ monospace/terminal defaults read as lazy and are discouraged. The engine stays
457
+ palette-agnostic — Kentō is a consistent starting point, never a restriction.
458
+ Every Kentō pairing is WCAG-AA verified: `npm run palette` is the gate.
459
+ - **Pickups and interactables carry a dark ink outline** (`stroke: KENTO.sumi`
460
+ on light, `KENTO.gofun` rim on dark), plus a glow/pulse for emphasis. An
461
+ accent-colored shape flat on the floor vanishes the moment two hues share a
462
+ family — contrast lives in the edge, not the fill. Judge contrast from a
463
+ rendered SVG, never from hex values.
464
+ - **Menus, titles, HUD, and settings are DOM overlays** (the ui/ shell), never
465
+ `Text` nodes drawn into the scene. Humans compare in-scene type to the DOM
466
+ around it and the scene loses. In-scene `Text` is for in-world labels only.
467
+ - **Strict layering** via the `z` field on nodes/commands: background → world →
468
+ actors → fx → HUD. Nothing gameplay-critical may be occluded. In-world `Text`
469
+ defaults to `z=0`, so a label placed ON a sprite (a tile value, a score over a
470
+ token) needs an explicit `z` ABOVE that sprite or it paints underneath and is
471
+ silently invisible. `layoutIssues` now flags this (`text … hidden behind …`).
472
+ - **Contrast is now headless-checkable.** Pass the page `background` to
473
+ `layoutIssues(commands, { background })` and it flags near-invisible fills and
474
+ sub-AA text from the hex alone — a first line of defense before the vision
475
+ judge. Still judge final contrast from a rendered SVG; the lint catches the
476
+ "dark shape on dark ground" class the pixel-blind proofs otherwise miss.
477
+
478
+ ## Definition of done
479
+
480
+ A game is done when ALL of the following hold. This is a checklist, not a vibe.
481
+
482
+ 1. **Typecheck clean** — `npm run check` reports zero errors. TypeScript strict
483
+ mode is a free correctness gate; use it.
484
+ 2. **Tests pass** — `npm test` green. Sim tests run headlessly in Node.
485
+ 3. **Every level is solver-proven or scripted-played** — each shipped level has
486
+ either a machine win-proof (`solve()`), or a scripted playthrough that
487
+ asserts the win condition on probes. In-head verification has a documented
488
+ failure rate (see [LESSONS.md](LESSONS.md)); it is not evidence.
489
+ 4. **Deterministic replay verified** — `assertDeterministic(game, seed,
490
+ inputLog)` passes: two runs of the same seed + input log produce an identical
491
+ `world.hash()` at every checkpoint.
492
+ 5. **The vision judge run and its high findings fixed** — `npm run judge <slug>`
493
+ renders the game to PNG; actually LOOK at it and score against
494
+ [JUDGE.md](JUDGE.md) (readability, depth, palette, juice, motion, chrome). Fix
495
+ every high-severity finding with cosmetic changes (the golden hash must stay
496
+ unchanged). "Passes the gates" is not "looks shipped"; this closes that gap.
497
+ 6. **A complete loop exists** — start → play → win/lose → restart, keyboard-only,
498
+ and `restart` fully rebuilds world state (no leaked nodes, no stale RNG).
499
+ 7. **The full run's hash is pinned** — `t.golden('full run', world.hash())` in
500
+ `verify.ts`, recorded via `UPDATE_GOLDEN=1 npm run verify`, `golden.json`
501
+ committed (VERIFICATION §Channel 1d).
502
+ 8. **Feel probes gate the genre's pacing** — at least two `recordTimeline`-based
503
+ metrics with tuned windows, plus a `renderFilmstrip` artifact reviewed for
504
+ motion/readability (VERIFICATION §Channel 3). Windows come from a run you
505
+ judged, not from thin air.
506
+ 9. **A feel spec is declared and its gates pass** — `export const feel: FeelSpec`
507
+ in `game.ts` (avatar fill for salience; controller config for forgiveness; a
508
+ feedback contract; `scrolls` for the camera gate). `npm run feel` runs every
509
+ gate the spec enables and is part of `npm run verify` — the professional FLOOR
510
+ is now a CI gate, not a hope (VERIFICATION §Channel 4). Declare only what is
511
+ honestly true; a false contract is worse than none.
package/docs/EMBED.md ADDED
@@ -0,0 +1,90 @@
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
+ - **Touch.** These targets (Artifacts, itch, js13k) are largely played on phones.
84
+ A keyboard game is unplayable there until you add `TouchControls` — a virtual
85
+ gamepad in one declaration that drives the same action set as keys:
86
+ `new TouchControls(handle, { left: ['up','down','left','right'], buttons: [{ action: 'fire' }] })`.
87
+ It only mounts on coarse-pointer devices by default. See CONVENTIONS §Pointer.
88
+ - **The one gap the engine can't close.** The window between the browser
89
+ *fetching* the file and the script *executing* is pre-engine by physics —
90
+ nothing hayao can draw yet. Recipe B shrinks it to a single local file.