Stream: wasmtime

Topic: WASM components in a game plugin context


view this post on Zulip Brian Schwind (May 26 2025 at 04:25):

Hi!

I'm working on creating a sort of game "scripting" system where scripts are created by simply implementing a trait, let's call it GameScript. The goal is to allow implementations of GameScript to be compiled to WASM components and hot reloaded during development, and in a release build, have them be statically compiled into the main game binary as simple trait objects. These guest "scripts" would be called every frame of the game.

Just as I have a GameScript trait for guest code, I have a ScriptHost trait for a host implementation. This would be implemented by the game engine itself, but it could also be implemented by any type so that the game scripts can be tested.

I have most of this working, but the part I'm struggling with is how to structure data on the host side, in terms of Linkers and Stores.

Here's what I currently have:

use std::rc::Rc;
use std::cell::RefCell;
use crate::wasm_runner::game_script::api::host::Host;
use script_api::ScriptHost;
use std::path::Path;
use wasmtime::{
    component::{Component, Linker},
    Config, Engine, Store,
};

wasmtime::component::bindgen!({
    path: "../script-api/wit",
});

pub struct WasmEngine<H: ScriptHost> {
    engine: Engine,
    linker: Linker<WasmHost<H>>,
    scripts: Vec<GuestScript<H>>,
    host_impl: WasmHost<H>,
}

impl<H: ScriptHost> WasmEngine<H> {
    pub fn new(host: H) -> Self {
        let mut config = Config::new();
        config.wasm_component_model(true);
        let engine = Engine::new(&config).unwrap();

        let mut linker = Linker::new(&engine);
        ScriptApiWorld::add_to_linker(&mut linker, |state: &mut WasmHost<H>| state).unwrap();

        let host_impl = WasmHost {
            host: Rc::new(RefCell::new(host)),
        };

        Self { engine, linker, scripts: vec![], host_impl }
    }

    pub fn load_script(&mut self, wasm_path: impl AsRef<Path>) {
        let mut guest_script = GuestScript::new(wasm_path, &self.engine, &self.linker, self.host_impl.clone());
        guest_script.register();
        self.scripts.push(guest_script);
    }

    pub fn on_update(&mut self) {
        for script in &mut self.scripts {
            script.on_update();
        }
    }
}

struct GuestScript<H: ScriptHost> {
    bindings: ScriptApiWorld,
    component: Component,
    store: Store<WasmHost<H>>,
}

impl<H: ScriptHost> GuestScript<H> {
    fn new(
        wasm_path: impl AsRef<Path>,
        engine: &Engine,
        linker: &Linker<WasmHost<H>>,
        host_impl: WasmHost<H>,
    ) -> Self {
        let component_bytes = convert_to_component(&wasm_path);
        let component = Component::from_binary(engine, &component_bytes).unwrap();
        let mut store = Store::new(engine, host_impl);

        let bindings = ScriptApiWorld::instantiate(&mut store, &component, linker).unwrap();

        Self { bindings, component, store }
    }

    fn register(&mut self) {
        // TODO - consider using self.store.data_mut() to swap out data?
        self.bindings.call_register_script(&mut self.store).unwrap();
    }

    fn on_update(&mut self) {
        self.bindings.call_on_update(&mut self.store).unwrap();
    }
}

// Implements the host API from the WIT definition.
struct WasmHost<H: ScriptHost> {
    host: Rc<RefCell<H>>,
}

impl<H: ScriptHost> Clone for WasmHost<H> {
    fn clone(&self) -> Self {
        Self {
            host: Rc::clone(&self.host),
        }
    }
}

impl<H: ScriptHost> Host for WasmHost<H> {
    fn print(&mut self, msg: String) {
        self.host.borrow().print(msg);
    }

    fn create_entity(&mut self) -> u32 {
        self.host.borrow_mut().create_entity()
    }
}

fn convert_to_component(path: impl AsRef<Path>) -> Vec<u8> {
    let bytes = &std::fs::read(&path).unwrap();
    wit_component::ComponentEncoder::default().module(bytes).unwrap().encode().unwrap()
}

And here's a very simple usage of it:

use script_api::ScriptHost;
use script_runner::wasm_runner::WasmEngine;

fn main() {
    let mut wasm_runner = WasmEngine::new(TestHost {});

    wasm_runner.load_script("target/wasm32-unknown-unknown/release/scripts.wasm");

    for _ in 0..1000 {
        wasm_runner.on_update();
    }
}

