Exporting a project to the web
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
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.
The exception is in sindri.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:
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:
[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 for native/browser proof and limitations.