Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Add a block

This guide adds two blocks from a game module: a plain building block and a light source. A block is a RON definition, a set of PNG textures, a declaration from the mod’s guest code and a display name in a Fluent catalog. The finished mod is tools/test-mods/guide-block.

Install the built .mod in a mods directory and its blocks join the game like the flagship’s own: in native singleplayer, on a dedicated server, and on every client that joins it, native or browser. Read Limits and not yet before you plan more.

What you’ll build

BlockNameWhat it shows
Slateguide-block:slateA solid, opaque block with one texture on top and bottom and another on the sides
Glow lampguide-block:glow_lampAn amber light source whose panes glow and whose frame does not
tools/test-mods/guide-block/
├── Cargo.toml
├── mod.toml
├── blocks/
│   ├── slate.ron
│   └── glow_lamp.ron
├── assets/guide-block/textures/block/
│   ├── slate_top.png
│   ├── slate_side.png
│   ├── glow_lamp.png
│   └── glow_lamp_e.png
├── locales/en/guide-block.ftl
└── src/lib.rs

The definitions in blocks/ are compiled into the guest (step 6), so they sit outside assets/, whose files the archive carries to every host.

Prerequisites

  • The toolchain from getting started (docs/development/getting-started.md), including the wasm32-unknown-unknown target and wasm-tools.
  • cargo mod: cargo install --path tools/cargo-mod --locked.
  • The mod crate as a workspace member. cargo mod build runs cargo build -p <name>, so add the mod’s path to members in the root Cargo.toml and commit Cargo.lock with it.

Steps

1. Create the crate

[package]
name = "guide-block"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
manifold-mod-substrate = { path = "../../../crates/manifold-mod-substrate" }
manifold-wasm-abi = { path = "../../../crates/manifold-wasm-abi" }
log = { version = "0.4", features = ["std"] }

[dev-dependencies]
manifold-world = { path = "../../../crates/manifold-world" }
manifold-modloader = { path = "../../../crates/manifold-modloader" }
manifold-l10n-types = { path = "../../../crates/manifold-l10n-types" }

A mod is a cdylib. manifold-mod-substrate is the guest SDK. #[mod_entry] expands to code that names manifold_wasm_abi and log, so the crate depends on them directly. The dev-dependencies serve only the test in step 8.

2. Write the manifest

[package]
id = "guide-block"
name = "guide-block"
display_name = "Guide Block"
version = "0.1.0"
default_locale = "en"
side = "both"
kind = "game-module"
wasm = "guide_block.component.wasm"

[assets]
dir = "assets"
  • kind = "game-module": block declarations come from game modules. Every host that runs a world loads the game modules in its mods directory (module sets).
  • wasm: cargo mod build writes <crate_name>.component.wasm, with - replaced by _. Without this field the loader looks for mod.wasm or guide-block.component.wasm.
  • default_locale: the language of the mod’s own catalog (default en).
  • side = "both": the server and every client run the module. It is the default; the example states it.

3. Define a block

// A plain building block: solid, opaque, with one texture on the top and
// bottom faces and another on the four sides.
Block(
    name: "guide-block:slate",
    is_solid: true,
    render_pass: Opaque,
    // The game's own break and place effects.
    particles_v1: Some((break: Some("flagship:block_break"), place: Some("flagship:block_place"))),
    textures: TopBottomSides(
        top: "guide-block:block/slate_top",
        bottom: "guide-block:block/slate_top",
        sides: "guide-block:block/slate_side",
    ),
)

The fields are those of BlockDefinition in block_loader.rs (crates/manifold-world/src/block_loader.rs):

FieldDefaultMeaning
namerequired<namespace>:<name>. Use your mod id as the namespace; the display-name lookup tries that mod first. Names are unique across every loaded mod.
texturesrequiredAll(..), TopBottomSides(top, bottom, sides) or PerFace(pos_x, neg_x, pos_y, neg_y, pos_z, neg_z). Fluid(still, flowing) is for fluids.
is_solidfalseSolid for players and physics.
render_passOpaqueOpaque, Cutout (alpha-tested, like leaves) or Translucent (alpha-blended, like glass).
collisionfrom is_solidSome(Full), Some(Empty), Some(Aabb(min: (x, y, z), max: (x, y, z))) or Some(Multi([(min, max), ..])), in block-local 0 to 1 coordinates.
light_attenuation15 if opaque, else 1How much light dims passing through.
bloom_weight1.0The block’s bloom contribution; 0.0 turns it off.
particles_v1noneEffect ids for break, place, hit, step, land and an always-on ambient effect, plus tint.
texture_animationnoneFlipbook playback of vertical-strip textures: Some((frame_time_ms: 400, interpolate: true)).

A half-height block, for example, collides as a slab:

collision: Some(Aabb(min: (0.0, 0.0, 0.0), max: (1.0, 0.5, 1.0))),

It still renders as a full cube.

