Tutorial · Divi Games

Build Tic-Tac-Toe

The reference game of the Divi gamechain, read line by line: the GameModule trait, state keys, stakes in escrow, the 150-block timeout, its tests, and a full game played on the live testnet.

About 20 minutes · Checked 2026-10-21

You should be able to read Rust and have seen the gamechain before (what a Divi sidechain is, how a move is signed). Everything below comes from the divi_games repository: the game is crates/dg-games/src/tictactoe.rs (689 lines, tests included) and the trait it implements is crates/dg-core/src/module.rs. The code blocks are copied from those files, and every command and transaction on this page was run on 2026-10-21 against games.divi.domains, chain divi-games-testnet-1.

1A game is one trait, and one rule

A game on the gamechain is one Rust type that implements GameModule. The node owns the chain, the accounts, the signatures and the blocks. Your type only answers two questions: what does this op do to my state, and what does this query return.

crates/dg-core/src/module.rs

/// A game (or any other on-chain module) the node can run.
pub trait GameModule: Send + Sync {
    /// Stable id used in `action.game` and state keys, e.g. "tictactoe".
    fn id(&self) -> &'static str;
    /// Apply one op. Must be deterministic. Return Err to fail the tx (state is rolled back).
    fn apply(&self, ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError>;
    /// Read-only query for the HTTP API.
    fn view(&self, state: &dyn StateRead, query: &Query) -> Result<Value, ActionError>;
    /// Runs once per block after every tx, e.g. to expire stale games. Writes are kept on Ok.
    fn end_block(&self, _ctx: &mut dyn Ctx) -> Result<(), ActionError> {
        Ok(())
    }
}

What apply may touch is the Ctx it is handed. It is scoped to your module and to the current transaction:

crates/dg-core/src/module.rs

pub trait Ctx {
    /// This module's id.
    fn module(&self) -> &str;
    fn height(&self) -> u64;
    fn block_time_ms(&self) -> u64;
    fn txid(&self) -> &str;
    fn divi_checkpoint(&self) -> &DiviCheckpoint;
    /// The sequencer's address.
    fn sequencer(&self) -> &str;

    /// Module-scoped state; `key` is stored as `m/<module>/<key>`.
    fn get(&self, key: &str) -> Option<Vec<u8>>;
    fn put(&mut self, key: &str, value: Vec<u8>);
    fn delete(&mut self, key: &str);
    /// Module-scoped scan; returned keys have the module prefix removed.
    fn scan(&self, prefix: &str, start_after: Option<&str>, limit: usize) -> Vec<(String, Vec<u8>)>;
    /// Read any key, including other modules' state. Read-only.
    fn read_raw(&self, key: &str) -> Option<Vec<u8>>;

    fn balance(&self, account: &str, token: &str) -> u128;
    /// Move `amount` of `token` from `from` (normally the tx sender) into `module:<id>`.
    fn escrow_in(&mut self, from: &str, token: &str, amount: u128) -> Result<(), ActionError>;
    /// Move `amount` of `token` from `module:<id>` to `to`.
    fn escrow_out(&mut self, to: &str, token: &str, amount: u128) -> Result<(), ActionError>;
    /// Create a token whose issuer is `module:<id>`.
    fn create_token(&mut self, symbol: &str, name: &str, decimals: u8, max_supply: Option<u128>) -> Result<(), ActionError>;
    /// Mint a token this module issued.
    fn mint(&mut self, to: &str, token: &str, amount: u128) -> Result<(), ActionError>;
    /// Burn a token this module issued from `from`'s balance.
    fn burn(&mut self, from: &str, token: &str, amount: u128) -> Result<(), ActionError>;

    fn emit(&mut self, kind: &str, data: Value);
}
The one rule: apply must be deterministic

Every node replays every block and must reach the same state root. So no clock, no random numbers, no floating point, no HashMap iteration order. What you may use is what Ctx gives you: ctx.height(), ctx.block_time_ms(), ctx.txid(), and ctx.public_random(domain) from CtxExt, which is SHA256(divi_checkpoint.hash || txid || domain). Tic-tac-toe needs only height() and txid().

Three small helpers from the same file read fields out of an op. The op is plain serde_json::Value; amounts are decimal strings of base units (GOLD has 2 decimals, so "100" is 1.00 GOLD):

crates/dg-core/src/module.rs

pub fn op_str<'a>(op: &'a Value, field: &str) -> Result<&'a str, ActionError> {
    op.get(field).and_then(Value::as_str).ok_or_else(|| ActionError::invalid(format!("missing string field `{field}`")))
}

/// Read a required base-unit amount field (decimal string) from an op.
pub fn op_amount(op: &Value, field: &str) -> Result<u128, ActionError> {
    op_str(op, field)?
        .parse::<crate::Amount>()
        .map(|a| a.0)
        .map_err(|_| ActionError::invalid(format!("`{field}` must be a decimal string of base units")))
}

