Tutorial · Divi Games

Build Tower Defense

A game the chain replays to check your score. The same Rust simulation runs in the browser as wasm and inside the node; the player only sends tower placements. Read the crate, rebuild the wasm, run the golden-vector test, read the module, then start, play and submit a run on the live testnet from Node.

About 20 minutes · Checked 2026-10-21

You should be able to read Rust and have seen a gamechain module before (Build tic-tac-toe explains the GameModule trait, state keys and MockCtx; this page does not repeat that). Everything below comes from the divi_games repository at the commit that is live today: the simulation is crates/dg-td/src/lib.rs (719 lines, tests included), its wasm wrapper is crates/dg-td-wasm/src/lib.rs (311 lines), the module is crates/dg-games/src/towerdefense.rs (715 lines) and the browser game is web/play/towerdefense/app.js. The code blocks are copied from those files. The build, the test and the run were done on 2026-10-21: the Rust parts on a build host, the chain parts against games.divi.domains, chain divi-games-testnet-1.

1The idea: one simulation, run twice

Most browser games cannot be trusted with their own score: the client can send any number it likes. Tower defense on the gamechain avoids that without putting the game on chain. The game is a deterministic simulation: given a 32-byte seed and a list of tower placements, it always produces the same result, bit for bit. So the browser runs it once, for the player to watch, and the node runs it again, to decide the score. The player never sends a score. The player sends the inputs.

The seed must be something neither the player nor the sequencer can pick, or a player would start runs until an easy one came up. It is taken from the public Divi checkpoint the block carries. In crates/dg-core/src/module.rs:

crates/dg-core/src/module.rs

    /// Public randomness: `SHA256(divi_checkpoint.hash || txid || domain)`. Neither the player nor the
    /// sequencer can choose it.
    fn public_random(&self, domain: &[u8]) -> [u8; 32] {
        let mut buf = Vec::new();
        buf.extend_from_slice(self.divi_checkpoint().hash.as_bytes());
        buf.extend_from_slice(self.txid().as_bytes());
        buf.extend_from_slice(domain);
        sha256(&buf)
    }

Three strings concatenated, then SHA-256: the Divi block hash the gamechain block anchors to (64 hex characters, as text), the start txid (64 hex characters, as text) and the module id towerdefense. The player cannot know the next Divi block hash, and the sequencer did not choose it. Section 7 recomputes this for a real run and gets the seed the node emitted.

Grid
16 × 12
Waves
20
Max placements
64
Max ticks
30,000
Why replay instead of trusting the client

The node does the same work the browser did, at most 30,000 ticks with at most 64 placements, so a submit is bounded and cheap. A placement the simulation rejects fails the whole submit with its index and reason, and the run stays open. There is no way to send a score, only a way to send moves the node can check.

2The simulation crate, dg-td

The crate has one job: be the same everywhere. It is #![no_std] with alloc, so it compiles for wasm32-unknown-unknown without a runtime, and it is written so that no platform detail can leak into the result. The rules at the top of the file are exact, so here they are, unedited:

crates/dg-td/src/lib.rs

//! The deterministic tower-defense simulation (ARCHITECTURE §10). The node links it natively to replay runs,
//! and `dg-td-wasm` compiles it for the browser, so both always agree on a score.
//!
//! Integer maths only: positions are in 1/256 cell, every rate is a whole number per tick, and the only
//! randomness is a splitmix64 stream seeded from the 32-byte run seed, drawn at wave launch and never
//! from player input. A run is the seed plus the player's tower placements; creeps never depend on them.
//!
//! # Rules
//!
//! - The grid is `GRID_W` x `GRID_H`. Creeps walk the fixed path `WAYPOINTS` from the left edge to the exit;
//!   towers go on any other cell, one per cell.
//! - Wave `w` (0-based) launches at tick `FIRST_WAVE_TICK + w * WAVE_GAP` with `6 + w` creeps, one every
//!   `SPAWN_GAP` ticks. Every fifth wave ends with a boss. Creep hit points grow 25% per wave.
//! - Each tick, in order: launch and spawn, towers fire (hitscan, furthest-along target in range), dead creeps
//!   go, survivors move, creeps past the exit cost lives.
//! - A kill pays gold (`bounty`) and points. A wave is cleared when every creep of it has been killed and none
//!   leaked.
//! - The run ends when lives hit 0, after the last wave resolves, or at `MAX_TICKS`.
//! - `score = kill points + 100 per wave cleared + 50 per life left`.

Three determinism rules fall out of that comment and the code below. Integer maths only: positions are in 1/256 of a cell (CELL = 256), speeds and damage are whole numbers per tick, so no float rounding differs between a browser and the node. One random stream, drawn in one place: a splitmix64 generator seeded from the 32-byte seed, consulted only in launch_wave to pick creep kinds; player input never touches it, so creeps are the same whatever the player does. A fixed order of operations each tick: launch and spawn, towers fire, dead creeps go, survivors move, leaks cost lives. The constants:

pub const GRID_W: u32 = 16;
pub const GRID_H: u32 = 12;
/// World units per cell. Positions and ranges are in these units.
pub const CELL: i32 = 256;
/// Hard cap on a run, about 25 minutes at 20 ticks per second.
pub const MAX_TICKS: u32 = 30_000;
pub const MAX_PLACEMENTS: usize = 64;
pub const WAVES: u32 = 20;
pub const START_GOLD: u32 = 150;
pub const START_LIVES: u32 = 20;
pub const FIRST_WAVE_TICK: u32 = 100;
pub const WAVE_GAP: u32 = 600;
pub const SPAWN_GAP: u32 = 24;

/// Corners of the creep path, as cells. Consecutive corners share a row or a column.
pub const WAYPOINTS: [(u32, u32); 8] = [(0, 1), (12, 1), (12, 4), (3, 4), (3, 7), (14, 7), (14, 10), (0, 10)];

Four creep kinds and four tower kinds. The index in these arrays is the kind number the browser and the chain use:

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CreepDef {
    pub name: &'static str,
    pub hp: u32,
    /// World units per tick.
    pub speed: u32,
    pub bounty: u32,
    pub points: u32,
    /// Lives lost when it reaches the exit.
    pub leak: u32,
}

pub const GRUNT: u8 = 0;
pub const RUNNER: u8 = 1;
pub const TANK: u8 = 2;
pub const BOSS: u8 = 3;

pub const CREEPS: [CreepDef; 4] = [
    CreepDef { name: "grunt", hp: 60, speed: 10, bounty: 8, points: 10, leak: 1 },
    CreepDef { name: "runner", hp: 35, speed: 18, bounty: 6, points: 15, leak: 1 },
    CreepDef { name: "tank", hp: 240, speed: 6, bounty: 25, points: 40, leak: 2 },
    CreepDef { name: "boss", hp: 1500, speed: 5, bounty: 150, points: 300, leak: 5 },
];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TowerDef {
    pub name: &'static str,
    pub cost: u32,
    /// World units.
    pub range: u32,
    pub damage: u32,
    /// Ticks between shots.
    pub cooldown: u32,
    /// Radius around the target that takes half damage; 0 for none.
    pub splash: u32,
    /// Ticks a hit creep moves at half speed; 0 for none.
    pub slow_ticks: u32,
}

pub const TOWERS: [TowerDef; 4] = [
    TowerDef { name: "arrow", cost: 50, range: 896, damage: 12, cooldown: 10, splash: 0, slow_ticks: 0 },
    TowerDef { name: "cannon", cost: 120, range: 640, damage: 36, cooldown: 45, splash: 256, slow_ticks: 0 },
    TowerDef { name: "frost", cost: 90, range: 768, damage: 4, cooldown: 25, splash: 0, slow_ticks: 40 },
    TowerDef { name: "sniper", cost: 200, range: 1536, damage: 110, cooldown: 80, splash: 0, slow_ticks: 0 },
];

