# Exporting and the web

Sindri AI knowledge pack, file `10-export-and-web.md`. Exporting a project to a static web page and how the browser host runs it.

Start with `00-start-here.md` if you have not read it. Every fact here is copied from the Sindri repository; nothing in this file is a guess.

## Contents of this file

- **Exporting a project to the web** (from `docs/export.md`): What comes out; What ships; Cache invalidation; Getting one onto GitHub Pages; Deployment, and the subpath; Shared browser host; What a player sees while it loads; What a browser is told when it cannot run this; What this does not do yet; Static imported models
- **The first time this engine ran in a browser** (from `docs/browser.md`): Why it is worth writing down; What was broken; What runs now; The game, which is the more interesting caller; Audio, the second time something ran there; A failure a player can read; What still has never run in a browser; Static imported GLB assets

---

Source: `docs/export.md`

## Exporting a project to the web

```bash
cargo run -p sindri-export --bin sindri-export -- game dist --base /sindri-engine/
wasm-pack build crates/sindri-player --target web --out-dir pkg
cp -R crates/sindri-player/pkg/. dist/pkg/
```

The export writes the project; `wasm-pack` writes the host. They are separate on
purpose: building WebAssembly needs a toolchain the export does not want to own
or version, and a step that silently ran it would be one nobody could reproduce
by hand.

### What comes out

```text
index.html                 the page, with the base path and the project's name
pkg/                       the host, as wasm-pack built it
assets/sindri.manifest     what the project is made of — never cache this
assets/<content hash>/     every asset — cache this for ever
```

### What ships

Everything is worked out from the scene. A texture ships because a component
names it, a font because a text element does, a script because an entity runs
one, a sheet because it sits beside a texture that has one. So an asset that
stopped being used stops being carried, and one that started being used cannot
be forgotten.

Physics material component references ship their `.profile` assets from scenes
and prefabs, even when an entity starts inactive. Physics profiles are validated
before output is written. Native and browser project hosts deliver them through
the normal profile loader and resolve coefficients at the scene boundary; no
manual include list is required. See the material contract (in `07-physics-and-movement.md`).

The exception is in `sindri.toml`:

```toml
[assets]
include = ["audio/pickup.wav", "audio/victory.wav"]
```

A script can name a clip at run time — `Audio.play("pickup.wav")` is a string
inside a program, and no walk of a scene can see it. Scanning script text for
anything that looks like a path would ship whatever a comment mentioned and miss
whatever was built from a variable, so a project says instead.

### Cache invalidation

**`assets/sindri.manifest` must never be cached. Everything under
`assets/<hash>/` can be cached for ever.**

The directory is named after what every asset in it hashes to, so a build
differs from another build exactly when something a player would download
differs. A changed asset cannot land in a directory anyone has already cached,
and an unchanged build keeps its name so a re-deploy re-downloads nothing.

The manifest is the one file that has to be re-fetched, because it is how a
browser learns which directory to look in. It is small.

For a static host, that is one rule each:

```
/assets/sindri.manifest   Cache-Control: no-cache
/assets/*                 Cache-Control: public, max-age=31536000, immutable
```

Exporting again removes the previous build's directory. Without that, every edit
would leave a whole copy of the project behind.

### Getting one onto GitHub Pages

`.github/workflows/pages.yml` exports every project the site serves and copies
the browser host in beside each one:

```bash
cargo run -p sindri-export --bin sindri-export -- \
  games/orbital-baked target/pages/examples/orbital-baked \
  --base "${PAGES_BASE_PATH}/examples/orbital-baked/"
cp -R crates/sindri-player/pkg/. target/pages/examples/orbital-baked/pkg/
```

One host serves every project, because it reads the manifest rather than
carrying a list of one game's assets. The `--base` is the route the project
will be served from, which for a Pages project site includes the repository
name. The workflow gets that prefix from `actions/configure-pages`'s
`base_path` output before exporting: `/sindri-engine` for the default project
site, and an empty prefix for the custom domain. Thus the current live route is
`https://sindri.vardir.no/examples/orbital-baked/`, with
`--base /examples/orbital-baked/`. Do not infer the prefix from the repository
name: custom-domain Pages sites are served at the domain root.