/// Read an optional base-unit amount field, defaulting to zero.
pub fn op_amount_or_zero(op: &Value, field: &str) -> Result<u128, ActionError> {
    if op.get(field).is_none() {
        Ok(0)
    } else {
        op_amount(op, field)
    }
}

2State keys and the ops table

State is one sorted key-value map for the whole chain. Your keys are prefixed for you: ctx.put("game/80566b04265ced78", …) is stored as m/tictactoe/game/80566b04265ced78 (module_key in module.rs). You cannot write another module's keys. Tic-tac-toe keeps three kinds of key:

KeyValueWhen
game/<id>The Game struct as JSONFrom create on, forever
open/<id>1While the table is open (also when it is reserved for an invited opponent); deleted by join and cancel
player/<address>/<id>1For every game the address created or joined; never deleted

Money never sits in a game's state. It moves between player accounts and the module's escrow account module:tictactoe, which is a name, not a key: nobody can sign as it, only the module's code moves its funds. ctx.escrow_in(player, GOLD, amount) takes a stake in; ctx.escrow_out(winner, GOLD, amount) pays it out. You can watch the escrow balance like any account: /v1/account/module:tictactoe.

The constants at the top of the file set the limits:

crates/dg-games/src/tictactoe.rs

pub const ID: &str = "tictactoe";
/// Blocks a player may take to move before the other player can claim the game.
pub const TIMEOUT_BLOCKS: u64 = 150;
/// Largest stake, in GOLD base units (1000 GOLD).
pub const MAX_STAKE: u128 = 100_000;
const LIST_LIMIT: usize = 50;
const EMPTY_BOARD: &str = ".........";
const LINES: [[usize; 3]; 8] = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];

A player submits an op inside a signed transaction whose action is {"type":"game","game":"tictactoe","op":{"kind":"…", …}}. These are the five kinds:

kindFieldsWho may send it
createstake? (default "0", at most "100000"), opponent? (an address)anyone
joinidanyone but x; only opponent when one was named
moveid, cell (a JSON number 0..=8, row-major)the player whose turn it is
cancelidx, while the game is still open
claim_timeoutidthe player waiting, once 150 blocks have passed since the last move

The board is a 9-character string, . for empty, index 0 top-left and 8 bottom-right. The creator is always x and moves first:

012345678

apply itself is just a dispatch on kind, and view a dispatch on the query:

crates/dg-games/src/tictactoe.rs

impl GameModule for TicTacToe {
    fn id(&self) -> &'static str {
        ID
    }

    fn apply(&self, ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
        match op_str(op, "kind")? {
            "create" => create(ctx, sender, op),
            "join" => join(ctx, sender, op),
            "move" => play(ctx, sender, op),
            "cancel" => cancel(ctx, sender, op),
            "claim_timeout" => claim_timeout(ctx, sender, op),
            other => Err(ActionError::invalid(format!("unknown tictactoe op `{other}`"))),
        }
    }

    fn view(&self, state: &dyn StateRead, query: &Query) -> Result<Value, ActionError> {
        if let Some(id) = query.get("id") {
            let game: Game = state
                .module_json::<Game>(ID, &game_key(id))?
                .ok_or_else(|| ActionError::not_found(format!("game {id}")))?;
            return serde_json::to_value(&game).map_err(|e| ActionError::Internal(e.to_string()));
        }
        if query.contains_key("open") {
            return list_games(state, "open/", |g| g.opponent.is_none());
        }
        if let Some(player) = query.get("player") {
            return list_games(state, &format!("player/{player}/"), |_| true);
        }
        Ok(json!({ "game": ID, "timeout_blocks": TIMEOUT_BLOCKS, "max_stake": Amount(MAX_STAKE) }))
    }
}

Two private helpers do the loading and saving. load turns a missing id into a not_found error, so none of the ops below has to:

fn load(ctx: &dyn Ctx, op: &Value) -> Result<Game, ActionError> {
    let id = op_str(op, "id")?;
    ctx.get_json::<Game>(&game_key(id))?.ok_or_else(|| ActionError::not_found(format!("game {id}")))
}

fn save(ctx: &mut dyn Ctx, game: &Game) {
    ctx.put_json(&game_key(&game.id), game);
}

3The code, op by op

create

crates/dg-games/src/tictactoe.rs