struct TestHost {}

impl ScriptHost for TestHost {
    fn print(&self, msg: String) {
        println!("{msg}");
    }

    fn create_entity(&mut self) -> u32 {
        0
    }
}

My issue: I would prefer to pass in &mut TestHost to functions like WasmEngine::on_update(), instead of passing ownership of TestHost into WasmEngine. However, when I want to actually call a guest function in a component, I have to pass &mut Store, which seems to need to own an implementation of my ScriptHost trait. I hacked this for now by storing the implementation in an Rc<RefCell<>> but that doesn't seem like the right way to do things.

If I want to do what I'm describing, does that mean I'll have to create a new Store every time I call a guest's on_update() function? But then if the guest implementation stores state, that would get reset, right?

I'm curious what other patterns people have come up with. Maybe the ScriptHost implementation should be owning the Store? But I've been running with the assumption I should have one Store per component. Is that a false assumption?

I see this documentation which was linked in zulip for an unreleased API for wasmtime, which describes this situation:

Let’s say you’re in a situation where you’re a library which wants to create a “simpler” trait than the Host-generated traits from bindgen! and then you want to implement Host in terms of this trait

That _might_ be relevant to me, but I'm not yet sure if it will work out.

view this post on Zulip Alex Crichton (May 27 2025 at 03:34):

FWIW from my perspective you're definitely on the right track and you've got all the right intuitions here, and sadly I at least don't know of a great solution for you. To answer some of your direct questions:

does that mean I'll have to create a new Store every time I call a guest's on_update() function? But then if the guest implementation stores state, that would get reset, right?

Correct, a Store is a unit of state for components so if you make a new store each time you'll have to also make a new instance each time. You're pretty likely to not want to do that so you'll either want to use a store-per-plugin (helps keep plugins isolated from each other) or a store-per-process (note that this will "leak" instances though since there's no internal GC, instances get deallocated when the store is deallocated). If you're hot-reloading my hunch is you want a store-per-plugin, so that way when a plugin gets reloaded you'll free up all wasm state.

Maybe the ScriptHost implementation should be owning the Store? But I've been running with the assumption I should have one Store per component. Is that a false assumption?

I think your store-per-component architecture is best for your use case, so I'd stick with that. It also unfortunately wouldn't work to put the store in ScriptHost somehow because to call a wasm method you need &mut Store, so if you've borrowed &mut Store from &mut ScriptHost then you won't be able to access &mut ScriptHost when the host is called. (that'd be unsound aliasing otherwise)

That _might_ be relevant to me, but I'm not yet sure if it will work out.

Alas probably not, the documentation you linked is about providing a host API via bindgen! in terms of a custom handwritten trait, but the problem you're running into is one moreso of ownership than modeling functionality.


My best suggestion for you is that you want something along the lines of scoped_tls. That way the T in Store<T> wouldn't own your ScriptHost implementation. You'd use scoped_tls to transfer the mutable borrow from the entrypoint of the wasm invocation to where the host is called on the other side. Wasmtime unfortunately has no means of transferring per-call state from the caller of wasm to the callee host function.

I'll note that scoped_tls probably doesn't exactly fit your use case, you want to transfer &mut T across call frames, not &T, so you'd probably have to tailor it for this use case (or there may already be creates that do that of which I'm unaware).

At a high level though most of the embeddings I've seen place owned state within the T of Store<T>. That's arguably due to Wasmtime's design though of not supporting a &mut T being transferred across function calls. It's an interesting idea though and might be an interesting feature to add to Wasmtime... (I'm not sure how exactly we'd go about it though)

view this post on Zulip Brian Schwind (May 27 2025 at 04:04):

Thank you for the reply, glad to hear my assumptions are mostly in line! I'll take this as a challenge then, and see if I can come up with a pattern that works. Thanks for the scoped_tls tip, I'll try to learn from it.

To start, each plugin will run sequentially on a single thread, with no contention on the ScriptHost, so I can probably figure out some way to pass ownership of it around with moves, or mem::swapping Options or something.

Once I want to execute the plugins in parallel, there's probably no getting around storing ScriptHost in an Arc or something similar. Perhaps if the WASM host implementation just accepted a read-only &ScriptHost and recorded commands to be run later, that could work.

I'll think more about it and post an update later if I come up with something good.

view this post on Zulip Alex Crichton (May 27 2025 at 04:07):

Ah yeah if you want threads you're almost surely going to want Arc which trivializes this since you can throw a clone in each plugin.