A placement is four integers. A rejected placement has a stable numeric code, which the wasm interface returns and app.js turns into a message:

/// One tower the player buys: at `tick`, before that tick is simulated.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Placement {
    pub tick: u32,
    pub x: u32,
    pub y: u32,
    pub kind: u32,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlaceError {
    /// More than `MAX_PLACEMENTS`.
    TooMany,
    /// A placement's tick is earlier than the one before it.
    OutOfOrder,
    /// The run was already over at that tick (or it is at or past `MAX_TICKS`).
    AfterEnd,
    OffGrid,
    OnPath,
    Occupied,
    BadKind,
    Unaffordable,
}

impl PlaceError {
    /// Stable numeric code for the wasm interface.
    pub fn code(self) -> u32 {
        match self {
            PlaceError::TooMany => 1,
            PlaceError::OutOfOrder => 2,
            PlaceError::AfterEnd => 3,
            PlaceError::OffGrid => 4,
            PlaceError::OnPath => 5,
            PlaceError::Occupied => 6,
            PlaceError::BadKind => 7,
            PlaceError::Unaffordable => 8,
        }
    }
}

The outcome is what the node stores and what the browser shows, with the score formula from the comment:

pub struct SimError {
    pub index: usize,
    pub error: PlaceError,
}

impl fmt::Display for SimError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "placement {}: {}", self.index, self.error)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Outcome {
    pub score: u32,
    pub waves_cleared: u32,
    pub lives: u32,
    pub ticks: u32,
}

    pub fn outcome(&self) -> Outcome {
        Outcome {
            score: self.points + 100 * self.waves_cleared + 50 * self.lives,
            waves_cleared: self.waves_cleared,
            lives: self.lives,
            ticks: self.tick,
        }
    }

The random stream, and how the 32-byte seed becomes one 64-bit state. Each 8-byte chunk is read little-endian, folded in and mixed:

fn splitmix(state: &mut u64) -> u64 {
    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
    let mut z = *state;
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    z ^ (z >> 31)
}

fn seed_state(seed: &[u8; 32]) -> u64 {
    let mut s = 0u64;
    for chunk in seed.chunks(8) {
        let mut b = [0u8; 8];
        b.copy_from_slice(chunk);
        s ^= u64::from_le_bytes(b);
        s = splitmix(&mut s);
    }
    s
}

Sim::place is the whole legality check: the run is still going, the cell exists, it is not on the path, it is free, the kind exists, you can pay. The tower is ready on the tick it is placed:

    pub fn place(&mut self, x: u32, y: u32, kind: u32) -> Result<(), PlaceError> {
        if self.done {
            return Err(PlaceError::AfterEnd);
        }
        if x >= GRID_W || y >= GRID_H {
            return Err(PlaceError::OffGrid);
        }
        let cell = (y * GRID_W + x) as usize;
        if self.on_path[cell] {
            return Err(PlaceError::OnPath);
        }
        if self.taken[cell] {
            return Err(PlaceError::Occupied);
        }
        let def = TOWERS.get(kind as usize).ok_or(PlaceError::BadKind)?;
        if self.gold < def.cost {
            return Err(PlaceError::Unaffordable);
        }
        self.gold -= def.cost;
        self.taken[cell] = true;
        self.towers.push(Tower { x, y, kind: kind as u8, ready_at: self.tick });
        Ok(())
    }

launch_wave is the only place the generator is read. Wave w has 6 + w creeps; the last creep of every fifth wave is a boss; from wave 3 a roll under 15 is a tank; from wave 1 a roll under 45 is a runner; hit points grow 25% per wave:

    fn launch_wave(&mut self, start: u32) {
        let w = self.waves_launched;
        let n = 6 + w;
        for i in 0..n {
            let roll = (splitmix(&mut self.rng) % 100) as u32;
            let kind = if i == n - 1 && (w + 1).is_multiple_of(5) {
                BOSS
            } else if w >= 3 && roll < 15 {
                TANK
            } else if w >= 1 && roll < 45 {
                RUNNER
            } else {
                GRUNT
            };
            let hp = CREEPS[kind as usize].hp * (100 + 25 * w) / 100;
            self.pending.push(Pending { tick: start + i * SPAWN_GAP, kind, hp, wave: w });
        }
        self.wave_left.push(n);
        self.wave_leaked.push(false);
        self.waves_launched += 1;
    }

step advances one tick in the fixed order. It is the longest function in the crate and the one the node and the browser must agree on, so it is here in full:

    /// Advance one tick. Does nothing once the run is over.
    pub fn step(&mut self) {
        if self.done {
            return;
        }
        let t = self.tick;
        self.shots.clear();

        if self.waves_launched < WAVES && t == FIRST_WAVE_TICK + self.waves_launched * WAVE_GAP {
            self.launch_wave(t);
        }
        while self.pending_head < self.pending.len() && self.pending[self.pending_head].tick <= t {
            let p = &self.pending[self.pending_head];
            let creep = Creep { id: self.next_id, kind: p.kind, hp: p.hp, max_hp: p.hp, pos: 0, wave: p.wave, slow_until: 0 };
            self.pending_head += 1;
            self.next_id += 1;
            self.creeps.push(creep);
        }

        self.xy.clear();
        for i in 0..self.creeps.len() {
            let w = self.world(self.creeps[i].pos);
            self.xy.push(w);
        }

        for ti in 0..self.towers.len() {
            let tw = self.towers[ti];
            if t < tw.ready_at {
                continue;
            }
            let def = TOWERS[tw.kind as usize];
            let origin = cell_center(tw.x, tw.y);
            let r2 = (def.range as i64) * (def.range as i64);
            let mut best: Option<usize> = None;
            for (ci, c) in self.creeps.iter().enumerate() {
                if c.hp == 0 || dist2(origin, self.xy[ci]) > r2 {
                    continue;
                }
                if best.is_none_or(|b| c.pos > self.creeps[b].pos) {
                    best = Some(ci);
                }
            }
            let Some(ci) = best else { continue };
            let target = self.xy[ci];
            self.towers[ti].ready_at = t + def.cooldown;
            self.shots.push(Shot { tower: ti as u32, from: origin, to: target });
            self.hit(ci, def.damage, def.slow_ticks);
            if def.splash > 0 {
                let s2 = (def.splash as i64) * (def.splash as i64);
                for cj in 0..self.creeps.len() {
                    if cj != ci && dist2(target, self.xy[cj]) <= s2 {
                        self.hit(cj, def.damage / 2, 0);
                    }
                }
            }
        }
        self.creeps.retain(|c| c.hp > 0);

        let exit = (self.path.len() as u32 - 1) * CELL as u32;
        let mut leaked: Vec<(u32, u32)> = Vec::new();
        for c in self.creeps.iter_mut() {
            let speed = CREEPS[c.kind as usize].speed;
            c.pos += if c.slow_until > t { (speed / 2).max(1) } else { speed };
            if c.pos >= exit {
                c.hp = 0;
                leaked.push((c.wave, CREEPS[c.kind as usize].leak));
            }
        }
        self.creeps.retain(|c| c.hp > 0);
        for (wave, leak) in leaked {
            self.lives = self.lives.saturating_sub(leak);
            self.wave_leaked[wave as usize] = true;
            self.resolve(wave);
        }

        self.tick += 1;
        let all_resolved = self.waves_launched == WAVES && self.pending_head == self.pending.len() && self.creeps.is_empty();
        if self.lives == 0 || all_resolved || self.tick >= MAX_TICKS {
            self.done = true;
        }
    }