The site used to copy an asset directory and write its own manifest instead.
That was a second answer to the question the export exists to answer, and the
two disagreed: the hand-written manifest named no kinds, the host reads kinds
to know what to ask for, and a project whose manifest names no scene is a
project that does not open. Nothing assembles a build by hand any more.

### Deployment, and the subpath

`--base` is where the export will be served from: `/` for a domain of its own,
`/repository-name/` for a GitHub Pages project site. It is baked into the page's
`<base href>` rather than guessed at run time, because a page that guessed is a
page that works locally and 404s once it is deployed.

The trailing slash is not optional and the export adds it: `<base href="/repo">`
resolves `pkg/host.js` against the *site* root, and `<base href="/repo/">`
resolves it inside the project. That is the whole GitHub Pages subpath problem.

### Shared browser host

The browser host reads the manifest and asks for what it names, kind by kind. It
used to carry a list of asset IDs per kind, compiled in — which meant adding a
texture meant editing Rust, and a project the host crate had never heard of
could not be exported at all. `AssetKind` in the manifest is what replaced that.

The bundle is `sindri-player` (`crates/sindri-player`), the one host every
export ships, so its files are `sindri_player.js` and `sindri_player_bg.wasm`
whatever the project is. It plays on the shared runtime session
(`sindri-runtime`) and knows nothing about any one game: the manifest decides
the scenes, assets and Decay scripts. Natively the same player is
`sindri-player <project>`, reading the project from its directory instead.

A project's browser save is kept under `sindri.<entry scene>.save`, named by
the scene it opens on. Every game on one site shares one origin's storage, and
the key used to be the fixed `sindri.causeway.save`, so two games deployed side
by side overwrote each other's progress. A save made under the old key is not
carried over.

Each kind has its own bounded asynchronous queue. The host sizes that queue from
the manifest before requesting the kind, while keeping the number of concurrent
fetches small. The bound therefore limits the project described by the manifest
instead of imposing the asset pipeline's default queue size as an undocumented
maximum number of textures, scripts, or other assets in an exported game.

### What a player sees while it loads

The page opens on the Sindri loading screen: one of fourteen animated
concepts of the Sindri mark and wordmark — forge, blueprint, pixel, cube,
arcade, diorama, boss, loot, puzzle, achievement, level-up, fog, speedrun and
torch — chosen at random each time the page opens, and never the one that
browser showed last. Each is a four-second loop. They are drawn by a script
inlined in the page (`crates/sindri-export/src/loading/`) as it is parsed,
so the screen is there the moment the page is, before any wasm or asset has
arrived. That covers the whole wait a browser build has: the host downloading,
WebGPU starting, and the project's assets arriving.

The concepts are written in Pixelify Sans and JetBrains Mono, both OFL-1.1,
which the export writes beside the page in `fonts/` rather than fetching from
a font service, so a game's page asks no third party for anything.

The bar under it measures the real wait. The page fetches the host's wasm
itself and counts the bytes as they arrive, which is the first 60% of the bar;
the host then announces `sindri:progress` on `window` with `{ loaded, total }`,
the project's asset bytes by the manifest's count, which fills it to 95%; and
`sindri:ready` finishes it. A host served compressed does not say its
uncompressed size, so for that part the bytes that have arrived are eased
towards their share rather than divided by a total. Any `DesktopApp` can report
its own wait the same way, through `DesktopApp::progress`.

The game does not play under it. The host counts a page whose
`#sindri-loading` is still there as hidden, exactly as it counts a hidden tab,
so the player holds the game paused on its first frame; the page dispatches
`sindri:revealed` once the screen has faded and gone, and the game starts then.

It stays until the host announces the game is on screen. The host dispatches
`sindri:ready` on `window` once, on the first frame it presents while the
application says it is ready (`DesktopApp::ready`; the browser game is ready
once its project is installed, not while it is still clearing the screen
waiting for assets). The animation plays through at least one whole loop, so
even a fast load sees it finish rather than a flash of its first second; the
still mark shown under reduced motion stays at least 0.9 seconds. A failure
removes it at once and shows the reason.