Otherwise you've also got a good point about movement. While not the most ergnomic it's a pattern I've also used before. It means you wouldn't be able to take &mut ScriptHost as your argument when calling a wasm function, it'd have to be something like &mut Option<ScriptHost> or something with some sort of internal take-ability, but if that works for your design I think that could be reasonable -- basically just a runtime check of "you put the thing here, right? ok let's call the method"

view this post on Zulip Brian Schwind (May 27 2025 at 04:12):

I'm willing to take a hit on ergonomics for the game "engine" (can barely be called an engine yet) side of things as long as the plugin authoring side remains nice, and the execution of the WASM plugins is fast enough. A game made this way would still statically compile the trait implementations into the final binary without any WASM executing capability at all, unless I wanted to support third-party user mods. So performance is only a small worry for development and not the final product.

view this post on Zulip Brian Schwind (May 29 2025 at 02:40):

So far the "move ownership of the ScriptHost implementation around" is working the best. It's a bit cumbersome in the engine code, but that is at least hidden from script/plugin authors. Essentially looks like this:

// WasmEngine
pub fn on_update(&mut self, mut host: H) -> H {
    for script in &mut self.scripts {
        host = script.on_update(host);
    }

    host
}

// GuestScript
fn on_update(&mut self, host: H) -> H {
    self.store.data_mut().host = Some(host);
    self.bindings.call_on_update(&mut self.store).unwrap();

    let host = self.store.data_mut().host.take().unwrap();
    host
}

// Usage

let mut test_host = TestHost {};
let mut wasm_runner = WasmEngine::new();

test_host = wasm_runner.load_script("target/wasm32-unknown-unknown/release/scripts.wasm", test_host);

let start = std::time::Instant::now();
for _ in 0..1000 {
    test_host = wasm_runner.on_update(test_host);
    test_host = wasm_runner.on_draw(test_host);
}

view this post on Zulip Brian Schwind (May 29 2025 at 02:42):

I'll need to have these functions return Result with the host in the Err() variant, so if the script panics I can get ownership of the host back.

I'll go with this pattern for now until I think of something better or it becomes a huge pain. Thanks again @Alex Crichton for helping me think through this!

view this post on Zulip Alex Crichton (May 30 2025 at 15:13):

A bit late, but one thing I might recommend is threading around &mut T through which you can acquire ownership of H. That'll make it a bit easier to pass around and scope the "take out and put back in" to just happening around the wasm. It'll also help the error case where you can still "put back in" when wasm generates an error such as a trap. An example would be to pass around &mut Option<H> but you might also want to wrap that up in something more official like &mut MyTypeContainingOptionH or similar

view this post on Zulip Brian Schwind (May 30 2025 at 15:49):

Not late at all! So if I do that then the engine code (game engine, not wasm engine) won't need to do that ownership dance - but I would need to store test_host (from the example above) in that wrapper type, right?

view this post on Zulip Alex Crichton (May 30 2025 at 15:50):

right yeah, and you'd still have to do the ownership dance, but only around invoking a wasm function (so e.g. in on_update) but you wouldn't have to do that anywhere else (e.g. not in the Usage section above)

view this post on Zulip Brian Schwind (May 30 2025 at 15:55):

I'll try that out tomorrow, that sounds nicer. The wrapper type might need a Deref/DerefMut to be more easily used in other contexts but I'll see how the API feels with that change

view this post on Zulip Alex Crichton (May 30 2025 at 15:55):

yeah IMO this is a reasonable spot for a Deref impl, the only place it would "fail" is during the wasm call but you'd audit those and it's be relatively easy to avoid using the original type that was passed in

view this post on Zulip Brian Schwind (May 31 2025 at 13:20):

This turned out to be a great suggestion! I just have a very simple HostWrapper struct that looks like this:

pub struct HostWrapper<H: ScriptHost> {
    inner: Option<H>,
}

impl<H: ScriptHost> HostWrapper<H> {
    pub fn new(host: H) -> Self {
        Self { inner: Some(host) }
    }

    pub fn into_inner(mut self) -> H {
        self.inner.take().unwrap()
    }
}

In the part of my game engine where I want to execute all the WASM guest scripts, I can temporarily put it in this wrapper and pass it around, and then call .into_inner() at the end to get back the owned value. The calling code, and a lot of the internal code, became a lot simpler. Nice!


Last updated: Dec 06 2025 at 06:05 UTC