Embedding hayao (offline / single-file)

hayao’s thesis is that an LLM can author a whole game as text. The natural distribution for that is a single self-contained HTML file — no install, no bundler, no CDN. That shape is what Claude Artifacts, js13k entries, itch.io HTML uploads, and CodePen all want. This page has the two recipes.

npm run build:lib emits three library targets:

FileFormatUse
dist/index.jsESM (readable, sourcemapped)bundlers, import in apps
dist/index.min.jsESM, minifiedmodern <script type="module"> / inline import
dist/hayao.global.jsIIFE, minified, window.hayaoplain <script src> / paste-inline

The minified builds are ~190 KB (~45 KB gzipped) and carry no sourceMappingURL comment, so an inlined copy stays console-clean.

Recipe A — <script src> global (simplest)

The IIFE build exposes everything on window.hayao. No modules, no plumbing:

<div id="app"></div>
<script src="hayao.global.js"></script>
<script>
  const { defineGame, runBrowser, Sprite, Text, Node, vec2 } = hayao;
  const game = defineGame({
    title: 'My Game',
    build(world) {
      const root = new Node({ name: 'root' });
      root.addChild(new Text({ text: 'hello', pos: vec2(640, 360), align: 'center' }));
      return root;
    },
  });
  runBrowser(game, document.getElementById('app'));
</script>

See examples/embed.html for a runnable version (pointer-follow demo). Serve the folder or open the file after npm run build:lib.

Recipe B — fully inline, single .html (truly offline)

For one file with zero external requests, paste the engine source directly into a <script> tag instead of referencing it:

<div id="app"></div>
<script>
  /* ⇩ paste the entire contents of dist/hayao.global.js here ⇩ */
  "use strict";var hayao=(()=> …;
  /* ⇧ end of pasted engine ⇧ */
</script>
<script>
  const { defineGame, runBrowser } = hayao;
  // …your game…
</script>

That’s the shape an LLM tool call can emit whole: one file, opens anywhere, works on a plane. Because the minified builds drop the sourcemap comment, there are no 404s or console warnings from the inlined copy.

ESM-inline variant

If you prefer modules, inline dist/index.min.js as a blob and dynamic-import it:

<script type="module">
  const src = `/* paste dist/index.min.js here */`;
  const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
  const hayao = await import(url);
  // …use hayao.defineGame / hayao.runBrowser…
</script>

Notes

Responsive fit (off-ratio screens)

defineGame({ width, height }) fixes a design box. Its aspect never matches every phone, so the question is what happens in the gap. The engine keeps one principle: every player sees the same play-field, and you author it once — no design warps to fit a window, so nobody gains visibility by picking a wide monitor, and you don’t test ten ratios. Three tools, from cheapest to most deliberate:

  1. fit: 'contain' (default). Letterbox the design box intact, scaled smoothly (non-integer) to the container. runBrowser(game, mount) — nothing to do. Bars are inevitable off-ratio; make them yours by extending background scenery, or move to bleed.

  2. fit: 'bleed'. Keep that same box centered as a guaranteed safe box, but grow the view in the short axis until its aspect matches the container — the bars fill with the game’s own margins instead of dead color. The catch that keeps it fair: only cosmetic (cosmetic = true) scenery may live in the margin — the extra a wide player sees is ambient art, never gameplay. Past a sane cap (BLEED_MAX) it letterboxes the remainder rather than stretch to a scenery desert. runBrowser(game, mount, { fit: 'bleed' }).

     portrait phone         matched ratio          ultrawide
    ┌───────────────┐   ┌───────────────┐   ┌───────────────────────┐
    │∴∴ scenery ∴∴∴∴│   │███████████████│   │∴∴│███████████████│∴∴∴∴│
    │∴┌───────────┐∴│   │██ SAFE  BOX ██│   │sc│   SAFE  BOX   │ enery│
    │∴│ SAFE  BOX │∴│   │██ (identical) │   │en│  (identical)  │∴∴∴∴∴│
    │∴└───────────┘∴│   │███████████████│   │∴∴│███████████████│∴∴∴∴│
    │∴∴ scenery ∴∴∴∴│   └───────────────┘   └───────────────────────┘
    └───────────────┘      no bleed            cosmetic bleed only
  3. forms (native per-device design). When a phone in portrait would letterbox a landscape game to a sliver, the design itself is the wrong shape — declare a second one. runBrowser picks the form whose ratio is closest to the container at boot (a phone loads portrait, a laptop landscape). The pure sim/Puzzle is shared, so determinism and solver proofs are untouched; only the framing (and optionally a form-specific build) differs.

    defineGame({
      title: 'My Game', width: 900, height: 520,        // the landscape default
      forms: [{ width: 540, height: 960, label: 'portrait', build: buildPortrait }],
      build: buildLandscape,
    });

    Form choice is fixed at load (swapping the whole design mid-session would reset play); live resizes and rotations within a form are absorbed by fit, so the game never resets under a window drag.

This page rendersthe repo's markdowndirectly — edit it there, it changes here.