fn create(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
    let stake = op_amount_or_zero(op, "stake")?;
    if stake > MAX_STAKE {
        return Err(ActionError::invalid(format!("stake is at most {MAX_STAKE} base units")));
    }
    let opponent = match op.get("opponent") {
        None | Some(Value::Null) => None,
        Some(_) => Some(op_str(op, "opponent")?.to_string()),
    };
    if let Some(opponent) = &opponent {
        if opponent.is_empty() || opponent == sender {
            return Err(ActionError::invalid("opponent must be another address"));
        }
    }
    let id = ctx.txid().get(..16).ok_or_else(|| ActionError::Internal("txid is too short".into()))?.to_string();
    if ctx.get(&game_key(&id)).is_some() {
        return Err(ActionError::Conflict(format!("game {id} exists")));
    }

    if stake > 0 {
        ctx.escrow_in(sender, GOLD, stake)?;
    }
    let height = ctx.height();
    let game = Game {
        id: id.clone(),
        x: sender.to_string(),
        o: None,
        opponent,
        stake: Amount(stake),
        board: EMPTY_BOARD.to_string(),
        turn: Mark::X,
        status: Status::Open,
        winner: None,
        result: None,
        reason: None,
        created_height: height,
        last_move_height: height,
        moves: 0,
    };
    save(ctx, &game);
    ctx.put(&open_key(&id), b"1".to_vec());
    ctx.put(&player_key(sender, &id), b"1".to_vec());
    ctx.emit("tictactoe.created", json!({ "id": id, "x": game.x, "stake": game.stake, "opponent": game.opponent }));
    Ok(())
}

join

fn join(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
    let mut game = load(ctx, op)?;
    if game.status != Status::Open {
        return Err(ActionError::invalid("game is not open"));
    }
    if sender == game.x {
        return Err(ActionError::invalid("cannot join your own game"));
    }
    if game.opponent.as_deref().is_some_and(|invited| invited != sender) {
        return Err(ActionError::unauthorized("game is reserved for another player"));
    }

    if game.stake.0 > 0 {
        ctx.escrow_in(sender, GOLD, game.stake.0)?;
    }
    game.o = Some(sender.to_string());
    game.status = Status::Playing;
    game.last_move_height = ctx.height();
    save(ctx, &game);
    ctx.delete(&open_key(&game.id));
    ctx.put(&player_key(sender, &game.id), b"1".to_vec());
    ctx.emit("tictactoe.joined", json!({ "id": game.id, "o": sender }));
    Ok(())
}

The checks come first and in a fixed order: not open is invalid, joining your own table is invalid, taking a seat reserved for someone else is unauthorized. Then the joiner escrows exactly the same stake, the status flips to playing, and last_move_height is reset so the timeout clock starts at the join, not at the create. Deleting open/<id> is what makes the table disappear from ?open=1.

move

The op is called move on the wire; the function is play because move is a Rust keyword.

fn play(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
    let mut game = load(ctx, op)?;
    if game.status != Status::Playing {
        return Err(ActionError::invalid("game is not in play"));
    }
    let mark = game.mark_of(sender).ok_or_else(|| ActionError::unauthorized("not a player in this game"))?;
    if mark != game.turn {
        return Err(ActionError::unauthorized("not your turn"));
    }
    let cell = op
        .get("cell")
        .and_then(Value::as_u64)
        .filter(|&c| c <= 8)
        .ok_or_else(|| ActionError::invalid("`cell` must be a number from 0 to 8"))? as usize;
    if game.cell(cell) != '.' {
        return Err(ActionError::invalid("cell is taken"));
    }

    game.place(cell, mark);
    game.moves += 1;
    game.last_move_height = ctx.height();
    game.turn = mark.other();
    ctx.emit("tictactoe.moved", json!({ "id": game.id, "by": sender, "cell": cell, "board": game.board, "turn": game.turn }));

    if game.has_line(mark) {
        finish(ctx, game, Some(mark), "line")
    } else if game.is_full() {
        finish(ctx, game, None, "draw")
    } else {
        save(ctx, &game);
        Ok(())
    }
}

Four checks, each with the error a player should see: wrong status is invalid, not a player or not your turn is unauthorized, a cell that is not a number 0..8 is invalid, and an occupied cell is invalid: cell is taken. Note the cell must be a JSON number; the string "1" is refused (one of the tests checks that). After the mark is placed, the eight LINES decide: a line finishes the game with a winner, a full board finishes it as a draw, otherwise the game is saved and the turn passes.

cancel and claim_timeout

fn cancel(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
    let mut game = load(ctx, op)?;
    if sender != game.x {
        return Err(ActionError::unauthorized("only the creator can cancel"));
    }
    if game.status != Status::Open {
        return Err(ActionError::invalid("only an open game can be cancelled"));
    }

    if game.stake.0 > 0 {
        ctx.escrow_out(&game.x, GOLD, game.stake.0)?;
    }
    game.status = Status::Cancelled;
    game.result = Some("cancelled".to_string());
    game.reason = Some("cancelled".to_string());
    save(ctx, &game);
    ctx.delete(&open_key(&game.id));
    ctx.emit(
        "tictactoe.finished",
        json!({ "id": game.id, "result": game.result, "winner": game.winner, "reason": game.reason, "payout": game.stake }),
    );
    Ok(())
}

