Guide to how to work on the engine.
The Rust code uses the default styles from cargo fmt.
Pull requests will be renamed to follow the Conventional Commits spec.
- 2 spaces for indentation.
snake_casefor locals, function names, table fields, and helper module names (e.g.enemy.lua,local fresh_state). SCREAMING_SNAKE_CASEfor compile-time-ish constants (file-scopelocal TICK = 0.12,local MAX_BULLETS = 12, the engine'sgfx.COLOR_*table). Distinguishes "tunable knob" from "runtime variable."- Engine API is lowercase (
gfx,input,sfx,music,usagi). It's declared inmeta/usagi.luaso the LSP treats it as predefined; reads in user code don't triplowercase-global. - Cross-frame globals are
Capitalized. This includes the canonical game-state container (State = { ... }set inside_init) and module imports kept as globals (Player = require("player")). The capitalization signals "intentional global, lives across reloads"; anything lowercase at file scope is treated bylowercase-globalas an accident (forgot alocal). - Why
Stateis a global: live reload re-execs the chunk on every saved edit. Alocal Stateat module scope would get re-bound to a fresh table every save and obliterate the running game. SettingStatein_init(and only in_init, which only runs at startup and on F5) lets the table outlive reloads. - If you have a real cross-frame need that isn't
State, the convention scales: capitalize and document it. Module-bound require results (Enemy,Bullet) are the common second case.
This pattern (engine-API lowercase / game-state capitalized / locals snake_case)
is the same one shipped in .luarc.json and the usagi init template, and is
what every example under examples/ follows.
Install Rust: https://rustup.rs
The usagi font bake subcommand brings in freetype-rs with vendored FreeType.
FreeType's bundled build compiles libpng, which #includes zlib.h, and
freetype-sys 0.23.0 doesn't expose zlib's include path properly on Windows.
Install zlib via vcpkg and point cl at it before building:
# `C:\vcpkg` ships preinstalled on GitHub's Windows runners. On a dev
# machine, see https://learn.microsoft.com/vcpkg/get_started/get-started.
vcpkg install zlib:x64-windows-static-md
$env:CFLAGS_x86_64_pc_windows_msvc = "-IC:\vcpkg\installed\x64-windows-static-md\include"
cargo buildLinux and macOS find a usable zlib path automatically and don't need this step.
CI handles Windows automatically (see .github/workflows/ci.yml).
THIRD_PARTY_LICENSES.md is generated from Cargo.lock by
cargo-about and committed. It's
regenerated as part of release prep, not on every CI run: cargo-about picks one
"representative" LICENSE file per SPDX group and the pick varies between
environments, so a CI sync check produces flaky diffs that don't reflect real
problems. Run it locally before tagging a release:
cargo install cargo-about --features cli # one-time
just licenses
git diff THIRD_PARTY_LICENSES.md # reviewThe accepted-licenses list lives in about.toml. If a new dep brings in a
license that's not in the list, cargo-about errors; add the SPDX ID after
eyeballing the terms, or replace the dep.
Documentation is written in Markdown. It's formatted with deno fmt, but it's
not a huge deal if there's drift there, so CI doesn't enforce this.
Changes to the README should be made in README_DEV.md, which is used to stage
docs for the next release. The README.md is for the most recently released
version of Usagi, so that when people come to GitHub they don't see
documentation for features that aren't in a shipped version yet.
Only update README.md too when fixing a typo or adding something that's not
version-specific.
CHANGELOG.md contains a list of changes for each version. Pre-release notes
get squashed into the full release when it happens.
After cloning, run just setup once. It points git at .githooks/, so
just ok runs automatically before every push (skip with --no-verify in a
pinch).
just ok runs fmt, clippy, and tests. Run before any commit.
just example <name> boots an example in dev mode with live reload.
While developing Usagi itself, replace usagi with cargo run -- (for example
cargo run -- dev examples/hello_usagi.lua).
just build-web then just serve-web builds the wasm runtime and serves it
locally on port 3535. Needs emscripten on PATH; run
./scripts/setup_emscripten.sh once to install it on Fedora.
brew install emscripten works on macOS.
See justfile for the full list of recipes.
just run- run hello_usagi examplejust ok- run all checksjust fmt- format Rust codejust serve-web- build and serve the web build at http://localhost:3535 (requiresemccon PATH; see docs/web-build.md)
Every Rust callback exposed to user Lua (anything registered with
lua.create_function or scope.create_function and assigned onto gfx,
input, sfx, music, usagi, effect, etc.) MUST go through the wrap
helper in src/api.rs before being attached to its public table.
Pattern:
let raw = lua.create_function(|_, c: i32| { /* ... */ })?;
gfx.set("clear", wrap(lua, raw, "gfx.clear", &["number"])?)?;types is a slice of expected type() strings ("number", "string",
"boolean", "function", "table", "nil", or "any" to skip a check). The
wrapper calls error(...) from Lua-land when validation fails.
Lua's only error mechanism is lua_error, which is longjmp. When a Rust
callback returns Err, mlua re-raises via lua_error, and the longjmp has to
unwind back to the nearest lua_pcall, crossing every Rust frame on the way. On
Windows MSVC that trips the GS stack-cookie check and aborts the process with
STATUS_STACK_BUFFER_OVERRUN. See
#103.
The wrapper sidesteps this by validating in Lua-land first. When a user calls
gfx.clear(nil), the validator's error(...) originates from Lua's error
builtin, so the longjmp traverses Lua/C frames only and lands safely at the
outer pcall on every platform.
This applies to BOTH stable callbacks (registered once at session start) and
per-frame scoped callbacks inside lua.scope blocks. The wrap call goes inside
the scope alongside the scope.create_function call.
If the new binding accepts something mlua's typed conversion can't easily
express (an Option<T>, a serializable LuaValue, a multi-type union), use
"any" for that arg and let the Rust closure handle the variant.
There are regression tests in src/api.rs (wrap_rejects_bad_arg_*) that lock
the contract in place.
usagi export --target web normally fetches a runtime template from the
matching release tag. On a -dev build there's no published release, so point
the CLI at the locally-built runtime instead. --template-path accepts an
extracted directory in addition to an archive:
just build-web # one-time: build the wasm runtime
cargo run -- export examples/snake --target web \
--template-path target/wasm32-unknown-emscripten/releaseThat writes snake-web.zip in the cwd. To run it in a browser:
unzip -d /tmp/snake-web snake-web.zip
simple-http-server --index --nocache -p 3535 /tmp/snake-web
# open http://localhost:3535simple-http-server is the same server just serve-web uses (installed by
just setup-web).
shell.html is auto-picked up from web/shell.html in the source tree when
running from the repo root, so you don't need to stage it. Pass
--web-shell PATH (or drop a shell.html next to your script) to use a custom
one.
just push-web-test EXAMPLE_NAME can be used to push an example game's web
build to a private testing project on itch.io to ensure web builds work. Best
used with notetris.
Runs on every push to main and every pull request. Three jobs:
check: matrix of Linux (ubuntu-22.04), macOS (macos-latest, Apple Silicon), and Windows (windows-latest). Runs fmt, clippy, tests, and a release build. Uploads the binary as an artifact.web: builds the emscripten wasm runtime. Uploadsusagi.jsandusagi.wasm.
CI artifacts expire after 90 days and require a GitHub login to download. Use them for spot-checking a PR. For distribution, cut a release.
- Run
just okto ensure all checks pass - Run
just examplesto verify everything is working as expected - Test syntax error recovery in a
requireto ensure it works - Test
usagi tools - Verify web builds work with
just push-web-test notetrisand test at https://brettchalupa.itch.io/usagi-web-test - Verify gamepads work
- Bump
versioninCargo.tomland runcargo update -p usagito refreshCargo.lockbefore tagging. The tag should match the manifest version - Run
just licensesto regenerateTHIRD_PARTY_LICENSES.mdfrom the currentCargo.lock. Review the diff and commit it. (See Third-party licenses for setup.) - Update the current version in README_DEV.md
- Update CHANGELOG.md
cp README_DEV.md README.md
Push a tag matching v* to trigger a release build:
git tag -a v0.1.0
# paste in the changelog into the tag
git push origin v0.1.0The workflow builds release binaries on every supported target, packages them, and creates a GitHub Release as a draft with the archives attached. Once the workflow finishes, there will be a draft release.
Copy the Changelog notes and assets on the Releases page, then click Publish to
make it public. Tags containing a hyphen (v0.1.0-rc.1) are flagged as
prereleases following semver convention.
| File | Target |
|---|---|
usagi-<ver>-linux-x86_64.tar.gz |
Linux x86_64, glibc 2.35+ |
usagi-<ver>-linux-aarch64.tar.gz |
Linux aarch64/arm64, glibc 2.35+ |
usagi-<ver>-macos-aarch64.tar.gz |
macOS, Apple Silicon |
usagi-<ver>-windows-x86_64.zip |
Windows 10+ |
usagi-<ver>-wasm.tar.gz |
Web runtime (usagi.js + usagi.wasm + shell.html) |
Each artifact also publishes a <file>.sha256 sidecar (sha256sum format).
usagi export fetches the sidecar alongside the archive and verifies before
extraction; mismatches fail loudly.
Filenames carry the architecture so future arm/x86 splits drop in without
renaming. usagi export resolves --target linux to the matching artifact via
the URL convention ${USAGI_TEMPLATE_BASE}/v<ver>/<file>.
just push-itch- Publish a devlog with the changes
Share the release:
- Bluesky
- Mastodon
- r/UsagiEngine
- YouTube post
- Discord
Push a new version of notetris to itch.io: ./examples/notetris/push.rb
After the release is made, bump the version in Cargo.toml to the next version
that will be worked on and add the -dev suffix. So if v1.1.0 was just
released, update it to 1.2.0-dev and run cargo update -p usagi. Commit and
push this to GitHub. This helps make it clear that what's on main is not the
published version nor the upcoming version (yet).
- The Linux runner is
ubuntu-22.04(glibc 2.35) for portability. Binaries should run on Debian 12+, RHEL 9+, Fedora, Arch, openSUSE Leap 15.4+. - The Linux aarch64 build runs natively on the
ubuntu-22.04-armrunner (glibc 2.35), so it carries the same distro floor as the x86_64 build. macos-latestis Apple Silicon. No Intel mac binary is produced.
The non-obvious bits of getting Usagi to compile and link for the web. If the web build breaks, start here.
- Stable Rust (currently 1.95.0 verified). No
rust-toolchain.tomlpin. - Emscripten via emsdk.
scripts/setup_emscripten.shinstalls to$XDG_DATA_HOME/emsdk(or~/.local/share/emsdk); source~/.local/share/emsdk/emsdk_env.shto putemcconPATH. On macOS, you can dobrew install emscripten. - emcc 5.0.6 verified.
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.
- rustc link args in
.cargo/config.tomlrustflags:-C link-arg=-sSUPPORT_LONGJMP=wasm. emcc rejectsSUPPORT_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. - cc-rs CFLAGS in
.cargo/config.toml[env]:CFLAGS_wasm32_unknown_emscriptenandCXXFLAGS_wasm32_unknown_emscriptenset to-fwasm-exceptions -sSUPPORT_LONGJMP=wasm. cc-rs reads these when compiling C deps for the target, including mlua's vendored Lua. - emcc CFLAGS via
EMCC_CFLAGSin 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 viaEMCC_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).
The [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 it 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:
- The JS shell (
web/shell.html) fetchesgame.usagi(overridable viawindow.USAGI_BUNDLE_URL) over HTTP after the runtime initializes. - JS writes the bytes into the wasm virtual FS at
/game.usagiusingModule.FS.writeFile(which is whyFSis inEXPORTED_RUNTIME_METHODS). - JS calls
Module.callMain([]). Rust'smain()on the emscripten target loads/game.usagi, builds aBundleBackedvfs, and runs.
This is the same .usagi bundle format usagi export --target 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 export --target web viable
without an emcc rebuild on the user's machine.
Quickstart:
- One-time setup (per machine):
bash scripts/setup_emscripten.sh(installs emsdk to~/.local/share/emsdk).just setup-web(adds the wasm target and a tiny static server).
- Each new shell session, source emsdk so
emccis on PATH:source ~/.local/share/emsdk/emsdk_env.sh
- Build + serve at
http://localhost:3535:Or just build (no server):just serve-web
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 tabThis works for any path that usagi export --target 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/shell.html shows a "Click to play" overlay that holds Module.main()
until the user clicks. It does three things on click:
- Fetches
game.usagiover HTTP and writes it to the wasm virtual FS. - Resumes any suspended AudioContext (browsers gate audio behind a user gesture).
- Calls
Module.callMain([])to start the game.
Module.noInitialRun = true is used 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.