A project may follow the Sindri mark with its own brand, in `sindri.toml`:

```toml
[web.splash]
image = "brand/logo.png"   # relative to the project: png, jpg, webp, svg or gif
title = "Vardir Games"     # under the image, or in its place
caption = "presents"       # optional, smaller
background = "#101820"     # optional, #rrggbb; the Sindri ink otherwise
seconds = 1.5              # the least it shows, 0 to 10 (default 1.5)
entrance = "random"        # how it comes on screen (default random)
```

The Sindri animation hands over to it rather than fading: the ENGINE tag flips
to PRESENTS, the mark and the wordmark fall apart, and the brand's background
rises behind them. Then the brand makes its entrance, one of:

| `entrance` | What it does |
| --- | --- |
| `forge` | Drops in glowing, strikes, throws sparks off its base and cools; the title heats and cools beside it |
| `blueprint` | A grid and a frame drawn edge by edge with the image's dimensions; the image as faint line-work, inked left to right; the title typed |
| `pixel` | Stacked out of its own pixels from the bottom row up, then sharpened through coarse resolutions to the image; the title steps in letter by letter |
| `arcade` | Switched on like a CRT: a dot, a line, the picture opening out overbright and split into colour, a glitch, then a clean screen |
| `achievement` | An "Achievement unlocked" toast with the brand as its icon drops in, flashes gold and settles |
| `fade` | A plain rise |
| `random` | One of the five above, picked each time the page opens, never the one that browser showed last |

The brand's time, `seconds`, starts after the handoff and is never shorter
than its entrance, so the game cannot cut one off halfway.

It needs an image, a title or both. The image is written beside the page,
named by its contents so a changed image is never served from a cache. The
game is revealed once it is ready *and* the brand has had its time. A setting
that is not one — an unknown key, a colour that is not `#rrggbb`, a time
outside 0 to 10 seconds — fails the export with the reason. With
`prefers-reduced-motion`, no concept, handoff or entrance plays: the screen is
the still forge mark, wordmark and bar in the page's own markup, then the
brand's still stage, and nothing on it moves but the bar filling.

### What a browser is told when it cannot run this

The page checks for a canvas, for `navigator.gpu`, and for an adapter that
actually answers — because `navigator.gpu` existing is not the same as WebGPU
working, and Chrome on Android exposes the interface more widely than its
drivers can serve. Each failure produces a sentence a player can read rather
than a blank canvas. The engine's own failures arrive on `window` as
`sindri:failed` and are shown the same way.

`scripts/browser/smoke.mjs` runs against the exported directory in CI — the page,
the manifest, the hashed assets, and the deliberate removal of each capability
to prove the message appears. It also waits for the loading screen to give way
before touching the game, and fails if it never does: that is the proof the
host announces `sindri:ready` in a real build.

The host module is imported by the page when it starts rather than at the top of
the script, so a host that is missing or broken is reported like any other
failure instead of leaving the loading screen up for ever.

### What this does not do yet

- **No compression.** Assets ship as they are; a host that serves gzip or brotli
  will do it on the wire, and pre-compressing is a size decision nothing has had
  to make yet.
- **No unused-scene pruning.** One scene is exported: the one the project names.
  A project with several would need to say which ship.
- **The host crate is named by hand.** The export takes a module name because a
  project does not say which binary will run it.

### Static imported models

`sindri.model.asset` references are discovered from every listed scene and
expanded prefab, including inactive instances. Models ship once as manifest
kind `model`; their GLB bytes are preserved rather than converted into scene
vertices. The exporter validates the bounded static subset before output, so
missing or unsupported/malformed models fail with the logical asset named.
Both project-root and `assets/` layouts are covered. See
imported models (in `09-3d.md`) for native/browser proof and limitations.

---

Source: `docs/browser.md`

## The first time this engine ran in a browser

