6.8 KiB
Writing a WASM AI Controller
Audience: Rust developers who want to ship a custom AI for Magic Civilization. This document is the interface contract — the host engine promises to call your module exactly as described here, and your module promises to honour these exports. The reference implementation lives in learned-duel-v1b (see below).
Status: contract is stable for Stage 5b onward. Items flagged TBD are subject to change before public release.
What an AI controller does
Once per AI turn, the engine serialises the visible tactical state, hands it to your WASM module, and asks for a plan. You return an ordered list of Actions; the engine applies them in sequence, validates each against the authoritative simulator, and discards any that fail validation. Your module never mutates game state directly — it only proposes actions.
Determinism is mandatory. Given the same (state, slot, seed) triple, your controller MUST emit the same Vec<Action> byte-for-byte every time. The host uses this property for replay, netcode rollback, and save fingerprints. Use the supplied seed for any randomness; do not call host clocks or system RNG (none are exposed).
Required WASM exports
Your module must export three symbols. Rust pseudocode signatures:
// Called once at load. Return non-zero to signal "ready"; zero rejects the module.
// No state passed in; do lazy init on first decide_turn if needed.
#[no_mangle] pub extern "C" fn init() -> u32;
// The hot path. Called once per AI turn.
// state_ptr..state_ptr+state_len : postcard-serialised TacticalState in linear memory
// slot : which player slot you are deciding for
// seed : deterministic PRNG seed for this turn
// out_ptr..out_ptr+out_cap : scratch buffer the host allocated for your reply
// Write a postcard-serialised Vec<Action> into out_ptr; return bytes written, or
// a negative i32 on error.
#[no_mangle] pub extern "C" fn decide_turn(
state_ptr: i32, state_len: i32,
slot: u32, seed: u64,
out_ptr: i32, out_cap: i32,
) -> i32;
Plus an identity string. WASM has no native string return, so expose two globals at fixed names; the host reads __ident_len bytes starting at __ident_ptr:
#[no_mangle] pub static __ident_ptr: i32 = /* address of a static b"name 1.2.3" */;
#[no_mangle] pub static __ident_len: i32 = /* its length */;
The string format is "<name> <semver>" (e.g. "learned-duel 1.0.0-b"). It feeds into controller_id mapping and save fingerprints.
Marshalling: postcard
The wire format on both sides is postcard — a no_std serde codec that is deterministic, compact, and schema-compatible with the Rust TacticalState / Action types. The host serialises once per turn, the guest deserialises once; same crate version on both sides means zero shape mismatch.
Backwards compatibility: adding a field to TacticalState is safe only if the host annotates the new field with #[serde(default)]. This is a host-side guarantee — you, the mod author, don't have to do anything, but you should pin the version of the mc-tactical-types crate your mod was built against so a newer host doesn't silently drop fields you depend on.
Fog of war
The TacticalState you receive is already vision-filtered for your bound player. Enemy units and cities whose hex is not in your player's visible set are omitted from players[other].units / .cities; tile resources outside explored are stripped. Your own slot is always surfaced in full. The host computes vision via mc_vision::compute_vision and feeds the filtered projection through project_tactical_with_vision (mc-player-api/src/projection.rs). Plan around this — your controller does not see the whole map, and assuming otherwise will produce moves that target hexes you never explored.
The CP_OMNISCIENT=1 env flag on the host disables filtering for debug repros only; a shipped mod must never rely on it.
manifest.json
Ship your .wasm next to a manifest.json:
{
"id": "learned-duel-v1b",
"name": "Learned Duel (1v1 sparring AI)",
"author": "your-handle",
"version": "1.0.0-b",
"kind": "ai_controller",
"controller_id": "learned:duel-v1b",
"sandbox": "wasm",
"signature": null
}
sandbox: "native" is permitted for .so / .dylib / .dll controllers but the signature field then becomes required — an ed25519 signature of the binary against the engine publisher pubkey. Unsigned native mods are refused at load time. TBD: the public key distribution channel is not yet finalised.
Sandbox guarantees and limits
The host runs WASM mods under Wasmtime with:
- Fuel budget per turn: 100M instructions (TBD — subject to change before release). Exceeding the budget cancels the turn; the engine treats it as if your controller returned an empty
Vec<Action>. - Linear memory cap: 16 MB (TBD).
- No host imports: no clocks, no filesystem, no network, no host RNG. The
seedargument is your only entropy source. - Epoch-based interruption: the host can cancel a stuck turn mid-execution without UB.
Native mods get none of these protections, which is why they require a signature.
Reference mod
public/games/age-of-dwarves/mods/learned-duel-v1b.wasm is the canonical reference controller (lands in Stage 6). Open-source repo: TBD — URL placeholder until the mod template repo is published.
A minimum viable controller — pass turns and do nothing — looks like:
#[no_mangle] pub extern "C" fn init() -> u32 { 1 }
#[no_mangle] pub extern "C" fn decide_turn(
_sp: i32, _sl: i32, _slot: u32, _seed: u64,
out_ptr: i32, _out_cap: i32,
) -> i32 {
// postcard-encoded empty Vec<Action> is a single 0x00 byte (length prefix = 0).
unsafe { *(out_ptr as *mut u8) = 0; }
1
}
Building your mod
cargo build --release --target wasm32-unknown-unknown
wasm-opt -Oz target/wasm32-unknown-unknown/release/<name>.wasm \
-o <name>.opt.wasm
wasm-opt ships with binaryen. Drop the optimised .wasm plus your manifest.json into:
~/Documents/MagicCivilization/mods/<id>/
manifest.json
<name>.opt.wasm
The engine scans that directory at startup and registers any well-formed ai_controller mods. For the FFI helpers many mod authors lean on (memory allocation hooks, panic handlers), wasm-bindgen is supported but not required — the three exports above are the only contract.
Open questions (TBD)
- Final fuel + memory caps.
- Native-mod signing key distribution.
- Reference-mod public repo URL.
- Versioning policy for
TacticalState/Action(host promises#[serde(default)]on new fields; we still need a deprecation policy for removals).