Skip to content

Write your first gamemode

A gamemode is an ordinary mod that the server designates as the session’s rule layer. The smallest useful one takes over spawning: it tells the host to stop placing players, then places them itself.

Under the game’s content root — assets/workshop/ beside the install — you are creating a namespace, an addon marker, and a mod directory. Your mod will be tutorial:hellomode: identity comes from the path, never from a manifest, so nothing below repeats the name.

  • Directoryworkshop
    • Directorytutorial your namespace
      • addon.toml marks this an addon root
      • Directorymods
        • Directoryhellomode the mod — this name IS its identity
          • mod.toml
          • Cargo.toml
          • .cargo/config.toml
          • Directorywit
            • world.wit
            • deps/ironlark-host/host.wit a copy of the host’s contract
          • Directoryserver
            • Cargo.toml
            • src/lib.rs
  1. Mark the addon rootaddon.toml

    version = "0.1.0"
  2. Declare the modmod.toml

    The roles line is what makes this a gamemode candidate rather than an ordinary mod.

    [mod]
    version = "0.1.0"
    [provides]
    roles = ["gamemode"]
  3. Set up the workspaceCargo.toml

    A workspace rather than a plain crate, because a mod may later grow a client/ half beside the server one.

    [workspace]
    resolver = "2"
    members = ["server"]
    [workspace.package]
    version = "0.1.0"
    edition = "2024"
    [workspace.dependencies]
    wit-bindgen = "0.58"
    [profile.release]
    opt-level = "z"
    lto = true
    strip = true
  4. Pin the target.cargo/config.toml

    [build]
    target = "wasm32-wasip2"
  5. Name your WIT packagewit/world.wit

    package ironlark:hellomode@0.1.0;
  6. Vendor the host contractwit/deps/ironlark-host/host.wit

    A copy of the contract your mod compiles against: download it here.

  7. Declare the server crateserver/Cargo.toml

    [package]
    name = "hellomode-server"
    version.workspace = true
    edition.workspace = true
    [lib]
    crate-type = ["cdylib"]
    [dependencies]
    wit-bindgen = { workspace = true }

server/src/lib.rs. The interesting part is eleven lines; the rest is the contract.

wit_bindgen::generate!({
path: "../wit",
world: "ironlark:host/server-mod",
});
use std::sync::atomic::{AtomicUsize, Ordering};
use exports::ironlark::host::server_api::Guest;
use ironlark::host::entity::{SpawnTransform, Vec3, control, spawn};
use ironlark::host::gamemode::set_default_spawn;
use ironlark::host::log::{Level, log};
use ironlark::host::map_api::list_spawns;
struct Component;
/// Spread joiners over the map's spawn points instead of stacking them.
static NEXT: AtomicUsize = AtomicUsize::new(0);
impl Guest for Component {
async fn init() {
// We place players, so the host must stop doing it. In init, because the
// host holds joins until every server mod has loaded — do this later and
// the first player is already spawned.
set_default_spawn(false);
log(Level::Info, "hellomode: init, we own spawn now");
}
async fn on_player_join(player_id: String) {
let spawns = list_spawns().await; // never empty
let point = &spawns[NEXT.fetch_add(1, Ordering::Relaxed) % spawns.len()];
let at = SpawnTransform {
position: Vec3 {
x: point.position.x,
y: point.position.y,
z: point.position.z,
},
yaw: point.yaw,
};
// Two steps, always: an entity exists, then someone drives it.
let body = match spawn("core:character".into(), at).await {
Ok(body) => body,
Err(e) => {
log(Level::Error, &format!("hellomode: spawn failed: {e}"));
return;
}
};
if let Err(e) = control(player_id.clone(), &body).await {
log(Level::Error, &format!("hellomode: control failed: {e}"));
return;
}
log(
Level::Info,
&format!("hellomode: spawned and controlled a body for {player_id}"),
);
}
// Everything below is required by the contract and does nothing here. A
// component missing one of them does not instantiate at all.
async fn on_player_leave(_player_id: String) {}
async fn update(_dt: f32) {}
async fn on_interact(
_player_id: String,
_target: String,
_hit_point: exports::ironlark::host::server_api::Vec3,
_distance: f32,
) {
}
async fn on_signal(_channel: String, _source: String, _payload: Vec<u8>) {}
async fn on_contact(
_target: String,
_other: exports::ironlark::host::server_api::ContactParty,
_point: exports::ironlark::host::server_api::Vec3,
_edge: exports::ironlark::host::server_api::ContactEdge,
) {
}
async fn handle_rpc(
_player_id: String,
method: String,
_args: Vec<u8>,
) -> Result<Vec<u8>, String> {
Err(format!("unknown method: {method}"))
}
}
export!(Component);

Note the shape of the two calls that matter: spawn then control, never one fused call. The entity exists first; a controller is bound to it second. That separation is what makes bodiless players, spectators and possession possible at all.

Terminal window
cargo build --release # from the mod directory

You should get target/wasm32-wasip2/release/hellomode_server.wasm.

Your gamemode and the bundled core:freeroam are now both candidates, so the session refuses to start until you name one — that refusal is the resolver working, not a bug:

Name yours in config/server.toml beside the game:

[session]
gamemode = "tutorial:hellomode"

Then host a session from the launcher as usual. The addon has to be in the content root the game reads — assets/workshop/ beside the install — which is where you built it in the first step.

In logs/host.log:

workshop: 7 mod(s) — … tutorial:hellomode 0.1.0
gamemode: tutorial:hellomode
hellomode: init, we own spawn now
gamemode: set default-spawn = false
server-mod loaded: tutorial:hellomode
spawn: spawned 'core:character' body 354v0 at Vec3(0.0, 5.0, 12.0) (uncontrolled)
hellomode: spawned and controlled a body for af74f667-…

The body is announced uncontrolled and becomes controlled a line later — that is the two steps, in the log.

You will also see has no client half, which is correct: this mod has no client half. Add one when you need something on a player’s screen.