Skip to content

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.