An unknown particle effect id logs a warning and plays nothing. The ids above are the bundled game’s effects; a mod can ship its own under assets/particles/. A definition that fails to parse, a duplicate name or an out-of-range value refuses your mod, naming the mod, the block id and the reason (mod 'guide-block' block 'guide-block:glow_lamp': …): a host stops at boot with startup-module_content_failed, and a client joining a server that has it fails with handshake-module_content_failed. A mod never loses blocks silently. The test in step 8 runs the same loader on your definitions first.

4. Add the textures

A texture address <namespace>:<path> names the file assets/<namespace>/textures/<path>.png, so guide-block:block/slate_top is assets/guide-block/textures/block/slate_top.png (asset_address.rs (crates/manifold-modloader/src/asset_address.rs)). The example’s textures are 16×16 RGBA PNGs.

  • The atlas resamples every block texture to 32×32. Use square images; a vertical strip of squares is a flipbook for texture_animation.
  • Append #tint=RRGGBB to an address to multiply the image by a colour, as the bundled grass and leaves do.
  • A missing or unreadable file does not fail the load. The atlas logs texture address '…' failed and draws a placeholder.
  • Every block shares one atlas of 256 layers. Masks and animation frames use layers too.

5. Add a light source

// A light source. `light_color` is the hue; `light_level` (0 to 15) is the
// strength and how many blocks the light reaches. `glow_lamp_e.png`, beside
// the texture, limits the visible glow to the panes.
Block(
    name: "guide-block:glow_lamp",
    is_solid: true,
    render_pass: Opaque,
    light_color: Some("#FFB347"),
    light_level: Some(12),
    particles_v1: Some((break: Some("flagship:block_break"), place: Some("flagship:block_place"))),
    textures: All("guide-block:block/glow_lamp"),
)
  • light_color is "#RRGGBB"; omitted, the light is white. light_level is 0 to 15; omitted with a colour set, it is 15.
  • light_faces: Some(["neg_z"]) lets the light leave only through the named faces, as on the bundled jack o’lantern.
  • light_flicker: Some((amplitude: 0.1, speed: 0.8)) pulses the light; amplitude is 0 to 1 and speed is above 0 and at most 60 Hz.
  • Do not combine light_color/light_level with the older light_emission or with optical_v1; the loader refuses the definition.

Emissive mask. An emitting block’s faces glow over every texel. A greyscale <texture>_e.png beside the texture limits the glow: each texel emits max(r, g, b) × alpha of full strength. glow_lamp_e.png is white over the panes and black over the frame. A mask changes appearance only; the block still lights the world at its full light_level. See the lighting semantics (docs/lighting-engine-quality-closure.md) for masks on animated textures.

6. Declare the blocks from the guest

//! The example mod for the handbook guide `docs/guides/add-a-block.md`: a
//! game module that declares two blocks, a plain one and a light source.

use manifold_mod_substrate::prelude::*;

/// Every block this mod declares, compiled in from `blocks/`. The order
/// assigns block ids, so add new blocks at the end.
pub const BLOCKS: [&str; 2] = [
    include_str!("../blocks/slate.ron"),
    include_str!("../blocks/glow_lamp.ron"),
];

#[derive(Default)]
#[mod_entry]
pub struct GuideBlock;

impl Plugin for GuideBlock {
    const ID: &'static str = "guide-block";

    fn build(&self, app: &mut ModApp) {
        for definition in BLOCKS {
            app.add_block(definition);
        }
    }
}
  • ModApp::add_block (crates/manifold-mod-substrate/src/runtime/mod_app.rs) takes the RON text itself. It travels in the mod’s declarations() payload (mod contract), and the host parses it with the same loader the step 8 test calls.
  • #[mod_entry] with no exports(..) exports only plugin-lifecycle. A mod that only declares content runs no systems and has no UI.
  • Keep the order. Block ids are assigned in declaration order, module by module in the session’s load order, and the join handshake compares a hash of the registry (names, order, physics, light and collision). A joining client builds its registry from the server’s own copy of the mod, so the two agree; within the mod, add blocks at the end. Saves record blocks by name, so a renamed block becomes the unknown block in existing worlds.

7. Name the blocks

## Block names, shown in the hotbar, the inventory and item tooltips.

# A dark, layered building stone.
block-slate-name = Slate
# A lamp that lights the blocks around it.
block-glow_lamp-name = Glow Lamp

The display name of guide-block:glow_lamp is the message key guide-block.block.glow_lamp.name, which is the message block-glow_lamp-name in the guide-block mod’s catalogs (item_name in manifold-l10n (crates/manifold-l10n/src/lib.rs)). The lookup tries the mod whose id matches the namespace first, which is why the namespace should be the mod id. A block with no message shows its humanized local name (glow_lamp becomes “Glow Lamp”). Give every message a # comment for translators. Catalogs and their lookup order are in text and localization.

8. Check and build

cargo test -p guide-block
cargo mod build --manifest tools/test-mods/guide-block/mod.toml

The test (src/lib.rs, below the plugin) loads both definitions with the engine’s load_blocks_from_rons, checks that every texture address names a file in assets/, and checks that every block has a message in the en catalog. A missing texture fails the test here instead of drawing a placeholder in game.

