Your first mod
A mod is the one unit of content: a directory the engine discovers, holding a manifest that declares what the mod publishes and a WebAssembly component that runs it — see the modding hub for the whole picture. This page takes you from an empty directory to a mod under your own author name. It will put one marker in the world and greet every player who joins, in the server log. Everything here is code you write; no engine checkout is involved.
What you need
Section titled “What you need”-
The game, installed and able to host a session.
-
A Rust toolchain, with the WebAssembly target this compiles to:
Terminal window rustup target add wasm32-wasip2
The mod in this page is called greeter and its author is called you. Put
your own name where you stands — the rest works unchanged.
Build it
Section titled “Build it”-
Make the directory — the path is the identity.
Content lives under your install’s
assets/workshop/, exactly two levels deep: author, then mod. Those two directory names ARE the mod’s identity,you:greeter— a manifest cannot claim a name, because where the files sit cannot be forged. Names are lowercase ASCII letters, digits and-, starting with a letter or a digit.By the end of this page the directory looks like this:
Directoryassets
Directoryworkshop
Directoryyou/ your author name
Directorygreeter/ the mod — together the two names are
you:greeter- mod.toml what the mod declares
- Cargo.toml the crate workspace
Directory.cargo
- config.toml pins the build target
Directoryserver
- Cargo.toml
Directorysrc
- lib.rs the code
-
Write the manifest —
mod.toml.The manifest is the mod’s public declaration: its version, what it puts in the world, and which events it wants to hear.
[mod]version = "0.1.0"description = "My first mod: one marker in the world, and a greeting for every joiner."[[declares.archetype]]id = "marker"shape = { kind = "box", size = [1.4, 2.4, 0.2] }material = { color = "#26bfe6" }[declares.server]hooks = ["on_join"]Two declarations, and each earns its place:
[[declares.archetype]]publishes a kind of thing that can exist in the world. This one is a host-builtshape— a box with a colour — so no model file is needed; mesh and collider come from the same numbers. Its full name isyou:greeter/archetype/marker, and your own code spawns it by the baremarker.[declares.server]lists the events your server code is woken for. A hook you do not declare never fires: writing anon_joinfunction without declaring it here compiles fine and then never runs, which is the least fun bug to find.initis the exception — it always runs, so it needs no line.
-
Write the server crate.
The mod’s code is an ordinary Rust library crate that compiles to a WebAssembly component. Three files.
The workspace
Cargo.toml, besidemod.toml:[workspace]resolver = "2"members = ["server"][workspace.package]version = "0.1.0"edition = "2024"[workspace.dependencies]ironlark = "0.1"log = "0.4"[profile.release]opt-level = "z"lto = truecodegen-units = 1strip = "debuginfo"The release profile keeps symbol names while dropping debug info: a guest panic then reports readable function names instead of bare wasm indices, and the component stays small.
.cargo/config.toml, so a plaincargo buildcan never fall back to your machine’s native target:# Mods compile to WASI Preview 2 components. Pin the target so a plain# `cargo build` never falls back to the native host target.[build]target = "wasm32-wasip2"And the code itself,
server/Cargo.toml:[package]name = "greeter-server"version.workspace = trueedition.workspace = true[lib]crate-type = ["cdylib"][dependencies]ironlark = { workspace = true }log = { workspace = true }server/src/lib.rs:// My first mod: spawns its marker once, then greets every joiner in the log.mod protocol {ironlark::declares!("../mod.toml");}use ironlark::server::prelude::*;struct Greeter;#[ironlark::hooks("../mod.toml")]impl ServerMod for Greeter {async fn init() {log::info!("greeter: init");let at = SpawnPoint {position: Vec3::new(0.0, 1.2, 4.0),yaw: 0.0,};let marker = match Entity::spawn(protocol::archetype::Marker, at).await {Ok(handle) => handle,Err(e) => {log::error!("greeter: spawn failed: {e}");return;}};if let Err(e) = marker.set_id("marker").await {log::error!("greeter: set-id failed: {e}");return;}log::info!("greeter: spawned the marker");}async fn on_join(_ctx: Context, player: Player) {log::info!("greeter: welcome, {player}");}}ironlark::export_server!(Greeter);Reading it top to bottom:
ironlark::declares!("../mod.toml")reads your manifest while the crate compiles and mints one Rust item per declared archetype —protocol::archetype::Markerhere. Spawning by a checked item instead of a string means a typo is a compile error, and renaming the archetype in the manifest breaks the build instead of the session. The path is relative to the crate —server/, hence../.impl ServerMod for Greeteris the whole surface: every hook has a default, so you write only the ones you want.initruns once when the mod loads; it spawns the marker withEntity::spawnand gives it a durable address withset_id, so other mods (and a reload) can find it.#[ironlark::hooks("../mod.toml")]ties the impl to the manifest: the hooks you declared in step 2 are checked against the functions you actually wrote, at compile time.on_joinreceives the joiner as aPlayer. Logging{player}printsplayer#<n>, the number this participation is addressed by. It fires because step 2 declared it.ironlark::export_server!(Greeter)makes the type the component’s exported server half. Without it the file is a library nobody calls.
-
Build it.
From the mod’s directory (beside
mod.toml):Terminal window cargo build --release --target wasm32-wasip2The component lands at
target/wasm32-wasip2/release/greeter_server.wasm, and the engine reads it from exactly there — no copy step. A shipped mod instead stages the component besidemod.tomlasgreeter_server.wasm, and a staged copy always wins: if you rebuild while a stale staged copy sits there, the engine loads the copy and warns that it is “using the staged component … while a newer build sits at …”, naming both paths. During development, simply do not stage. -
Let the session pick it up.
Installed is enabled, by default: when the server configuration has no
[session] modslist, nobody has curated the install and everything underworkshop/runs — your mod included, with nothing to switch on.A curated install lists what runs, in
config/server.tomlbeside the install. If yours has such a list, add your identity to it:[session]mods = ["you:greeter"]How that list works — order, dependencies, what an empty list means — is Enabled mods.
-
Run it and read the log.
Host a session. The hosting instance writes its log to
logs/host.logunder the directory it runs in. Three lines tell you everything worked, in this order:server-mod loaded: you:greetergreeter: initgreeter: spawned the markerThe first is the engine reporting your component loaded; the other two are your own
initspeaking. Every line a mod writes is attributed with its identity, so a session full of mods stays readable. Then join, and your greeting appears with the joiner’s participation number in place of<n>:greeter: welcome, player#<n>In the world itself, the marker stands as a coloured box a few steps ahead of the default spawn.
If instead the log says your mod “has a server realm that is not built: no component at …”, the engine found your manifest but not your component — the message names the exact
cargo buildcommand that fixes it. Run it and start again.
Where to go next
Section titled “Where to go next”Your mod declares, spawns and listens — every mod is a variation on those three moves.
- Signals — say things other mods (and clients) can hear, and hear theirs.
- Entities — move the marker, find it again, own it properly.
- Declarations — everything a manifest can say.