hayao.js — Architecture
hayao.js is an AI-first game engine. Its one non-negotiable idea:
The game is a pure, deterministic function of its inputs. Everything else — rendering, audio, the browser — is a plugin that observes that function but can never change its result.
state₀ ──step(inputs₀)──▶ state₁ ──step(inputs₁)──▶ state₂ ──▶ …
Hold that invariant and every hard problem for an AI author collapses:
| Normally hard for an AI | Why it’s easy here |
|---|---|
| ”Does my game work?” | step() runs in Node. Assert on state. No browser, no pixels. |
| ”Is this level winnable?” | step() is a pure transition → BFS/DFS search proves it. |
| ”Did my refactor change behavior?” | Re-run recorded inputs, compare a state hash. Bit-identical or it’s a bug. |
| ”Undo / time-travel / replay” | Snapshot the state, or replay the input log. Free. |
| ”Testing a canvas game is opaque” | Rendering is a projection of state; the sim never needed the canvas. |
This is the lesson from One Hundred Year Garden (a pure genetics core made the whole game Node-testable, and DOM/SVG made it browser-inspectable for free) fused with narrow-js (probe contract, winnability solvers, docs-as-prompts, code-as-art, determinism-as-keystone) — but pushed one level deeper: instead of bolting a capture seam onto a browser-coupled engine, hayao makes the simulation headless-native so no capture seam is needed at all.
The layers
Everything is one npm package, imported through a single barrel seam — @hayao.
Games and examples import ONLY from @hayao (narrow-js’s @engine discipline):
the internals are swappable behind it, and the whole public surface is greppable
in one file (src/index.ts).
┌─────────────────────────────────────────────────────────────┐
│ app/ browser driver (rAF loop) · headless runner │ ← plugs the kernel into a host
│ defineGame() project/scene registry, config │
├─────────────────────────────────────────────────────────────┤
│ ui/ DOM overlays (menus, HUD) · settings shell │ observers
│ render/ display list → SVG | Canvas2D | Headless │ (never mutate sim state)
│ audio/ procedural Web Audio bus │
├─────────────────────────────────────────────────────────────┤
│ verify/ probes · replay · determinism · solver │ ← the AI-first harness
├─────────────────────────────────────────────────────────────┤
│ scene/ Godot-style node tree: Node2D, Sprite, Text, │
│ Camera2D, Timer, AnimationPlayer, behaviors │ THE STATE
│ input/ action map · per-step sampling · record/replay │
│ core/ RNG · Clock · EventBus · World · state hash │ THE KERNEL (headless, pure-ish)
└─────────────────────────────────────────────────────────────┘
core + scene + input are DETERMINISTIC and run in Node.
render + audio + ui + app are the browser-only observer shell.
core/ — the deterministic kernel (no DOM, ever)
Rng— seeded, splittable (SplitMix64/xoshiro). All randomness in a game flows through the World’s rng or a child stream. NeverMath.random().Clock— fixed-timestep accumulator (default 60 Hz). Real time in, whole fixed steps out. Tests drive a virtual clock, so “pump N frames” is exact.EventBus— synchronous typed signals (Godot-styleemit/connect).World— owns the root scene, the rng, the clock, the input state, a resource table, and the frame counter.world.step(dtMs)advances the sim by whole fixed steps, updating the scene tree in fixed tree order.hashState(world)— deterministic structural hash → the spine of replay verification.
scene/ — the authoring model (Godot-like, and IS the state)
A mutable node tree that updates in fixed order, so it stays deterministic while
being ergonomic. Nodes have a transform, a lifecycle (ready → update(dt) →
exit), and signals. Composition via behaviors (small attachable update
units) rather than deep inheritance. Trees serialize (save/prefab/instancing)
and snapshot (undo/time-travel). Built-in node types: Node, Node2D,
Sprite (a code-as-art shape/glyph/path), Text, Camera2D, Timer,
AnimationPlayer (tweens).
input/ — actions, not keys
Author binds actions (“jump”, “left”) to physical keys; game logic reads
actions. Input is sampled once per fixed step and fed into step, so a run is
a function of its input log. InputRecorder/replayInputs make any session
reproducible and any playthrough scriptable — in Node or the browser identically.
render/ — projection, then paint
The scene tree projects to a flat display list of DrawCommands (rect,
circle, poly, path, text, image, with transform/fill/stroke/opacity/z). Backends
consume it: SvgRenderer (crisp, resolution-independent, DOM-inspectable —
the OHYG lesson), Canvas2DRenderer (many primitives/particles), and
HeadlessRenderer (records commands for tests — assert on the display list
without a GPU). Authoring is in a fixed design space (default 1280×720); the
camera and backend handle DPR/scale/letterboxing.
A GPU backend is a fourth plug, not a rewrite (see issue #39). The seam is
already correct for it: the sim never reads the renderer, so a WebGL/WebGPU
backend is another DrawCommand[] → pixels consumer and cannot touch
world.hash() — determinism survives by construction. It stays deferred on
purpose, gated on two things the project’s “prove it” bar requires, not on
effort: (1) a driving example whose entity density or post-FX genuinely
exceeds what Canvas2D holds (we build from the mechanic, not from speculative
ceilings), and (2) a headless cross-renderer agreement path — GPU output
proven to match the SVG/headless reference at the command level — since a GPU
framebuffer isn’t diffable-as-text the way SVG is. Until both exist, a GPU
backend would be unverifiable surface area, so the reference renderers remain
SVG (golden, text-diffable) and Headless (command-level). Any GPU-only effect
(shaders, batched particles) must enter as a declarative command type the
headless/SVG backends can still see, so the verify channels keep working on the
symbolic form.
audio/ — code-as-sound
A procedural Web Audio bus (master/music/sfx), zzfx-style tone synthesis and ambient pads (ported from OHYG). No audio files. A no-op in Node so headless runs are silent by construction.
ui/ — DOM for chrome
Menus, titles, HUD, and the pause/settings shell (volume, mute, fullscreen, restart, back) are DOM overlays — crisp type, trivial persistence — never canvas text (narrow-js lesson: humans compare canvas text to DOM and canvas loses).
verify/ — the reason this engine exists
- Probes:
world.probe()→ compact JSON snapshot;world.goto(scene). - Determinism:
assertDeterministic(game, seed, inputLog)runs the sim twice and compares state hashes; any divergence is a hidden nondeterminism bug. - Solver: a generic search over a
Puzzle<State, Move>interface proves levels winnable (narrow-js’s single-agent BFS, generalized). - Scripted playthrough: feed an input script headlessly, assert on probes.
- Browser capture:
window.__hayaoexposesstep/pump/probe/shot/save. Trivial here — hayao owns the clock and never touches focus, so none of narrow-js’s four canvas-capture hacks are needed.
app/ — hosting the kernel
runBrowser(game, mount)— rAF loop samples real input, drives the fixed-step kernel, projects to a renderer, wires audio + the UI shell.runHeadless(game, inputLog)— the same kernel with no host; returns the final world/state hash. This is what tests and the CI verifier call.
Core vs. recipes: the seam that keeps the engine small
The engine is broad by design (444 exports across 26 genres of tooling). Broad is fine; broad-and-fused is not. If “add a genre” means adding genre logic to the framework, every new game makes the engine heavier instead of the ecosystem richer, and the barrel calcifies into an API nobody can safely change.
So there is a seam:
- core — everything behind
@hayaothat is genre-agnostic: world, rng, hash, clock, the scene tree, physics, render backends, and the verify harness. Every export here is public API, maintained forever. Deleting a piece would break many games. - recipes (
recipes/) — genre feel as patterns you copy into your game and own, not APIs you import. A platformer’s input→juice wiring, a wave director, a draft loop. Deleting one only changes its game.
If deleting it breaks another game, it’s core. If it only changes this game, it’s a recipe — copy it, don’t import it.
This inversion is a staged migration, stated honestly: the barrel is not split
yet, and genre helpers still live under @hayao. What is in place is the seam and
its doctrine (recipes/README.md), plus the first artifact that clearly belongs in
core rather than a recipe — the level format.
content/ — geometry as provable data (the level format)
A LevelData is a plain-JSON stage: ASCII terrain, a spawn, a goal, legend-tagged
entities. It earns a place in core the way a scene does — because a solver can bite
on it:
defineLevel/levelIssues— author and validate a stage as data.levelToTilemap— realize its collision geometry.levelReachable(flood) andplatformerReachable(foothold + jump-arc) — prove the goal reachable from the data alone, before any node is built. This is “procgen connectivity gate FIRST” (FUN law) made checkable for hand-authored stages too.diffLevels— a structural diff, so a data-authored stage is iterable: change three tiles, see exactly three changes — not a rewrittenbuild()only a human can eyeball.
The reference instance is examples/small-flame: its
chamber is authored as LevelData, proven winnable by a 0-death waypoint bot, then
skinned by a cosmetic view. Geometry-as-data is core; the feel laid over it is a
recipe (recipes/platformer-feel.md).
Determinism rules (the contract that makes all of the above true)
- All randomness flows through
Rng.Math.random()is banned insrc/and games. - No wall-clock reads in the sim (
Date.now(),performance.now()) — time comes from theClock.new Date()argless is banned. - The scene tree updates in a fixed order (depth-first, child index order).
- Iteration over collections is ordered (arrays / insertion-ordered maps; never
Set/Objectkey order for logic). - Floating point is fine as long as the above hold (same ops, same order, same result on the same engine).
Break one of these and assertDeterministic fails loudly — which is the point.
Why not just use Godot / a canvas engine?
See docs/ENGINE.md. Short version: Godot’s authoring model (nodes, signals, scenes, resources, input actions, tweens) is excellent and we borrow it wholesale — but its interface is GUIs, binary scenes, and a bespoke language, all opaque to an LLM. hayao keeps the model and makes every part of it text, typed, greppable, and headlessly verifiable. Canvas engines couple the sim to the render loop and the browser; hayao decouples them so the sim is a pure function you can test in Node.