This file captures the non-obvious bits of getting Usagi to compile and link for the web. If the web build breaks, start here.
rust-toolchain.toml pin.setup-emscripten.sh installs to $XDG_DATA_HOME/emsdk
(or ~/.local/share/emsdk); source ~/.local/share/emsdk/emsdk_env.sh to put
emcc on PATH. On macOS, you can do brew install emscripten.Build with just build-web (or just build-web-release).
Rust 1.93+ unconditionally passes -fwasm-exceptions to emcc when targeting
wasm32-unknown-emscripten. This was rust-lang/rust#147224. The
older panic = "abort"-disables-it advice from blog posts and Stack Overflow no
longer applies: rustc emits -fwasm-exceptions regardless of panic strategy
because the prebuilt stable sysroot is itself built with wasm-eh (see
rust-lang/rust#135450).
That means every C/C++ object file in the link must also use the wasm-eh ABI.
If anything is built with the legacy JS-EH ABI, the link fails with undefined
symbols like __cxa_find_matching_catch_3.
.cargo/config.toml rustflags:
-C link-arg=-sSUPPORT_LONGJMP=wasm. emcc rejects
SUPPORT_LONGJMP=emscripten (the legacy setjmp ABI) when wasm-eh is on, so
we use the wasm-native pairing. mlua’s vendored Lua uses setjmp for error
handling, which is why this matters..cargo/config.toml [env]:
CFLAGS_wasm32_unknown_emscripten and CXXFLAGS_wasm32_unknown_emscripten
set to -fwasm-exceptions -sSUPPORT_LONGJMP=wasm. cc-rs reads these when
compiling C deps for the target, including mlua’s vendored Lua.EMCC_CFLAGS in the justfile recipes:
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm (alongside the raylib port flags).
raylib’s CMake build is invoked through emcc directly, bypassing cc-rs, so it
needs the same flags via EMCC_CFLAGS.If you change one of these, change them together. A mismatch shows up as either
a __cxa_find_matching_catch_* undefined symbol error (some object file used
JS-EH ABI) or as a
SUPPORT_LONGJMP=emscripten is not compatible
with -fwasm-exceptions rejection
(legacy longjmp ABI).
panic = "abort" is and isn’t doingThe [profile.dev] and [profile.release] panic = "abort" settings in
Cargo.toml are there for binary size, on all targets. They are NOT what makes
the web build link. (The 2024-era articles claiming panic=abort disables
-fwasm-exceptions are stale; that path was removed.)
Cargo automatically uses panic = "unwind" for the test profile, so
cargo test is unaffected by these settings.
Usagi’s wasm build uses emscripten_set_main_loop_arg (not ASYNCIFY). The
browser drives the per-frame body at requestAnimationFrame rate. ASYNCIFY was
tried earlier and rejected because it conflicts with -fwasm-exceptions and
adds runtime overhead. The session struct owns all per-frame state so we can
hand a single &mut Session to emscripten’s main loop.
The wasm runtime is game-agnostic. It does NOT have a game baked in via
--preload-file. Instead:
web/shell.html) fetches game.usagi (overridable via
window.USAGI_BUNDLE_URL) over HTTP after the runtime initializes./game.usagi using
Module.FS.writeFile (which is why FS is in EXPORTED_RUNTIME_METHODS).Module.callMain([]). Rust’s main() on the emscripten target
loads /game.usagi, builds a BundleBacked vfs, and runs.This is the same .usagi bundle format usagi compile --bundle produces on
native. So the build artifacts in target/web/ are:
| File | Role | Game-specific? |
|---|---|---|
index.html |
Shell with click-to-play overlay | No |
usagi.js |
Emscripten loader / glue | No |
usagi.wasm |
Usagi runtime | No |
game.usagi |
Bundled main.lua + sprites.png + sfx |
Yes |
To swap games you only need to replace game.usagi; the other three files are
reusable across games. That’s also what makes usagi compile --target web
viable without an emcc rebuild on the user’s machine.
usagi compile <path> produces all targets at once by default:
usagi compile path/to/your/game
# -> ./your_game-export/{your_game, your_game.usagi, web/}
For just the web slice:
usagi compile path/to/your/game --target web
# -> ./your_game-web/ (zip and upload)
The web output contains index.html, usagi.js, usagi.wasm, and
game.usagi. Zip it and upload to an itch.io HTML5 project page (set “This file
will be played in the browser” and point to the zip). itch serves index.html
from the zip root.
Release-built usagi (e.g. cargo build --release, just install) has the
wasm runtime embedded in the native binary at compile time. So an installed
usagi produces a working web/ from anywhere on the user’s machine, no source
checkout, no emcc, no target/web/.
Debug builds (cargo run -- compile ...) skip the embed to keep dev fast, and
fall back to reading target/web/ from disk. So the dev loop in this checkout
is:
just build-web-release # builds wasm, populates target/web/
cargo build --release # rebuilds native, embeds the wasm
# -> 3-4 MB binary including the runtime
Re-run just build-web-release whenever the runtime source changes; the next
cargo build --release picks it up via build.rs’s rerun-if-changed. See
build.rs for the embed mechanics.
Quickstart:
bash setup-emscripten.sh (installs emsdk to ~/.local/share/emsdk).just setup-web (adds the wasm target and a tiny static server).emcc is on PATH:
source ~/.local/share/emsdk/emsdk_env.sh
http://localhost:3535:
just serve-web
Or just build (no server):
just build-web # debug
just build-web-release # release, much smaller, no source maps
The runtime in target/web/ is game-agnostic. To swap the game without
rebuilding the runtime:
just example-web spr # rebundles examples/spr -> target/web/game.usagi
# refresh the browser tab
This works for any path that usagi compile --bundle accepts: a directory with
a main.lua, a single .lua file, etc. If you change something in the bundled
game’s source, rerun just example-web <name> and refresh.
Skip just build-web between examples; the runtime is identical across games.
Web bugs love to hide in code paths native doesn’t exercise. Before declaring a web build done, in the browser:
emscripten_sleep, which only fires on the first sfx playback. A silent
smoke test passes even when ASYNCIFY is missing, then the game aborts the
moment it tries to beep.[usagi] messages land there. Lua runtime errors show up too.usagi.wasm /
usagi.js from the Sources panel.Module.audioContext.state in the console after clicking the start
overlay to confirm audio is running, not suspended.usagi dev is the right loop for fast iteration; use web for smoke tests and
shipping.)usagi tools on web. Tools are native-only.onRuntimeInitialized. Look
in the DevTools Console / Network panels for a wasm instantiation failure or a
404 on usagi.wasm / usagi.js.game.usagi couldn’t be fetched. The message
under the button is the fetch error (typically a 404 if you haven’t run
just build-web or just example-web).just build-web builds the wasm runtime, copies usagi.{wasm,js} and
web/shell.html (as index.html) into target/web/, and bundles
examples/snake as target/web/game.usagi.just example-web <name> rebundles a different example into
target/web/game.usagi without rebuilding the runtime.just serve-web does build-web and starts simple-http-server on
${PORT:=3535}.Files in target/web/ are overwritten on every build, so don’t edit them in
place.
web/shell.html shows a “Click to play” overlay that holds Module.main()
until the user clicks. It does three things on click:
game.usagi over HTTP and writes it to the wasm virtual FS.Module.callMain([]) to start the game.We use Module.noInitialRun = true so emscripten doesn’t auto-run main() the
moment the runtime initializes; the click handler drives it instead. The bundle
URL defaults to game.usagi (relative to the page); set
window.USAGI_BUNDLE_URL before loading usagi.js to override.