Write a guest in Rust
Goal
Your game logic is a Rust crate compiled straight to a WebAssembly component, loaded by the same createSandbox({ mode: 'wasm' }) that loads a TypeScript guest. The host never learns which language you used.
Files you will edit
Cargo.tomlsrc/lib.rs
Steps
Install the target once.
rustcemits a component forwasm32-wasip2by itself, so there is nocargo-componentand nowasm-tools component newstep.shrustup target add wasm32-wasip2Declare a
cdylibwith one dependency, and a release profile that cares about size. An empty[workspace]stops cargo walking up into a parent manifest.toml# Cargo.toml [package] name = "my-guest" version = "0.1.0" edition = "2021" [workspace] [lib] crate-type = ["cdylib"] [dependencies] wit-bindgen = "0.62.0" [profile.release] opt-level = "s" lto = true codegen-units = 1 strip = true panic = "abort"Generate the bindings from the engine's WIT — do not copy it into your crate, or it will drift from the host — and implement the five exports.
rust// src/lib.rs wit_bindgen::generate!({ path: "../../wit", world: "game-module" }); use crate::aos::engine::env; use crate::exports::aos::engine::game::{FrameInput, FrameOutput, GameConfig, GameError, Guest}; struct Component; impl Guest for Component { fn init(config: GameConfig) -> Result<(), GameError> { // Seed here, never at module scope, exactly as in the TS guest. STATE.with(|s| s.borrow_mut().rng = (config.seed ^ env::seed()) | 1); Ok(()) } fn tick(input: FrameInput) -> FrameOutput { /* … */ } fn shutdown() {} fn snapshot() -> Vec<u8> { /* … */ } fn restore(bytes: Vec<u8>) -> Result<(), GameError> { /* … */ } } export!(Component);The
Guestmethods are free functions — there is noself— so state is global.thread_local! { static STATE: RefCell<State> }keeps that safe with nounsafe, and wasm is single-threaded so the borrow never contends.Build and transpile. Two steps, not three: cargo produces the component,
jco transpileproduces the loader, with the same flags the TypeScript guest uses.shcargo build --release --target wasm32-wasip2 npx jco transpile target/wasm32-wasip2/release/my_guest.wasm \ --instantiation async --no-nodejs-compat --name game -o dist/guestexamples/wasm-guest-rust/build.mjswraps both steps, verifies the output really is a component (preamble00 61 73 6d 0d 00 01 00, layer 1, not a core module's00 61 73 6d 01 00 00 00) and prints raw and brotli sizes.
Verify
sh
node examples/wasm-guest-rust/build.mjs
AOS_BOUNDARY=1 npx vitest run -c tests/boundary/vitest.config.ts tests/boundary/rust-guest.test.tsThe build prints a component around 107 KiB (39 KiB brotli) against the QuickJS guest's ~2.1 MiB, and all eleven boundary assertions pass: the frame-0 spawn and add-body commands, a stride-12 transform buffer, the raycast import round trip, the HUD, and a snapshot/restore that hashes identically.
See also
- The wasm boundary
- Build the wasm guest
examples/wasm-guest-rust/README.md— the worked examplepackages/wasm-host/README.md— @aosengine/wasm-host