plugin
A plugin is a WebAssembly component in a <name>.plugin/ asset. Code that
uses one requires it and calls it like any module: the calls answer values,
and a refusal raises. Nothing about the call says compiled code ran, the way
calling into a native library from Python does not.
local counter = require("counter")
local total = await(counter.add(2)) -- a number
print(await(counter.total())) -- 2
It is for work Luau cannot do fast enough, or cannot do at all: a simulation step, a decoder, a solver, a model.
Getting one into a world
Write the component into the world as a .wasm file (upload it). It becomes
a plugin asset of the same name, with the component moved inside, and it
loads. The asset starts with:
counter.plugin/
├── plugin.wasm the component
├── plugin.yaml what it offers, and where it loads
├── surface.module/ the module a `require` of the asset lands on
├── README.md this plugin's own description
└── .metadata
Nothing is callable until plugin.yaml lists it. Edit it to name the methods
the component offers, and the plugin reloads with them.
plugin.yaml
profiles: # where it loads; required
- editor
- runtime
storage: none # none | private; none when left out
events: private # private | exposed; private when left out
memory: 512MiB # the most memory it may hold; 4GiB when left out
exposed: # the methods a caller may run; none when left out
- name: add
- name: total
- profiles says where the plugin loads. A
runtimeplugin is part of the game: components and scene entrypoints can call it, and a player loads it. Aneditorplugin is for authoring, and a call to it from a component or an entrypoint is refused, because the shipped game would not have it. - storage
privategives the plugin a store of its own, in this world, that survives a restart. Withnonewhat it stores lasts until it is reloaded. - events
exposedlets code in the world listen to the events the plugin raises. Withprivatenothing in the world hears them. - memory is the most memory the plugin may hold, written with
KiB,MiBorGiB, up to4GiB, which is also what a plugin that says nothing gets. A plugin that would grow past it stops, and the log says it went past itsmemory:. - exposed is the plugin's whole callable surface. A method not listed cannot be called, whatever the component contains, and a listing that names a method the component does not have is refused when the plugin loads.
Using it
The asset's surface.module is its Luau face, and require("<name>"), with
the asset's name, lands on it. Its code is surface.module/init.luau, and
each of its functions calls the plugin through the asset it belongs to:
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
return M
-
Arguments are numbers, strings, booleans, nil, buffers, handles, and tables of them. A whole number arrives as an integer. A string has to be text; send bytes as a
buffer, which arrives whole, as bytes. -
A file or a folder goes as a handle, which the plugin reads or writes by itself, without a copy through the call:
local weights = vfs.open(self.path .. "/weights.bin") -- read a file local model = vfs.openDir(self.path .. "/model") -- read a folder local out = vfs.sink("/source/out/sphere.zvol", { maxBytes = 64 * 1024 * 1024 }) local frames = vfs.dirSink("/source/frames", { maxFiles = 240, maxBytes = 200 * 1024 * 1024 }) me.call("render", model, frames)A handle lets the plugin do what this script could do at that path, and nothing more: a plugin has no filesystem of its own. A sink's file appears when the plugin closes the sink or lets go of it, and only the world's own files take a sink. The
plugin-developmentguide has the rules. -
Entities go as a binding:
ecs.bindEntities(ids):forPlugin(). The plugin reads and writes their fields as packed floats and never learns an id; a write it makes answers to the same locks, owners and builds this script's ownentity.batchWritedoes. -
A GPU buffer goes as
buf:forPlugin()(a"gpu"buffer fromsubstrate.createBuffer), and a compute shader asshaderRef:forPlugin(). The plugin writes and reads the buffer's floats and dispatches the shader over buffers the same script handed it. It never learns a buffer's name or a shader's key, so it reaches no buffer it was not given. -
The answer is the value the plugin returned: a table as a table, a number as a number, bytes as a
buffer. -
A refusal raises where it is awaited: a method not exposed, a plugin that is not loaded, or the plugin's own error.
-
me.on(function(eventType, payload) ... end)listens to the plugin's events, waiting for it to load first. The payload is a value, as an answer is. It needsevents: exposed. -
A refusal is an error like any other, and
pcallcatches it. -
plugins.isLoaded("<name>")answers whether it is running, and never raises.
More than one part
A plugin can carry more than its surface: modules the surface builds on, and
an editor panel. They sit beside surface.module inside the asset:
counter.plugin/
├── plugin.wasm
├── plugin.yaml
├── surface.module/ what `require("counter")` lands on
├── history.module/ a module the surface uses
└── panel.editorPanel/ a dock tab
The parts reach each other with the dot form, which means the same thing wherever the asset is installed:
- From a module,
require(".history")is the module beside it. - From the panel,
require(".")is the plugin (its surface), andrequire(".history")the module beside the panel.
The surface IS the plugin: it has no name of its own, so no part requires
.surface. Don't write a path (./history.module) or a name that says where
the asset is installed (require("counter.history")): moved into a library,
the asset stops finding its own parts.
While it runs
- A new build replaces it in place. Upload the component again under the same name and the running plugin is replaced, with no restart. Uploading the same bytes again changes nothing.
- Editing plugin.yaml reloads it.
- Removing the asset stops it.
- A new build or an edit stops a call in progress. A call the plugin is in the middle of answers that the plugin was stopped, however long it would have run, and the new build starts.
- A crash stops it once. If the component traps, the plugin stops, the log
says so in one line, and every later call raises that it stopped and why,
until it loads again: a new build, or an edit to
plugin.yaml. plugins.status()lists each plugin: whether it is loaded, why not, and its component: the SHA-256, the size, and what built it.
The same holds in the desktop engine and in a browser.
Building one
The plugin-development guide covers writing a component: the interface in
zero-plugin.wit beside this file, a Rust project that builds one, how calls
are encoded, what a plugin may use, and the development loop.