Log inGet started

Building a plugin

Updated September 30, 2026

Everything here is built from two things: the interface file zero-plugin.wit, which ships beside the plugin asset type, and crates from crates.io. You need nothing else from the engine.

What you build

One WebAssembly component (the component model, not a core module) that exports the zero:plugin/plugin interface of the zero-plugin world:

ExportWhen the engine calls it
manifest() -> plugin-manifestonce, when the plugin loads: what it is and what it offers
init(config-json) -> resultonce, after manifest; an error stops the load
call(plugin-call) -> plugin-resultevery time code in the world calls one of its methods
tick(delta-secs) -> tick-resultonce a frame, with the seconds since the last one
shutdown()once, before it is replaced or stopped

Every export is async, and so is every import that does I/O.

Getting the interface

Read zero-plugin.wit out of the engine and save it into your project as wit/zero-plugin.wit:

local wit = asset.resolve("@builtin::assetTypes.plugin")
print(vfs.read(wit.path .. "/zero-plugin.wit"))

A complete plugin

A counter that keeps its total in the plugin's own store. Build it as it is, then change it.

Cargo.toml:

[package]
name = "counter"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = { version = "0.54", default-features = false, features = ["async", "macros"] }
serde_json = "1"

[profile.release]
opt-level = "s"
lto = true
strip = true

Every function in zero-plugin.wit is async, and wit-bindgen before 0.54 cannot read it. 0.54 and 0.62 are both known to build it.

src/lib.rs:

wit_bindgen::generate!({
    world: "zero-plugin",
    path: "wit/zero-plugin.wit",
    generate_all,
});

use exports::zero::plugin::plugin::Guest;
use zero::plugin::types::{
    ArgDescriptor, MethodDescriptor, NamespaceDescriptor, OutboundEvent, PluginCall,
    PluginManifest, PluginResult, ReturnDescriptor, TickResult,
};
use zero::plugin::{zero_log, zero_storage};

struct Counter;

fn answer(value: serde_json::Value) -> PluginResult {
    PluginResult { value_json: value.to_string(), byte_slots: Vec::new(), error: None }
}

fn refuse(why: &str) -> PluginResult {
    PluginResult { value_json: String::new(), byte_slots: Vec::new(), error: Some(why.to_string()) }
}

async fn total() -> i64 {
    zero_storage::get("total".to_string())
        .await
        .and_then(|text| text.parse().ok())
        .unwrap_or(0)
}

impl Guest for Counter {
    async fn manifest() -> PluginManifest {
        PluginManifest {
            name: "counter".into(),
            version: "0.1.0".into(),
            description: "Counts, and remembers the count".into(),
            namespaces: vec![NamespaceDescriptor {
                name: "counter".into(),
                methods: vec![
                    MethodDescriptor {
                        name: "add".into(),
                        signature: "counter.add(amount: number) -> number".into(),
                        description: "Add to the count and answer the new total".into(),
                        args: vec![ArgDescriptor {
                            name: "amount".into(),
                            arg_type: "number".into(),
                            description: "How much to add".into(),
                            optional: false,
                        }],
                        returns: Some(ReturnDescriptor {
                            return_type: "number".into(),
                            description: "The new total".into(),
                        }),
                    },
                    MethodDescriptor {
                        name: "total".into(),
                        signature: "counter.total() -> number".into(),
                        description: "The count so far".into(),
                        args: vec![],
                        returns: Some(ReturnDescriptor {
                            return_type: "number".into(),
                            description: "The total".into(),
                        }),
                    },
                ],
            }],
        }
    }

    async fn init(_config_json: String) -> Result<(), String> {
        zero_log::log(zero::plugin::types::LogLevel::Info, "counter ready");
        Ok(())
    }

    async fn call(request: PluginCall) -> PluginResult {
        let args: Vec<serde_json::Value> = serde_json::from_str(&request.args_json).unwrap_or_default();
        match request.method.as_str() {
            "add" => {
                let Some(amount) = args.first().and_then(serde_json::Value::as_i64) else {
                    return refuse("add needs a whole number");
                };
                let next = total().await + amount;
                zero_storage::set("total".to_string(), next.to_string()).await;
                answer(next.into())
            }
            "total" => answer(total().await.into()),
            other => refuse(&format!("counter has no method '{other}'")),
        }
    }

    async fn tick(_delta_secs: f32) -> TickResult {
        TickResult { events: vec![] }
    }