A session note rather than a contract. It records what happened when the engine
was finally loaded in a browser, and what that found.

### Why it is worth writing down

`PROJECT_OVERVIEW.md` and `docs/FEASIBILITY.md` call this a WebGPU-first engine.
CI has run `cargo check --target wasm32-unknown-unknown` across the whole
workspace for several releases, `wasm-pack build examples/cube --target web` is a
documented command, and `examples/cube/index.html` has been in the tree the whole
time. Nobody had ever opened the page.

That is this repository's own documented failure mode — depth without a caller,
the reason `docs/capabilities.md` exists at all. The browser target had a green
CI column and no caller, and it was broken in two ways that a compile check
cannot see.

### What was broken

**A failure in a browser was silent by construction.** On a desktop, `run` calls
`event_loop.run_app`, and afterwards reads the error the host recorded. On
`wasm32` it calls `spawn_app`, which hands the loop to the page and returns
immediately — so `run` returns `Ok` before the app has started, and the recorded
error has nobody to return to. The engine stopped at the device request and said
nothing at all: no panic, no console output, a blank canvas.

`Host::fail` now logs as well as records. On a desktop that is redundant with the
returned error, which is fine; in a browser it is the only report there is.

**The surface refused every canvas.** A browser canvas offers `bgra8unorm` and no
sRGB format, and `SurfaceProfile` required one, so every page load ended in
`GpuError::NoSrgbSurfaceFormat`. The guard was right about the danger and wrong
about the conclusion — a canvas can be encoded to, through a view format. See
`docs/rendering-color.md`.

Both had been true since the browser target was added.

### What runs now

The cube example draws the same picture in Chromium as it does natively, in the
same colours: the module instantiates, `run` executes, winit adopts the page's
canvas, a WebGPU adapter and device open, the surface configures, and the frame
pipeline renders a textured cube and five alpha-blended sprites.

The companion game runs too, and is playable — see below.

The production-shaped build is published at
<https://vardirhq.github.io/sindri-engine/>. `.github/workflows/pages.yml` builds
`game/` with wasm-pack, assembles only its static HTML/module/Wasm output, and
deploys it through GitHub's Pages artifact flow whenever `main` changes. The
page uses relative imports, so the repository's `/sindri-engine/` base path is part of
the real delivery path rather than something development serving hides.

`scripts/browser/smoke.mjs` is how that is checked, and it exits non-zero when
the page does not start the engine. The tell it uses is the canvas: one the
engine never configured keeps the HTML default of 300x150, which is exactly the
difference between "the page loaded" and "the engine started". That is worth
knowing, because both look like a blank screen.

### The game, which is the more interesting caller

The cube proves a frame can be drawn. The companion game proves the engine can be
*played*, and it was the second thing pointed at a browser for that reason. It
needed a `cdylib` crate type and a page, and nothing else: the `wasm_bindgen`
entry point and the wasm-only dependencies were already there.

Driven through the same browser host and fixed-step gameplay as native, the
player crosses the floor, an orb disappears, and the first lamp lights. That
single frame is the whole chain running on the browser target:

- the keyboard reaches `player.decay`, which moves and clamps a logical
  coordinate before `Grid.place` projects it through the floor tilemap
- `orb.decay` calls `World.find("Player")` and compares both entities in that
  same grid through typed references
- the pickup writes the score to the blackboard with `Game.set`
- `pip.decay` reads it and lights a lamp

**So Decay executes in a browser**, which it never had. `ROADMAP.md` had said so
truthfully for as long as the item existed. Entity references, the blackboard,
the fixed step, and input all came with it.

Two frames seven hundred milliseconds apart also differ by around three thousand
pixels with nothing touched, because the orbs bob. That is the cheapest available
proof that scripts are running rather than that a scene loaded.

### Audio, the second time something ran there

Audio was written for the browser and shipped without being loaded in one, and
two faults compounded. `HtmlAudioElement::play()` hands back a promise, and the
backend kept only the synchronous result — so a browser refusing a clip rejected
that promise into the console while the backend reported a playing voice. What it
was refusing was the sample rate: the three clips Gather shipped were 2 kHz, and
Web Audio decodes 3 kHz upward. Every test in the workspace passed, because the
silent backend records the *name* of a clip and never looks at its bytes.

