Skip to main content

Tic-Tac-Toe

This guide covers three different implementations of the game tic-tac-toe on Sui. The first example utilizes a centralized admin that marks the board on the users’ behalf. The second example utilizes a shared object that both users can mutate. And the third example utilizes a multisig, where instead of sharing the game board, it's in a 1-of-2 multisig of both users’ accounts.

owned.move (source)

In this first example of tic-tac-toe, the game object, including the game board, is controlled by a game admin.

examples/tic-tac-toe/move/sources/owned.move
public struct Game has key, store {
id: UID,
board: vector<u8>,
turn: u8,
x: address,
o: address,
admin: vector<u8>,
}

Because the players don’t own the game board, they cannot directly mutate it. Instead, they indicate their move by creating a Mark object with their intended placement and send it to the game object using transfer to object:

examples/tic-tac-toe/move/sources/owned.move
public struct Mark has key, store {
id: UID,
player: address,
row: u8,
col: u8,
}

Games are created with the new function:

examples/tic-tac-toe/move/sources/owned.move
public fun new(x: address, o: address, admin: vector<u8>, ctx: &mut TxContext): Game {
let game = Game {
id: object::new(ctx),
board: vector[
MARK__, MARK__, MARK__,
MARK__, MARK__, MARK__,
MARK__, MARK__, MARK__,
],
turn: 0,
x,
o,
admin,
};

let turn = TurnCap {
id: object::new(ctx),
game: object::id(&game),
};

transfer::transfer(turn, x);
game
}

Some things to note:

  • The game is created and returned by this function, it is up to its creator to send it to the game admin to own.
  • There is an admin field, we can ignore this for now, as it is only relevant for the multisig approach.
  • The first player is sent a TurnCap which gives them permission to take the next turn, they consume it to make their Mark, and the admin mints and sends a new TurnCap to the next player if the game has not ended.

When playing the game, the admin operates a service that keeps track of marks using events. When a request is received (send_mark), the admin tries to place the marker on the board (place_mark). Each move requires two steps (thus two transactions): one from the player and one from the admin. This setup relies on the admin's service to keep the game moving.

examples/tic-tac-toe/move/sources/owned.move
public fun send_mark(cap: TurnCap, row: u8, col: u8, ctx: &mut TxContext) {
assert!(row < 3 && col < 3, EInvalidLocation);

let TurnCap { id, game } = cap;
id.delete();

let mark = Mark {
id: object::new(ctx),
player: ctx.sender(),
row,
col,
};

event::emit(MarkSent { game, mark: object::id(&mark) });
transfer::transfer(mark, game.to_address());
}

public fun place_mark(
game: &mut Game,
mark: Receiving<Mark>,
ctx: &mut TxContext,
) {
assert!(game.ended() == TROPHY_NONE, EAlreadyFinished);

let Mark { id, row, col, player } = transfer::receive(&mut game.id, mark);
id.delete();

let (me, them, sentinel) = game.next_player();
assert!(me == player, EWrongPlayer);

if (game[row, col] == MARK__) {
*(&mut game[row, col]) = sentinel;
game.turn = game.turn + 1;
};

let end = game.ended();
if (end == TROPHY_WIN) {
transfer::transfer(game.mint_trophy(end, them, ctx), me);
event::emit(GameEnd { game: object::id(game) });
} else if (end == TROPHY_DRAW) {
transfer::transfer(game.mint_trophy(end, them, ctx), me);
transfer::transfer(game.mint_trophy(end, me, ctx), them);
event::emit(GameEnd { game: object::id(game) });
} else if (end == TROPHY_NONE) {
let cap = TurnCap { id: object::new(ctx), game: object::id(game) };
let (to, _, _) = game.next_player();
transfer::transfer(cap, to);
} else {
abort EInvalidEndState
}
}

To view the entire source code, see the owned.move source file. You can find the rest of the logic, including how to check for a winner, as well as deleting the gameboard after the game concludes there.

