Building a plugin
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:
| Export | When the engine calls it |
|---|---|
manifest() -> plugin-manifest | once, when the plugin loads: what it is and what it offers |
init(config-json) -> result | once, after manifest; an error stops the load |
call(plugin-call) -> plugin-result | every time code in the world calls one of its methods |
tick(delta-secs) -> tick-result | once 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
-
Write
counter.wasminto the world's files, by any upload or from Luau withvfs.write("/zero/source/counter.wasm", bytes). It becomes the assetcounter.plugin/, with the component inside asplugin.wasm, aplugin.yaml, asurface.module/and a README, and it loads. The asset's name,counter, is what code in the world calls it by. -
Nothing is callable yet. Edit
plugin.yamlto list the methods:profiles: - editor - runtime storage: private exposed: - name: add - name: totalThe plugin reloads with them. A listed method the component does not declare is refused at load, naming it. Left out,
storageisnone,eventsisprivateandexposedis empty;profilesis required. -
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 Mme.callcalls the plugin this module belongs to. -
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 asvfs. A method may be named anything exceptonEventandoffEvent. - The engine reads each method's
name.signature,description,arg-type,return-typeand the argument descriptions are text for people and are not parsed. The types a caller sees are the ones you write insurface.module. name,versionanddescriptiondescribe the component.
Calls and answers
A call from Luau arrives as a plugin-call:
methodis the method's name.args-jsonis 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, not2.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
bufferarrives as bytes, whole, wherever it sits: its place inargs-jsonholdsnull, andbyte-slotscarries the bytes with a JSON pointer (RFC 6901) to that place./0is the first argument,/1/datathedatafield of the second. A table shaped like a slot is only a table: nothing inargs-jsonis reserved. - A handle (
vfs.open,vfs.openDir,vfs.sink,vfs.dirSink) arrives as a resource, the same way: its place holdsnull, andhandle-slotscarries it with a pointer to that place. See "Files and folders". - A boolean arrives as a boolean.
nilarrives asnull.- A table with keys
1..nand 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.
- A whole number arrives as a JSON integer (
Answer with a plugin-result:
- To answer, set
value-jsonto one JSON value anderrortonone. The caller receives it as a Luau value: an object as a table, an array as an array-like table,nullasnil. - To answer with bytes, put
nullwhere they belong invalue-jsonand abyte-slotinbyte-slotswhose pointer names that place (""when the whole answer is bytes). The caller receives abufferthere. A slot that names a place the answer does not have, or a place holding anything butnull, is refused rather than dropped. - To refuse, set
errorto a message. The caller'sawaitraises with it, andpcallcatches it like any other error.value-jsonis 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
lenbytes wherelencame 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). Withstorage: privateinplugin.yamlit is kept for this asset in this world and survives a restart; asetthat cannot be saved traps the call rather than answering as if it had. Withstorage: noneit lives in memory until the plugin is reloaded. No size limit is enforced, and the whole store is written again on everyset, 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.
| Luau | The plugin gets | What it can do |
|---|---|---|
vfs.open(path) | reader | size(), read(offset, len) |
vfs.openDir(path) | dir-reader | entries(path), open(path) for a reader |
vfs.sink(path, { maxBytes = n }) | byte-sink | write(bytes), close() |
vfs.dirSink(path, { maxFiles = n, maxBytes = n, extensions = { ... } }) | dir-sink | create(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 goneand nothing else. - A folder reader reads everything under its folder.
entries("")lists the folder itself,entries("cfg")a folder inside it;openanswers 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 callclose()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
maxFilesfiles andmaxBytesbytes together, and only with an extension fromextensionswhen 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.valuesmust 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.batchWritedoes: 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 overxbyybyzworkgroups, withbuffersbound to its storage bindings in the order itsbindings.yamldeclares 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(),
}],
}
namespacemust be your plugin's namespace; an event on any other is dropped with a warning.payload-jsonmust 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.yamlsaysevents: exposed. The surface module listens withme.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.yamlreloads 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 says | What to change |
|---|---|
plugin.yaml exposes 'x', which the component does not offer | list 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 engine | add 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 guid | the asset has no .meta; let the engine create it by uploading the .wasm |
its memory starts at X, past the Y its plugin.yaml allows | the 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.0 | build it against the zero-plugin.wit this engine ships (the asset type's copy) |