Skip to content

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-wasip2 target installed: bash rustup target add wasm32-wasip2
  • wasm-tools CLI (for validation): bash cargo install wasm-tools
  • A checkout of the plantangenet workspace (for the WIT file and world-runner binary).

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