An alternative version of this game, shared tic-tac-toe, uses shared objects for a more straightforward implementation that doesn't use a centralized service. This comes at a slightly increased cost, as using shared objects is more expensive than transactions involving wholly owned objects.

Toggle full source code

examples/tic-tac-toe/move/sources/owned.move
/// An implementation of Tic Tac Toe, using owned objects.
///
/// The `Game` object is owned by an admin, so players cannot mutate the game
/// board directly. Instead, they convey their intention to place a mark by
/// transferring a `Mark` object to the `Game`.
///
/// This means that every move takes two owned object fast path operations --
/// one by the player, and one by the admin. The admin could be a third party
/// running a centralized service that monitors marker placement events and
/// responds to them, or it could be a 1-of-2 multisig address shared between
/// the two players, as demonstrated in the demo app.
///
/// The `shared` module shows a variant of this game implemented using shared
/// objects, which provides different trade-offs: Using shared objects is more
/// expensive, however the implementation is more straightforward and each move
/// only requires one transaction.
module tic_tac_toe::owned {
use sui::event;
use sui::transfer::Receiving;

// === Object Types ===

/// The state of an active game of tic-tac-toe.
public struct Game has key, store {
id: UID,
/// Marks on the board.
board: vector<u8>,
/// The next turn to be played.
turn: u8,
/// The address expected to send moves on behalf of X.
x: address,
/// The address expected to send moves on behalf of O.
o: address,
/// Public key of the admin address.
admin: vector<u8>,
}

/// The player that the next turn is expected from is given a `TurnCap`.
public struct TurnCap has key {
id: UID,
game: ID,
}

/// A request to make a play -- only the player with the `TurnCap` can
/// create and send `Mark`s.
public struct Mark has key, store {
id: UID,
player: address,
row: u8,
col: u8,
}

/// An NFT representing a finished game. Sent to the winning player if there
/// is one, or to both players in the case of a draw.
public struct Trophy has key {
id: UID,
/// Whether the game was won or drawn.
status: u8,
/// The state of the board at the end of the game.
board: vector<u8>,
/// The number of turns played
turn: u8,
/// The other player (relative to the player who owns this Trophy).
other: address,
}

// === Event Types ===

public struct MarkSent has copy, drop {
game: ID,
mark: ID,
}

public struct GameEnd has copy, drop {
game: ID,
}

// === Constants ===

// Marks
const MARK__: u8 = 0;
const MARK_X: u8 = 1;
const MARK_O: u8 = 2;

// Trophy status
const TROPHY_NONE: u8 = 0;
const TROPHY_DRAW: u8 = 1;
const TROPHY_WIN: u8 = 2;

// === Errors ===

#[error]
const EInvalidLocation: vector<u8> =
b"Move was for a position that doesn't exist on the board";

#[error]
const EWrongPlayer: vector<u8> =
b"Game expected a move from another player";

#[error]
const ENotFinished: vector<u8> =
b"Game has not reached an end condition";

#[error]
const EAlreadyFinished: vector<u8> =
b"Can't place a mark on a finished game";

#[error]
const EInvalidEndState: vector<u8> =
b"Game reached an end state that wasn't expected";

// === Public Functions ===

/// Create a new game, played by `x` and `o`. The game should be
/// transfered to the address that will administrate the game. If
/// that address is a multi-sig of the two players, its public key
/// should be passed as `admin`.
public fun new(x: address, o: address, admin: vector<u8>, ctx: &mut TxContext): Game {
let game = Game {
id: object::new(ctx),
board: vector[
MARK__, MARK__, MARK__,
MARK__, MARK__, MARK__,
MARK__, MARK__, MARK__,
],
turn: 0,
x,
o,
admin,
};

let turn = TurnCap {
id: object::new(ctx),
game: object::id(&game),
};

// X is the first player, so send the capability to them.
transfer::transfer(turn, x);
game
}

/// Called by the active player to express their intention to make a move.
/// This consumes the `TurnCap` to prevent a player from making more than
/// one move on their turn.
public fun send_mark(cap: TurnCap, row: u8, col: u8, ctx: &mut TxContext) {
assert!(row < 3 && col < 3, EInvalidLocation);

let TurnCap { id, game } = cap;
id.delete();

let mark = Mark {
id: object::new(ctx),
player: ctx.sender(),
row,
col,
};

event::emit(MarkSent { game, mark: object::id(&mark) });
transfer::transfer(mark, game.to_address());
}

/// Called by the admin (who owns the `Game`), to commit a player's
/// intention to make a move. If the game should end, `Trophy`s are sent to
/// the appropriate players, if the game should continue, a new `TurnCap` is
/// sent to the player who should make the next move.
public fun place_mark(
game: &mut Game,
mark: Receiving<Mark>,
ctx: &mut TxContext,
) {
assert!(game.ended() == TROPHY_NONE, EAlreadyFinished);

// Fetch the mark on behalf of the game -- only works if the mark in
// question was sent to this game.
let Mark { id, row, col, player } = transfer::receive(&mut game.id, mark);
id.delete();

// Confirm that the mark is from the player we expect -- it should not
// be possible to hit this assertion, because the `Mark`s can only be
// created by the address that owns the `TurnCap` which cannot be
// transferred, and is always held by `game.next_player()`.
let (me, them, sentinel) = game.next_player();
assert!(me == player, EWrongPlayer);

if (game[row, col] == MARK__) {
*(&mut game[row, col]) = sentinel;
game.turn = game.turn + 1;
};

// Check win condition -- if there is a winner, send them the trophy,
// otherwise, create a new turn cap and send that to the next player.
let end = game.ended();
if (end == TROPHY_WIN) {
transfer::transfer(game.mint_trophy(end, them, ctx), me);
event::emit(GameEnd { game: object::id(game) });
} else if (end == TROPHY_DRAW) {
transfer::transfer(game.mint_trophy(end, them, ctx), me);
transfer::transfer(game.mint_trophy(end, me, ctx), them);
event::emit(GameEnd { game: object::id(game) });
} else if (end == TROPHY_NONE) {
let cap = TurnCap { id: object::new(ctx), game: object::id(game) };
let (to, _, _) = game.next_player();
transfer::transfer(cap, to);
} else {
abort EInvalidEndState
}
}

public fun burn(game: Game) {
assert!(game.ended() != TROPHY_NONE, ENotFinished);
let Game { id, .. } = game;
id.delete();
}

/// Test whether the game has reached an end condition or not.
public fun ended(game: &Game): u8 {
if (
// Test rows
test_triple(game, 0, 1, 2) ||
test_triple(game, 3, 4, 5) ||
test_triple(game, 6, 7, 8) ||
// Test columns
test_triple(game, 0, 3, 6) ||
test_triple(game, 1, 4, 7) ||
test_triple(game, 2, 5, 8) ||
// Test diagonals
test_triple(game, 0, 4, 8) ||
test_triple(game, 2, 4, 6)
) {
TROPHY_WIN
} else if (game.turn == 9) {
TROPHY_DRAW
} else {
TROPHY_NONE
}
}

#[syntax(index)]
public fun mark(game: &Game, row: u8, col: u8): &u8 {
&game.board[(row * 3 + col) as u64]
}

#[syntax(index)]
fun mark_mut(game: &mut Game, row: u8, col: u8): &mut u8 {
&mut game.board[(row * 3 + col) as u64]
}

// === Private Helpers ===

/// Address of the player the move is expected from, the address of the
/// other player, and the mark to use for the upcoming move.
fun next_player(game: &Game): (address, address, u8) {
if (game.turn % 2 == 0) {
(game.x, game.o, MARK_X)
} else {
(game.o, game.x, MARK_O)
}
}

/// Test whether the values at the triple of positions all match each other
/// (and are not all EMPTY).
fun test_triple(game: &Game, x: u8, y: u8, z: u8): bool {
let x = game.board[x as u64];
let y = game.board[y as u64];
let z = game.board[z as u64];

MARK__ != x && x == y && y == z
}

/// Create a trophy from the current state of the `game`, that indicates
/// that a player won or drew against `other` player.
fun mint_trophy(
game: &Game,
status: u8,
other: address,
ctx: &mut TxContext,
): Trophy {
Trophy {
id: object::new(ctx),
status,
board: game.board,
turn: game.turn,
other,
}
}

// === Test Helpers ===
#[test_only] public use fun game_board as Game.board;
#[test_only] public use fun trophy_status as Trophy.status;
#[test_only] public use fun trophy_board as Trophy.board;
#[test_only] public use fun trophy_turn as Trophy.turn;
#[test_only] public use fun trophy_other as Trophy.other;

#[test_only]
public fun game_board(game: &Game): vector<u8> {
game.board
}

#[test_only]
public fun trophy_status(trophy: &Trophy): u8 {
trophy.status
}

#[test_only]
public fun trophy_board(trophy: &Trophy): vector<u8> {
trophy.board
}

#[test_only]
public fun trophy_turn(trophy: &Trophy): u8 {
trophy.turn
}

#[test_only]
public fun trophy_other(trophy: &Trophy): address {
trophy.other
}
}

