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-plugins repository. The SDK is its sdk/ directory; the crate is named rustmc-guest. It is one Cargo workspace with every plugin under plugins/. 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 wasm feature. A build without it ignores .wasm files 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 only command_run. An empty or missing list would hear every event.
  • commands: register /fakeblock for everybody (level 0). It appears in tab completion, and running it fires command_run with command: "fakeblock" and rest the rest of the line.
  • hooks: run before the game sends each title (prepend on 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:

  • position answers [x, y, z], or null for somebody who has gone.
  • send_packet is an SDK helper: it builds the packet body from named fields with packet_encode, then sends it to one player with send_packet. Ok(false) means the player has gone.
  • message sends 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

  1. Copy the .wasm file into the server's plugins/ directory. That is plugins/ 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 *.wasm there is loaded.
  2. 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.
  3. Run /plugins to 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.