Browser Integration
The browser runs the same brand game logic as the server, but through a different binding layer. Instead of the WIT Component Model, the browser uses wasm-bindgen targeting wasm32-unknown-unknown.
Why Two Targets?
The server and browser need fundamentally different API surfaces:
Server (world-runner) |
Browser | |
|---|---|---|
| API shape | Coarse lifecycle hooks (provision, on_frame_active, drain_signals) |
Fine-grained methods (new(), tick(), add_hob(), query methods) |
| Clock owner | Host drives the clock via FrameManager |
JavaScript drives the clock via requestAnimationFrame or setInterval |
| State access | Signals only (guest to host) | Direct method calls for reads and writes |
| WASM target | wasm32-wasip2 (Component Model) |
wasm32-unknown-unknown (wasm-bindgen) |
Both targets compile the same brand-core crate. The binding layer is the only difference.
Crate Structure
my-brand/
├── brand-core/ # Shared game logic (rlib)
├── brand-component/ # Server: WIT guest (cdylib, wasm32-wasip2)
└── brand/ # Browser: wasm-bindgen shim (cdylib + rlib, wasm32-unknown-unknown)
Brand Core: Feature-Gated APIs
The brand core exposes methods to JavaScript via conditional #[wasm_bindgen] annotations gated behind a browser feature:
# brand-core/Cargo.toml
[features]
browser = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen"]
[dependencies]
wasm-bindgen = { version = "0.2", optional = true }
serde-wasm-bindgen = { version = "0.6", optional = true }
// brand-core/src/lib.rs
#[cfg_attr(feature = "browser", wasm_bindgen::prelude::wasm_bindgen)]
pub struct MyBrandSim { /* ... */ }
#[cfg_attr(feature = "browser", wasm_bindgen::prelude::wasm_bindgen)]
impl MyBrandSim {
#[cfg_attr(feature = "browser", wasm_bindgen::prelude::wasm_bindgen(constructor))]
pub fn new() -> Self { /* ... */ }
pub fn tick(&mut self) { /* ... */ }
}
When compiled with --features browser, these become callable from JavaScript. Without the feature, they are plain Rust methods used by the component crate.
Browser Shim
The browser shim crate depends on brand-core with the browser feature and adds DOM glue:
# brand/Cargo.toml
[dependencies]
my-brand-core = { path = "../brand-core", features = ["browser"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Document", "Window", "Element"] }
// brand/src/lib.rs
pub use my_brand_core::MyBrandSim;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
fn run() -> Result<(), JsValue> {
// DOM setup: hide loading indicator, start clock, etc.
Ok(())
}
Building for the Browser
# Using wasm-pack (recommended)
wasm-pack build my-brand/brand/ --target web --out-dir ../../clients/html/pkg
# Or using cargo directly
cargo build --target wasm32-unknown-unknown -p my-brand --release
wasm-pack produces a pkg/ directory with:
| File | Purpose |
|---|---|
my_brand.js |
JavaScript glue with init() default export and class re-exports |
my_brand_bg.wasm |
The compiled WASM binary |
my_brand.d.ts |
TypeScript type definitions |
package.json |
npm-compatible package metadata |
JavaScript Integration
The HTML client imports the wasm-pack output and wraps it in a runtime:
// sim-bridge.js
import init, { MyBrandSim } from "../../pkg/my_brand.js";
export async function createSim(wasmUrl) {
await init(wasmUrl); // Load and instantiate the WASM module
return new MyBrandSim(); // Call the #[wasm_bindgen(constructor)]
}
The JavaScript runtime drives the clock and calls methods directly:
const sim = await createSim();
// Game loop
function frame() {
sim.tick();
const state = sim.query_state(); // Direct method call
render(state);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
This is fundamentally different from the server path, where the host drives the clock and the brand only responds to lifecycle hooks.
Verifying Both Targets Build
After any change to brand-core, verify both targets compile:
# Server component
cargo build --target wasm32-wasip2 -p my-brand-component
# Browser shim
cargo build --target wasm32-unknown-unknown -p my-brand
Both should succeed without errors. If the brand-core uses cfg(target_arch = "wasm32") anywhere, verify it works for both targets — wasm32-wasip2 and wasm32-unknown-unknown both report target_arch = "wasm32". Use feature flags (e.g. #[cfg(feature = "browser")]) instead of target-arch checks to distinguish browser from component builds.
:::caution Avoid cfg(target_arch) for Browser Detection
Both WASM targets set target_arch = "wasm32". If you gate browser-only code behind cfg(target_arch = "wasm32"), it will also compile into the server component. Use cfg(feature = "browser") instead.
:::
Testing
The brand-core tests run on the native target with standard cargo test:
cargo test -p my-brand-core
Browser-specific integration tests (e.g. testing wasm-bindgen API shapes) run under the rally-brand test harness:
cargo test -p my-brand
For end-to-end browser testing, use a manual smoke test or a Playwright/Puppeteer script against the HTML client served locally.