Finally simulate, which the node calls on submit and the browser calls through td_simulate: step to each placement's tick, place, repeat, then run to the end. Placements must be in tick order and before MAX_TICKS; the first bad one fails the whole run with its index:

/// Replay a run. Fails on the first invalid placement; the same inputs always give the same `Outcome`.
pub fn simulate(seed: &[u8; 32], placements: &[Placement]) -> Result<Outcome, SimError> {
    if placements.len() > MAX_PLACEMENTS {
        return Err(SimError { index: MAX_PLACEMENTS, error: PlaceError::TooMany });
    }
    let mut sim = Sim::new(seed);
    let mut last = 0;
    for (index, p) in placements.iter().enumerate() {
        let fail = |error| SimError { index, error };
        if p.tick < last {
            return Err(fail(PlaceError::OutOfOrder));
        }
        last = p.tick;
        if p.tick >= MAX_TICKS {
            return Err(fail(PlaceError::AfterEnd));
        }
        while sim.tick < p.tick && !sim.done {
            sim.step();
        }
        sim.place(p.x, p.y, p.kind).map_err(fail)?;
    }
    while !sim.done {
        sim.step();
    }
    Ok(sim.outcome())
}
The crate's own golden test

dg-td has a test, golden_vector, that reads web/play/towerdefense/golden.json and asserts simulate returns exactly the outcome in it. Section 5 runs the Node side of the same file against the wasm. Change a rule and both fail until the vector is updated, which is the point.

3The wasm boundary

The browser build is a second crate, dg-td-wasm, that depends on dg-td and exposes it as plain extern "C" functions. There is no wasm-bindgen and no JavaScript glue to generate: the module needs no imports at all (the test in section 5 checks that), and JavaScript talks to it through a few fixed buffers whose addresses it asks for.

crates/dg-td-wasm/Cargo.toml

[package]
name = "dg-td-wasm"
description = "dg-td compiled to wasm for the browser, with a plain extern \"C\" interface"
version.workspace = true
edition.workspace = true
license.workspace = true

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

[dependencies]
dg-td.workspace = true

[dev-dependencies]
serde_json = "1"

crates/dg-td-wasm/src/lib.rs

//! `dg-td` for the browser: build with `cargo build -p dg-td-wasm --target wasm32-unknown-unknown --release`.
//! The interface is plain `extern "C"` functions over linear memory; there is no wasm-bindgen.
//!
//! JavaScript talks to it through a few fixed buffers whose addresses it asks for, so nothing is allocated
//! across the boundary:
//!
//! - `td_seed_ptr()`: 32 bytes. Write the run seed, then call `td_init()`.
//! - `td_input_ptr()`: `MAX_PLACEMENTS` records of four little-endian u32 (`tick, x, y, kind`) for
//!   `td_simulate(n)`.
//! - `td_result_ptr()`: four u32 (`score, waves_cleared, lives, ticks`) written by `td_simulate`.
//! - `td_frame_ptr()` / `td_frame()`: the live game as i32 words, see [`td_frame`].
//!
//! Errors are numbers: 0 is success, otherwise a `dg_td::PlaceError::code()`. `td_simulate` returns
//! `(index << 8) | code` for the first rejected placement.

All state lives in one thread-local struct with fixed-size buffers. Nothing is allocated across the boundary and no pointer JavaScript holds can go stale, except that the memory object may grow, which is why app.js rebuilds its typed-array views on every call:

use std::cell::RefCell;

use dg_td::{PlaceError, Placement, Sim, CREEPS, GRID_H, GRID_W, MAX_PLACEMENTS, MAX_TICKS, TOWERS, WAVES};

struct State {
    seed: [u8; 32],
    sim: Option<Sim>,
    input: [u32; MAX_PLACEMENTS * 4],
    result: [u32; 4],
    frame: Vec<i32>,
    path: Vec<u32>,
}

thread_local! {
    static STATE: RefCell<State> = const { RefCell::new(State {
        seed: [0; 32],
        sim: None,
        input: [0; MAX_PLACEMENTS * 4],
        result: [0; 4],
        frame: Vec::new(),
        path: Vec::new(),
    }) };
}

