Guides
Getting started: a WebAssembly plugin in Rust
On this page
This guide builds a WebAssembly plugin with the guest SDK, rustmc-guest, installs it on a
server, and gives it settings. The worked example is packet-demo, a real plugin of 65 lines in
the rustmc-plugins repository (plugins/packet-demo/).
A plugin can be written in any language that compiles to a WebAssembly core module and can
export functions and use linear memory. Your first WebAssembly plugin shows
the raw ABI with a hand-written .wat file. This guide uses Rust, because that is what the SDK
is for.
What you need
- The
rustmc-pluginsrepository. The SDK is itssdk/directory; the crate is namedrustmc-guest. It is one Cargo workspace with every plugin underplugins/. Both rustmc repositories are private: ask the server owner for access. - Rust at the workspace's
rust-version(1.98) or newer, edition 2024, with the WebAssembly target:rustup target add wasm32-unknown-unknown. - A rustmc server built with the
wasmfeature. A build without it ignores.wasmfiles and says so in its log:N .wasm plugin(s) in <dir> were ignored: this build was made without the wasm feature.
File layout
Put a new plugin beside the others, so it builds in the workspace:
rustmc-plugins/
Cargo.toml # the workspace; "plugins/*" is already a member
sdk/ # rustmc-guest
plugins/
packet-demo/
Cargo.toml
src/lib.rs
plugins/packet-demo/Cargo.toml:
[package]
name = "rustmc-packet-demo"
version = "1.0.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib", "rlib"] # cdylib is the .wasm; rlib lets `cargo test` build it natively
[dependencies]
rustmc-guest = { path = "../../sdk" }
The plugin
packet-demo does two things ProtocolLib is usually used for. /fakeblock <block> shows one
player a block under their feet that is not in the world. And every title sent to any player is
rewritten in gold capitals by a packet hook. Here is its src/lib.rs, in parts.
The manifest
use rustmc_guest::{Guest, Value, call, json, packet_body, packet_fields, send_packet};
const TITLE: &str = "packet.play.clientbound.set_title_text";
pub struct PacketDemo;
impl Guest for PacketDemo {
fn manifest() -> Value {
json!({
"name": "packet-demo",
"version": "1.0",
"events": ["command_run"],
"commands": [{"name": "fakeblock", "description": "A block only you can see", "level": 0}],
"hooks": [{"point": TITLE, "mode": "prepend"}],
})
}
The manifest says:
events: hear onlycommand_run. An empty or missing list would hear every event.commands: register/fakeblockfor everybody (level0). It appears in tab completion, and running it firescommand_runwithcommand: "fakeblock"andrestthe rest of the line.hooks: run before the game sends each title (prependon a packet hook point). Hooks are opt-in: none unless named.
An event
fn event(event: &Value) -> bool {
if event["command"] != "fakeblock" {
return false;
}
if let Some(player) = event["player"].as_i64() {
fakeblock(player, event["rest"].as_str().unwrap_or("").trim()
.split(' ').next().filter(|b| !b.is_empty()).unwrap_or("glass"));
}
true
}
An event arrives as JSON with "event": "<name>" and the event's fields.
Returning true cancels a cancellable event; command_run is one. The demo cancels its own
command once it has handled it.
Host calls
fn fakeblock(player: i64, block: &str) {
let Some(at) = call("position", json!({"entity": player})).ok()
.and_then(|at| at.as_array().cloned()) else { return };
let floor = |i: usize| at.get(i).and_then(Value::as_f64).unwrap_or(0.0).floor() as i64;
let block = if block.contains(':') { block.to_owned() } else { format!("minecraft:{block}") };
let sent = send_packet(player, "play.clientbound.block_update",
&json!({"at": [floor(0), floor(1) - 1, floor(2)], "state": block}));
let line = match sent {
Ok(true) => format!("Only you can see the {block} under you."),
Ok(false) => return,
Err(problem) => problem,
};
let _ = call("message", json!({"player": player, "text": line}));
}
call(name, fields) makes any host call: it sends
{"call": name, ...fields} and answers Ok(value) or Err(message). Three calls are used here:
positionanswers[x, y, z], ornullfor somebody who has gone.send_packetis an SDK helper: it builds the packet body from named fields withpacket_encode, then sends it to one player withsend_packet.Ok(false)means the player has gone.messagesends a line of chat.
A hook
fn hook(hook: &Value) -> Option<Value> {
let input = &hook["input"];
let mut fields = packet_fields(TITLE, input["body"].as_str()?).ok()?;
let text: Value = rustmc_guest::serde_json::from_str(fields["text"].as_str()?).ok()?;
let words = match &text {
Value::String(words) => words.clone(),
Value::Object(component) => component.get("text").and_then(Value::as_str).unwrap_or("").to_owned(),
_ => return None,
};
fields["text"] = json!(json!({"text": words.to_uppercase(), "color": "gold"}).to_string());
let body = packet_body(TITLE, &fields).ok()?;
Some(json!({"player": input["player"], "id": input["id"], "body": body}))
}
}
rustmc_guest::export!(PacketDemo);
A hook call arrives as {"hook", "mode", "input"}. A packet hook's input is
{player, id, body}, with the body in hex. packet_fields decodes it into named fields
(packet_decode) and packet_body encodes
them back. A prepend answers Some(new input), the whole {player, id, body}; None leaves the
packet alone. A reply of the wrong shape is refused and logged, and the packet goes out unchanged.
export!(PacketDemo) writes the functions the server calls: rustmc_manifest,
rustmc_on_event, rustmc_on_hook, rustmc_on_message (the ABI).
Two styles
The demo uses the SDK's JSON style: a Guest with associated functions, and call by name.
The SDK's typed style is a Plugin with &mut self methods, a Manifest builder, an
Event type, and typed functions in rustmc_guest::host for most calls
(the SDK index). It is exported with
export!(Type, constructor):
use rustmc_guest::{host, Event, Manifest, Outcome, Plugin};
#[derive(Default)]
struct Hello;
impl Plugin for Hello {
fn manifest(&self) -> Manifest {
Manifest::new("hello", "1").hearing(&["player_joined"])
}
fn on_event(&mut self, event: &Event) -> Outcome {
if let Some(player) = event.player() {
host::message(player, "Welcome!");
}
Outcome::Continue
}
}
rustmc_guest::export!(Hello, Hello::default());
(From the SDK's own documentation, sdk/src/lib.rs.)
Build
From the root of rustmc-plugins:
cargo build --release --target wasm32-unknown-unknown -p rustmc-packet-demo
The plugin is target/wasm32-unknown-unknown/release/rustmc_packet_demo.wasm.
The plugin API crate comes from rustmc over git. To build against a local rustmc checkout instead, patch it (from the plugins repository's README):
cargo --config 'patch."https://github.com/Jowcey/rustmc".rustmc-plugin.path="../rustmc/crates/rustmc-plugin"' build ...
Test natively
The rlib crate type lets cargo test build the plugin for your own machine. There the SDK
routes every host call to a fake host you install with rustmc_guest::testing::with_host, and
testing::calls() lists the calls the plugin made:
rustmc_guest::testing::with_host(|request| match request["call"].as_str() {
Some("position") => Ok(rustmc_guest::json!([0.5, 64.0, 0.5])),
_ => Ok(rustmc_guest::json!(true)),
}, || {
// call the plugin's functions here
});
Install
- Copy the
.wasmfile into the server'splugins/directory. That isplugins/inside the server's files directory: the server directory when one is set, else the world's directory, else the working directory. The file name does not matter; every*.wasmthere is loaded. - Restart the server. Plugins are loaded once, at start, in file-name order. File-name order is also the tie-break between plugins of the same priority.
- Run
/pluginsto see it listed. A plugin that cannot load is named in the log with the reason:<file> could not be loaded: ...(a bad module, a missing export) or<file> was not loaded: ...(a refused manifest, such as a name already taken).
The plugin keeps its data under plugins/<name>/, where <name> is the manifest's name.
Settings
Settings are declared in the manifest, kept by the server in plugins/<name>/settings.json,
and edited by the server owner in the web portal. Add them to the manifest:
"settings": [
{"key": "default_block", "type": "block", "default": "minecraft:glass",
"title": "Default block", "help": "Shown when /fakeblock names none"},
{"key": "uppercase_titles", "type": "boolean", "default": true, "title": "Shout titles"}
]
Read them with the settings host call, which
answers every key and its value: call("settings", json!({})), or host::settings() in the typed
style. Change one of your own with setting_set. Add settings_changed to events to hear when
somebody changes them: the event's plugin is whose settings changed, and keys which.
The manifest reference lists every setting type.
What next
- Host calls: everything a plugin can ask, by group.
- Events and hook points.
- Limits: fuel, sizes, and what happens when a call fails.
- Recipes: an alias command, a packet filter, a chat formatter, a block rule.
From docs/api/guide/getting-started.md in the RustMC repository at bbd81209ad.