    async fn shutdown() {}
}

export!(Counter);

Where the pieces are, because the generated paths are not obvious:

  • The trait you implement is exports::zero::plugin::plugin::Guest: the world exports a named interface, plugin.
  • The records are in zero::plugin::types: PluginManifest, NamespaceDescriptor, MethodDescriptor, ArgDescriptor, ReturnDescriptor, PluginCall, PluginResult, ByteSlot, TickResult, OutboundEvent, LogLevel.
  • The imports are modules under zero::plugin: zero_log, zero_storage.
  • The bindings cover every interface in the WIT, but a component imports only what its code calls. Calling one a world plugin may not use is what makes it refuse to load.

Build it:

rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2

The component is target/wasm32-wasip2/release/counter.wasm.

Putting it in a world

  1. Write counter.wasm into the world's files, by any upload or from Luau with vfs.write("/zero/source/counter.wasm", bytes). It becomes the asset counter.plugin/, with the component inside as plugin.wasm, a plugin.yaml, a surface.module/ and a README, and it loads. The asset's name, counter, is what code in the world calls it by.

  2. Nothing is callable yet. Edit plugin.yaml to list the methods:

    profiles:
      - editor
      - runtime
    storage: private
    exposed:
      - name: add
      - name: total
    

    The plugin reloads with them. A listed method the component does not declare is refused at load, naming it. Left out, storage is none, events is private and exposed is empty; profiles is required.

  3. Give the surface a function per method. It is the file surface.module/init.luau:

    local self = asset.containing(__FILE__)
    local me = self.modules.shared.forAsset(self)
    
    local M = {}
    
    function M.add(amount: number)
        return me.call("add", amount)
    end
    
    function M.total()
        return me.call("total")
    end
    
    return M
    

    me.call calls the plugin this module belongs to.

  4. Call it from anywhere in the world:

    local counter = require("counter")
    print(await(counter.add(2)))   -- 2
    print(await(counter.total()))  -- 2
    

The manifest

plugin.yaml may say how much memory the plugin may hold: memory: 512MiB (KiB, MiB or GiB, up to 4GiB, the default). The limit is enforced on both hosts: on the desktop by the engine's runtime, in a browser by compiling your component with its memory capped. Past it your component stops, and the log and every waiting call say it went past its memory:. A plugin that loads a model declares enough for the model.

  • Declare one namespace. A caller names your plugin's asset, and every call reaches that namespace; its name comes back to you in plugin-call.namespace. A component that declares none, or several, does not load.
  • The namespace name must be a Luau identifier (letters, digits and _, not starting with a digit) and not the name of one of the engine's own bindings, such as vfs. A method may be named anything except onEvent and offEvent.
  • The engine reads each method's name. signature, description, arg-type, return-type and the argument descriptions are text for people and are not parsed. The types a caller sees are the ones you write in surface.module.
  • name, version and description describe the component.

Calls and answers

A call from Luau arrives as a plugin-call:

  • method is the method's name.
  • args-json is a JSON array with one entry per argument, in order. A call with no arguments sends [].
    • A whole number arrives as a JSON integer (2, not 2.0), up to 2^53 in magnitude, where Luau numbers stop being exact; beyond that it arrives as a JSON float. Any other number is a JSON number. A number that is not finite (NaN, inf), anywhere in the arguments, is refused before the call is sent, naming the argument.
    • A string arrives as a string, whole, however long. It has to be text: a string that is not UTF-8, or that holds a NUL byte, is refused naming the argument. Send bytes as a buffer.
    • A buffer arrives as bytes, whole, wherever it sits: its place in args-json holds null, and byte-slots carries the bytes with a JSON pointer (RFC 6901) to that place. /0 is the first argument, /1/data the data field of the second. A table shaped like a slot is only a table: nothing in args-json is reserved.
    • A handle (vfs.open, vfs.openDir, vfs.sink, vfs.dirSink) arrives as a resource, the same way: its place holds null, and handle-slots carries it with a pointer to that place. See "Files and folders".
    • A boolean arrives as a boolean.
    • nil arrives as null.
    • A table with keys 1..n and nothing else arrives as an array (an empty table as []); any other table as an object with string keys. A table of more than 262,144 items or 4,096 keys, a value nested more than 64 deep, and a function or coroutine are refused, naming the argument.