Only the creator can cancel, and only while nobody has joined. The stake goes straight back with escrow_out, and the game emits the same tictactoe.finished event as a real ending, with result: "cancelled", so a UI has one event to listen for.

fn claim_timeout(ctx: &mut dyn Ctx, sender: &str, op: &Value) -> Result<(), ActionError> {
    let game = load(ctx, op)?;
    if game.status != Status::Playing {
        return Err(ActionError::invalid("game is not in play"));
    }
    let mark = game.mark_of(sender).ok_or_else(|| ActionError::unauthorized("not a player in this game"))?;
    if mark == game.turn {
        return Err(ActionError::invalid("it is your turn"));
    }
    let due = game.last_move_height.saturating_add(TIMEOUT_BLOCKS);
    if ctx.height() < due {
        return Err(ActionError::invalid(format!("timeout can be claimed from height {due}")));
    }
    finish(ctx, game, Some(mark), "timeout")
}

This is the whole timeout. There is no timer anywhere: a player who is waiting for the other side may claim the win once the chain is at least last_move_height + TIMEOUT_BLOCKS blocks along. The module never looks at a clock, only at ctx.height(), so a replay on another node reaches the same answer. Claiming too early is invalid and tells you the height to come back at; in the live game below that error read timeout can be claimed from height 1564, which is the move at height 1414 plus 150.

finish

/// Pay out, mark the game finished and emit `tictactoe.finished`. `winner` is `None` for a draw.
fn finish(ctx: &mut dyn Ctx, mut game: Game, winner: Option<Mark>, reason: &str) -> Result<(), ActionError> {
    let stake = game.stake.0;
    let payout = match winner {
        Some(mark) => {
            let address = game.address(mark).to_string();
            let pot = 2 * stake;
            if pot > 0 {
                ctx.escrow_out(&address, GOLD, pot)?;
            }
            game.winner = Some(address);
            game.result = Some(mark.won().to_string());
            pot
        }
        None => {
            if stake > 0 {
                ctx.escrow_out(game.address(Mark::X), GOLD, stake)?;
                ctx.escrow_out(game.address(Mark::O), GOLD, stake)?;
            }
            game.result = Some("draw".to_string());
            stake
        }
    };
    game.status = Status::Finished;
    game.reason = Some(reason.to_string());
    save(ctx, &game);
    ctx.emit(
        "tictactoe.finished",
        json!({ "id": game.id, "result": game.result, "winner": game.winner, "reason": game.reason, "payout": Amount(payout) }),
    );
    Ok(())
}

finish is the only place money leaves escrow after a game has started. A winner gets 2 * stake; a draw refunds stake to each side. payout in the event is what the winner received, or what each player got back. Because every ending (line, draw, timeout, cancel) goes through escrow_out, the escrow account is back to zero for that game whatever happens. The test suite asserts exactly that.

Errors and events

crates/dg-core/src/module.rs

pub enum ActionError {
    #[error("invalid: {0}")]
    Invalid(String),
    #[error("not allowed: {0}")]
    Unauthorized(String),
    #[error("not found: {0}")]
    NotFound(String),
    #[error("insufficient {token}: need {need}, have {have}")]
    Insufficient { token: String, need: u128, have: u128 },
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("internal: {0}")]
    Internal(String),
}

