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.
start(a transaction with no fields): the node opens a run and fixes its seed. The run id is the first 16 hex characters of the start txid.- Play: the browser loads
dg_td_wasm.wasm, the simulation compiled to WebAssembly, writes the seed into it, and steps it on every animation frame (TICKS_PER_FRAME = 3, times a speed of 1, 4 or 16). Every tower the player buys is recorded as{tick, x, y, kind}. submit {run, placements}: the node links the same crate natively, replays the placements against the stored seed, and writes the score, waves cleared, lives and ticks into the run. One best run per address per week goes on a leaderboard; the top ten of each week are paid in the module's own token, TDGOLD.
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.
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())
}
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();
}
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
| File | SHA-256 |
|---|---|
| Rebuilt on 2026-10-21 (cargo 1.99.0, rustc 1.99.0) | e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686 |
Checked in: web/play/towerdefense/dg_td_wasm.wasm | e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686 |
| Served: games.divi.domains/play/towerdefense/dg_td_wasm.wasm | e790d58c16a44c9ddf8c82b7ad12c3552ff6c5d0f1fe186074309fa8baec7686 |
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 };
},
};
}
- the wasm needs no imports:
WebAssembly.Module.imports(mod)is empty. The page can instantiate it with{}, and so can any other host. - simulate matches the golden outcome: score 3525, 9 waves cleared, 0 lives, 9839 ticks.
- simulate is deterministic across calls: the same seed and list twice in one instance give the same object.
- a different seed gives a different run:
ff× 32 does not reach the golden outcome. - invalid placements are rejected with their index and code: cell (99, 0) is
(0 << 8) | 4, off the grid at index 0; cell (0, 1) is(1 << 8) | 5, on the path at index 1. - stepping live with the same placements reaches the same outcome:
td_init,td_placeandtd_stepin 25-tick chunks end on the same score, lives and tick astd_simulate. This is the test that says the browser's live run and the node's replay are the same thing.
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;
kind | Fields | Who |
|---|---|---|
start | none | anyone without an open run |
submit | run, 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:
| Key | Holds |
|---|---|
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 |
week | the 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", ¤t);
Ok(())
}
None => {
ctx.put_json("week", ¤t);
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:
| Rank | Basis points | Base units | TDGOLD |
|---|---|---|---|
| 1 | 3000 | 150,000 | 1,500.00 |
| 2 | 2000 | 100,000 | 1,000.00 |
| 3 | 1400 | 70,000 | 700.00 |
| 4 | 1000 | 50,000 | 500.00 |
| 5 | 800 | 40,000 | 400.00 |
| 6 | 600 | 30,000 | 300.00 |
| 7 | 500 | 25,000 | 250.00 |
| 8 | 400 | 20,000 | 200.00 |
| 9 | 200 | 10,000 | 100.00 |
| 10 | 100 | 5,000 | 50.00 |
| Total | 500,000 | 5,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}.
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:
- seed recomputed … true:
SHA256("fd0278f332005dc2a622450ca4ad369237e8efd226d48dfded35097a884ba726" + "ad4e976c40e8f71c63d9369f3a70fa45da6a91b85bc118e670e2500a4b8284b2" + "towerdefense")is59bf610027502d6d439082377ccc1c5b958d2bc72397b652be551a6c5ab727fa, the seed in the event. Block 1744 of the gamechain carries Divi testnet checkpoint 360244 with that hash; you can check it at /v1/blocks/1744. - run id
ad4e976c40e8f71cis the first 16 characters of the start txid, asstartdoes withctx.txid().get(..16). - gold differs from the dry run at every placement after tick 0 (132 at tick 900 here, 138 in the earlier run, 128 in the dry run): different seed, different creep kinds, different bounties. The placements are the same; the outcomes are not.
- predicted outcome
{score 2920, waves_cleared 8, lives 0, ticks 9332}is what the node wrote intorun/ad4e976c40e8f71c: "node agrees with the local sim: true". That is the whole mechanism in one line. - leaderboard shows one entry for the address, the better of its two runs. The earlier run (next callout) scored 2915; this one scored 2920, so
submitdeleted the oldlb/key and wrote the new one, and the event saysleaderboard: true.paidisnull: week 0 ends at height 302,400 and the chain was at height 1745. - player lists both runs, newest first, with
open: null.
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:
- The run: /v1/games/towerdefense?run=ad4e976c40e8f71c
- The leaderboard: /v1/games/towerdefense?view=leaderboard&week=0
- The player: /v1/games/towerdefense?player=y4tDLEENv8BLJW4vXRHLrFfBvdGJsUC5tr
- The start transaction and its receipt: /v1/tx/ad4e976c40e8f71c…; the submit: /v1/tx/2bf12955af42ee13…
- The same game in a browser: games.divi.domains/play/towerdefense/
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:
- A simulation crate like
dg-td:#![no_std]+alloc, integer maths, one seeded generator read in one place and never from player input, a fixed per-tick order, a hard cap on ticks and on inputs. Give itsimulate(seed, inputs) -> Result<Outcome, Error>and a numeric code for every rejection. - A golden vector: one seed, one input list, one outcome, in a JSON file read by a Rust test in the crate and by a Node test against the wasm.
- A wasm crate like
dg-td-wasm:crate-type = ["cdylib"], a thread-local state with fixed buffers,extern "C"exports for seed, input, result, init, step, place and simulate. No imports, so any host can run it. Build withcargo build -p <crate> --target wasm32-unknown-unknown --releaseand check in the file the site serves. - A module like
towerdefense.rs:startthat fixes a seed withctx.public_random(ID.as_bytes())and refuses a second open run;submitthat parses inputs strictly, callssimulateand stores the outcome; an expiry in blocks; one best entry per address per period; prizes in a token the module issues itself, since a module can only mint its own. Register it inall()as Build tic-tac-toe shows. - A page that loads the wasm with
WebAssembly.instantiate(bytes, {}), rebuilds its views on every call, records an input only when the sim accepted it, and sends inputs, never a score. The SDK calls are in The JavaScript SDK. - A Node script like
play.mjsthat plays one run end to end on the testnet before the page goes up, and prints the node's outcome next to the local one.
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 see | What 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 integer | The 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 number | Decode 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 fails | Your 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 installed | The wasm target is missing: rustup target add wasm32-unknown-unknown. |
| Your rebuilt wasm has a different hash | Compare 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 30s | waitForReceipt 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.