Answer with a plugin-result:

  • To answer, set value-json to one JSON value and error to none. The caller receives it as a Luau value: an object as a table, an array as an array-like table, null as nil.
  • To answer with bytes, put null where they belong in value-json and a byte-slot in byte-slots whose pointer names that place ("" when the whole answer is bytes). The caller receives a buffer there. A slot that names a place the answer does not have, or a place holding anything but null, is refused rather than dropped.
  • To refuse, set error to a message. The caller's await raises with it, and pcall catches it like any other error. value-json is ignored.
  • An answer's strings and buffers arrive whole up to 256 MiB each; an answer with a longer string, an array of more than 262,144 items or an object of more than 4,096 keys is refused, saying where, rather than delivered cut.
  • A panic in your component is a trap: see "When it crashes" below. A component is 32-bit, so a size read from its input and trusted as it is (an allocation of len bytes where len came from the data) can overflow and panic there even when it passed on a 64-bit build. Check a length against the input before allocating for it.

What a plugin may use

A plugin in a world is linked against three of the engine's interfaces:

  • zero-log: log(level, message) writes a line to the engine's log, under your plugin's name. Anything written to stdout or stderr goes there too.
  • zero-storage: a key/value store of strings. get(key), set(key, value), delete(key), list-keys(prefix). With storage: private in plugin.yaml it is kept for this asset in this world and survives a restart; a set that cannot be saved traps the call rather than answering as if it had. With storage: none it lives in memory until the plugin is reloaded. No size limit is enforced, and the whole store is written again on every set, so keep it small. It is kept on the machine running the engine: a player has their own.
  • zero-handles: the files, folders and entities a script passes you in a call. See "Files and folders" and "Entities".

The other interfaces in zero-plugin.wit belong to the engine's own plugins. A component in a world that imports one of them does not load, and the refusal names the import.

From WASI it gets what Rust's standard library imports: clocks, random numbers, stdout and stderr (to the engine's log), an empty stdin and environment, exit, and the I/O streams those use. It has no filesystem (no directory is opened for it) and no network: a component that imports any wasi:sockets interface does not load, in any world. Anything it keeps, it keeps in zero-storage; a file it reads or writes, a script hands it.

It sets no globals and sees none. Code reaches it only through the methods its plugin.yaml lists.

Files and folders

A plugin cannot name a path. A script that wants it to read or write a file passes it a handle, which carries what that script could do there, fixed when the script made it. The plugin can do nothing more with it, and can reach nothing else through it.

LuauThe plugin getsWhat it can do
vfs.open(path)readersize(), read(offset, len)
vfs.openDir(path)dir-readerentries(path), open(path) for a reader
vfs.sink(path, { maxBytes = n })byte-sinkwrite(bytes), close()
vfs.dirSink(path, { maxFiles = n, maxBytes = n, extensions = { ... } })dir-sinkcreate(path) for a byte-sink

Take one with request.take_reader(i), take_dir_reader(i), take_sink(i) or take_dir_sink(i), where i is the argument it was passed as (from 0), or take_handle_at(pointer) for one inside a table. Asking for the wrong kind answers None and leaves the handle where it is.

"load" => {
    let Some(weights) = request.take_reader(0) else {
        return PluginResult::err("load needs a reader of the weights");
    };
    let size = match weights.size().await {
        Ok(size) => size,
        Err(why) => return PluginResult::err(&why),
    };
    let mut offset = 0;
    while offset < size {
        let chunk = match weights.read(offset, 4 << 20).await {
            Ok(chunk) => chunk,
            Err(why) => return PluginResult::err(&why),
        };
        offset += chunk.len() as u64;
        // ... use the chunk
    }
    PluginResult::ok_json(&size)
}
  • A reader reads its file by range. A read that reaches the end answers the bytes that are there, and a read past the end answers none. A reader whose file leaves its path (removed, or another asset put there) answers the asset is gone and nothing else.
  • A folder reader reads everything under its folder. entries("") lists the folder itself, entries("cfg") a folder inside it; open answers a reader. Paths are relative and /-separated: .., an absolute path, a drive or a backslash is refused.
  • A sink writes its file from empty, up to maxBytes. A write that would go past it is refused, and what was written before stays. The file appears when you call close() or let the sink go; until then nothing sees it. If the plugin stops or traps first, nothing appears.
  • A folder sink makes new files under its folder, subfolders included, within maxFiles files and maxBytes bytes together, and only with an extension from extensions when the script gave any. The path rules are a folder reader's.
  • Only the world's own files take a sink. A script cannot make one for a library, the engine's trusted files or the engine's bookkeeping.
  • You may keep a handle across calls, in a thread_local! or a field. It works until you drop it or the plugin stops.
  • A handle goes one way, from a script to a plugin. An answer cannot carry one.
  • In Luau a handle prints its kind and path. It cannot be built from a table, and nothing can be read out of it.