impl ActionError {
    /// Short machine-readable code for receipts and the API.
    pub fn code(&self) -> &'static str {
ConstructorReceipt text starts withTic-tac-toe uses it when
ActionError::invalid(msg)invalid: the op is malformed or illegal right now: bad stake, bad cell, cell taken, game not open, too early to claim
ActionError::unauthorized(msg)not allowed: this sender may not do this: not a player, not your turn, reserved table, not the creator
ActionError::not_found(msg)not found: the game id does not exist (from load and from view ?id=)
Insufficient {…}insufficient GOLD: need …, have …raised by escrow_in when the stake is more than the player has

A transaction whose apply fails is still in the block, with a receipt {"ok": false, "error": "invalid: cell is taken", "events": []}. Its writes are gone and its nonce is used up. The short codes from ActionError::code() (invalid, unauthorized, not_found, insufficient, conflict, internal) are what the trait's tests match on; a receipt carries the full text, so write messages a player can act on.

Events are how a browser follows a game without polling. The WebSocket at /v1/ws delivers every block with its receipts, and web/play/tictactoe/app.js redraws when it sees a tictactoe.* event whose data.id is the table on screen. Tic-tac-toe emits four kinds, documented at the top of the file because the page is written against them:

EventData
tictactoe.created{id, x, stake, opponent}
tictactoe.joined{id, o}
tictactoe.moved{id, by, cell, board, turn}
tictactoe.finished{id, result, winner, reason, payout}

The views are the last piece. ?id= returns one game or not found; ?open=1 and ?player= scan the open/ or player/<address>/ keys with a limit of 50 and sort by created_height, so the order never depends on storage; no parameters returns the module's limits (try it):

/// Games whose ids are the last path segment of the keys under `m/tictactoe/<prefix>`, kept by `keep`.
fn list_games(state: &dyn StateRead, prefix: &str, keep: impl Fn(&Game) -> bool) -> Result<Value, ActionError> {
    let prefix = module_key(ID, prefix);
    let mut games = Vec::new();
    for (key, _) in state.scan_raw(&prefix, None, LIST_LIMIT) {
        if let Some(game) = state.module_json::<Game>(ID, &game_key(&key[prefix.len()..]))? {
            if keep(&game) {
                games.push(game);
            }
        }
    }
    games.sort_by_key(|g| g.created_height);
    Ok(json!({ "games": games }))
}

4Tests with MockCtx

crates/dg-games/src/mock.rs is an in-memory Ctx: a BTreeMap for state, balances at bal/<account>/<TOKEN>, a settable height, and a submit that gives each op a fresh txid and rolls the state back when apply fails, like the real executor. A test needs no node. The helpers at the top of the test module keep each test to a few lines:

crates/dg-games/src/tictactoe.rs, mod tests

    fn setup() -> MockCtx {
        let mut ctx = MockCtx::new(ID);
        ctx.set_balance(ALICE, GOLD, 1_000);
        ctx.set_balance(BOB, GOLD, 1_000);
        ctx.height = 10;
        ctx
    }

    fn run(ctx: &mut MockCtx, sender: &str, op: Value) -> Result<(), ActionError> {
        ctx.submit(&TicTacToe, sender, op)
    }

    /// Create a game and return its id.
    fn create_game(ctx: &mut MockCtx, sender: &str, op: Value) -> String {
        run(ctx, sender, op).expect("create");
        ctx.txid[..16].to_string()
    }

    /// Alice creates with `stake`, Bob joins.
    fn started(ctx: &mut MockCtx, stake: &str) -> String {
        let id = create_game(ctx, ALICE, json!({ "kind": "create", "stake": stake }));
        run(ctx, BOB, json!({ "kind": "join", "id": id })).expect("join");
        id
    }

The first test plays the game we will play on the live chain below, and checks the balances, the board, the events and the payout along the way:

    #[test]
    fn x_wins_a_row_and_takes_the_pot() {
        let mut ctx = setup();
        let id = create_game(&mut ctx, ALICE, json!({ "kind": "create", "stake": "100" }));
        assert_eq!((gold(&ctx, ALICE), escrow(&ctx)), (900, 100));
        assert!(ctx.get(&open_key(&id)).is_some());

        run(&mut ctx, BOB, json!({ "kind": "join", "id": id })).unwrap();
        assert_eq!((gold(&ctx, BOB), escrow(&ctx)), (900, 200));
        assert!(ctx.get(&open_key(&id)).is_none());

        for (sender, cell) in [(ALICE, 0), (BOB, 3), (ALICE, 1), (BOB, 4), (ALICE, 2)] {
            mv(&mut ctx, sender, &id, cell).unwrap();
        }

        let g = game(&ctx, &id);
        assert_eq!(g.status, Status::Finished);
        assert_eq!(g.board, "xxxoo....");
        assert_eq!(g.winner.as_deref(), Some(ALICE));
        assert_eq!(g.result.as_deref(), Some("x_won"));
        assert_eq!(g.reason.as_deref(), Some("line"));
        assert_eq!(g.moves, 5);
        assert_eq!((gold(&ctx, ALICE), gold(&ctx, BOB), escrow(&ctx)), (1_100, 900, 0));

        let kinds: Vec<&str> = ctx.events.iter().map(|(k, _)| k.as_str()).collect();
        assert_eq!(
            kinds,
            ["tictactoe.created", "tictactoe.joined"]
                .into_iter()
                .chain(["tictactoe.moved"; 5])
                .chain(["tictactoe.finished"])
                .collect::<Vec<_>>()
        );
        let (_, created) = &ctx.events[0];
        assert_eq!(created["stake"], "100");
        assert_eq!(created["opponent"], Value::Null);
        let (_, finished) = ctx.events.last().unwrap();
        assert_eq!(finished["result"], "x_won");
        assert_eq!(finished["winner"], ALICE);
        assert_eq!(finished["payout"], "200");

        assert!(mv(&mut ctx, BOB, &id, 8).is_err());
    }

The timeout test moves the mock height instead of waiting. One block too early is invalid; the player whose turn it is cannot claim; a stranger is unauthorized; then the claim pays the pot:

