Log inGet started

plugin

Updated September 30, 2026

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 runtime plugin is part of the game: components and scene entrypoints can call it, and a player loads it. An editor plugin 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 private gives the plugin a store of its own, in this world, that survives a restart. With none what it stores lasts until it is reloaded.
  • events exposed lets code in the world listen to the events the plugin raises. With private nothing in the world hears them.
  • memory is the most memory the plugin may hold, written with KiB, MiB or GiB, up to 4GiB, 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 its memory:.
  • 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-development guide 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 own entity.batchWrite does.

  • A GPU buffer goes as buf:forPlugin() (a "gpu" buffer from substrate.createBuffer), and a compute shader as shaderRef: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 needs events: exposed.

  • A refusal is an error like any other, and pcall catches 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), and require(".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.

  • asset-type
  • reference