Entities

A script that wants a plugin to move entities binds them and passes the binding: local bones = ecs.bindEntities(ids), then me.call("animate", bones:forPlugin()). Take it with request.take_entities(i). The plugin never learns an entity's id; it names a component field, and the values come packed in binding order: one float per entity for a number field, three for a vec3, four for a quat.

"animate" => {
    let Some(bones) = request.take_entities(0) else {
        return PluginResult::err("animate needs the bones, bound");
    };
    let result = async {
        let mut positions = bones.read("Transform".into(), "translation".into()).await?;
        for p in positions.chunks_exact_mut(3) {
            p[1] += 0.1;
        }
        bones.write("Transform".into(), "translation".into(), positions).await
    };
    match result.await {
        Ok(written) => PluginResult::ok_json(&written.written),
        Err(why) => PluginResult::err(&why),
    }
}
  • len() is how many entities the binding holds.
  • read(component, field) is a snapshot taken at the frame point after the call.
  • write(component, field, values) lands at the frame point, before skinning, so a pose written this frame is drawn this frame. values must be the binding's length times the field's width; any other length is refused and nothing is written.
  • A write answers to the rules the script's own entity.batchWrite does: an entity whose component is locked, one another peer owns, or one outside a build the script has open is left as it was. The answer says how many it wrote and, for each it skipped, its place in the binding and why.
  • Once the script destroys the binding, every operation on it answers that it is gone.

GPU buffers and compute shaders

A script hands a plugin GPU work the same way: a buffer it made with substrate.createBuffer({ kind = "gpu", ... }) as buf:forPlugin(), and a compute shader asset as shaderRef:forPlugin(). Take them with request.take_gpu_buffer(i) and request.take_compute_shader(i). A buffer is floats, however the script typed it: a vec3 buffer of n is 3n floats.

"step" => {
    let (Some(step), Some(field)) = (request.take_compute_shader(0), request.take_gpu_buffer(1)) else {
        return PluginResult::err("step needs the shader and the field");
    };
    let result = async {
        field.write(0, initial).await?;
        step.dispatch(vec![&field], 16, 1, 1).await?;
        field.read(0, field.len().await?).await
    };
    match result.await {
        Ok(values) => PluginResult::ok_json(&values.len()),
        Err(why) => PluginResult::err(&why),
    }
}
  • len() is how many floats the buffer holds.
  • write(offset, values) is queued at the frame point, behind everything queued before it, so a dispatch asked for after the write sees it. A write that runs past the end is refused and nothing is written.
  • read(offset, len) answers once the GPU has delivered the floats, a frame or more after the call, and holds what every write and dispatch queued before it left there. A read past the end is refused.
  • dispatch(buffers, x, y, z) runs the shader over x by y by z workgroups, with buffers bound to its storage bindings in the order its bindings.yaml declares them. The wrong number of buffers, a destroyed buffer, or a zero dimension is refused and nothing is dispatched. A shader that binds a texture or a sampler cannot be passed to a plugin at all: shaderRef:forPlugin() refuses it.
  • The plugin never learns a buffer's name or a shader's key. A handle holds its buffer, not the name: once the script destroys the buffer, the handle answers that it is gone, even if another buffer takes the name.

Events

tick is called about every 16 ms (60 times a second), in the editor and in play, one at a time, with the seconds since the last one. Return quickly: a tick that awaits a long operation delays the next one. Calls are not held back by it: a call can run while a tick, or another call, awaits.

To tell listeners something, return events from tick:

TickResult {
    events: vec![OutboundEvent {
        namespace: "counter".into(),        // your one namespace
        event_type: "changed".into(),
        payload_json: r#"{"total":3}"#.into(),
    }],
}
  • namespace must be your plugin's namespace; an event on any other is dropped with a warning.
  • payload-json must be JSON; a payload that is not is dropped with a warning. Listeners receive it as a Luau value.
  • Code in the world hears them only when plugin.yaml says events: exposed. The surface module listens with me.on(function(eventType, payload) ... end), which waits for the plugin to load and returns nothing; to let other code listen, give the surface a function that calls it.