    #[test]
    fn timeout_after_150_blocks() {
        let mut ctx = setup();
        let id = started(&mut ctx, "100");
        mv(&mut ctx, ALICE, &id, 0).unwrap();
        let claim = json!({ "kind": "claim_timeout", "id": id });

        ctx.height = 10 + TIMEOUT_BLOCKS - 1;
        assert!(matches!(run(&mut ctx, ALICE, claim.clone()), Err(ActionError::Invalid(_))));
        ctx.height = 10 + TIMEOUT_BLOCKS;
        assert!(matches!(run(&mut ctx, BOB, claim.clone()), Err(ActionError::Invalid(_))));
        assert!(matches!(run(&mut ctx, CAROL, claim.clone()), Err(ActionError::Unauthorized(_))));
        run(&mut ctx, ALICE, claim).unwrap();

        let g = game(&ctx, &id);
        assert_eq!(g.status, Status::Finished);
        assert_eq!(g.winner.as_deref(), Some(ALICE));
        assert_eq!(g.result.as_deref(), Some("x_won"));
        assert_eq!(g.reason.as_deref(), Some("timeout"));
        assert_eq!((gold(&ctx, ALICE), gold(&ctx, BOB), escrow(&ctx)), (1_100, 900, 0));
    }

The other nine tests cover a draw refunding both players, a free game moving no GOLD, turn order, occupied and bad cells, invited opponents, cancel, joining without enough GOLD, bad ops, and the three views. Rust is built on a Linux build host, not on a laptop; the repository's scripts/remote-test.sh rsyncs the tree to it and runs cargo there over ssh. The equivalent command, as we ran it on the build host on 2026-10-21:

cargo test --release -p dg-games tictactoe
running 11 tests
test tictactoe::tests::bad_ops_are_invalid ... ok
test tictactoe::tests::cancel_refunds_and_only_while_open ... ok
test tictactoe::tests::invited_opponent_is_enforced ... ok
test tictactoe::tests::free_game_moves_no_gold ... ok
test tictactoe::tests::occupied_or_bad_cell_is_invalid ... ok
test tictactoe::tests::draw_refunds_both_players ... ok
test tictactoe::tests::join_without_enough_gold_fails ... ok
test tictactoe::tests::timeout_after_150_blocks ... ok
test tictactoe::tests::only_the_player_to_move_may_move ... ok
test tictactoe::tests::view_by_id_open_and_player ... ok
test tictactoe::tests::x_wins_a_row_and_takes_the_pot ... ok

test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 38 filtered out; finished in 0.00s

The 38 filtered out are the blackjack and tower defense tests in the same crate.

5Register the game

A new game is one file in crates/dg-games/src/ and one line in dg_games::all(). The node registers dg_games::all(), dg_market::all() and dg_system::all() together in bins/divi-gamechain/src/config.rs:

crates/dg-games/src/lib.rs

//! The built-in game modules. Each game is one file implementing `dg_core::GameModule`.

pub mod blackjack;
pub mod tictactoe;
pub mod towerdefense;

#[cfg(test)]
pub(crate) mod mock;

pub use blackjack::Blackjack;
pub use tictactoe::TicTacToe;
pub use towerdefense::TowerDefense;

/// Every built-in module, for registration with the executor.
pub fn all() -> Vec<Box<dyn dg_core::GameModule>> {
    vec![Box::new(TicTacToe), Box::new(Blackjack), Box::new(TowerDefense)]
}
Reserved module ids

market, htlc, backend, certificate, bridge and system are the system modules (dg_chain::SYSTEM_MODULES). They are ordinary GameModules with their own action types, and a game action naming one of them is refused before any module runs: `market` is a system module; use action type `market`. Pick any other id for yours. The live chain lists its modules at /v1/info.

Once the node is rebuilt and restarted, GET /v1/games/<your id> calls your view and a {"type":"game","game":"<your id>"} action calls your apply. Run the gamechain shows how to run such a node yourself; Deploy your game puts it behind nginx.

6Play a game on the live chain

Now the same game for real, with the JavaScript SDK from Node. The script below makes two fresh testnet keys, gets each 1000 GOLD from the gamechain's free faucet, and plays the exact moves of the first test. It sends every transaction with chain.send, which fetches the account's next_nonce, signs the body as a Divi signed message and posts it to /v1/tx; then waitForReceipt polls /v1/tx/<txid> until the transaction is in a block. The SDK is one ES module with no build step; copy web/sdk/ and web/vendor/ from the repository next to the script (or load them from games.divi.domains/sdk/divigames.js).

play.mjs

// play.mjs: one full tic-tac-toe game on the live Divi Games testnet gamechain.
// Run with Node 20+:  node play.mjs
// Needs the SDK next to it: copy web/sdk/ and web/vendor/ from the divi_games repo.
import fs from 'node:fs';
import { Chain, Key } from './sdk/divigames.js';

const chain = new Chain('https://games.divi.domains/chain');
const DIR = process.env.HOME + '/tut-i/build-tic-tac-toe';

// Two fresh testnet keys. The WIFs go to 0600 files, never to the terminal.
function newKey(name) {
  const k = Key.generate('testnet');
  fs.writeFileSync(`${DIR}/${name}.wif`, k.toWif() + '\n', { mode: 0o600 });
  return k;
}
const alice = newKey('alice');
const bob = newKey('bob');
console.log('alice', alice.address);
console.log('bob  ', bob.address);

const gold = async (who) => (await chain.account(who)).balances.GOLD ?? '0';
const nonce = async (k) => (await chain.account(k.address)).nonce;
const balances = async (label) =>
  console.log(`${label} alice ${await gold(alice.address)} bob ${await gold(bob.address)} escrow ${await gold('module:tictactoe')}`);

// 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 ttt = (op) => ({ type: 'game', game: 'tictactoe', op });

// 1. GOLD for both players (1000 GOLD each from the faucet)
await send(alice, { type: 'faucet', token: 'GOLD' }, 'faucet alice');
await send(bob, { type: 'faucet', token: 'GOLD' }, 'faucet bob');
await balances('before:');

// 2. Alice creates a table with a 1.00 GOLD stake ("100" base units)
const { txid: createTxid, receipt: created } = await send(alice, ttt({ kind: 'create', stake: '100' }), 'create');
const id = created.events[0].data.id;
console.log(`game id ${id} == first 16 hex of the txid: ${createTxid.slice(0, 16) === id}`);
console.log('open tables:', JSON.stringify(await chain.game('tictactoe', { open: 1 })));

// 3. Bob joins; Alice (x) moves first
await send(bob, ttt({ kind: 'join', id }), 'join bob');
await send(alice, ttt({ kind: 'move', id, cell: 0 }), 'move alice 0');
await send(alice, ttt({ kind: 'claim_timeout', id }), 'claim_timeout alice (too early)');

// 4. A bad move: Bob tries the cell Alice took. The tx fails but still uses a nonce.
console.log('bob nonce before', await nonce(bob));
await send(bob, ttt({ kind: 'move', id, cell: 0 }), 'move bob 0 (taken)');
console.log('bob nonce after ', await nonce(bob));

// 5. Play on to a win for x: the same moves as the first test in tictactoe.rs
await send(bob, ttt({ kind: 'move', id, cell: 3 }), 'move bob 3');
await send(alice, ttt({ kind: 'move', id, cell: 1 }), 'move alice 1');
await send(bob, ttt({ kind: 'move', id, cell: 4 }), 'move bob 4');
await send(alice, ttt({ kind: 'move', id, cell: 2 }), 'move alice 2');

// 6. What the chain says afterwards
await balances('after: ');
console.log('game:', JSON.stringify(await chain.game('tictactoe', { id })));
console.log('alice games:', (await chain.game('tictactoe', { player: alice.address })).games.map((g) => g.id));
console.log('bob games:  ', (await chain.game('tictactoe', { player: bob.address })).games.map((g) => g.id));

What it printed on 2026-10-21, untrimmed; every txid is on the live chain:

node play.mjs
alice xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7
bob   xysuzCS9FmeSvPjxZHcZXkK8cwj9iEQBki
faucet alice: txid 1a9867af239620983eb27ee3fc28bd7ba1d889f1a8a7a32308ec5969d175d9a1 height 1410 ok true
   event faucet {"amount":"100000","to":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7","token":"GOLD"}
faucet bob: txid f1b3560e5b60eaa8aa4b56767106e948ae8affe0402819a194c206bef42f6885 height 1411 ok true
   event faucet {"amount":"100000","to":"xysuzCS9FmeSvPjxZHcZXkK8cwj9iEQBki","token":"GOLD"}
before: alice 100000 bob 100000 escrow 0
create: txid 80566b04265ced78cfb54f8695443c53cde48f3d6493d881ed0ea136d4ca163d height 1412 ok true
   event tictactoe.created {"id":"80566b04265ced78","opponent":null,"stake":"100","x":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7"}
game id 80566b04265ced78 == first 16 hex of the txid: true
open tables: {"games":[{"board":".........","created_height":1412,"id":"80566b04265ced78","last_move_height":1412,"moves":0,"o":null,"opponent":null,"reason":null,"result":null,"stake":"100","status":"open","turn":"x","winner":null,"x":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7"}]}
join bob: txid c23ed899d7b3b4a26582b7823cd8ee74668de4b1d84569997674aa7d4e5eb54e height 1413 ok true
   event tictactoe.joined {"id":"80566b04265ced78","o":"xysuzCS9FmeSvPjxZHcZXkK8cwj9iEQBki"}
move alice 0: txid 20167c28639734f078564e520cd6197c2d4b40c55a22548c8e34c3c39963317c height 1414 ok true
   event tictactoe.moved {"board":"x........","by":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7","cell":0,"id":"80566b04265ced78","turn":"o"}
claim_timeout alice (too early): txid 253f2f7aea1c5d0c5c3f8fcc0509687b95935674f0fd7a1781e3acf1b6fcee17 height 1415 ok false error "invalid: timeout can be claimed from height 1564"
bob nonce before 2
move bob 0 (taken): txid 22173708b86d726ac2c7369dd56f232ea0b2fce5ac02e3ee062a8dc60ac1ea44 height 1416 ok false error "invalid: cell is taken"
bob nonce after  3
move bob 3: txid 68721c7104a4421c461d1acf71a3f0bcb4f44d2b4f57acf9d3d93e20407290c2 height 1417 ok true
   event tictactoe.moved {"board":"x..o.....","by":"xysuzCS9FmeSvPjxZHcZXkK8cwj9iEQBki","cell":3,"id":"80566b04265ced78","turn":"x"}
move alice 1: txid 18a8b55360151afb771f40c4746b65206f6b5d25d3a088ac24857e883647306f height 1418 ok true
   event tictactoe.moved {"board":"xx.o.....","by":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7","cell":1,"id":"80566b04265ced78","turn":"o"}
move bob 4: txid 2e668b95c4b7fb44fcb842b7e74901be2ebe091a178e81f68b1fcfc31feeac57 height 1419 ok true
   event tictactoe.moved {"board":"xx.oo....","by":"xysuzCS9FmeSvPjxZHcZXkK8cwj9iEQBki","cell":4,"id":"80566b04265ced78","turn":"x"}
move alice 2: txid d76f7c3a1f9d4e9a8c4d4cb8de2f57b851a40b6e74c6e6b004bfa7cdc54da6aa height 1420 ok true
   event tictactoe.moved {"board":"xxxoo....","by":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7","cell":2,"id":"80566b04265ced78","turn":"o"}
   event tictactoe.finished {"id":"80566b04265ced78","payout":"200","reason":"line","result":"x_won","winner":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7"}
after:  alice 100100 bob 99900 escrow 0
game: {"board":"xxxoo....","created_height":1412,"id":"80566b04265ced78","last_move_height":1420,"moves":5,"o":"xysuzCS9FmeSvPjxZHcZXkK8cwj9iEQBki","opponent":null,"reason":"line","result":"x_won","stake":"100","status":"finished","turn":"o","winner":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7","x":"xxURsC5wFbNzCrKzzQjeTqkLfEoZT6Y8F7"}
alice games: [ '80566b04265ced78' ]
bob games:   [ '80566b04265ced78' ]

Read it against the code:

The same data is one GET away. These are the views view() serves, for the game above:

xxxoo5678
Keys

The script writes the two WIFs to files with mode 0600 and prints only addresses. Treat a testnet key the same way you would a real one; the habit is the point. The browser game keeps its key in localStorage under dg.playkey.wif.

7Checklist for your own game

Next: Build blackjack adds randomness nobody can choose, with a committed hash chain. The JavaScript SDK is the browser side of this page. Deploy your game runs the node and the site as services behind nginx.

If something goes wrong

You seeWhat it means
bad tx: invalid signature from POST /v1/txThe signature does not recover to body.from. Sign the exact body string; the node never re-serialises it. chain.send does this for you.
400 {"error":"nonce: expected 6, got 99"} from POST /v1/txThe body's nonce is not the account's next one (last mined plus anything in the mempool). The transaction is refused before the mempool, so no nonce is spent. Two scripts sharing a key will race; give each its own key, or read next_nonce right before signing.
ok: false, error: "invalid: cell is taken" (or any invalid:)Your op was well-formed but illegal right now. The transaction is in the block and the nonce is spent; read the game with ?id= and try again.
error: "not allowed: not your turn"An unauthorized error: wait for the tictactoe.moved event with turn set to your mark.
error: "insufficient GOLD: need 100, have 0"The stake is more than the account holds. Send {"type":"faucet","token":"GOLD"}; it pays 1000 GOLD once every 1800 blocks per address.
conflict: faucet cooling down; next claim at height …That address already claimed within the last 1800 blocks. Use another key or wait.
{"error":"not found: game nope"} from ?id=No such game id. Ids are 16 lowercase hex characters, the start of the create txid.
tx … not in a block after 30swaitForReceipt gave up. The default timeout is 30 s; pass { timeoutMs: 90000 } as the script does, since an idle chain only makes a block every minute.
`market` is a system module; use action type `market`You sent a game action with a reserved id. Your own game needs a different id.

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