Both halves are now checked rather than remembered. `AudioAssetDecoder` rejects a
WAV outside the rate browsers decode, so the bad clips fail at load on every
target instead of only in a browser; the rejection handler is owned by the voice
and logs what a browser refused; and `scripts/browser/smoke.mjs` presses a key to
satisfy the gesture requirement, then watches how each `play()` promise settles.
The looping background music has been observed playing to 1.75 s in headless
Chromium over software Vulkan.

#### Phones, the third time

The element-per-sound backend had one more fault, which only a phone showed: it
unlocked on a key or a mouse press, never a touch, and iOS Safari lets a new
`<audio>` element play only from inside a gesture's own handler, never from the
frame loop that starts a game's sounds. The platformer, played with a finger on
its stick, made no sound at all. The backend is now Web Audio: each clip is
decoded once, every sound is a buffer source on one `AudioContext`, and the
backend resumes that context from document-level tap, click and key listeners,
inside the gesture. The host unlocks on a touch starting or ending, since a
player may hold a finger down for the whole level and browsers grant audio when
it lifts. `smoke.mjs` now watches buffer sources start on a running context and
its clock move on, and records a clip the browser cannot decode as refused.

CI runs both pages now — the cube and the game — rather than only compiling
them, so the next thing that works on wasm32 and not in a browser fails a check
instead of waiting for someone to look.

The pickup and victory one-shots have not been observed in a browser — they need
gameplay to reach an orb, which the smoke check does not drive. They take the
same path as the loop, and the headless test asserts the game asks for them.

### A failure a player can read

The blank canvas on a phone was not a rendering fault. `run` returns `Ok` as
soon as `spawn_app` hands the loop to the page, so everything after that —
adapter, device, surface, assets — fails with nobody to return an error to, and
`Host::fail` could only write it to a console no player opens. The page's own
guard checked `navigator.gpu` for existence, which is not the same question as
whether an adapter can be had: Chrome on Android exposes the interface more
widely than its drivers serve it, so the check passed and the engine started and
stopped in silence.

The engine now announces a failure on `window` as a `sindri:failed` event, and
the page shows it. The engine still knows nothing about that page's markup — it
names the failure, and the page decides what to do with it, the same way it is
handed a canvas rather than creating one. The page also asks for a real adapter
before starting rather than trusting the interface's presence.

`scripts/browser/smoke.mjs` takes away the canvas, WebGPU, an adapter, and a
device in turn. The last is the one that matters most: it is the only failure
the page cannot check for itself, because it happens after `init()` has already
resolved, and it is the shape the phone was hitting.

### What still has never run in a browser

**Asset loading.** Not one HTTP request for an asset has been made by either
caller. The cube embeds its texture with `include_bytes!` and the game embeds its
scene, scripts, sheets and textures the same way, so the *decoders* run and the
loader does not: `AssetLoader`, its queue, and `UrlRoot` — which exists
specifically so browser URL rules are exercised on every target — are still only
exercised by tests.

Embedding is a legitimate way to ship a game, so this is not a fault in the game.
It does mean the asset pipeline's browser half needs a caller of its own, and the
honest candidate is the editor rather than another example: an editor opens files
it was not compiled with, which is exactly what fetching is for.

### Static imported GLB assets

The manifest-driven project host fetches kind `model` through a bounded
`AssetLoader<ModelAssetDecoder>`, verifies bytes, prepares one shared renderer
resource per logical ID through the public facade, and binds scene references.
Models keep their hierarchy and use normal depth testing, base-color material
factors/textures and ambient/directional lighting. The external Low Tide cutaway
crawler was visually inspected in Chrome headless WebGPU at 1200 × 1000; smoke
confirmed scene/model/texture HTTP delivery and loading completion. Native
offscreen rendering was inspected separately. Model shadows and full glTF
animation/skin support are deferred; see imported models (in `09-3d.md`).