init

init receives a JSON object describing the engine the plugin runs in, for plugins that run an agent (orientation, skill, identity: text). A plugin that has no use for it ignores it. Return Err(message) to refuse to start; the load fails with that message.

While it runs

  • It never holds up the engine. Your component runs on a thread of its own (a worker of its own in a browser). A call that computes for seconds costs your plugin's thread, not the frame: the script that called it waits for the answer, and the world goes on around it.
  • Your calls and ticks share that one thread. On the desktop a call that computes is paused every few milliseconds so your other calls and ticks run beside it. In a browser a worker cannot pause running code, so while one call computes without yielding, your other calls and ticks wait for it.
  • A new build, an edit or removing the asset stops a call in progress. The call answers that the plugin was stopped, however long it would have run; nothing waits on it.
  • A new build replaces it in place. Upload the component again under the same name: the running instance gets shutdown, and the new one is loaded, with no restart. Its private store is kept.
  • The same bytes change nothing. Uploading an identical component is not a reload.
  • Editing plugin.yaml reloads it.
  • Removing the asset stops it.
  • plugins.status() lists every plugin: whether it is loaded, why not, and its component: SHA-256, size, and what built it.

When it crashes

A trap (a panic, an out-of-bounds access, unreachable) stops the plugin at once. The log says so in one line, plugins.status() shows stopped: <reason>, and every later call raises that it stopped and why. It stays stopped until it loads again: a new build, or an edit to plugin.yaml.

Saying what built it

The engine states, at load and in plugins.status(), what built a component. Put a zero-build custom section in yours: key=value lines, name, version, source, revision, built. Each value is text; one left empty is left out. #[link_section] puts the section inside the component's core module, and the engine reads it there.

const BUILD_RECORD: &str = concat!(
    "name=", env!("CARGO_PKG_NAME"), "\n",
    "version=", env!("CARGO_PKG_VERSION"), "\n",
    "source=https://example.com/counter\n",
    "revision=", env!("ZERO_BUILD_REVISION"), "\n",
    "built=", env!("ZERO_BUILD_TIME"), "\n",
);
#[unsafe(link_section = "zero-build")]
#[used]
static BUILD: [u8; BUILD_RECORD.len()] = {
    let bytes = BUILD_RECORD.as_bytes();
    let mut out = [0u8; BUILD_RECORD.len()];
    let mut i = 0;
    while i < bytes.len() {
        out[i] = bytes[i];
        i += 1;
    }
    out
};

with a build.rs that sets the two variables:

fn main() {
    // Empty outside a git checkout, and then left out of the record.
    let revision = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_default();
    println!("cargo:rustc-env=ZERO_BUILD_REVISION={revision}");
    println!(
        "cargo:rustc-env=ZERO_BUILD_TIME={}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0)
    );
}

The section survives a stripped release build. A component without one loads, and is said to carry no build record.

In a browser

The same component runs in the desktop engine and in a browser, with the same rules. In a browser:

  • It needs JavaScript Promise Integration: Chrome or Edge 137 or later. In a browser without it every plugin load is refused, saying so.
  • The component is compiled in the page the first time it loads, so a larger component takes longer to start.
  • It runs in a worker of its own, and each operation on a handle is a message to the engine's worker. Read in ranges of a few MiB rather than a few bytes.

When a load is refused

The log saysWhat to change
plugin.yaml exposes 'x', which the component does not offerlist only methods the manifest declares
the component declares no namespace / declares 'a', 'b'declare exactly one namespace
declares no 'runtime' profile, so it does not load in this engineadd the profile to profiles
a plugin has no network of its own, and this component imports wasi:sockets/...remove whatever pulls in sockets
a plugin in a world uses zero-log, zero-storage and zero-handles, and this component imports zero:plugin/...import only those three
grants storage: private, and the engine's index gives the asset no guidthe asset has no .meta; let the engine create it by uploading the .wasm
its memory starts at X, past the Y its plugin.yaml allowsthe component's initial memory is larger than memory:; declare more
init() plugin error: ...your init returned an error
this component was built against zero:plugin 0.4.0 ..., and this engine speaks zero:plugin 0.5.0build it against the zero-plugin.wit this engine ships (the asset type's copy)
  • documentation
  • guide