fn with<R>(f: impl FnOnce(&mut State) -> R) -> R {
    STATE.with(|s| f(&mut s.borrow_mut()))

The exports. td_seed_ptr, td_input_ptr and td_result_ptr hand out buffer addresses; td_init, td_place and td_step drive a live run; td_simulate replays a list the way the node will:

#[no_mangle]
pub extern "C" fn td_seed_ptr() -> *mut u8 {
    with(|s| s.seed.as_mut_ptr())
}

#[no_mangle]
pub extern "C" fn td_input_ptr() -> *mut u32 {
    with(|s| s.input.as_mut_ptr())
}

#[no_mangle]
pub extern "C" fn td_result_ptr() -> *const u32 {
    with(|s| s.result.as_ptr())
}

/// Start a fresh run from the seed buffer.
#[no_mangle]
pub extern "C" fn td_init() {
    with(|s| s.sim = Some(Sim::new(&s.seed)));
}

/// Buy a tower now. 0 on success.
#[no_mangle]
pub extern "C" fn td_place(x: u32, y: u32, kind: u32) -> u32 {
    with(|s| match s.sim.as_mut() {
        Some(sim) => sim.place(x, y, kind).err().map_or(0, PlaceError::code),
        None => PlaceError::AfterEnd.code(),
    })
}

/// Advance up to `n` ticks, stopping when the run ends. Returns 1 once it is over.
#[no_mangle]
pub extern "C" fn td_step(n: u32) -> u32 {
    with(|s| {
        let Some(sim) = s.sim.as_mut() else { return 1 };
        for _ in 0..n {
            sim.step();
        }
        sim.done() as u32
    })
}

/// Replay `n` placements from the input buffer against the seed buffer, as the node does. Fills the
/// result buffer and returns 0, or returns `(index << 8) | code` for the first bad placement.
#[no_mangle]
pub extern "C" fn td_simulate(n: u32) -> u32 {
    with(|s| {
        let n = n as usize;
        if n > MAX_PLACEMENTS {
            return ((MAX_PLACEMENTS as u32) << 8) | PlaceError::TooMany.code();
        }
        let placements: Vec<Placement> =
            s.input[..n * 4].chunks(4).map(|c| Placement { tick: c[0], x: c[1], y: c[2], kind: c[3] }).collect();
        match dg_td::simulate(&s.seed, &placements) {
            Ok(o) => {
                s.result = [o.score, o.waves_cleared, o.lives, o.ticks];
                0
            }
            Err(e) => ((e.index as u32) << 8) | e.error.code(),
        }
    })
}

For drawing, td_frame() snapshots the live run into a buffer of i32 words and returns its length. The layout, from its doc comment:

/// Snapshot the live run into the frame buffer; returns its length in words. Layout (i32 words):
///
/// - header, 9 words: `tick, gold, lives, waves_launched, score, done, n_creeps, n_towers, n_shots`
/// - `n_creeps` x 7: `id, x, y, hp, max_hp, kind, slowed`; x and y are 1/256 cell
/// - `n_towers` x 3: `cell_x, cell_y, kind`
/// - `n_shots` x 5: `from_x, from_y, to_x, to_y, tower_kind`, the shots fired on the last tick

There are also td_path() / td_path_ptr() (the 56 path cells), td_const(i) (grid size, max ticks, waves, start gold and lives, max placements) and td_tower_stat / td_creep_stat, so the page never hard-codes a number the crate owns.

This is how app.js loads it. WebAssembly.instantiate with an empty import object, then small closures over the exports:

web/play/towerdefense/app.js

async function loadWasm() {
  const res = await fetch(new URL('dg_td_wasm.wasm', import.meta.url));
  const { instance } = await WebAssembly.instantiate(await res.arrayBuffer(), {});
  const x = /** @type {any} */ (instance.exports);
  const mem = () => /** @type {WebAssembly.Memory} */ (x.memory).buffer;
  const c = (/** @type {number} */ i) => x.td_const(i);
  return {
    x,
    W: c(0), H: c(1), maxTicks: c(2), waves: c(3), startGold: c(4), startLives: c(5), maxPlacements: c(6),
    /** Views are rebuilt on every call because the wasm memory may have grown. */
    seed: (/** @type {Uint8Array} */ b) => new Uint8Array(mem(), x.td_seed_ptr(), 32).set(b),
    path() {
      const n = x.td_path();
      const w = new Uint32Array(mem(), x.td_path_ptr(), n * 2);
      return Array.from({ length: n }, (_, i) => [w[2 * i], w[2 * i + 1]]);
    },
    frame() {
      const n = x.td_frame();
      return n ? new Int32Array(mem(), x.td_frame_ptr(), n).slice() : null;
    },
    tower: (/** @type {number} */ k, /** @type {number} */ f) => x.td_tower_stat(k, f),
  };
}

Starting a run that already exists on chain is: write the 32 seed bytes, call td_init, read a frame. A placement is: ask td_place now, and only if it answers 0, record the current tick with the cell and kind. That recorded tick is what the node will step to before placing, so the list the browser keeps is the list the node replays:

const hexBytes = (/** @type {string} */ h) => Uint8Array.from(h.match(/../g) ?? [], (b) => parseInt(b, 16));

/** Start playing locally with a run that already exists on chain. */
function beginLocal(/** @type {{id: string, seed: string}} */ r) {
  const w = state.wasm;
  state.run = r;
  state.placements = [];
  state.playing = true;
  state.paused = false;
  state.over = false;
  state.submitted = false;
  w.seed(hexBytes(r.seed));
  w.x.td_init();
  state.frame = parseFrame(w.frame());
  $('run-id').textContent = r.id;
  $('seed').textContent = short(r.seed);
  $('seed-line').hidden = false;
  $('result').hidden = true;
  $('submit').hidden = true;
  $('start').disabled = true;
  syncButtons();
  say($('play-msg'), 'Pick a tower and click a grass cell. The run ends when lives hit 0 or every wave has gone by.', 'info');
  renderHud();
  draw();
  requestAnimationFrame(tick);
}

function place(/** @type {number} */ x, /** @type {number} */ y) {
  const w = state.wasm;
  const msg = $('play-msg');
  if (!state.playing || state.over) {
    say(msg, 'Start a run first.', 'info');
    return;
  }
  if (state.placements.length >= w.maxPlacements) {
    say(msg, PLACE_ERRORS[1], 'error');
    return;
  }
  const at = state.frame.tick;
  const code = w.x.td_place(x, y, state.kind);
  if (code) {
    say(msg, /** @type {any} */ (PLACE_ERRORS)[code] ?? `Cannot build there (${code}).`, 'error');
    return;
  }
  say(msg, '');
  state.placements.push({ tick: at, x, y, kind: state.kind });
  state.frame = parseFrame(w.frame());
  renderHud();
  draw();
}
The encoding, end to end

In the browser a placement is a JavaScript object {tick, x, y, kind}. For td_simulate it is written as four little-endian u32 words per record into the input buffer. For submit it is sent as JSON numbers, and the module's parse_placements (section 6) rejects anything that is not a non-negative integer that fits a u32. Tower kinds are the TOWERS index: 0 arrow, 1 cannon, 2 frost, 3 sniper.

4Build the wasm and compare hashes

The repository checks in the built file, web/play/towerdefense/dg_td_wasm.wasm, because the site serves it as-is. Anyone can rebuild it and compare. This is the script we ran on the build host (Linux, rustup with the wasm32-unknown-unknown target installed). The clean first makes sure the two crates are really compiled, not reused:

td-build.sh

set -e
. ~/.cargo/env
cd ~/remote-check/tut-i-divi_games
export CARGO_TARGET_DIR=~/remote-cargo/target/tut-i-divi_games
cargo --version; rustc --version
rustup target list --installed | grep wasm32
cargo "clean" --release --target wasm32-unknown-unknown -p dg-td -p dg-td-wasm 2>&1 | tail -1
cargo "build" --release --target wasm32-unknown-unknown -p dg-td-wasm
ls -l $CARGO_TARGET_DIR/wasm32-unknown-unknown/release/dg_td_wasm.wasm
sha256sum $CARGO_TARGET_DIR/wasm32-unknown-unknown/release/dg_td_wasm.wasm web/play/towerdefense/dg_td_wasm.wasm
sh td-build.sh
cargo 1.99.0 (5f94df478 2026-08-27)
rustc 1.99.0 (b940084d7 2026-09-28)
wasm32-unknown-unknown
     Removed 22 files, 634.0KiB total
   Compiling dg-td v0.1.0 (/home/bshuler/remote-check/tut-i-divi_games/crates/dg-td)
   Compiling dg-td-wasm v0.1.0 (/home/bshuler/remote-check/tut-i-divi_games/crates/dg-td-wasm)
    Finished `release` profile [optimized] target(s) in 0.58s
-rwxrwxr-x 2 bshuler bshuler 42192 Oct 22 00:59 /home/bshuler/remote-cargo/target/tut-i-divi_games/wasm32-unknown-unknown/release/dg_td_wasm.wasm
e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686  /home/bshuler/remote-cargo/target/tut-i-divi_games/wasm32-unknown-unknown/release/dg_td_wasm.wasm
e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686  web/play/towerdefense/dg_td_wasm.wasm
FileSHA-256
Rebuilt on 2026-10-21 (cargo 1.99.0, rustc 1.99.0)e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686
Checked in: web/play/towerdefense/dg_td_wasm.wasme790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686
Served: games.divi.domains/play/towerdefense/dg_td_wasm.wasme790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686

All three are identical: 42,192 bytes, one hash. The checked-in file was last changed in commit f36d90c on 2026-10-20. The workspace release profile in Cargo.toml (codegen-units = 4, lto = "thin") is part of what makes the output reproducible; a different rustc version will usually give a different file, so if your hash differs, compare toolchains before anything else. The hash of the file is a convenience for readers; the chain does not care which wasm a browser ran, because the node replays with its own native build of the same crate.

5The golden-vector test

tests/towerdefense/wasm.test.mjs loads the checked-in wasm in Node, with no dependencies, and runs six tests. It needs Node 20 or newer. This is the real run, on the same host, right after the build above:

node --version
node --test tests/towerdefense/wasm.test.mjs
TAP version 13
# Subtest: the wasm needs no imports
ok 1 - the wasm needs no imports
  ---
  duration_ms: 3.202622
  ...
# Subtest: simulate matches the golden outcome
ok 2 - simulate matches the golden outcome
  ---
  duration_ms: 18.87484
  ...
# Subtest: simulate is deterministic across calls
ok 3 - simulate is deterministic across calls
  ---
  duration_ms: 7.322499
  ...
# Subtest: a different seed gives a different run
ok 4 - a different seed gives a different run
  ---
  duration_ms: 4.110091
  ...
# Subtest: invalid placements are rejected with their index and code
ok 5 - invalid placements are rejected with their index and code
  ---
  duration_ms: 1.358319
  ...
# Subtest: stepping live with the same placements reaches the same outcome
ok 6 - stepping live with the same placements reaches the same outcome
  ---
  duration_ms: 6.815714
  ...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 131.085678

What golden.json pins. It is one seed (the bytes 0 to 31), seven placements and the outcome that simulate must produce for them. The Rust test golden_vector in dg-td reads the same file, so the native build and the wasm build are tied to one number:

web/play/towerdefense/golden.json

{
  "seed": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
  "placements": [
    { "tick": 0, "x": 4, "y": 2, "kind": 0 },
    { "tick": 0, "x": 6, "y": 2, "kind": 0 },
    { "tick": 900, "x": 8, "y": 2, "kind": 1 },
    { "tick": 3000, "x": 5, "y": 5, "kind": 0 },
    { "tick": 4500, "x": 7, "y": 6, "kind": 3 },
    { "tick": 6500, "x": 9, "y": 6, "kind": 1 },
    { "tick": 9000, "x": 12, "y": 6, "kind": 3 }
  ],
  "outcome": { "score": 3525, "waves_cleared": 9, "lives": 0, "ticks": 9839 }
}

The test file's loader is the smallest complete example of driving the wasm from JavaScript, and section 7 reuses it word for word:

tests/towerdefense/wasm.test.mjs

// The browser build and the node's replay must agree bit for bit. golden.json is also read by the Rust
// test `golden_vector` in crates/dg-td, so a change to the rules fails there and here until both are updated.

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const dir = new URL('../../web/play/towerdefense/', import.meta.url);
const golden = JSON.parse(readFileSync(new URL('golden.json', dir), 'utf8'));
const bytes = readFileSync(new URL('dg_td_wasm.wasm', dir));

const hex = (h) => Uint8Array.from(h.match(/../g), (b) => parseInt(b, 16));

async function load() {
  const { instance } = await WebAssembly.instantiate(bytes, {});
  const x = instance.exports;
  const mem = () => x.memory.buffer;
  return {
    x,
    setSeed: (h) => new Uint8Array(mem(), x.td_seed_ptr(), 32).set(hex(h)),
    /** Run `simulate` over `placements`; returns the outcome or the packed error. */
    simulate(seedHex, placements) {
      this.setSeed(seedHex);
      const input = new Uint32Array(mem(), x.td_input_ptr(), placements.length * 4);
      placements.forEach((p, i) => input.set([p.tick, p.x, p.y, p.kind], i * 4));
      const code = x.td_simulate(placements.length);
      if (code) return { error: code };
      const [score, waves_cleared, lives, ticks] = new Uint32Array(mem(), x.td_result_ptr(), 4);
      return { score, waves_cleared, lives, ticks };
    },
  };
}

6The module: start, submit, prizes

The chain side is an ordinary GameModule, id towerdefense, with two ops. Its constants:

crates/dg-games/src/towerdefense.rs

pub const ID: &str = "towerdefense";
pub const TOKEN: &str = "TDGOLD";
const DECIMALS: u8 = 2;
/// Blocks per leaderboard week: seven days at 2 s.
pub const WEEK_BLOCKS: u64 = 302_400;
/// Blocks a player has to submit a run (two hours at 2 s).
pub const RUN_EXPIRY_BLOCKS: u64 = 3_600;
/// TDGOLD minted into each week's prizes at most, in base units (5,000.00).
pub const WEEKLY_POOL: u128 = 500_000;
/// Weeks the token's supply cap covers.
pub const POOL_WEEKS: u128 = 520;
/// Prize share per rank in basis points; sums to 10,000.
pub const PRIZE_BPS: [u128; 10] = [3000, 2000, 1400, 1000, 800, 600, 500, 400, 200, 100];
const LIST_LIMIT: usize = 50;
/// Weeks `end_block` will settle in one block if the chain skipped any.
const MAX_SETTLE_WEEKS: u64 = 8;
kindFieldsWho
startnoneanyone without an open run
submitrun, placements ([{tick, x, y, kind}], JSON numbers, at most 64)the run's player

start. Refuse if the sender still has a live open run; take the run id from the txid; create the prize token the first time anyone ever starts; fix the seed with public_random; store the run, the open pointer and the per-player index; emit the seed:

fn start(ctx: &mut dyn Ctx, sender: &str) -> Result<(), ActionError> {
    let height = ctx.height();
    if let Some(open) = ctx.get_json::<String>(&open_key(sender))? {
        let live = ctx.get_json::<Run>(&run_key(&open))?.is_some_and(|r| r.status == Status::Open && height <= r.start_height + RUN_EXPIRY_BLOCKS);
        if live {
            return Err(ActionError::Conflict(format!("run {open} is still open; submit it or wait for it to expire")));
        }
    }
    let id = ctx.txid().get(..16).ok_or_else(|| ActionError::Internal("txid is too short".into()))?.to_string();
    if ctx.get(&run_key(&id)).is_some() {
        return Err(ActionError::Conflict(format!("run {id} exists")));
    }
    if ctx.read_raw(&format!("token/{TOKEN}")).is_none() {
        ctx.create_token(TOKEN, "Tower Defense Gold", DECIMALS, Some(WEEKLY_POOL * POOL_WEEKS))?;
    }

    let seed = hex::encode(ctx.public_random(ID.as_bytes()));
    let run = Run {
        id: id.clone(),
        player: sender.to_string(),
        seed: seed.clone(),
        start_height: height,
        status: Status::Open,
        score: None,
        waves_cleared: None,
        lives: None,
        ticks: None,
        submitted_height: None,
        week: None,
    };
    ctx.put_json(&run_key(&id), &run);
    ctx.put_json(&open_key(sender), &id);
    ctx.put(&player_key(sender, height, &id), b"1".to_vec());
    ctx.emit("towerdefense.started", json!({ "run": id, "player": sender, "seed": seed }));
    Ok(())
}

submit. Parse the placements strictly, then the replay is one line: dg_td::simulate(&seed, &placements), the same function the wasm wraps. A simulation error becomes an invalid: error with the placement index and reason, and the transaction fails without touching state:

/// Read one placement field as a `u32`; anything else (negative, fractional, too large) is invalid.
fn field(p: &Value, name: &str, index: usize) -> Result<u32, ActionError> {
    p.get(name)
        .and_then(Value::as_u64)
        .and_then(|n| u32::try_from(n).ok())
        .ok_or_else(|| ActionError::invalid(format!("placement {index}: `{name}` must be a non-negative integer")))
}

fn parse_placements(op: &Value) -> Result<Vec<Placement>, ActionError> {
    let list = op.get("placements").and_then(Value::as_array).ok_or_else(|| ActionError::invalid("missing array field `placements`"))?;
    if list.len() > MAX_PLACEMENTS {
        return Err(ActionError::invalid(format!("at most {MAX_PLACEMENTS} placements")));
    }
    list.iter()
        .enumerate()
        .map(|(i, p)| Ok(Placement { tick: field(p, "tick", i)?, x: field(p, "x", i)?, y: field(p, "y", i)?, kind: field(p, "kind", i)? }))
        .collect()
}

fn submit(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
    let id = op_str(op, "run")?;
    let mut run: Run = ctx.get_json(&run_key(id))?.ok_or_else(|| ActionError::not_found(format!("run {id}")))?;
    if run.player != sender {
        return Err(ActionError::unauthorized("only the player who started a run can submit it"));
    }
    if run.status != Status::Open {
        return Err(ActionError::Conflict(format!("run {id} is already scored")));
    }
    let height = ctx.height();
    if height > run.start_height + RUN_EXPIRY_BLOCKS {
        return Err(ActionError::invalid(format!("run {id} expired {RUN_EXPIRY_BLOCKS} blocks after it started")));
    }
    let placements = parse_placements(op)?;

    let mut seed = [0u8; 32];
    hex::decode_to_slice(&run.seed, &mut seed).map_err(|e| ActionError::Internal(format!("stored seed: {e}")))?;
    let outcome = dg_td::simulate(&seed, &placements).map_err(|e| ActionError::invalid(e.to_string()))?;

    let week = height / WEEK_BLOCKS;
    run.status = Status::Scored;
    run.score = Some(outcome.score);
    run.waves_cleared = Some(outcome.waves_cleared);
    run.lives = Some(outcome.lives);
    run.ticks = Some(outcome.ticks);
    run.submitted_height = Some(height);
    run.week = Some(week);
    ctx.put_json(&run_key(id), &run);
    ctx.delete(&open_key(sender));

    let mut on_board = false;
    if outcome.score > 0 {
        let previous: Option<Best> = ctx.get_json(&best_key(week, sender))?;
        if previous.as_ref().is_none_or(|b| outcome.score > b.score) {
            if let Some(b) = previous {
                ctx.delete(&lb_key(week, b.score, &b.run));
            }
            ctx.put_json(&lb_key(week, outcome.score, id), &Entry { player: sender.to_string(), run: id.to_string(), score: outcome.score });
            ctx.put_json(&best_key(week, sender), &Best { score: outcome.score, run: id.to_string() });
            on_board = true;
        }
    }
    ctx.emit(
        "towerdefense.scored",
        json!({
            "run": id, "player": sender, "score": outcome.score, "waves_cleared": outcome.waves_cleared,
            "lives": outcome.lives, "ticks": outcome.ticks, "week": week, "leaderboard": on_board,
        }),
    );
    Ok(())

The state keys, as the file documents them and as the helpers build them:

KeyHolds
run/<id>the run: id, player, seed, start_height, status (open or scored), score, waves_cleared, lives, ticks, submitted_height, week (the outcome fields are null while open)
open/<address>the id of the address's unsubmitted run; deleted on submit
player/<address>/<start height, 12 digits>/<id>every run the address started, in height order
lb/<week, 10 digits>/<u32::MAX − score, 10 digits>/<id>leaderboard entries; the inverted score makes a prefix scan return the best first
best/<week, 10 digits>/<address>{score, run} of that address's entry in lb; a better run replaces the old entry
paid/<week, 10 digits>the payouts made for a finished week
weekthe week end_block last saw
fn run_key(id: &str) -> String {
    format!("run/{id}")
}

fn open_key(player: &str) -> String {
    format!("open/{player}")
}

fn player_key(player: &str, height: u64, id: &str) -> String {
    format!("player/{player}/{height:012}/{id}")
}

fn lb_prefix(week: u64) -> String {
    format!("lb/{week:010}/")
}

fn lb_key(week: u64, score: u32, run: &str) -> String {
    format!("{}{:010}/{run}", lb_prefix(week), u32::MAX - score)
}

fn best_key(week: u64, player: &str) -> String {
    format!("best/{week:010}/{player}")
}

fn paid_key(week: u64) -> String {
    format!("paid/{week:010}")
}

Prizes. GOLD is issued by the chain core, so a module cannot mint it; the module issues its own token TDGOLD with 2 decimals, capped at WEEKLY_POOL × POOL_WEEKS = 500,000 × 520 = 260,000,000 base units, which is 2,600,000.00 TDGOLD or ten years of weeks. A week is height / WEEK_BLOCKS, 302,400 blocks. end_block runs after every block; at the first block of a new week it pays the week that just ended (up to MAX_SETTLE_WEEKS missed weeks at once), top ten by PRIZE_BPS; an empty rank leaves its share unminted:

    fn end_block(&self, ctx: &mut dyn Ctx) -> Result<(), ActionError> {
        let current = ctx.height() / WEEK_BLOCKS;
        match ctx.get_json::<u64>("week")? {
            Some(seen) if seen >= current => Ok(()),
            Some(seen) => {
                for week in seen.max(current.saturating_sub(MAX_SETTLE_WEEKS))..current {
                    pay_week(ctx, week)?;
                }
                ctx.put_json("week", &current);
                Ok(())
            }
            None => {
                ctx.put_json("week", &current);
                Ok(())
            }
        }
    }

/// Mint the prizes for a finished week. A failed mint (the supply cap) skips that prize and carries on.
fn pay_week(ctx: &mut dyn Ctx, week: u64) -> Result<(), ActionError> {
    let top = ctx.scan(&lb_prefix(week), None, PRIZE_BPS.len());
    if top.is_empty() {
        return Ok(());
    }
    let mut payouts = Vec::new();
    for (i, (_, raw)) in top.iter().enumerate() {
        let entry: Entry = serde_json::from_slice(raw).map_err(|e| ActionError::Internal(e.to_string()))?;
        let amount = WEEKLY_POOL * PRIZE_BPS[i] / 10_000;
        if ctx.mint(&entry.player, TOKEN, amount).is_ok() {
            payouts.push(json!({ "rank": i + 1, "player": entry.player, "run": entry.run, "score": entry.score, "amount": Amount(amount) }));
        }
    }
    ctx.put_json(&paid_key(week), &payouts);
    ctx.emit("towerdefense.paid", json!({ "week": week, "payouts": payouts }));
    Ok(())
}

The amounts are WEEKLY_POOL × PRIZE_BPS[i] / 10_000, in base units:

RankBasis pointsBase unitsTDGOLD
13000150,0001,500.00
22000100,0001,000.00
3140070,000700.00
4100050,000500.00
580040,000400.00
660030,000300.00
750025,000250.00
840020,000200.00
920010,000100.00
101005,00050.00
Total500,0005,000.00

Views, all on GET /v1/games/towerdefense: ?run=<id> returns the run; ?view=leaderboard&week=<n> returns {week, entries: [{rank, player, run, score}], paid} for the top ten, paid being null until the week is paid; ?player=<address> returns {open, runs}, that player's 50 newest runs; no query returns the constants. Events: towerdefense.started {run, player, seed}, towerdefense.scored {run, player, score, waves_cleared, lives, ticks, week, leaderboard} where leaderboard is true when the run is now the player's best of the week, and towerdefense.paid {week, payouts}.

Two rules that bite

A run must be submitted within RUN_EXPIRY_BLOCKS (3,600 blocks) of its start, and until then the address cannot start another. A score of 0 is recorded in the run but never goes on the board (if outcome.score > 0).

7Play a run on the live chain from Node

The browser page is the normal way to play. To show that nothing in it is magic, we played a run from a Node script instead: fresh key, start, read the seed from the event, drive the same wasm from Node to choose placements, submit, and read everything back. The script needs sdk/ and vendor/ from web/ in the repository next to it, plus the wasm and golden.json. It uses the JavaScript SDK: Key.generate, chain.send, chain.waitForReceipt, chain.game.

Three things to notice. The dry run at the top plays the golden placements against the golden seed with the live stepping API and checks it lands on the committed outcome before any transaction is sent. play() records a placement only when td_place returns 0 and stores the tick it was placed at, exactly as app.js does. And after start, the script recomputes the seed from the block's Divi checkpoint, the txid and the module id, and compares it with the seed the node emitted.

play.mjs

// play.mjs: start, play and submit one tower-defense run on the live Divi Games testnet gamechain.
// Run with Node 20+:  node play.mjs
// Needs next to it: sdk/ and vendor/ (from web/ in the divi_games repo), dg_td_wasm.wasm and
// golden.json (from web/play/towerdefense/).
import fs from 'node:fs';
import { createHash } from 'node:crypto';
import { Chain, Key } from './sdk/divigames.js';

const BASE = 'https://games.divi.domains/chain';
const chain = new Chain(BASE);
const DIR = process.env.HOME + '/tut-i/build-tower-defense';
const golden = JSON.parse(fs.readFileSync(`${DIR}/golden.json`, 'utf8'));
const bytes = fs.readFileSync(`${DIR}/dg_td_wasm.wasm`);
console.log('wasm sha256', createHash('sha256').update(bytes).digest('hex'));

const hex = (h) => Uint8Array.from(h.match(/../g), (b) => parseInt(b, 16));

// The same wasm the browser runs, driven from Node. Views are rebuilt on every call because the
// wasm memory may grow.
async function load() {
  const { instance } = await WebAssembly.instantiate(bytes, {});
  const x = instance.exports;
  const mem = () => x.memory.buffer;
  return {
    x,
    setSeed: (h) => new Uint8Array(mem(), x.td_seed_ptr(), 32).set(hex(h)),
    /** Replay `placements` against the seed in one go, exactly as the node does. */
    simulate(seedHex, placements) {
      this.setSeed(seedHex);
      const input = new Uint32Array(mem(), x.td_input_ptr(), placements.length * 4);
      placements.forEach((p, i) => input.set([p.tick, p.x, p.y, p.kind], i * 4));
      const code = x.td_simulate(placements.length);
      if (code) return { error: code, index: code >> 8, reason: code & 0xff };
      const [score, waves_cleared, lives, ticks] = new Uint32Array(mem(), x.td_result_ptr(), 4);
      return { score, waves_cleared, lives, ticks };
    },
    /** The live view: the 9-word header of td_frame (tick, gold, lives, waves, score, done, ...). */
    frame() {
      const n = x.td_frame();
      const f = new Int32Array(mem(), x.td_frame_ptr(), n);
      return { tick: f[0], gold: f[1], lives: f[2], waves: f[3], score: f[4], done: f[5] === 1 };
    },
  };
}

// Play a run live, the way app.js does: step to the tick we want, ask td_place, and record the
// placement only when the sim accepted it (return code 0). A rejected wish is skipped, not sent.
function play(w, seedHex, wish) {
  w.setSeed(seedHex);
  w.x.td_init();
  const placements = [];
  for (const p of wish) {
    let f = w.frame();
    if (f.tick < p.tick) w.x.td_step(p.tick - f.tick);
    f = w.frame();
    if (f.done) break;
    const code = w.x.td_place(p.x, p.y, p.kind);
    if (code === 0) placements.push({ tick: f.tick, x: p.x, y: p.y, kind: p.kind });
    console.log(`  tick ${f.tick} gold ${f.gold} lives ${f.lives}: place kind ${p.kind} at (${p.x},${p.y}) -> ${code === 0 ? 'ok' : `rejected, code ${code}`}`);
  }
  while (!w.frame().done) w.x.td_step(500);
  const f = w.frame();
  console.log(`  run over at tick ${f.tick}: score ${f.score} lives ${f.lives} waves launched ${f.waves}`);
  return placements;
}

const w = await load();

// 0. Dry run against the golden vector: the live stepping must land on the committed outcome.
console.log('golden dry run');
const dry = play(w, golden.seed, golden.placements);
const dryOut = w.simulate(golden.seed, dry);
console.log('  simulate', JSON.stringify(dryOut), 'matches golden.json:', JSON.stringify(dryOut) === JSON.stringify(golden.outcome));
if (process.env.DRY) process.exit(0);

// 1. One fresh testnet key. The WIF goes to a 0600 file, never to the terminal.
const WIF = `${DIR}/player.wif`;
let player;
if (fs.existsSync(WIF)) {
  player = Key.fromWif(fs.readFileSync(WIF, 'utf8').trim());
} else {
  player = Key.generate('testnet');
  fs.writeFileSync(WIF, player.toWif() + '\n', { mode: 0o600 });
}
console.log('player', player.address);

// Sign and send one action, wait for its receipt, print what matters.
async function send(k, action, label) {
  const txid = await chain.send(k, action);
  const r = await chain.waitForReceipt(txid, { timeoutMs: 90000 });
  console.log(`${label}: txid ${txid} height ${r.height} ok ${r.ok}${r.error ? ` error "${r.error}"` : ''}`);
  for (const ev of r.events) console.log(`   event ${ev.kind} ${JSON.stringify(ev.data)}`);
  return { txid, receipt: r };
}
const td = (op) => ({ type: 'game', game: 'towerdefense', op });
const tokenNames = async () => (await chain.tokens()).map((t) => t.symbol).sort();

// 2. start {}: the node picks the seed. If an earlier attempt left a run open, reuse it.
console.log('tokens before start:', (await tokenNames()).join(' '));
const mine = await chain.game('towerdefense', { player: player.address });
let run;
if (mine.open) {
  run = await chain.game('towerdefense', { run: mine.open });
  console.log('reusing open run', run.id);
} else {
  const { txid, receipt } = await send(player, td({ kind: 'start' }), 'start');
  const ev = receipt.events.find((e) => e.kind === 'towerdefense.started');
  run = { id: ev.data.run, seed: ev.data.seed, start_height: receipt.height, txid };
  console.log(`run id ${run.id} == first 16 hex of the txid: ${txid.slice(0, 16) === run.id}`);

  // The seed is public: SHA256(divi checkpoint hash || txid || "towerdefense"), all as ASCII text.
  const block = await (await fetch(`${BASE}/v1/blocks/${receipt.height}`)).json();
  const expect = createHash('sha256').update(block.header.divi.hash + txid + 'towerdefense').digest('hex');
  console.log(`seed recomputed from block ${receipt.height} (divi ${block.header.divi.height}): ${expect === run.seed}`);
}
console.log('tokens after start: ', (await tokenNames()).join(' '));

// 3. Play the run in Node against the real seed with the golden wish list, then confirm with simulate.
console.log(`playing run ${run.id} seed ${run.seed}`);
const placements = play(w, run.seed, golden.placements);
const predicted = w.simulate(run.seed, placements);
console.log('placements', JSON.stringify(placements));
console.log('predicted outcome', JSON.stringify(predicted));

// 4. submit {run, placements}: the node replays the same placements with the same crate.
const { receipt: scored } = await send(player, td({ kind: 'submit', run: run.id, placements }), 'submit');
const sc = scored.events.find((e) => e.kind === 'towerdefense.scored')?.data;
if (sc) console.log('node agrees with the local sim:', sc.score === predicted.score && sc.lives === predicted.lives && sc.ticks === predicted.ticks && sc.waves_cleared === predicted.waves_cleared);

// 5. What the chain says afterwards
console.log('run:', JSON.stringify(await chain.game('towerdefense', { run: run.id })));
console.log('leaderboard:', JSON.stringify(await chain.game('towerdefense', { view: 'leaderboard', week: String(sc?.week ?? 0) })));
console.log('player:', JSON.stringify(await chain.game('towerdefense', { player: player.address })));

The run. The wish list is the seven golden placements; against a real seed they all fitted the budget, and the run ended at tick 9332 with 8 waves cleared. The node's towerdefense.scored event carries the same four numbers the local td_simulate predicted:

node play.mjs
wasm sha256 e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686
golden dry run
  tick 0 gold 150 lives 20: place kind 0 at (4,2) -> ok
  tick 0 gold 100 lives 20: place kind 0 at (6,2) -> ok
  tick 900 gold 128 lives 20: place kind 1 at (8,2) -> ok
  tick 3000 gold 230 lives 20: place kind 0 at (5,5) -> ok
  tick 4500 gold 393 lives 20: place kind 3 at (7,6) -> ok
  tick 6500 gold 706 lives 20: place kind 1 at (9,6) -> ok
  tick 9000 gold 1043 lives 12: place kind 3 at (12,6) -> ok
  run over at tick 9839: score 3525 lives 0 waves launched 17
  simulate {"score":3525,"waves_cleared":9,"lives":0,"ticks":9839} matches golden.json: true
player y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr
tokens before start: GOLD TDGOLD TDIVI
start: txid ad4e976c40e8f71c63d9369f3a70fa45da6a91b85bc118e670e2500a4b8284b2 height 1744 ok true
   event towerdefense.started {"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","run":"ad4e976c40e8f71c","seed":"59bf610027502d6d439082377ccc1c5b958d2bc72397b652be551a6c5ab727fa"}
run id ad4e976c40e8f71c == first 16 hex of the txid: true
seed recomputed from block 1744 (divi 360244): true
tokens after start:  GOLD TDGOLD TDIVI
playing run ad4e976c40e8f71c seed 59bf610027502d6d439082377ccc1c5b958d2bc72397b652be551a6c5ab727fa
  tick 0 gold 150 lives 20: place kind 0 at (4,2) -> ok
  tick 0 gold 100 lives 20: place kind 0 at (6,2) -> ok
  tick 900 gold 132 lives 20: place kind 1 at (8,2) -> ok
  tick 3000 gold 206 lives 20: place kind 0 at (5,5) -> ok
  tick 4500 gold 386 lives 20: place kind 3 at (7,6) -> ok
  tick 6500 gold 535 lives 15: place kind 1 at (9,6) -> ok
  tick 9000 gold 840 lives 5: place kind 3 at (12,6) -> ok
  run over at tick 9332: score 2920 lives 0 waves launched 16
placements [{"tick":0,"x":4,"y":2,"kind":0},{"tick":0,"x":6,"y":2,"kind":0},{"tick":900,"x":8,"y":2,"kind":1},{"tick":3000,"x":5,"y":5,"kind":0},{"tick":4500,"x":7,"y":6,"kind":3},{"tick":6500,"x":9,"y":6,"kind":1},{"tick":9000,"x":12,"y":6,"kind":3}]
predicted outcome {"score":2920,"waves_cleared":8,"lives":0,"ticks":9332}
submit: txid 2bf12955af42ee1346446a642dbfde34e81c78aa86bbad7c7359fe0146a4511f height 1745 ok true
   event towerdefense.scored {"leaderboard":true,"lives":0,"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","run":"ad4e976c40e8f71c","score":2920,"ticks":9332,"waves_cleared":8,"week":0}
node agrees with the local sim: true
run: {"id":"ad4e976c40e8f71c","lives":0,"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","score":2920,"seed":"59bf610027502d6d439082377ccc1c5b958d2bc72397b652be551a6c5ab727fa","start_height":1744,"status":"scored","submitted_height":1745,"ticks":9332,"waves_cleared":8,"week":0}
leaderboard: {"entries":[{"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","rank":1,"run":"ad4e976c40e8f71c","score":2920}],"paid":null,"week":0}
player: {"open":null,"runs":[{"id":"ad4e976c40e8f71c","lives":0,"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","score":2920,"seed":"59bf610027502d6d439082377ccc1c5b958d2bc72397b652be551a6c5ab727fa","start_height":1744,"status":"scored","submitted_height":1745,"ticks":9332,"waves_cleared":8,"week":0},{"id":"4ca5fb9f763cf84e","lives":0,"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","score":2915,"seed":"4f26fc12761b6157f7af765f137029d0ab89cae366e9f5701714ed5b17212406","start_height":1742,"status":"scored","submitted_height":1743,"ticks":9047,"waves_cleared":8,"week":0}]}

Read it against the code:

The first run created TDGOLD

This address's first start, earlier the same evening, was the first one ever on this chain, so its receipt also carried the token creation. From that run's output (txid 4ca5fb9f763cf84ebafcc1880e6814cbf4ead2286b6e223aa6440d17397baeab, height 1742, run 4ca5fb9f763cf84e):

start: txid 4ca5fb9f763cf84ebafcc1880e6814cbf4ead2286b6e223aa6440d17397baeab height 1742 ok true
   event token_created {"decimals":2,"issuer":"module:towerdefense","max_supply":"260000000","name":"Tower Defense Gold","symbol":"TDGOLD"}
   event towerdefense.started {"player":"y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr","run":"4ca5fb9f763cf84e","seed":"4f26fc12761b6157f7af765f137029d0ab89cae366e9f5701714ed5b17212406"}

The token list went from GOLD and TDIVI to GOLD, TDGOLD and TDIVI; see /v1/tokens. That run was played with the same wish list against seed 4f26fc12… and scored 2915 (8 waves, tick 9047). The script printed the token names badly in that first version (an array indexed as an object); the line was fixed and the script run again, which is the run shown above.

Where to look it up:

Keys

The script writes the player's WIF to player.wif with mode 0600 and reads it back on the next run; it never prints it. The browser page keeps its key in localStorage under dg.playkey.wif. These are testnet keys with nothing of value on them, but the habit matters: a key never goes on a command line, into a log, or into a git tree.

8Make your own replay-checked game

The pattern is reusable for any single-player game whose result follows from a seed and a list of inputs: a puzzle, a racing line, a card solitaire. What to change, in the order the repository is laid out:

Next: Build blackjack is the other kind of fairness, hidden information with a committed hash chain. Deploy your game runs the node and the site as services behind nginx.

If something goes wrong

You seeWhat it means
error: "conflict: run … is still open; submit it or wait for it to expire"This address already has an open run less than 3,600 blocks old. Submit it (the browser offers to replay it from the top; the script reuses open from ?player=), or wait.
error: "invalid: run … expired 3600 blocks after it started"Too late to submit. Start a new run; the expired one no longer blocks you.
error: "not allowed: only the player who started a run can submit it"The submit was signed by a different key than the start. The browser keeps its key in localStorage; a cleared browser is a new player.
error: "conflict: run … is already scored"A run is scored once. Start another.
error: "invalid: placement 3: not enough gold" (or cell is on the creep path, cell already has a tower, placements are out of tick order, …)The node's replay rejected placement 3. The browser only records placements td_place accepted, so this means the list was edited, or the wasm and the node disagree, which the golden test exists to catch. The transaction failed; the run is still open and can be resubmitted.
error: "invalid: missing array field `placements`" or placement 0: `tick` must be a non-negative integerThe JSON shape is wrong. Each placement is an object with four non-negative integer fields; floats, strings and negatives are refused.
{"error":"not found: run nope"} from ?run=No such run id. Ids are 16 lowercase hex characters, the start of the start txid.
td_simulate returns a non-zero numberDecode it: index n >> 8, code n & 0xff; codes are 1 to 8 in PlaceError::code. (0 << 8) | 4 is "placement 0 is off the grid".
simulate matches the golden outcome failsYour wasm and golden.json disagree. Either the rules changed and the vector was not updated (the Rust test golden_vector fails too), or the wasm on disk was built from other code. Rebuild and compare hashes as in section 4.
error[E0463]: can't find crate for `core` or target may not be installedThe wasm target is missing: rustup target add wasm32-unknown-unknown.
Your rebuilt wasm has a different hashCompare cargo --version and rustc --version with the ones above first. The chain does not use the browser's wasm, so a different hash only matters for reproducing the checked-in file.
tx … not in a block after 30swaitForReceipt gave up. Pass { timeoutMs: 90000 } as the script does and check the chain is producing blocks at /v1/info.

Still stuck? Ask the Divi chatbot, or see all tutorials.