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. This guide compares and contrasts the design philosophies behind the three different games, as well as the pros and cons of each.

tic_tac_toe.move

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

public struct TicTacToe has key {
id: UID,
gameboard: vector<vector<Option<Mark>>>,
cur_turn: u8,
game_status: u8,
x_address: address,
o_address: address,
}

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

public struct Mark has key, store {
id: UID,
player: address,
row: u64,
col: u64,
}

The main logic of the game is in the following create_game function.

/// `x_address` and `o_address` are the account address of the two players.
public entry fun create_game(x_address: address, o_address: address, ctx: &mut TxContext) {
// TODO: Validate sender address, only GameAdmin can create games.

let id = object::new(ctx);
let game_id = id.to_inner();
let gameboard = vector[
vector[option::none(), option::none(), option::none()],
vector[option::none(), option::none(), option::none()],
vector[option::none(), option::none(), option::none()],
];
let game = TicTacToe {
id,
gameboard,
cur_turn: 0,
game_status: IN_PROGRESS,
x_address: x_address,
o_address: o_address,
};
transfer::transfer(game, ctx.sender());
let cap = MarkMintCap {
id: object::new(ctx),
game_id,
remaining_supply: 5,
};
transfer::transfer(cap, x_address);
let cap = MarkMintCap {
id: object::new(ctx),
game_id,
remaining_supply: 5,
};
transfer::transfer(cap, o_address);
}

Some things to note:

  • The game exists as an owned object in the game admin’s account.
  • The board is initialized as a 3x3 vector of vectors, instantiated via option::none().
  • Both players get five MarkMintCaps each, giving them the capability to place a maximum of five marks each.

When playing the game, the admin operates a service that keeps track of these placement requests. When a request is received (send_mark_to_game), 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.

/// Generate a new mark intended for location (row, col).
/// This new mark is not yet placed, just transferred to the game.
public entry fun send_mark_to_game(
cap: &mut MarkMintCap,
game_address: address,
row: u64,
col: u64,
ctx: &mut TxContext,
) {
if (row > 2 || col > 2) {
abort EInvalidLocation
};
let mark = mint_mark(cap, row, col, ctx);
// Once an event is emitted, it should be observed by a game server.
// The game server will then call `place_mark` to place this mark.
event::emit(MarkSentEvent {
game_id: *&cap.game_id,
mark_id: object::id(&mark),
});
transfer::public_transfer(mark, game_address);
}

public entry fun place_mark(game: &mut TicTacToe, mark: Mark, ctx: &mut TxContext) {
// If we are placing the mark at the wrong turn, or if game has ended,
// destroy the mark.
let addr = game.get_cur_turn_address();
if (game.game_status != IN_PROGRESS || &addr != &mark.player) {
mark.delete();
return
};
let cell = get_cell_mut_ref(game, mark.row, mark.col);
if (cell.is_some()) {
// There is already a mark in the desired location.
// Destroy the mark.
mark.delete();
return
};
cell.fill(mark);
game.update_winner();
game.cur_turn = game.cur_turn + 1;

if (game.game_status != IN_PROGRESS) {
// Notify the server that the game ended so that it can delete the game.
event::emit(GameEndEvent { game_id: object::id(game) });
if (game.game_status == X_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, *&game.x_address);
} else if (game.game_status == O_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, *&game.o_address);
}
}
}

To view the entire source code, see the tic_tac_toe.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.

shared_tic_tac_toe.move

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 TicTacToe object in this example is almost identical to the one before it. The only difference is that the gameboard is represented as vector<vector<u8>> instead of vector<vector<Option<Mark>>>. The reason for this approach is explained following the code.

public struct TicTacToe has key {
id: UID,
gameboard: vector<vector<u8>>,
cur_turn: u8,
game_status: u8,
x_address: address,
o_address: address,
}

Take a look at the create_game function:

/// `x_address` and `o_address` are the account address of the two players.
public entry fun create_game(x_address: address, o_address: address, ctx: &mut TxContext) {
// TODO: Validate sender address, only GameAdmin can create games.

let id = object::new(ctx);
let gameboard = vector[
vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
];
let game = TicTacToe {
id,
gameboard,
cur_turn: 0,
game_status: IN_PROGRESS,
x_address: x_address,
o_address: o_address,
};
// Make the game a shared object so that both players can mutate it.
transfer::share_object(game);
}

As the code demonstrates, each position on the board is replaced with MARK_EMPTY instead of option::none(). 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 MarkMintCaps to the two players anymore, because the only two addresses that can play this game are x_address and o_address, and this is checked in the next function, place_mark:

public entry fun place_mark(game: &mut TicTacToe, row: u8, col: u8, ctx: &mut TxContext) {
assert!(row < 3 && col < 3, EInvalidLocation);
assert!(game.game_status == IN_PROGRESS, EGameEnded);
let addr = game.get_cur_turn_address();
assert!(addr == ctx.sender(), EInvalidTurn);

let cell = &mut game.gameboard[row as u64][col as u64];
assert!(*cell == MARK_EMPTY, ECellOccupied);

*cell = game.cur_turn % 2;
game.update_winner();
game.cur_turn = game.cur_turn + 1;

if (game.game_status != IN_PROGRESS) {
// Notify the server that the game ended so that it can delete the game.
event::emit(GameEndEvent { game_id: object::id(game) });
if (game.game_status == X_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, game.x_address);
} else if (game.game_status == O_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, game.o_address);
}
}
}

