Modding Guide
Make Ourcraft your own: new blocks, gadgets, chat commands and whole new games. This guide starts from nothing and builds a real mod step by step. You'll need to be comfortable typing commands in a terminal; a little Rust helps, but you can learn it as you go by changing the examples.
1. What a mod can do
- Blocks: new blocks with your own 16ร16 textures, see-through or glowing.
- Chat commands: type
/somethingand the mod does it (like/rainbowfrom Paint Blocks). - Building for you: read and change any block in the world.
- Tools and projectiles: put gadgets in players' hotbars; throw, shoot or lob things that fly and hit blocks or players.
- Players: teleport them, change their speed, flying and building, give them a shirt colour and a name tag, show a health bar.
- Screen: chat messages, big titles, a text panel (HUD), particles and sounds.
- Survival: new items (food, fuel, treasure), crafting recipes, and new animals that wander, follow food and have babies.
- Game modes: all of the above together make a game with rules. The whole Splat Squad team game is a mod.
Mods run on the server only. When friends join your world, their game gets your new blocks and tools automatically: they don't need to install anything.
Mods can't do anything else. They can't read files, use the internet or see the clock. That's what makes it safe to try mods other people made.
2. What you need
- The game's source and Rust, as in Getting started.
- The WebAssembly target for Rust, which turns your mod into a
.wasmfile:rustup target add wasm32-unknown-unknown git clone https://github.com/rsantacroce/ourcraft cd ourcraft/mods - A text editor. VS Code with the
rust-analyzer extension is a good choice: it shows mistakes as you type and
completes names like
guest::for you.
Everything mod-related lives in the mods/ folder:
mods/paint_blocks/ | A small example: paint blocks and /rainbow. |
mods/snow_fun/ | The tutorial mod from this guide, finished. |
mods/team_shooter/ | Splat Squad: a big example of a game mode. |
mods/build.sh | Builds every mod into mods/dist/. |
crates/mod_api/ | The mod API: every event and function. |
3. Your first mod
A mod that says hello to everyone who joins. In the mods folder:
- Make a folder
hello_modwith asrcfolder inside it. - Create
hello_mod/Cargo.toml:
The[package] name = "hello_mod" version.workspace = true edition.workspace = true license.workspace = true [lib] crate-type = ["cdylib"] [dependencies] ourcraft_mod_api = { path = "../../crates/mod_api" }namebecomes the mod's name and the.wasmfile name. Use lowercase letters and underscores. - Add it to the
memberslist inmods/Cargo.toml:[workspace] resolver = "3" members = ["paint_blocks", "snow_fun", "team_shooter", "hello_mod"] - Create
hello_mod/src/lib.rs:use ourcraft_mod_api::guest::{self, Mod}; use ourcraft_mod_api::{Event, EventReply}; #[derive(Default)] struct Hello; impl Mod for Hello { fn on_event(&mut self, event: Event) -> EventReply { if let Event::PlayerJoined { name, .. } = event { guest::chat(None, &format!("Hello, {name}! Welcome to my world.")); } EventReply::Ok } } ourcraft_mod_api::export_mod!(Hello); - Build it:
./build.sh # built dist/hello_mod.wasm (... bytes) - Start the game again from the top folder. It bundles everything in
mods/dist/, so the new mod is included:
Open a world and you'll be greeted. In the terminal you'll see:cd .. cargo run --releaseINFO Loaded mod 'hello_mod'
.wasm file into the mods folder of
your game data (see Sharing your mod) and just reopen the world.4. How mods work
A mod is a conversation with the game:
- The game tells the mod that something happened: an event (a player joined, someone typed in chat, a tick of the clock...).
- While handling it, the mod can ask the game to do things by calling
guest::functions (place a block, send a chat message...), or ask questions (what block is here? where are the players?). - The mod answers the event with a reply: usually
EventReply::Ok;Denyto stop a player changing a block;Handledto say a chat message was your command.
Your mod is a Rust type that implements Mod, with one function,
on_event. Its fields are the mod's memory: they keep their values from one
event to the next. It must implement Default, which is how the mod starts.
export_mod! at the bottom connects it to the game.
Events come one at a time, so you never have to worry about two things happening at once.
5. Tutorial: Snow Fun
Now a bigger mod, in four steps: a greeting, two new blocks, a /snowman
command, and a snowball thrower whose snowballs leave snow bricks where they land. The
finished mod is in the repository at
mods/snow_fun/src/lib.rs,
and comes with the game, so you can try it first: type /snowman or
/snowballs in a building world.
Set it up like hello_mod above, with the name snow_fun. First,
what the mod needs to remember:
use std::collections::BTreeSet;
use ourcraft_mod_api::{EntityId, PlayerId};
/// Everything the mod remembers between events.
#[derive(Default)]
struct SnowFun {
/// Off in game-mode worlds, so we don't interfere with the game.
active: bool,
/// Players holding the snowball thrower.
throwers: BTreeSet<PlayerId>,
/// Snowballs in the air.
snowballs: BTreeSet<EntityId>,
}
A BTreeSet is a set of values, sorted. We'll fill these in steps 4.
Step 1: Say hello, but only in building worlds
Every mod is loaded in every world. Snow Fun shouldn't hand out snowballs in the middle of a Splat Squad match, so it checks the world's game mode when it starts, and ignores everything in game-mode worlds:
impl Mod for SnowFun {
fn on_event(&mut self, event: Event) -> EventReply {
match event {
Event::Init { game_mode, .. } => {
register_content(); // step 2
self.active = game_mode.is_none(); // a plain building world?
guest::log("Snow Fun loaded");
EventReply::Ok
}
// In a game-mode world (like Splat Squad), ignore everything else.
_ if !self.active => EventReply::Ok,
Event::PlayerJoined { player, name } => {
guest::chat(
Some(player),
&format!("Brrr, hi {name}! Try /snowman and /snowballs"),
);
EventReply::Ok
}
Event::PlayerLeft { player } => {
self.throwers.remove(&player);
EventReply::Ok
}
// ... steps 3 and 4 go here ...
_ => EventReply::Ok,
}
}
}
The line _ if !self.active => EventReply::Ok catches every event when the
mod is off. Events after it only run in building worlds. chat(Some(player), ...)
sends a message only to that player; None would send it to everyone.
Step 2: New blocks
Blocks, textures, models and tools can only be added during Init. We do it in
every world, even when the mod is off, so that worlds stay compatible.
const SNOW_WHITE: [u8; 4] = [240, 248, 255, 255];
fn register_content() {
// Snow Bricks, drawn by hand: each letter is a colour from the palette.
let rows = [
"AAAAAAAAAAAAAAAA",
"BBBBBBBBBBBBBBBA",
"BBBBBBBBBBBBBBBA",
"BBBBBBBBBBBBBBBA",
"AAAAAAAAAAAAAAAA",
"BBBBBBBABBBBBBBB",
"BBBBBBBABBBBBBBB",
"BBBBBBBABBBBBBBB",
"AAAAAAAAAAAAAAAA",
"BBBBBBBBBBBBBBBA",
"BBBBBBBBBBBBBBBA",
"BBBBBBBBBBBBBBBA",
"AAAAAAAAAAAAAAAA",
"BBBBBBBABBBBBBBB",
"BBBBBBBABBBBBBBB",
"BBBBBBBABBBBBBBB",
];
let palette = [('A', [190, 210, 230, 255]), ('B', SNOW_WHITE)];
guest::register_texture("snow_bricks", pixel_art(&rows, &palette));
guest::register_block(BlockSpec::simple(
"snow_fun:snow_bricks",
"Snow Bricks",
"snow_bricks",
));
// An icy blue lantern that glows in the dark.
guest::register_texture("ice_lantern", speckled([150, 210, 255, 255], 7));
guest::register_block(BlockSpec {
light: 12,
..BlockSpec::simple("snow_fun:ice_lantern", "Ice Lantern", "ice_lantern")
});
// A recipe for survival: a snow brick on top of a piece of coal makes
// an ice lantern (on a crafting table or in the inventory's grid).
guest::register_recipe(RecipeSpec::Shaped {
pattern: vec!["S".into(), "C".into()],
key: vec![
('S', "snow_fun:snow_bricks".into()),
('C', "core:coal".into()),
],
result: "snow_fun:ice_lantern".into(),
count: 1,
});
}
The texture is drawn as text: 16 rows of 16 characters. Each character is looked up in
the palette, a list of (character, [red, green, blue, alpha]). Characters not
in the palette are see-through. Colours go from 0 to 255; alpha 255 is solid.
Names have two parts, yourmod:thing, so two mods never pick the same name.
BlockSpec::simple makes a normal solid block. To change something, use the
.. trick above, or write every field out:
guest::register_block(BlockSpec {
name: "mymod:window".into(),
display_name: "Fancy Window".into(),
render: Render::Cutout, // has fully see-through pixels
solid: true, // you can't walk through it
breakable: true,
light: 0, // 0-15; above 0 glows
texture_top: "window_frame".into(),
texture_bottom: "window_frame".into(),
texture_side: "window".into(),
});
Use Render::Cutout for blocks with holes (alpha 0 pixels), like leaves, and
Render::Translucent for partly see-through blocks, like water. New blocks
appear in the creative inventory (E) automatically.
In survival worlds players can't just pick blocks from a list, so the
register_recipe at the end lets them craft an ice lantern: a snow brick on top
of a piece of coal. Every block is also an item with the same name, so recipes can use
your blocks and the game's (core:coal). Mod blocks drop themselves when
broken.
Step 3: A chat command
Chat messages arrive as Event::Chat. Add this to on_event:
Event::Chat { player, text } => match text.trim() {
"/snowman" => {
build_snowman(player);
EventReply::Handled // hide the command from chat
}
"/snowballs" => {
self.toggle_thrower(player);
EventReply::Handled
}
_ => EventReply::Ok, // normal chat: let it through
},
And the function that builds the snowman:
/// Build a snowman three blocks in front of the player.
fn build_snowman(player: PlayerId) {
let Some(p) = guest::players().into_iter().find(|p| p.id == player) else {
return;
};
let (Some(brick), Some(lantern)) = (
guest::block_id("snow_fun:snow_bricks"),
guest::block_id("snow_fun:ice_lantern"),
) else {
return;
};
let x = (p.position[0] + p.look[0] * 3.0).floor() as i32;
let z = (p.position[2] + p.look[2] * 3.0).floor() as i32;
let Some(ground) = guest::ground_height(x, z) else {
return;
};
// Two snowy blocks for the body and a glowing head.
let body: [(i32, u16); 3] = [(1, brick), (2, brick), (3, lantern)];
let mut placed = 0;
for (dy, block) in body {
let pos: BlockPos = [x, ground + dy, z];
// Only build in empty air. `try_set_block` says whether it worked
// (it doesn't if that part of the world hasn't loaded yet).
if guest::get_block(pos) == 0 && guest::try_set_block(pos, block) {
placed += 1;
}
}
if placed == 0 {
guest::chat(Some(player), "No room for a snowman here. Try somewhere else!");
return;
}
guest::effect(EffectKind::Confetti, center([x, ground + 3, z]), SNOW_WHITE);
guest::chat(Some(player), "Ta-da! A snowman.");
}
Things to notice:
players()tells you where everyone is and which way they're looking.lookis a direction, soposition + look * 3is three blocks in front.- Block ids are numbers, and they can be different in each world. Always look them up by
name with
block_id, rather than writing numbers in your code. - Block 0 is always air. Checking for it means the mod never builds over someone's work.
- A chat command with lots of building can't take forever: each event has a time limit (see Rules). A few thousand blocks at once is fine.
Step 4: A snowball thrower
A tool is something in the hotbar that sends your mod Event::UseTool when a
player clicks. A projectile is an entity that flies by itself, falls with gravity, and sends
Event::ProjectileHit when it hits something. Register a model for the snowball
and the tool, in register_content:
// (inside register_content)
// What a snowball looks like in the air: one small white box.
guest::register_model(ModelSpec {
name: "snow_fun:snowball".into(),
boxes: vec![ModelBox {
min: [-0.15; 3],
max: [0.15; 3],
color: SNOW_WHITE,
}],
spin: true,
});
// The thrower, with a 16x16 icon drawn the same way as a texture.
guest::register_tool(ToolSpec {
name: "snow_fun:thrower".into(),
display_name: "Snowball Thrower".into(),
icon: pixel_art(&icon, &icon_palette),
cooldown: 0.3, // seconds between throws while the button is held
});
/snowballs swaps the player's hotbar for the thrower, and back:
impl SnowFun {
fn toggle_thrower(&mut self, player: PlayerId) {
if self.throwers.remove(&player) {
// An empty list gives the normal block hotbar back.
guest::set_tools(player, Vec::new());
guest::chat(Some(player), "Snowball thrower put away");
} else {
self.throwers.insert(player);
guest::set_tools(player, vec!["snow_fun:thrower".into()]);
guest::show_title(Some(player), "Snowball time!", 2.0);
}
}
}
Throwing, and what happens when a snowball lands:
Event::UseTool { player, tool, origin, direction, .. }
if tool == "snow_fun:thrower" =>
{
let start = add(origin, scale(direction, 0.6)); // just past the face
if let Some(id) = guest::spawn_entity(EntitySpec {
model: "snow_fun:snowball".into(),
position: start,
velocity: scale(direction, 22.0), // blocks per second
motion: EntityMotion::Projectile {
gravity: 12.0,
radius: 0.2,
hits_players: true,
},
lifetime: 4.0,
owner: Some(player),
}) {
self.snowballs.insert(id); // remember it's ours
}
EventReply::Ok
}
// Every mod hears about every projectile, so only handle our own.
Event::ProjectileHit { entity, hit, .. } if self.snowballs.remove(&entity) => {
match hit {
Hit::Block { pos, normal } => {
// Put a snow brick on the face it hit, if that's air.
let target = [pos[0] + normal[0], pos[1] + normal[1], pos[2] + normal[2]];
if guest::get_block(target) == 0
&& let Some(brick) = guest::block_id("snow_fun:snow_bricks")
{
guest::set_block(target, brick);
}
guest::effect(EffectKind::Splat, center(target), SNOW_WHITE);
}
Hit::Player { at, .. } => {
guest::effect(EffectKind::Poof, at, SNOW_WHITE);
guest::sound("pop", Some(at));
}
}
EventReply::Ok
}
originis the player's eyes anddirectionis where they're aiming. The game moves the snowball for you, smoothly, on every player's screen.normalpoints out of the face that was hit, sopos + normalis the empty space in front of it.- The mod remembers the ids of its own snowballs. That matters: if two mods both throw things, each must only react to its own.
Build with ./build.sh, start a world, type /snowballs, and have a
snowball fight. ๐
6. More recipes
Do something regularly
Tick arrives 20 times a second; tick counts up from the start.
Event::Tick { tick, .. } => {
// 20 ticks = 1 second. Do something every 10 seconds:
if tick % 200 == 0 {
guest::chat(None, "Ten more seconds of fun!");
}
EventReply::Ok
}
Protect an area
Reply Deny to a BlockChange and the block goes back as it was.
// No building or breaking within 20 blocks of the spawn point.
Event::BlockChange { player, pos, .. } => {
if let Some(s) = guest::world_spawn() {
let (dx, dz) = (pos[0] - s[0], pos[2] - s[2]);
if dx * dx + dz * dz < 20 * 20 {
guest::chat(Some(player), "This area is protected!");
return EventReply::Deny;
}
}
EventReply::Ok
}
Talk to players: HUD, titles, health and rules
use ourcraft_mod_api::{HudLine, PlayerRules};
// The panel in the top right corner.
guest::set_hud(Some(player), vec![
guest::hud_line("Treasure hunt"),
HudLine { text: format!("Gems found: {gems}"), color: [120, 220, 255, 255] },
]);
// A big message in the middle of the screen, for 3 seconds.
guest::show_title(None, "Go!", 3.0);
// A health bar (pass max = 0.0 to hide it).
guest::set_health(player, 75.0, 100.0);
// Rules for one player: speed, flying, building, shirt colour, name tag.
guest::set_rules(player, PlayerRules {
speed: 1.3,
can_fly: false,
can_build: true,
color: Some([255, 200, 0, 255]),
tag: Some("[Gold]".into()),
});
The world, time, rays, teleports and sounds
guest::set_time_of_day(0.5); // 0 = midnight, 0.5 = noon
let noon = guest::time_of_day() > 0.25 && guest::time_of_day() < 0.75;
let stone = guest::block_id("core:stone").unwrap();
let y = guest::ground_height(10, -4).unwrap_or(64);
guest::set_block([10, y + 1, -4], stone);
// What is the player looking at (up to 8 blocks away)?
if let Some(Hit::Block { pos, .. }) = guest::raycast(origin, direction, 8.0, false, Some(player)) {
guest::chat(Some(player), &format!("That's block {:?}", pos));
}
guest::teleport(player, [0.5, 80.0, 0.5]);
guest::sound("cheer", None); // for everyone
Crafting recipes
Recipes are registered in Init, like blocks. A Shaped recipe is a
little picture of up to 3ร3 squares; it works anywhere in the grid and mirrored. A
Shapeless one just needs the ingredients, in any squares. Recipes that fit in
2ร2 work in the inventory, bigger ones need a crafting table.
// Any arrangement: a log and a coal make 8 torches.
guest::register_recipe(RecipeSpec::Shapeless {
ingredients: vec!["core:log".into(), "core:coal".into()],
result: "core:torch".into(),
count: 8,
});
// Shaped, 3 by 3: ' ' is an empty square.
guest::register_recipe(RecipeSpec::Shaped {
pattern: vec!["GGG".into(), "G G".into(), "GGG".into()],
key: vec![('G', "core:glass".into())],
result: "mymod:fish_tank".into(),
count: 1,
});
A new item
Every block is already an item. For things that aren't blocks (a gem, a coin, a snack), register an item with a picture. Snow Fun adds a snow cone you can eat:
// In register_content: a picture (16 by 16, drawn like the bricks)...
guest::register_texture("snow_cone", pixel_art(&cone, &cone_colours));
// ...and the item. ItemSpec::simple stacks to 64 and does nothing else.
guest::register_item(ItemSpec {
food: 3, // fills 3 of the 20 hunger points
..ItemSpec::simple("snow_fun:snow_cone", "Snow Cone", "snow_cone")
});
// A way to make it: a snow brick and an apple give two snow cones.
guest::register_recipe(RecipeSpec::Shapeless {
ingredients: vec!["snow_fun:snow_bricks".into(), "core:apple".into()],
result: "snow_fun:snow_cone".into(),
count: 2,
});
An ItemSpec can also be a fuel (fuel: seconds of burning) or
turn into something in the furnace (smelts_into). Food is eaten by holding
"place", like apples.
A new animal
Animals walk around by themselves, follow players holding their food, have babies, and can be petted. You give them a model and a few numbers; the game does the rest.
// In register_content: a model (facing +z, feet at y = 0)...
guest::register_model(ModelSpec {
name: "mymod:penguin".into(),
boxes: vec![
ModelBox { min: [-0.25, 0.0, -0.2], max: [0.25, 0.7, 0.2], color: [30, 30, 40, 255] },
ModelBox { min: [-0.18, 0.1, 0.15], max: [0.18, 0.6, 0.22], color: [245, 245, 245, 255] },
ModelBox { min: [-0.05, 0.45, 0.2], max: [0.05, 0.5, 0.32], color: [255, 160, 30, 255] },
],
spin: false,
});
// ...then the animal itself.
guest::register_mob(MobSpec {
name: "mymod:penguin".into(),
display_name: "Penguin".into(),
model: "mymod:penguin".into(),
size: [0.5, 0.75], // width, height in blocks
speed: 0.8, // blocks per second
spawns_on: Some("core:snow".into()), // appears by itself on snowy grass
food: Some("core:wheat".into()), // its favourite (apples work too); two fed ones have a baby
sound: "pop".into(),
});
// Later, e.g. for a "/penguin" chat command:
if let Some(me) = guest::players().into_iter().find(|p| p.id == player) {
guest::spawn_mob("mymod:penguin", me.position);
guest::effect(EffectKind::Hearts, me.position, [255, 120, 160, 255]);
}
Random numbers
Mods can't ask the computer for random numbers, so make your own. This little generator is plenty for games:
/// A tiny random number generator (xorshift). Keep one in your mod state.
#[derive(Default)]
struct Rng(u32);
impl Rng {
fn next(&mut self) -> u32 {
if self.0 == 0 { self.0 = 0x9E37_79B9; }
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 17;
self.0 ^= self.0 << 5;
self.0
}
/// A number from 0 to n-1.
fn below(&mut self, n: u32) -> u32 { self.next() % n }
}
// Mix in the tick count so each game is different:
// Event::Tick { tick, .. } => self.rng.0 ^= tick as u32,
7. Making a game mode
A game mode is a mod that turns itself on only in worlds made for it. Each world can have
one game mode, chosen when it's created and saved with the world. The mod sees it in
Init:
const MODE: &str = "treasure_hunt";
Event::Init { game_mode, .. } => {
register_content(); // always, so block ids are the same in every world
self.active = game_mode.as_deref() == Some(MODE);
EventReply::Ok
}
_ if !self.active => EventReply::Ok,
Event::Start => {
// The world is ready: build the arena, hide the treasure...
EventReply::Ok
}
Start a world with your mode from the command line:
cargo run --release -- --world "Treasure Island" --mode treasure_hunt
# or a dedicated server
cargo run --release -p ourcraft_server --bin ourcraft-server -- \
--world hunt --mode treasure_hunt
Ideas for a game mode, and how you'd make them:
- Treasure hunt: hide glowing gem blocks around the spawn on
Start; watchBlockChangefor someone breaking one; keep score on the HUD. - Race: a start line and checkpoints of coloured blocks; on each
Tick, checkplayers()positions;show_titlefor the winner. - Build contest: a timer on the HUD; when time's up,
set_ruleswithcan_build: falseand let everyone vote in chat.
For a full example, with teams, classes, flags, health, respawning and a scoreboard, read Splat Squad's source.
8. API reference
Everything here comes from ourcraft_mod_api
(lib.rs
and guest.rs),
which has more comments. Run cargo doc -p ourcraft_mod_api --open in the game
folder to browse it.
Events
| Event | When | What it's for |
|---|---|---|
Init { api_version, game_mode } | Once, when the world opens | Register blocks, textures, models and tools. The only time you can. |
Start | Once, when the world is ready | Build arenas, set things up. world_spawn() works here. |
Tick { tick, dt } | 20 times a second | Timers, game rules, moving things. |
PlayerJoined { player, name } | Someone joins | Greet them, give tools, pick a team. |
PlayerLeft { player } | Someone leaves | Forget about them. |
BlockChange { player, pos, old, new } | A player tries to break or place | Reply Deny to stop it (the block goes back). |
Chat { player, text } | A chat message | Reply Handled if it was your command. |
UseTool { player, tool, origin, direction, secondary } | A player clicks with your tool | Fire, swing, build... secondary is the right button / tap. |
ProjectileHit { entity, owner, hit } | A projectile hit a block or player | Splash, score, place blocks. Check it's yours! |
Every mod receives every event. Mods take turns in alphabetical order. For Chat and
BlockChange, the first mod to reply Handled or Deny
decides, and the mods after it don't see the event.
Functions
All in ourcraft_mod_api::guest. to means Some(player)
for one player or None for everyone.
| guest:: function | What it does |
|---|---|
log(text) | Write a line in the game's log, as [your_mod] text. |
register_block(BlockSpec) | A new block. Init only. |
register_texture(name, rgba) | A 16ร16 texture (1024 bytes). Init only. |
register_model(ModelSpec) | A shape made of coloured boxes, for entities. Init only. |
register_tool(ToolSpec) | An item for the hotbar. Init only. |
register_recipe(RecipeSpec) | A crafting recipe for survival. Init only. |
register_item(ItemSpec) | An item that isn't a block (register its texture first). Init only. |
register_mob(MobSpec) | A new kind of animal (register its model first). Init only. |
spawn_mob(name, position) โ Option<EntityId> | Put an animal in the world, e.g. "core:pig". It's kept: saved with the world, so don't spawn the same ones every time the world opens. |
block_id(name) โ Option<BlockId> | Look up a block by name, e.g. "core:stone". |
get_block(pos) โ BlockId | What's at a position (0 = air, or not loaded). |
set_block(pos, id) | Change a block. Everyone nearby sees it. |
try_set_block(pos, id) โ bool | Same, but tells you if it worked. |
ground_height(x, z) โ Option<i32> | The y of the highest solid block. |
raycast(origin, dir, max, hit_players, ignore) โ Option<Hit> | What a straight line hits first. |
world_spawn() โ Option<BlockPos> | Where new players appear. Always the same for a world. |
time_of_day() โ f32, set_time_of_day(t) | 0 = midnight, 0.25 = sunrise, 0.5 = noon. |
players() โ Vec<PlayerInfo> | Everyone online: id, name, position, velocity, look, on_ground. |
chat(to, text) | A chat message to one player (Some(id)) or everyone (None). |
teleport(player, position) | Move a player (feet position). |
set_rules(player, PlayerRules) | Speed, flying, building, shirt colour, name tag. |
set_health(player, health, max) | Show a health bar (max 0 hides it). The number is yours to manage. |
set_hud(to, Vec<HudLine>) | Replace the text panel in the top right. |
show_title(to, text, seconds) | A big message in the middle of the screen. |
set_tools(player, Vec<String>) | Fill the hotbar with your tools; an empty list gives blocks back. |
spawn_entity(EntitySpec) โ Option<EntityId> | Something that floats or flies, drawn with a model. |
move_entity(id, position), despawn_entity(id) | Move or remove an entity. |
effect(kind, position, color) | Particles: Splat, Confetti, Poof or Hearts. |
sound(name, at) | A built-in sound at a place, or for everyone with None. |
hud_line(text) โ HudLine | A white HUD line (helper). |
Types
| Type | Fields |
|---|---|
BlockSpec | name, display_name, render (Opaque / Cutout / Translucent), solid, breakable, light (0โ15), texture_top/_bottom/_side. BlockSpec::simple(name, display, texture) fills in the rest. |
ToolSpec | name, display_name, icon (16ร16 RGBA), cooldown (seconds). |
ModelSpec | name, boxes (each ModelBox { min, max, color }, in blocks around the origin), spin. |
EntitySpec | model, position, velocity, motion (Fixed or Projectile { gravity, radius, hits_players }), lifetime (0 = forever), owner. |
ItemSpec | name, display_name, texture (a registered texture), max_stack (1โ64), food and heals (out of 20), fuel (seconds), smelts_into. ItemSpec::simple(name, display, texture) fills in the rest. |
RecipeSpec | Shaped { pattern, key, result, count }: pattern is up to 3 rows of up to 3 characters (' ' = empty), key says which item each character is. Shapeless { ingredients, result, count }. Items are named like blocks. |
MobSpec | name, display_name, model, size ([width, height]), speed, spawns_on (a block name, or None), food (an item name, or None), sound. |
PlayerRules | speed (1.0 normal), can_fly, can_build, color, tag. PlayerRules::default() is a normal player. |
PlayerInfo | id, name, position (feet), velocity, look (direction), on_ground. |
Hit | Block { pos, normal } (normal points out of the face that was hit) or Player { id, at }. |
HudLine | text, color. |
| Small types | BlockPos = [i32; 3], Vec3 = [f32; 3], Rgba = [u8; 4], BlockId = u16, PlayerId and EntityId = u64. |
| Helpers | pixel_art(&rows, &palette) draws a texture from 16 strings; speckled(color, seed) makes a one-colour texture with a light speckle. |
Built-in blocks, items, animals and sounds
Blocks: core:air, core:stone, core:dirt, core:grass, core:sand, core:log, core:leaves, core:planks, core:snow, core:cactus, core:bedrock, core:gravel, core:cobblestone, core:glass, core:bricks, core:lamp, core:water, core:lava, core:clay, core:wool, core:coal_ore, core:iron_ore, core:gold_ore, core:diamond_ore, core:iron_block, core:gold_block, core:diamond_block, core:crafting_table, core:furnace, core:chest, core:torch, core:door, core:trapdoor, core:fence, core:ladder, core:sign, core:bed, core:fence_gate, core:farmland, core:pumpkin, core:melon, core:pumpkin_lantern, core:sugar_cane, core:tall_grass, core:red_mushroom, core:brown_mushroom, core:cake, core:composter, core:planks_stairs, core:planks_slab, core:cobblestone_stairs, core:cobblestone_slab, core:stone_stairs, core:stone_slab, core:bricks_stairs, core:bricks_slab (core:snow is snowy grass). Paint
Blocks adds paint:red, paint:orange, paint:yellow,
paint:lime, paint:blue, paint:purple,
paint:pink, paint:white and paint:rainbow_lamp.
Items (for recipes and food): every block above, plus core:stick, core:coal, core:iron_ingot, core:gold_ingot, core:diamond, core:apple, core:golden_apple, core:egg, core:fried_egg, core:brick, core:clay_ball, core:shears, core:bucket, core:water_bucket, core:lava_bucket, core:milk_bucket, core:wheat, core:wheat_seeds, core:carrot, core:potato, core:baked_potato, core:beetroot, core:beetroot_seeds, core:pumpkin_seeds, core:melon_seeds, core:melon_slice, core:sugar, core:paper, core:bread, core:cookie, core:pumpkin_pie, core:bowl, core:beetroot_soup, core:mushroom_stew, core:bone_meal, core:wood_hoe, core:wood_pickaxe, core:stone_axe, core:iron_shovel, core:diamond_pickaxe
(tools are core: + wood, stone, iron or diamond + _pickaxe,
_axe or _shovel).
Colours: white, light_gray, gray, black, brown, red, orange, yellow, lime, green, cyan, light_blue, blue, purple, magenta, pink. Each has core:<colour>_dye, _wool (white is core:wool), _bed (red is core:bed), _carpet, _stained_glass and _clay_tiles. Flowers: core:dandelion, core:poppy, core:cornflower, core:daisy, core:pink_tulip, core:orange_tulip, core:allium, core:forget_me_not.
Woods: oak (its blocks keep the old names core:log, core:planks, core:leaves, core:fence, core:door...), birch, pine, jungle, cherry, dark: core:<wood>_log, _planks, _leaves, _sapling, _stairs, _slab, _fence, _fence_gate, _door, _trapdoor, and a core:<wood>_boat item. Stone: core:stone_bricks, core:sandstone, core:granite, core:diorite, core:andesite, core:deep_slate, core:obsidian, core:ice and more, with walls like core:cobblestone_wall. In recipes, #planks, #logs, #wool and #leaves mean any of that kind.
Animals: core:sheep, core:pig, core:cow, core:chicken.
Sounds: place, break, pop, splat, grab, cheer, click, baa, moo, oink, cluck, munch, bonk, boing.
9. Rules and limits
- Content only in
Init. Registering later fails with "content can only be registered during Init". - Time: each event may run about 50 million WebAssembly instructions. That's a lot, but an endless loop will hit it. The event is stopped, and after 3 failures (time-outs or crashes) the mod is switched off until the world is reopened.
- Memory: up to 64 MiB per mod.
- Crashes count as failures. A Rust
panic!, anunwrap()onNone, or dividing by zero stops the event. Preferif letandlet ... else, like the examples. - No outside world: no files, internet, clock or system random numbers. A mod that tries to use them won't load.
- Names: give everything your mod's prefix (
yourmod:thing). Worlds save blocks by name, so you can add and remove mods safely; blocks from a removed mod turn into air. - API version: mods are built for an API version (now 1). If the game's version changes, rebuild your mod.
- Be kind: Ourcraft is made for kids. Keep mods friendly: cartoon splats and confetti, not blood.
10. Testing and debugging
- Log messages:
guest::log("got here")prints[your_mod] got herein the terminal running the game (or the server). - Chat:
guest::chat(None, &format!("{:?}", something))shows a value right in the game. - Watch the terminal when a world opens: you'll see
Loaded mod 'your_mod', or why it wasn't loaded. - Unit tests: logic that doesn't call the game can be tested with plain
cargo test(outside the game,guest::calls just return nothing):// Logic that doesn't call the game can be tested normally: // cd mods && cargo test -p my_mod #[cfg(test)] mod tests { use super::*; #[test] fn rng_stays_in_range() { let mut rng = Rng::default(); assert!((0..1000).all(|_| rng.below(6) < 6)); } } - End-to-end tests:
crates/server/tests/snow_fun.rs
starts a real server with the mods and a pretend player, types
/snowman, throws a snowball and checks what happens. Copy it to test your own mod.
11. Sharing your mod
A mod is a single file: mods/dist/your_mod.wasm. Send it to a friend, and
they put it in the mods folder next to their worlds folder
(create it if it isn't there):
| Computer | Mods folder |
|---|---|
| macOS | ~/Library/Application Support/Ourcraft/mods |
| Windows | %APPDATA%\Ourcraft\mods |
| Linux | ~/.local/share/ourcraft/mods |
| Dedicated server | mods next to where it runs, or
--mods FOLDER |
Mods are loaded when a world opens. A file in the mods folder replaces a bundled mod with the same name, which is handy for trying changes to Paint Blocks or Splat Squad.
iPad and Android can't easily add files, so they use the mods bundled into
the game: put your .wasm in mods/dist/ and rebuild the app. When an
iPad joins a computer's world, though, it gets that world's mods automatically.
Only the person hosting the world (or the server) needs the mod. Friends who join get everything they need to see your blocks and tools.
12. Troubleshooting
"Mod 'x' wasn't loaded: it was made for mod API version..."
The game was updated. Run ./build.sh again.
"...it needs `env.something`, which mods aren't allowed to use"
Something in your mod (or a library it uses) wants a file, the clock or random numbers.
Remove that code or library. Libraries that use time, files or system random numbers (like
rand) won't build or won't load; use the random number recipe.
"Mod 'x' failed" / "switched off after 3 failures"
The mod crashed or ran out of time. Look for unwrap(), indexing past the end
of a list, dividing by zero, or a loop that never ends. Add guest::log lines to
find where.
My block isn't in the inventory
It has to be registered while handling Event::Init. Check the terminal for
"content can only be registered during Init" and that the mod loaded at all.
set_block does nothing
That part of the world isn't loaded (it's too far from every player). Use
try_set_block to find out, and build near players.
My command shows up in chat
Return EventReply::Handled for it. Also check the spelling: it's compared
exactly, so trim spaces with text.trim().
My mod reacts to other mods' projectiles
Remember the ids that spawn_entity gives you and check
ProjectileHit's entity against them, as in
step 4.
13. Other languages
Rust is the easiest way, because the API is a Rust crate. But a mod is just a WebAssembly module, so any language that compiles to WebAssembly can make one, with a bit more work. The contract:
exports:
memory
ourcraft_api_version() -> u32 // must return API_VERSION (1)
ourcraft_alloc(len: u32) -> u32 // give the host a buffer of len bytes
ourcraft_event(ptr: u32, len: u32) -> u64 // handle an Event, return an EventReply
import (the only one allowed):
ourcraft.call(ptr: u32, len: u32) -> u64 // send a HostCall, get a HostReply
Messages are postcard-encoded. A u64 result packs a buffer as (ptr << 32) | len.
The message types are the enums Event, EventReply,
HostCall and HostReply in
crates/mod_api/src/lib.rs,
encoded with postcard's wire format.
New features are only ever added at the end of the enums, so old mods keep working.