shared.move (source)

In the previous version, the admin owned the game object, preventing players from directly changing the gameboard, as well as requiring two transactions for each marker placement. In this version, the game object is a shared object, allowing both players to access and modify it directly, enabling them to place markers in just one transaction. However, using a shared object generally incurs extra costs because Sui needs to sequence the operations from different transactions. In the context of this game, where players are expected to take turns, this shouldn't significantly impact performance. Overall, this shared object approach simplifies the implementation compared to the previous method.

As the following code demonstrates, the Game object in this example is almost identical to the one before it. The only differences are that it does not include an admin field, which is only relevant for the multisig version of the game, and it does not have store, because it only ever exists as a shared object (so it cannot be transferred or wrapped).

examples/tic-tac-toe/move/sources/shared.move
public struct Game has key {
id: UID,
board: vector<u8>,
turn: u8,
x: address,
o: address,
}

Take a look at the new function:

examples/tic-tac-toe/move/sources/shared.move
public fun new(x: address, o: address, ctx: &mut TxContext) {
transfer::share_object(Game {
id: object::new(ctx),
board: vector[
MARK__, MARK__, MARK__,
MARK__, MARK__, MARK__,
MARK__, MARK__, MARK__,
],
turn: 0,
x,
o,
});
}

Instead of the game being sent to the game admin, it is instantiated as a shared object. The other notable difference is that there is no need to mint a TurnCap because the only two addresses that can play this game are x and o, and this is checked in the next function, place_mark:

examples/tic-tac-toe/move/sources/shared.move
public fun place_mark(
game: &mut Game,
row: u8,
col: u8,
ctx: &mut TxContext,
) {
assert!(game.ended() == TROPHY_NONE, EAlreadyFinished);
assert!(row < 3 && col < 3, EInvalidLocation);

let (me, them, sentinel) = game.next_player();
assert!(me == ctx.sender(), EWrongPlayer);

if (game[row, col] != MARK__) {
abort EAlreadyFilled
};

*(&mut game[row, col]) = sentinel;
game.turn = game.turn + 1;

let end = game.ended();
if (end == TROPHY_WIN) {
transfer::transfer(game.mint_trophy(end, them, ctx), me);
} else if (end == TROPHY_DRAW) {
transfer::transfer(game.mint_trophy(end, them, ctx), me);
transfer::transfer(game.mint_trophy(end, me, ctx), them);
} else if (end != TROPHY_NONE) {
abort EInvalidEndState
}
}

Toggle full source code

examples/tic-tac-toe/move/sources/shared.move
/// An implementation of Tic Tac Toe, using shared objects.