You can find the full source code in shared_tic_tac_toe.move

multisig_tic_tac_toe.move

In this implementation of the game, the game is in a 1-of-2 multisig account that acts as the game admin. In this particular case, because there are only two players, the previous example is a more convenient use case. However, this example illustrates that in some cases, a multisig can replace shared objects, thus allowing transactions to bypass consensus when using such an implementation.

Examine the two main objects in this game, TicTacToe, and Mark:

/// TicTacToe struct should be owned by the game-admin.
/// This should be the multisig 1-out-of-2 account for both players to make moves.
public struct TicTacToe has key {
id: UID,
/// Column major 3x3 game board
gameboard: vector<u8>,
/// Index of current turn
cur_turn: u8,
x_addr: address,
o_addr: address,
/// 0 not finished, 1 X Winner, 2 O Winner, 3 Draw
finished: u8
}

/// Mark is passed between game-admin (Multisig 1-out-of-2), x-player and o-player.
public struct Mark has key {
id: UID,
/// Column major 3x3 placement
placement: Option<u8>,
/// Flag that sets when the Mark is owned by a player
during_turn: bool,
/// Multi-sig account to place the mark
game_owners: address,
/// TicTacToe object this mark is part of
game_id: ID
}

The biggest difference in this TicTacToe object is that gameboard is a vector<u8>, but otherwise the main functionality of the gameboard is the same. The Mark object makes a reappearance in this version, as we need a way to identify the current player’s turn (this was accomplished in the shared version of the game in the TicTacToe object itself).

The create_game function is fairly similar to one in the previous two versions:

/// This should be called by a multisig (1 out of 2) address.
/// x_addr and o_addr should be the two addresses part-taking in the multisig.
public fun create_game(x_addr: address, o_addr: address, ctx: &mut TxContext) {
let id = object::new(ctx);
let game_id = id.to_inner();

let tic_tac_toe = TicTacToe {
id,
gameboard: vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY,
MARK_EMPTY, MARK_EMPTY, MARK_EMPTY,
MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
cur_turn: 0,
x_addr,
o_addr,
finished: 0
};
let mark = Mark {
id: object::new(ctx),
placement: option::none(),
during_turn: true, // Mark is passed to x_addr
game_owners: ctx.sender(),
game_id
};

transfer::transfer(tic_tac_toe, ctx.sender());
transfer::transfer(mark, x_addr);
}

Now take a look at send_mark_to_game and place_mark:

/// This is called by the one of the two addresses participating in the multisig, but not from
/// the multisig itself.
/// row: [0 - 2], col: [0 - 2]
public fun send_mark_to_game(mark: Mark, row: u8, col: u8) {
// Mark.during_turn prevents multisig-acc from editing mark.placement after it has been sent to it.
assert!(mark.during_turn, ETriedToCheat);

mark.placement.fill(get_index(row, col));
mark.during_turn = false;
let game_owners = mark.game_owners;
transfer::transfer(mark, game_owners);
}

/// This is called by the multisig account to execute the last move by the player who used
/// `send_mark_to_game`.
public fun place_mark(game: &mut TicTacToe, mark: Mark, ctx: &mut TxContext) {
assert!(mark.game_id == game.id.to_inner(), EMarkIsFromDifferentGame);

let addr = get_cur_turn_address(game);
// Note here we empty the option
let placement: u8 = mark.placement.extract();
if (game.gameboard.get_cell_by_index(placement) != MARK_EMPTY) {
mark.during_turn = true;
transfer::transfer(mark, addr);
return
};

// Apply turn
let mark_symbol = if (addr == game.x_addr) {
MARK_X
} else {
MARK_O
};
* &mut game.gameboard[placement as u64] = mark_symbol;

// Check for winner
let winner = game.get_winner();

// Game ended!
if (winner.is_some()) {
let played_as = winner.extract();
let (winner, loser, finished) = if (played_as == MARK_X) {
(game.x_addr, game.o_addr, 1)
} else {
(game.o_addr, game.x_addr, 2)
};

transfer::transfer(
TicTacToeTrophy {
id: object::new(ctx),
winner,
loser,
played_as,
game_id: game.id.to_inner()
},
winner
);

mark.delete();
* &mut game.finished = finished;
return
} else if (game.cur_turn >= 8) { // Draw
make.delete();
* &mut game.finished = 3;
return
};

// Next turn
* &mut game.cur_turn = game.cur_turn + 1;
addr = game.get_cur_turn_address();
mark.during_turn = true;
transfer::transfer(mark, addr);
}

The first function is straightforward. The player sends the location of the mark to the multisig account. Then in the next function, the multisig actually places down the mark the player requested, as well as all the logic to check to see if there is a winner, end the game, and award a player a trophy if so, or to advance to the next player’s turn if not. See the multisig_tic-tac-toe repo for the full source code on this version of the game.