Practical Guide: Creating a WASM Mod (Tier 3)
This is a step-by-step walkthrough for creating, testing, and publishing a Tier 3 WASM mod. It uses a custom pathfinder mod as the running example — the same approach applies to AI strategy mods, render mods, and format loaders.
Prerequisite knowledge: You should be comfortable with Rust (or another language that compiles to WASM). You don’t need to know IC internals — this guide covers everything from the modder’s perspective.
When to use Tier 3: Most mods don’t need WASM. If you’re changing unit stats, use YAML (Tier 1). If you’re scripting a campaign mission, use Lua (Tier 2). WASM is for when you need to replace an engine algorithm — a different pathfinding system, a different AI strategy, a 3D render backend, or a custom file format loader. See
04-MODDING.mdfor the tier decision guide.
Step 1: Scaffold the Project
# Create a new WASM mod project from the pathfinder template
ic mod init --template pathfinder my-hex-pathfinder
# This generates:
my-hex-pathfinder/
├── mod.toml # Mod manifest (identity, capabilities, limits)
├── Cargo.toml # Rust project (targets wasm32-unknown-unknown)
├── src/
│ └── lib.rs # Your pathfinder implementation
├── tests/
│ └── basic_paths.lua # Test scenarios (Lua-based, run via ic mod test)
└── README.md
The template includes the IC SDK crate (ic-mod-sdk) as a dependency, which provides:
- The
#[wasm_export]proc-macro (generates ABI glue — you write normal Rust signatures) - Type definitions (
WorldPos,PathId,LocomotorType,TerrainType, etc.) - The
#[wasm_host_fn]declarations (functions you can call into the engine)
# Cargo.toml (generated by template)
[package]
name = "my-hex-pathfinder"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # compiles to .wasm
[dependencies]
ic-mod-sdk = "0.1" # IC's WASM mod SDK (types + macros)
[profile.release]
opt-level = "z" # optimize for size (smaller .wasm)
lto = true
Step 2: Write the Mod Manifest
# mod.toml — your mod's identity and capability declaration
[mod]
id = "my-hex-pathfinder"
title = "Hex Grid Pathfinder"
version = "0.1.0"
authors = ["YourName"]
description = "A* pathfinding on hex grids for hex-based total conversions"
license = "MIT"
engine_version = "^0.5.0"
wasm_module = "target/wasm32-unknown-unknown/release/my_hex_pathfinder.wasm"
# What this mod does — which engine trait it implements
[mod.type]
kind = "pathfinder" # tells the engine this implements the Pathfinder trait
# other options: "ai_strategy", "render", "format_loader"
# Capabilities this mod needs
[capabilities]
pathfinding = true # required — this IS a pathfinder mod
# No elevated capabilities needed (no network, no filesystem)
# If you needed network access, you'd add:
# network = { essential = false, domains = ["api.example.com"], reason = "..." }
# But pathfinder mods CANNOT request network/filesystem (sim-tick exclusion rule)
# Execution limits (optional — defaults are usually fine)
[capabilities.limits]
pathfinder_fuel_per_tick = 6_000_000 # slightly above default 5M (hex math is heavier)
max_memory_bytes = 8_388_608 # 8 MB (we cache the hex grid)
Step 3: Implement the Pathfinder
This is where you write your actual algorithm. The IC SDK provides the trait contract as a set of functions you must export:
#![allow(unused)]
fn main() {
// src/lib.rs
use ic_mod_sdk::prelude::*;
// ============================================================
// REGISTRATION — called once when the engine loads your mod
// ============================================================
#[wasm_export]
fn pathfinder_register() -> &'static str {
// Return your pathfinder's identifier
"hex-grid-astar"
}
// ============================================================
// TRAIT IMPLEMENTATION — these map to Pathfinder trait methods
// ============================================================
/// Called by the engine when a unit needs a path.
/// You receive the start position, destination, and what kind of
/// unit is moving (infantry, vehicle, naval, air).
/// Return a PathId handle — the engine will call get_path() later.
#[wasm_export]
fn pathfinder_request_path(
origin: WorldPos,
dest: WorldPos,
locomotor: LocomotorType,
) -> PathId {
// Your pathfinding algorithm goes here.
// You can call engine functions to query the map:
let terrain = ic_pathfind_get_terrain(origin);
let obstacles = ic_pathfind_query_obstacles(origin, 1000);
let (min, max) = ic_pathfind_map_bounds();
// Run A* on your hex grid...
let path = my_hex_astar(origin, dest, locomotor, &obstacles);
// Store the result and return a handle
PATHS.store(path)
}
/// Called by the engine to retrieve a completed path.
#[wasm_export]
fn pathfinder_get_path(id: PathId) -> Option<Vec<WorldPos>> {
PATHS.get(id)
}
/// Called by the engine to check if a position is passable.
#[wasm_export]
fn pathfinder_is_passable(pos: WorldPos, locomotor: LocomotorType) -> bool {
let terrain = ic_pathfind_get_terrain(pos);
match locomotor {
LocomotorType::Infantry => terrain != TerrainType::Water,
LocomotorType::Vehicle => terrain == TerrainType::Clear || terrain == TerrainType::Road,
LocomotorType::Naval => terrain == TerrainType::Water,
LocomotorType::Air => true,
_ => false,
}
}
/// Called when terrain changes (building placed, bridge destroyed).
/// Invalidate cached paths in the affected area.
#[wasm_export]
fn pathfinder_invalidate_area(center: WorldPos, radius: SimCoord) {
PATHS.invalidate_near(center, radius);
// Rebuild hex grid cache for this area
rebuild_hex_cache(center, radius);
}
// ============================================================
// YOUR ALGORITHM — private to your mod
// ============================================================
fn my_hex_astar(
origin: WorldPos,
dest: WorldPos,
locomotor: LocomotorType,
obstacles: &[(WorldPos, SimCoord)],
) -> Vec<WorldPos> {
// ... your hex grid A* implementation ...
// Use fixed-point math (ic-mod-sdk re-exports fixed-game-math)
// Never use f32/f64 in pathfinding — determinism required
todo!()
}
// Global path storage (WASM linear memory)
static PATHS: PathStore = PathStore::new();
}
Key points for the modder:
-
You write normal Rust. The
#[wasm_export]macro generates the primitive ABI wrapper (pointer serialization, u64 splitting, etc.). You never deal with raw pointers or MessagePack yourself. -
You call engine functions freely.
ic_pathfind_get_terrain(),ic_pathfind_query_obstacles(), etc. are available because your manifest declarespathfinding = true. If you tried to callic_ai_get_own_units()(an AI function), it would fail at load time — your mod doesn’t have theai_strategycapability. -
Use fixed-point math. The
ic-mod-sdkcrate re-exportsfixed-game-math(D076). Never usef32/f64in sim-affecting code — the engine enforces NaN canonicalization, but integer math is safer and faster. -
Your algorithm is your own. Everything inside
my_hex_astar()is your private implementation. The engine doesn’t care how you pathfind — only that you implement the four exported functions.
Step 4: Build the WASM Binary
# Build the WASM binary (release mode)
ic mod build
# This runs:
# cargo build --target wasm32-unknown-unknown --release
# wasm-opt -Oz target/.../my_hex_pathfinder.wasm (size optimization)
# ic mod validate (checks exports match mod.toml type, verifies capabilities)
# Output:
# target/wasm32-unknown-unknown/release/my_hex_pathfinder.wasm (~50-200 KB)
ic mod build does three things:
- Compiles your Rust code to WASM via
cargo build --target wasm32-unknown-unknown - Runs
wasm-optto shrink the binary (optional but recommended) - Validates the binary: checks that the expected exports exist (
pathfinder_register,pathfinder_request_path, etc.) and that yourmod.tomlcapabilities match what the binary actually imports
Step 5: Test Locally
# Run the mod test suite (headless — no game window)
ic mod test
# This:
# 1. Loads your WASM binary in a sandboxed wasmtime instance
# 2. Runs each test scenario in tests/*.lua
# 3. Verifies path results against expected outputs
# 4. Checks fuel consumption (did you stay within budget?)
# 5. Reports pass/fail
# Run determinism tests (critical for multiplayer — same inputs = same output)
ic mod test --determinism
# This runs the same tests on two platforms (x86_64 + aarch64 via cross-compilation)
# and compares results tick-by-tick. Any divergence = fail.
# Run conformance tests (required for pathfinder certification)
ic mod test --conformance pathfinder
# This runs the engine's built-in pathfinder conformance suite:
# - Path validity (does the path actually connect origin to destination?)
# - Determinism (same request = same result, always)
# - Invalidation (does invalidate_area() actually clear stale paths?)
# - Snapshot/restore (does the pathfinder survive save/load?)
Test file example:
-- tests/basic_paths.lua
Test.describe("basic hex pathfinding", function()
Test.it("finds a straight-line path on open terrain", function()
local map = Test.load_map("test-maps/open-hex-16x16.yaml")
local path_id = Pathfinder.request_path(
{ x = 0, y = 0 }, -- origin
{ x = 10, y = 10 }, -- destination
"infantry"
)
local path = Pathfinder.get_path(path_id)
Test.assert_not_nil(path, "path should exist")
Test.assert_eq(path[1], { x = 0, y = 0 }, "starts at origin")
Test.assert_eq(path[#path], { x = 10, y = 10 }, "ends at destination")
end)
Test.it("returns nil for unreachable destination", function()
local map = Test.load_map("test-maps/island-hex.yaml")
local path_id = Pathfinder.request_path(
{ x = 0, y = 0 },
{ x = 15, y = 15 }, -- on a disconnected island
"vehicle"
)
local path = Pathfinder.get_path(path_id)
Test.assert_nil(path, "path should not exist (unreachable)")
end)
end)
Step 6: Test In-Game
# Launch the game with your mod loaded
ic mod run
# This starts IC with your mod active. You can:
# - Play a skirmish and see your pathfinder in action
# - Use /debug pathfinding to visualize paths
# - Check the console for fuel usage warnings
Step 7: Package and Publish
# Package for Workshop distribution
ic mod package
# This creates:
# dist/my-hex-pathfinder-0.1.0.icmod
# Contents:
# mod.toml
# my_hex_pathfinder.wasm (the compiled binary)
# tests/ (included for reproducibility)
# README.md
# Publish to Workshop
ic mod publish
# This:
# 1. Reads mod.toml capabilities
# 2. Validates: every elevated capability has a reason field
# 3. Computes content digest (SHA-256 of the package)
# 4. Signs with your Ed25519 author key
# 5. Uploads to Workshop
# 6. Workshop validates WASM integrity + capability declaration
# 7. If capabilities changed since last version, flags for moderator review
What Happens on the Engine Side
When a player installs and uses your mod, here’s what the engine does:
At Install Time
1. Player clicks [Install] in Workshop browser
2. Package downloaded, signature verified (author + registry)
3. If mod declares elevated capabilities (network/filesystem):
→ Capability review screen shown (§ Install-Time Capability Review)
→ Player grants/denies optional capabilities
4. WASM binary hash recorded for lobby fingerprinting
5. Mod available in mod profile (D062)
At Lobby / Game Start
1. Host configures lobby — selects mod profile including your pathfinder
2. All players must have the same pathfinder mod (sim-affecting → gameplay fingerprint)
3. Lobby verifies: matching mod hash + matching execution limits
4. Loading screen:
a. Engine instantiates wasmtime runtime
b. Loads your .wasm binary into a sandboxed wasmtime instance
c. Validates exports: checks pathfinder_register, pathfinder_request_path,
pathfinder_get_path, pathfinder_is_passable, pathfinder_invalidate_area exist
d. Calls pathfinder_register() → your mod returns "hex-grid-astar"
e. Engine creates WasmPathfinderAdapter — a native Rust struct that:
- Implements the Pathfinder trait
- Dispatches trait method calls to your WASM exports
- Handles MessagePack serialization across the ABI boundary
- Enforces fuel metering per call
f. Registers the adapter as the active Pathfinder via GameModule
g. Sim starts — your pathfinder is now live
During Gameplay (Every Tick)
1. A unit needs to move → sim calls pathfinder.request_path(origin, dest, locomotor)
2. WasmPathfinderAdapter:
a. Serializes arguments to MessagePack
b. Sets fuel budget (pathfinder_fuel_per_tick from limits)
c. Calls your WASM export: pathfinder_request_path(origin, dest, locomotor)
d. Your code runs inside wasmtime sandbox:
- Can call ic_pathfind_get_terrain(), ic_pathfind_query_obstacles(), etc.
- Each host call counted against max_host_calls_per_tick
- Each WASM instruction counted against fuel budget
e. Your code returns a PathId
f. Adapter deserializes the return value
g. Returns PathId to the sim
3. If fuel exhausted mid-execution:
→ Your function is terminated early
→ Adapter returns PathResult::Deferred (path not ready this tick)
→ Sim continues — unit idles for one tick, retries next tick
→ This is deterministic: all clients hit the same fuel limit at the same point
The Adapter (What the Engine Generates)
This is the bridge between the native trait and your WASM exports. You never write this — the engine generates it:
#![allow(unused)]
fn main() {
/// Auto-generated wrapper. Implements native Pathfinder trait
/// by dispatching to WASM exports.
struct WasmPathfinderAdapter {
instance: wasmtime::Instance,
store: wasmtime::Store<ModState>,
// Cached function references (resolved once at load time)
fn_request_path: wasmtime::TypedFunc<(i32, i32, i32, i32, i32, i32, i32), i64>,
fn_get_path: wasmtime::TypedFunc<i64, i64>,
fn_is_passable: wasmtime::TypedFunc<(i32, i32, i32, i32), i32>,
fn_invalidate_area: wasmtime::TypedFunc<(i32, i32, i32, i32), ()>,
limits: WasmExecutionLimits,
}
impl Pathfinder for WasmPathfinderAdapter {
fn request_path(
&mut self,
origin: WorldPos,
dest: WorldPos,
locomotor: LocomotorType,
) -> PathId {
// 1. Set fuel budget
self.store.set_fuel(self.limits.pathfinder_fuel_per_tick);
// 2. Serialize arguments to WASM primitives
let (ox, oy, oz) = origin.to_raw();
let (dx, dy, dz) = dest.to_raw();
let loc = locomotor as i32;
// 3. Call WASM export
match self.fn_request_path.call(
&mut self.store,
(ox, oy, oz, dx, dy, dz, loc),
) {
Ok(raw_id) => PathId::from_raw(raw_id),
Err(wasmtime::Trap::OutOfFuel) => PathId::DEFERRED,
Err(e) => {
log::warn!("WASM pathfinder trapped: {e}");
PathId::FAILED
}
}
}
fn get_path(&self, id: PathId) -> Option<Vec<WorldPos>> {
// Similar: call fn_get_path, deserialize Vec<WorldPos> from
// MessagePack in guest linear memory
// ...
}
fn is_passable(&self, pos: WorldPos, locomotor: LocomotorType) -> bool {
// ...
}
fn invalidate_area(&mut self, center: WorldPos, radius: SimCoord) {
// ...
}
}
}
Replay and Save/Load
1. Replay records which WASM mods were active (mod hash in replay metadata)
2. Replay playback requires the same WASM binary (hash must match)
3. Save games snapshot the WASM module's linear memory (wasmtime supports this)
4. Load restores the WASM memory → your mod's internal state is preserved
Quick Reference: What You Export vs. What You Call
Pathfinder Mod
You export (engine calls these):
#![allow(unused)]
fn main() {
pathfinder_register() -> &str
pathfinder_request_path(origin, dest, locomotor) -> PathId
pathfinder_get_path(id) -> Option<Vec<WorldPos>>
pathfinder_is_passable(pos, locomotor) -> bool
pathfinder_invalidate_area(center, radius)
}
You call (engine provides these):
#![allow(unused)]
fn main() {
ic_pathfind_get_terrain(pos) -> TerrainType
ic_pathfind_get_height(pos) -> SimCoord
ic_pathfind_query_obstacles(center, radius) -> Vec<(WorldPos, SimCoord)>
ic_pathfind_map_bounds() -> (WorldPos, WorldPos)
ic_pathfind_scratch_alloc(bytes) -> ptr // scratch memory from engine
}
AI Strategy Mod
You export:
#![allow(unused)]
fn main() {
ai_decide(player_id, tick) -> Vec<PlayerOrder>
ai_on_unit_created(handle, type) // optional event callbacks
ai_on_unit_destroyed(handle, attacker)
ai_on_enemy_spotted(handle, type)
ai_get_parameters() -> Vec<ParameterSpec> // lobby-visible settings
ai_set_parameter(name, value)
ai_tick_budget_hint() -> u64
}
You call:
#![allow(unused)]
fn main() {
ic_ai_get_own_units() -> Vec<AiUnitInfo>
ic_ai_get_visible_enemies() -> Vec<AiUnitInfo>
ic_ai_get_resources() -> AiResourceInfo
ic_ai_get_production_queues() -> Vec<AiProductionQueue>
ic_ai_can_build(unit_type) -> bool
ic_ai_issue_order(order) -> bool
}
Render Mod
You export:
#![allow(unused)]
fn main() {
renderable_init()
renderable_render(entity_type, state)
screen_to_world(screen_x, screen_y) -> WorldPos
}
You call:
#![allow(unused)]
fn main() {
ic_render_draw_sprite(sprite_id, frame, pos, facing, palette)
ic_render_draw_mesh(mesh, pos, rotation, scale)
ic_render_draw_line(start, end, color, width)
ic_render_load_asset(path) -> handle
ic_query_visible_units() -> Vec<RenderUnitInfo> // if read_visible_state enabled
ic_query_visible_buildings() -> Vec<RenderBuildingInfo>
}
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Using f32/f64 in pathfinding | ic mod test --determinism fails (different results on x86 vs ARM) | Use FixedPoint from fixed-game-math. The SDK re-exports it |
| Exceeding fuel budget | Path requests return DEFERRED, units stutter | Optimize your algorithm, or request higher pathfinder_fuel_per_tick in mod.toml |
| Calling functions you don’t have capabilities for | Mod fails to load (“missing capability: ai_strategy”) | Add the capability to mod.toml. Remember: pathfinder mods can’t request network/filesystem |
Not handling invalidate_area() | Stale paths after terrain changes — units walk through buildings | Clear your path cache in the invalidation radius |
| Allocating too much WASM memory | Mod terminated (“exceeded max_memory_bytes”) | Use ic_pathfind_scratch_alloc() for temp data, or request higher memory limit |
| Publishing without conformance tests | Workshop rejects submission for pathfinder certification | Run ic mod test --conformance pathfinder and fix all failures |
End-to-End Example: From Zero to Published Mod
# 1. Create project
ic mod init --template pathfinder hex-pathfinder
cd hex-pathfinder
# 2. Edit src/lib.rs — implement your hex A* algorithm
# 3. Edit mod.toml — set your mod identity and limits
# 4. Build
ic mod build
# 5. Test
ic mod test # unit tests
ic mod test --determinism # cross-platform determinism
ic mod test --conformance pathfinder # engine conformance suite
# 6. Play-test
ic mod run # launch game with mod
# 7. Package
ic mod package # creates .icmod file
# 8. Publish
ic mod publish # upload to Workshop
# Done. Players can now install your pathfinder from the Workshop
# and select it in their mod profile (D062).