cargo mod build compiles the component, checks the mod’s declarations against its exports, and packs guide-block.mod next to mod.toml, with assets/ and locales/. Native hosts install and deliver that archive. For browser clients, build with cargo mod publish --manifest tools/test-mods/guide-block/mod.toml instead: it writes the same guide-block.mod with a browser bundle added, and needs Node, the pinned jco and Chromium.

Test it in game

A host that runs a world loads every game module in its mods directory, <data root>/<pack>/mods/, when it starts. The development session keeps its data in target/dev-session/data with the pack baseline, apart from your usual saves.

Singleplayer. Install the archive and start a throwaway world, so the experiment stays out of your other saves:

mkdir -p target/dev-session/data/baseline/mods
cp tools/test-mods/guide-block/guide-block.mod target/dev-session/data/baseline/mods/
python scripts/dev-session.py singleplayer --test --world guide-block

MANIFOLD_STARTUP=ready means the world is up, and the log’s game modules: line lists guide-block beside flagship-game. An installed mod that does not resolve or fold (a bad manifest, a missing dependency, a component that does not load) is disabled: the game starts with the bundled game alone and logs the mod set failed to load with the cause. A block definition the loader refuses stops the start with the loader’s error; the step 8 test catches those first. Browser singleplayer runs only the modules built into it; a browser reaches your blocks by joining a server.

A dedicated server and a client. The development server reads the same mods directory:

python scripts/dev-session.py server --world guide-block

It prints a RELAY_TICKET. In another terminal, join from a native client, or from a browser if you built the mod with cargo mod publish:

python scripts/dev-session.py native --test
python scripts/dev-session.py browser --build-web

A joining client starts from the modules built into it and takes the rest from the server. This native client shares the server’s state directory, so it finds guide-block.mod in its own mods directory; a copy whose SHA-256 matches the server’s artifact is used without a transfer. A client without that copy, such as a browser or a client on another machine (--state-dir, --ticket), receives the mod over CH_MODS at join. Either way it builds its blocks, textures and catalogs from the server’s set before any world data arrives. A client that cannot build them is refused with a message that names the mod (handshake-module_content_failed) instead of joining a partial world. See running and testing (docs/development/running-and-testing.md) for second machines.

On a dedicated server, /setblock needs Admin: a signed-in client whose platform username is in the server’s saves/ops.ron (target/dev-session/data/baseline/saves/ops.ron for the development server). Development identities (--username) are never Admin. In singleplayer the local player is an operator.

In the world:

  • Open the console with the backquote key (`) and run /setblock <x> <y> <z> guide-block:glow_lamp. A short name such as glow_lamp works when no other mod has a block of that name.
  • Or press Tab to open the inventory. New worlds start in Creative, whose catalog lists placeable blocks in registry order, the flagship’s first. It has 45 slots and the flagship fills most of them, so a block past the 45th is reachable only with /setblock. Pick a block, put it in a hotbar slot, close the inventory and right-click to place it.
  • Press F5 to show light levels around the lamp. Light from level 12 reaches about 12 blocks.

What to check: each face shows its texture (a placeholder means a texture address does not resolve; check the log), the hotbar and tooltips show “Slate” and “Glow Lamp” from your catalog, the lamp’s frame stays dark while its panes glow, and breaking a block plays the break effect.

To change a block, edit its definition, rebuild the .mod, copy it over the installed one and restart. Removing the mod leaves the unknown block where yours stood in worlds that used it.

Limits and not yet

The capability map has the current status of each item below.

  • Every block renders as a full cube. collision changes only what players and physics collide with, and only as boxes (Aabb, Multi). Block models are designed in Enki D (docs/superpowers/specs/2026-09-27-enki-d-block-models-props-design.md).
  • No block states. A block has no orientation and no properties; it cannot face the player or change form. See Enki A (docs/superpowers/specs/2026-09-27-enki-a-block-data-model-design.md).
  • No behaviour hooks for mods. Break, place and random-tick behaviour is a native engine trait; a mod cannot run code when its block changes.
  • One mod decides player edits. Players’ block edits go to the one module that declares [block_world] player_edits = "handler", and the flagship does. A second handler fails the load naming both mods, and filters, which would let another mod reject an edit, are refused until they run. Your mod cannot approve or reject edits of its blocks. See the mod contract and block transactions.
  • Block lighting has known defects. Read the lighting audit (docs/benchmarks/lighting-audit-2026-09-20.md) before you report light that looks wrong around an edited emitter.
  • No hot reload. Definitions are compiled into the component, and a host reads its installed mods when it starts, an unpacked mod directory included; restart after a rebuild. MANIFOLD_ASSET_DEV_DIR is the exception for files: it is read from disk at each start and join, so pointing it at the mod’s assets/ tries new textures without rebuilding the .mod.
  • Browser singleplayer cannot load installed mods; browsers get your blocks only by joining a server (several game modules).
  • The Creative catalog has 45 slots, filled in registry order, so a mod that adds many blocks reaches some only through /setblock.

See also