Hello World
This tutorial walks through building a minimal brand plugin from scratch, compiling it to a WASM component, and running it in world-runner.
By the end you will have a brand that increments a counter each frame and emits a signal with the current count.
Prerequisites
- Rust toolchain with
wasm32-wasip2target installed:bash rustup target add wasm32-wasip2 wasm-toolsCLI (for validation):bash cargo install wasm-tools- A checkout of the
plantangenetworkspace (for the WIT file andworld-runnerbinary).
Step 1: Create the Brand Core
The brand core is a plain Rust library with no host dependencies. Create a new crate:
mkdir -p my-brand/brand-core/src
my-brand/brand-core/Cargo.toml
[package]
name = "my-brand-core"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
my-brand/brand-core/src/lib.rs
use serde::Serialize;
/// A minimal simulation that counts frames.
pub struct CounterSim {
count: u64,
pending_signals: Vec<CounterSignal>,
}
#[derive(Serialize)]
pub struct CounterSignal {
pub count: u64,
}
impl CounterSim {
pub fn new() -> Self {
Self {
count: 0,
pending_signals: Vec::new(),
}
}
/// Advance the simulation by one tick.
pub fn tick(&mut self) {
self.count += 1;
self.pending_signals.push(CounterSignal {
count: self.count,
});
}
/// Drain all pending signals, clearing the buffer.
pub fn drain_signals(&mut self) -> Vec<CounterSignal> {
std::mem::take(&mut self.pending_signals)
}
}
Verify it compiles:
cd my-brand/brand-core && cargo check
Step 2: Create the Brand Component
The component crate implements the WIT guest interface using the brand core. It targets wasm32-wasip2.
mkdir -p my-brand/brand-component/src
my-brand/brand-component/Cargo.toml
[package]
name = "my-brand-component"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.51.0"
my-brand-core = { path = "../brand-core" }
serde_json = "1.0"
my-brand/brand-component/src/lib.rs
mod generated_guest;
use std::cell::RefCell;
use generated_guest::exports::plant::brand::brand_lifecycle::{Guest, SignalEnvelope};
use my_brand_core::CounterSim;
thread_local! {
static SIM: RefCell<Option<CounterSim>> = const { RefCell::new(None) };
}
fn with_sim<R>(f: impl FnOnce(&mut CounterSim) -> R) -> R {
SIM.with(|cell| {
let mut borrow = cell.borrow_mut();
let sim = borrow.get_or_insert_with(CounterSim::new);
f(sim)
})
}
struct Component;
impl Guest for Component {
fn provision() -> Result<(), String> {
SIM.with(|cell| {
*cell.borrow_mut() = Some(CounterSim::new());
});
Ok(())
}
fn on_frame_init(_frame_id: u64) -> Result<(), String> {
Ok(())
}
fn on_frame_active(_frame_id: u64) -> Result<(), String> {
with_sim(|sim| sim.tick());
Ok(())
}
fn on_frame_aggregation(_frame_id: u64) -> Result<(), String> {
Ok(())
}
fn on_frame_cleanup(_frame_id: u64) -> Result<(), String> {
Ok(())
}
fn drain_signals() -> Vec<SignalEnvelope> {
with_sim(|sim| {
sim.drain_signals()
.into_iter()
.map(|s| SignalEnvelope {
schema: "counter.tick.v1".to_string(),
payload: serde_json::to_string(&s).unwrap_or_default(),
})
.collect()
})
}
}
generated_guest::export!(Component with_types_in generated_guest);
Generate Guest Bindings
Before the component compiles, you need the generated_guest.rs file. Run the xtask from the plantangenet workspace:
cargo xtask codegen-wit
This generates guest bindings from brand-wit/wit/plugin.wit into your component crate. Alternatively, you can use the wit-bindgen macro directly:
// Instead of a separate generated_guest.rs, place this at the top of lib.rs:
wit_bindgen::generate!({
world: "plugin",
path: "../../plantangenet/brand-wit/wit/plugin.wit",
exports: {
"plant:brand/brand-lifecycle": Component,
},
});
Step 3: Build the Component
cargo build --target wasm32-wasip2 -p my-brand-component --release
The output is at target/wasm32-wasip2/release/my_brand_component.wasm.
Validate it:
wasm-tools validate --features all target/wasm32-wasip2/release/my_brand_component.wasm
Silent output means success.
Step 4: Run in world-runner
Build and start world-runner with your brand component:
# From the plantangenet workspace
cargo build -p plant_world --features server,brand-plugin --bin plant_world_runner
./target/debug/plant_world_runner \
--brand-wasm target/wasm32-wasip2/release/my_brand_component.wasm \
--dev \
--timeout 10
You should see log output like:
INFO booting FrameManager dust=10000
INFO loading brand component path="target/..."
INFO brand component registered
INFO world-runner listening addr=127.0.0.1:8080
Drive a Frame Tick
In another terminal, step the clock:
curl -X POST http://127.0.0.1:8080/clock -d '{"action":"step"}'
Watch the Signal Stream
Connect to the WebSocket stream to see your brand's signals:
websocat ws://127.0.0.1:8080/stream
After a clock step, you should see a JSON message:
{
"event": "counter.tick.v1",
"path": "/brand/signal",
"value": { "count": 1 }
}
Each subsequent POST /clock step increments the count.
Step 5: Test the Brand Core
The brand core is an ordinary Rust library — test it with standard #[test]:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tick_increments_and_drains() {
let mut sim = CounterSim::new();
sim.tick();
sim.tick();
let signals = sim.drain_signals();
assert_eq!(signals.len(), 2);
assert_eq!(signals[0].count, 1);
assert_eq!(signals[1].count, 2);
// Second drain is empty
assert!(sim.drain_signals().is_empty());
}
}
cargo test -p my-brand-core
What's Next
- Add a browser shim so the same game logic runs in the HTML client.
- Read the WIT Contract Reference for the full interface specification.
- See world-runner Host for server configuration, URL policies, and the HTTP/WebSocket API.