본문으로 건너뛰기

Tic-Tac-Toe

🧠Expected effort

This guide is rated as basic.

You can expect basic guides to take 30-45 minutes of dedicated time. The length of time necessary to fully understand some of the concepts raised in this guide might increase this estimate.

이 가이드는 Sui에서 tic-tac-toe 게임을 세 가지 다른 방식으로 구현하는 방법을 다룬다. 첫 번째 예시는 보드 object를 소유하고 사용자를 대신해 표시하는 중앙화된 admin을 사용한다. 두 번째 예시는 두 사용자가 모두 변경할 수 있는 shared object를 사용한다. 세 번째 예시는 게임 보드를 공유하는 대신 두 사용자의 계정이 함께 소유하는 1-of-2 다중 서명 다중 서명 인증 계정에 게임 보드를 두는 방식을 사용한다.

이 가이드는 tic-tac-toe 게임 보드를 서로 다른 방식으로 구현한 세 부분으로 나뉜다:

  1. Centralized game board: 플레이어의 움직임을 추적하고 게임 보드를 업데이트하는 admin 서비스.
  2. Shared game board: 플레이어가 게임 보드를 직접 업데이트할 수 있게 하는 shared object.
  3. Multisig operated game board: 게임 admin으로 동작하는 다중 서명(multisig) 계정으로, 어느 플레이어든 게임 보드를 직접 업데이트할 수 있게 한다.
Click to open

Create Sui account and setup CLI environment

$ sui client

If this is the first time running the sui client CLI tool, it asks you to provide a Sui full node server URL and a meaningful environment alias. It also generates an address with a random key pair in sui.keystore and a config client.yaml.

By default, the client.yaml and sui.keystore files are located in ~/.sui/sui_config. For more information, refer to the Sui client CLI tutorial.

If this is not your first time running sui client, then you already have a client.yaml file in your local environment. If you'd like to create a new address for this tutorial, use the command:

$ sui client new-address ed25519
  • Obtain test tokens.
Click to open

How to obtain tokens

If you are connected to Devnet or Testnet networks, use the Faucet UI to request tokens.

If you are connected to a local full node, learn how to get local network tokens.

정보

Sui repository에서 이 앱 예시의 complete source code for this app example를 볼 수 있다.

What the guide teaches

  • Owned objects: 이 가이드는 중앙화 버전과 다중 서명 버전의 tic-tac-toe에서 게임 보드 역할을 하는 owned objects를 사용하는 방법을 설명한다. Owned objects는 단일 계정이 소유하며 해당 계정만 수정할 수 있는 object이다. 이 경우 게임 보드는 게임 admin이 소유하며, 게임 admin은 각 플레이어의 움직임으로 보드를 업데이트할 책임이 있다.
  • Shared objects: 이 가이드는 보다 탈중앙화된 tic-tac-toe 버전에서 게임 보드 역할을 하는 shared objects를 사용하는 방법을 설명한다. Shared objects는 여러 계정이 수정할 수 있는 object이다. 이 경우 게임 보드는 두 플레이어 사이에서 공유되므로 플레이어가 보드를 직접 업데이트할 수 있다.
  • 다중 서명 계정: 이 가이드는 두 플레이어가 게임 보드의 소유권을 공유하기 위해 multisig accounts를 사용하는 방법을 설명한다. 다중 서명 계정은 transaction을 승인하기 위해 일정 임계값 이상의 서명이 필요한 계정이다. 이 경우 게임 보드는 1-of-2 다중 서명 계정이 소유한다.
  • Dynamic object fields: 이 가이드는 플레이어의 액션을 게임 보드로 전달하고 game admin이 이를 가져오는 데 사용하는 dynamic object fields를 사용하는 방법을 설명한다. Dynamic object fields에 대해 더 알아보려면 The Move Book을 참조한다.

Directory structure

먼저 시스템에 모든 파일을 담을 tic-tac-toe라는 새 폴더를 만든다.

이 폴더 안에 다음 하위 폴더를 만든다:

  • 게임 보드의 Move 코드를 담기 위한 move.
    • Move source 파일을 담기 위한 sources.
Click to open

Add Move.toml to tic-tac-toe/move/

[package]
name = "tic_tac_toe"
edition = "2024.beta"

[dependencies]
[addresses]
tic_tac_toe = "0x0"
CHECKPOINT
  • 최신 version의 Sui가 설치되어 있다. terminal 또는 console에서 sui --version을 실행하면 현재 설치된 version이 출력된다.
  • 생성하는 파일을 둘 디렉터리가 있다.
  • tic-tac-toe/move/ 디렉터리에 Move.toml 파일을 만들었다.

owned.move

tic-tac-toe/move/sourcesowned.move라는 새 파일을 만든다. 나중에 이 파일을 업데이트해 중앙화 버전(그리고 다중 서명 버전)의 tic-tac-toe 게임 보드를 위한 Move 코드를 담게 된다.

tic-tac-toe의 첫 번째 예시에서는 게임 보드를 포함한 Game object를 게임 admin이 제어한다.

public struct Game has key, store {
id: UID,
board: vector<u8>,
turn: u8,
x: address,
o: address,
admin: vector<u8>,
}

admin 필드는 다중 서명 접근 방식에서만 관련되므로 지금은 무시한다.

게임은 new 함수로 생성한다:

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
}

유의할 점은 다음과 같다:

  • MARK__는 게임 보드의 빈 칸을 나타내는 상수이다. MARK_XMARK_O는 두 플레이어의 마커를 나타낸다.
  • 첫 번째 플레이어는 다음 차례를 둘 권한을 주는 TurnCap을 받는다.
  • 이 함수는 Game object를 생성해 반환하며, 생성자는 이를 게임 admin이 소유하도록 보내야 한다.

플레이어는 게임 보드 object를 소유하지 않으므로 이를 직접 변경할 수 없다. 대신 원하는 위치를 담은 Mark object를 생성하고 transfer to object를 사용해 이를 게임 object로 보내 자신의 움직임을 표시한다:

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

게임을 플레이할 때 admin은 이벤트를 사용해 mark를 추적하는 서비스를 운영한다. 요청이 수신되면(send_mark) admin은 보드에 마커를 놓으려 시도한다(place_mark). 각 움직임에는 두 단계가 필요하며(즉 transaction도 두 개이다), 하나는 플레이어가 보내고 다른 하나는 admin이 보낸다. 이 설정은 게임이 계속 진행되도록 admin의 서비스에 의존한다.

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
}
}

플레이어가 mark를 보내면 Mark object가 생성되어 Game object로 전송된다. 그다음 admin이 mark를 받아 보드에 배치한다. 이는 Game 같은 object가 Mark 같은 다른 object를 담을 수 있게 하는 dynamic object fields의 사용 예이다.

전체 source 코드를 보려면 owned.move source file을 참조한다. 거기에서 승자를 확인하는 방법과 게임이 끝난 뒤 게임 보드를 삭제하는 방법을 포함한 나머지 로직을 찾을 수 있다.

Click to open

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 tic-tac-toe는 중앙화된 서비스를 사용하지 않는 더 직관적인 구현을 위해 shared objects를 사용한다. 이 방식은 shared objects를 사용하는 것이 wholly owned objects만 관련된 transaction보다 더 비싸기 때문에 비용이 약간 더 든다.

shared.move

이전 버전에서는 admin이 게임 object를 소유했기 때문에 플레이어가 게임 보드를 직접 바꾸지 못했고 각 마커 배치에 transaction이 두 개 필요했다. 이 버전에서는 게임 object가 shared object이므로 두 플레이어가 모두 이에 직접 접근하고 수정할 수 있어 마커를 한 번의 transaction으로 배치할 수 있다. 하지만 shared object를 사용하면 일반적으로 추가 비용이 발생하는데, Sui가 서로 다른 transaction의 작업 순서를 정해야 하기 때문이다. 이 게임의 맥락에서는 플레이어가 차례대로 움직일 것으로 예상되므로 이것이 성능에 큰 영향을 주지는 않아야 한다. 전체적으로 보면 이 shared object 접근 방식은 이전 방법보다 구현을 단순화한다.

다음 코드가 보여주듯 이 예시의 Game object는 앞선 예시와 거의 동일하다. 차이점은 게임의 다중 서명 버전에서만 관련되는 admin 필드가 없고 shared object로만 존재하므로(transfer하거나 wrap할 수 없으므로) store도 없다는 점뿐이다.

public struct Game has key {
id: UID,
board: vector<u8>,
turn: u8,
x: address,
o: address,
}

new 함수를 살펴본다:

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,
});
}

게임을 게임 admin에게 보내는 대신 shared object로 인스턴스화한다. 또 다른 눈에 띄는 차이는 이 게임을 플레이할 수 있는 address가 xo 두 개뿐이고 다음 함수인 place_mark에서 이를 확인하기 때문에 TurnCap을 mint할 필요가 없다는 점이다:

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
}
}
Click to open

shared.move

/// An implementation of Tic Tac Toe, using shared objects.
///
/// The `Game` object is shared so both players can mutate it, and
/// contains authorization logic to only accept a move from the
/// correct player.
///
/// The `owned` module shows a variant of this game implemented using
/// only fast path transactions, which should be cheaper and lower
/// latency, but either requires a centralized service or a multi-sig
/// set-up to own the game.
module tic_tac_toe::shared;

/// The state of an active game of tic-tac-toe.
public struct Game has key {
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,
}

/// 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,
}

// === 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 EAlreadyFilled: vector<u8> = b"Attempted to place a mark on a filled slot.";

#[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`. This function should be called
/// by the address responsible for administrating the game.
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,
});
}

/// Called by the next player to add a new mark.
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);

// Confirm that the mark is from the player we expect.
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;

// Check win condition -- if there is a winner, send them the trophy.
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
}
}

// === 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,
}
}

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]
}

// === 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
}

Multisig

다중 서명(multisig) tic-tac-toe는 게임의 owned 버전과 같은 Move 코드를 사용하지만 상호작용 방식이 다르다. 플레이어는 게임을 제3의 admin 계정으로 전송하는 대신 어느 플레이어든 admin을 대신해 서명할 수 있도록 게임 admin 역할을 하는 1-of-2 다중 서명 계정을 만든다. 이 패턴은 합의에 의존하지 않고 최대 10개 계정 사이에서 리소스를 공유하는 방법을 제공한다.

이 구현에서 게임은 게임 admin 역할을 하는 1-of-2 다중 서명 계정 안에 있다. 이 특정 경우에는 플레이어가 두 명뿐이므로 이전 예시가 더 편리한 사용 사례이다. 하지만 이 예시는 어떤 경우에는 다중 서명이 shared objects를 대체할 수 있어 그러한 구현을 사용할 때 transaction이 합의를 우회할 수 있음을 보여준다.

Creating a multisig account

다중 서명 계정(multisig account)은 구성 키 쌍의 공개 키, 그들의 상대 가중치, 그리고 threshold로 정의되며, 구성 키 가운데 서명한 키의 가중치 합이 threshold를 넘으면 서명이 유효하다. 이 경우에는 구성 키 쌍이 최대 두 개이며 각각의 가중치는 1이고 threshold도 1이다. 다중 서명은 같은 공개 키를 두 번 포함할 수 없으므로 플레이어가 자기 자신과 플레이하는 경우를 처리하기 위해 다중 서명을 구성하기 전에 키를 deduplicate한다:

export function multiSigPublicKey(keys: PublicKey[]): MultiSigPublicKey {
const deduplicated: { [key: string]: PublicKey } = {};
for (const key of keys) {
deduplicated[key.toSuiAddress()] = key;
}

return MultiSigPublicKey.fromPublicKeys({
threshold: 1,
publicKeys: Object.values(deduplicated).map((publicKey) => {
return { publicKey, weight: 1 };
}),
});
}
Click to open

MultiSig.ts

import { PublicKey } from "@mysten/sui/cryptography";
import { MultiSigPublicKey } from "@mysten/sui/multisig";

/**
* Generate the public key corresponding to a 1-of-N multi-sig
* composed of `keys` (all with equal weighting).
*/
export function multiSigPublicKey(keys: PublicKey[]): MultiSigPublicKey {
// Multi-sig addresses cannot contain the same public keys multiple
// times. In our case, it's fine to de-duplicate them because all
// keys get equal weight and the threshold is 1.
const deduplicated: { [key: string]: PublicKey } = {};
for (const key of keys) {
deduplicated[key.toSuiAddress()] = key;
}

return MultiSigPublicKey.fromPublicKeys({
threshold: 1,
publicKeys: Object.values(deduplicated).map((publicKey) => {
return { publicKey, weight: 1 };
}),
});
}

Sui의 address는 공개 키에서 파생될 수 있지만(이 사실은 앞선 예시에서 공개 키와 함께하는 address를 기준으로 공개 키를 deduplicate하는 데 사용된다), 그 반대는 성립하지 않는다. 이는 다중 서명 tic-tac-toe 게임을 시작하려면 플레이어가 address가 아니라 공개 키를 교환해야 함을 의미한다.

Building a multisig transaction

다중 서명 게임을 만들 때는 owned::Gameadmin 필드를 사용해 admin 계정의 다중 서명 공개 키를 저장한다. 이 값은 나중에 move의 두 번째 transaction에 대한 서명을 구성하는 데 사용되며, on-chain에 저장할 필요는 없지만 Game의 내용을 가져올 때 공개 키도 함께 얻도록 편의를 위해 저장한다:

newMultiSigGame(player: PublicKey, opponent: PublicKey): Transaction {
const admin = multiSigPublicKey([player, opponent]);
const tx = new Transaction();

const game = tx.moveCall({
target: `${this.packageId}::owned::new`,
arguments: [
tx.pure.address(player.toSuiAddress()),
tx.pure.address(opponent.toSuiAddress()),
tx.pure(bcs.vector(bcs.u8()).serialize(admin.toRawBytes()).toBytes()),
],
});

tx.transferObjects([game], admin.toSuiAddress());

return tx;
}

useTransactions.ts에는 mark를 배치하고, 보내고, 받고, 게임을 끝내고, 완료된 게임을 소각하는 함수도 들어 있다. 이 함수들은 모두 Transaction object를 반환하며, React frontend는 이를 사용해 적절한 서명자로 transaction을 실행한다.

Click to open

useTransactions.ts

import { bcs } from "@mysten/sui/bcs";
import { PublicKey } from "@mysten/sui/cryptography";
import { ObjectRef, Transaction } from "@mysten/sui/transactions";
import { useNetworkVariable } from "config";
import { Game } from "hooks/useGameQuery";
import { TurnCap } from "hooks/useTurnCapQuery";
import { multiSigPublicKey } from "MultiSig";

/** Hook to provide an instance of the Transactions builder. */
export function useTransactions(): Transactions | null {
const packageId = useNetworkVariable("packageId");
return packageId ? new Transactions(packageId) : null;
}

/**
* Builds on-chain transactions for the Tic-Tac-Toe game.
*/
export class Transactions {
readonly packageId: string;

constructor(packageId: string) {
this.packageId = packageId;
}

newSharedGame(player: string, opponent: string): Transaction {
const tx = new Transaction();

tx.moveCall({
target: `${this.packageId}::shared::new`,
arguments: [tx.pure.address(player), tx.pure.address(opponent)],
});

return tx;
}

newMultiSigGame(player: PublicKey, opponent: PublicKey): Transaction {
const admin = multiSigPublicKey([player, opponent]);
const tx = new Transaction();

const game = tx.moveCall({
target: `${this.packageId}::owned::new`,
arguments: [
tx.pure.address(player.toSuiAddress()),
tx.pure.address(opponent.toSuiAddress()),
tx.pure(bcs.vector(bcs.u8()).serialize(admin.toRawBytes()).toBytes()),
],
});

tx.transferObjects([game], admin.toSuiAddress());

return tx;
}

placeMark(game: Game, row: number, col: number): Transaction {
if (game.kind !== "shared") {
throw new Error("Cannot place mark directly on owned game");
}

const tx = new Transaction();

tx.moveCall({
target: `${this.packageId}::shared::place_mark`,
arguments: [tx.object(game.id), tx.pure.u8(row), tx.pure.u8(col)],
});

return tx;
}

sendMark(cap: TurnCap, row: number, col: number): Transaction {
const tx = new Transaction();

tx.moveCall({
target: `${this.packageId}::owned::send_mark`,
arguments: [tx.object(cap.id.id), tx.pure.u8(row), tx.pure.u8(col)],
});

return tx;
}

receiveMark(game: Game, mark: ObjectRef): Transaction {
if (game.kind !== "owned") {
throw new Error("Cannot receive mark on shared game");
}

const tx = new Transaction();

tx.moveCall({
target: `${this.packageId}::owned::place_mark`,
arguments: [tx.object(game.id), tx.receivingRef(mark)],
});

return tx;
}

ended(game: Game): Transaction {
const tx = new Transaction();

tx.moveCall({
target: `${this.packageId}::${game.kind}::ended`,
arguments: [tx.object(game.id)],
});

return tx;
}

burn(game: Game): Transaction {
const tx = new Transaction();

tx.moveCall({
target: `${this.packageId}::${game.kind}::burn`,
arguments: [tx.object(game.id)],
});

return tx;
}
}

Placing a mark

Mark를 배치하려면 owned 예시와 마찬가지로 transaction 두 개가 필요하지만 둘 다 한 플레이어가 주도한다. 첫 번째 transaction은 플레이어 자신으로서 mark를 게임에 보내기 위해 실행되고, 두 번째 transaction은 플레이어가 admin 역할로 방금 보낸 mark를 배치하기 위해 실행된다. React frontend에서는 이를 다음과 같이 수행한다:

function OwnedGame({
game,
trophy,
invalidateGame,
invalidateTrophy,
}: {
game: GameData;
trophy: Trophy;
invalidateGame: InvalidateGameQuery;
invalidateTrophy: InvalidateTrophyQuery;
}): ReactElement {
const adminKey = game.admin ? new MultiSigPublicKey(new Uint8Array(game.admin)) : null;

const client = useCurrentClient();
const { mutate: signAndExecute } = useExecutor();
const { mutate: multiSignAndExecute } = useExecutor({
execute: ({ bytes, signature }) => {
const multiSig = adminKey!!.combinePartialSignatures([signature]);
return client.core.executeTransaction({
transaction: bytes,
signatures: [multiSig, signature],
});
},
});

const [turnCap, invalidateTurnCap] = useTurnCapQuery(game.id);
const account = useCurrentAccount();
const tx = useTransactions()!!;

// ...

const onMove = (row: number, col: number) => {
signAndExecute(
{
tx: tx.sendMark(turnCap?.data!!, row, col),
showObjectChanges: true,
},
({ objectChanges }) => {
const mark = objectChanges?.find(
(c) => c.type === 'created' && c.objectType.endsWith('::Mark'),
);

if (mark && mark.type === 'created') {
const recv = tx.receiveMark(game, mark);
recv.setSender(adminKey!!.toSuiAddress());
recv.setGasOwner(account?.address!!);

multiSignAndExecute({ tx: recv }, () => {
invalidateGame();
invalidateTrophy();
invalidateTurnCap();
});
}
},
);
};

// ...
}
Click to open

Game.tsx

import "./Game.css";

import { useCurrentAccount, useSuiClient } from "@mysten/dapp-kit";
import { MultiSigPublicKey } from "@mysten/sui/multisig";
import { TrashIcon } from "@radix-ui/react-icons";
import { AlertDialog, Badge, Button, Flex } from "@radix-ui/themes";
import { Board } from "components/Board";
import { Error } from "components/Error";
import { IDLink } from "components/IDLink";
import { Loading } from "components/Loading";
import { Game as GameData, InvalidateGameQuery, Mark, useGameQuery } from "hooks/useGameQuery";
import { useTransactions } from "hooks/useTransactions";
import { InvalidateTrophyQuery, Trophy, useTrophyQuery } from "hooks/useTrophyQuery";
import { useTurnCapQuery } from "hooks/useTurnCapQuery";
import { useExecutor } from "mutations/useExecutor";
import { ReactElement } from "react";

type Props = {
id: string;
};

enum Turn {
Spectating,
Yours,
Theirs,
}

enum Winner {
/** Nobody has won yet */
None,

/** X has won, and you are not a player */
X,

/** O has won, and you are not a player */
O,

/** You won */
You,

/** The other player won */
Them,

/** Game ended in a draw */
Draw,
}

/**
* Render the game at the given ID.
*
* Displays the noughts and crosses board, as well as a toolbar with:
*
* - An indicator of whose turn it is.
* - A button to delete the game.
* - The ID of the game being played.
*/
export default function Game({ id }: Props): ReactElement {
const [game, invalidateGame] = useGameQuery(id);
const [trophy, invalidateTrophy] = useTrophyQuery(game?.data);

if (game.status === "pending") {
return <Loading />;
} else if (game.status === "error") {
return (
<Error title="Error loading game">
Could not load game at <IDLink id={id} size="2" display="inline-flex" />.
<br />
{game.error.message}
</Error>
);
}

if (trophy.status === "pending") {
return <Loading />;
} else if (trophy.status === "error") {
return (
<Error title="Error loading game">
Could not check win for <IDLink id={id} size="2" display="inline-flex" />:
<br />
{trophy.error.message}
</Error>
);
}

return game.data.kind === "shared" ? (
<SharedGame
game={game.data}
trophy={trophy.data}
invalidateGame={invalidateGame}
invalidateTrophy={invalidateTrophy}
/>
) : (
<OwnedGame
game={game.data}
trophy={trophy.data}
invalidateGame={invalidateGame}
invalidateTrophy={invalidateTrophy}
/>
);
}

function SharedGame({
game,
trophy,
invalidateGame,
invalidateTrophy,
}: {
game: GameData;
trophy: Trophy;
invalidateGame: InvalidateGameQuery;
invalidateTrophy: InvalidateTrophyQuery;
}): ReactElement {
const account = useCurrentAccount();
const { mutate: signAndExecute } = useExecutor();
const tx = useTransactions()!!;

const { id, board, turn, x, o } = game;
const [mark, curr, next] = turn % 2 === 0 ? [Mark.X, x, o] : [Mark.O, o, x];

// If it's the current account's turn, then empty cells should show
// the current player's mark on hover. Otherwise show nothing, and
// disable interactivity.
const player = whoseTurn({ curr, next, addr: account?.address });
const winner = whoWon({ curr, next, addr: account?.address, turn, trophy });
const empty = Turn.Yours === player && trophy === Trophy.None ? mark : Mark._;

const onMove = (row: number, col: number) => {
signAndExecute({ tx: tx.placeMark(game, row, col) }, () => {
invalidateGame();
invalidateTrophy();
});
};

const onDelete = (andThen: () => void) => {
signAndExecute({ tx: tx.burn(game) }, andThen);
};

return (
<>
<Board marks={board} empty={empty} onMove={onMove} />
<Flex direction="row" gap="2" mx="2" my="6" justify="between">
{trophy !== Trophy.None ? (
<WinIndicator winner={winner} />
) : (
<MoveIndicator turn={player} />
)}
{trophy !== Trophy.None && account ? <DeleteButton onDelete={onDelete} /> : null}
<IDLink id={id} />
</Flex>
</>
);
}

function OwnedGame({
game,
trophy,
invalidateGame,
invalidateTrophy,
}: {
game: GameData;
trophy: Trophy;
invalidateGame: InvalidateGameQuery;
invalidateTrophy: InvalidateTrophyQuery;
}): ReactElement {
const adminKey = game.admin ? new MultiSigPublicKey(new Uint8Array(game.admin)) : null;

const client = useSuiClient();
const { mutate: signAndExecute } = useExecutor();
const { mutate: multiSignAndExecute } = useExecutor({
execute: ({ bytes, signature }) => {
// SAFETY: We check below whether the admin key is available,
// and only allow moves to be submitted when it is.
const multiSig = adminKey!!.combinePartialSignatures([signature]);
return client.executeTransactionBlock({
transactionBlock: bytes,
// The multi-sig authorizes access to the game object, while
// the original signature authorizes access to the player's
// gas object, because the player is sponsoring the
// transaction.
signature: [multiSig, signature],
options: {
showRawEffects: true,
},
});
},
});

const [turnCap, invalidateTurnCap] = useTurnCapQuery(game.id);
const account = useCurrentAccount();
const tx = useTransactions()!!;

if (adminKey == null) {
return (
<Error title="Error loading game">
Could not load game at <IDLink id={game.id} size="2" display="inline-flex" />.
<br />
Game has no admin.
</Error>
);
}

if (turnCap.status === "pending") {
return <Loading />;
} else if (turnCap.status === "error") {
return (
<Error title="Error loading game">
Could not load turn capability.
<br />
{turnCap.error?.message}
</Error>
);
}

const { id, board, turn, x, o } = game;
const [mark, curr, next] = turn % 2 === 0 ? [Mark.X, x, o] : [Mark.O, o, x];

// If it's the current account's turn, then empty cells should show
// the current player's mark on hover. Otherwise show nothing, and
// disable interactivity.
const player = whoseTurn({ curr, next, addr: account?.address });
const winner = whoWon({ curr, next, addr: account?.address, turn, trophy });
const empty = Turn.Yours === player && trophy === Trophy.None ? mark : Mark._;

const onMove = (row: number, col: number) => {
signAndExecute(
{
// SAFETY: TurnCap should only be unavailable if the game is over.
tx: tx.sendMark(turnCap?.data!!, row, col),
options: { showObjectChanges: true },
},
({ objectChanges }) => {
const mark = objectChanges?.find(
(c) => c.type === "created" && c.objectType.endsWith("::Mark"),
);

if (mark && mark.type === "created") {
// SAFETY: UI displays error if the admin key is not
// available, and interactivity is disabled if there is not a
// valid account.
//
// The transaction to make the actual move is made by the
// multi-sig account (which owns the game), and is sponsored
// by the player (as the multi-sig account doesn't have coins
// of its own).
const recv = tx.receiveMark(game, mark);
recv.setSender(adminKey!!.toSuiAddress());
recv.setGasOwner(account?.address!!);

multiSignAndExecute({ tx: recv }, () => {
invalidateGame();
invalidateTrophy();
invalidateTurnCap();
});
}
},
);
};

const onDelete = (andThen: () => void) => {
// Just like with making a move, deletion has to be implemented as
// a sponsored multi-sig transaction. This means only one of the
// two players can clean up a finished game.
const burn = tx.burn(game);
burn.setSender(adminKey!!.toSuiAddress());
burn.setGasOwner(account?.address!!);

multiSignAndExecute({ tx: burn }, andThen);
};

return (
<>
<Board marks={board} empty={empty} onMove={onMove} />
<Flex direction="row" gap="2" mx="2" my="6" justify="between">
{trophy !== Trophy.None ? (
<WinIndicator winner={winner} />
) : (
<MoveIndicator turn={player} />
)}
{trophy !== Trophy.None && player !== Turn.Spectating ? (
<DeleteButton onDelete={onDelete} />
) : null}
<IDLink id={id} />
</Flex>
</>
);
}

/**
* Figure out whose turn it should be based on who the `curr`ent
* player is, who the `next` player is, and what the `addr`ess of the
* current account is.
*/
function whoseTurn({ curr, next, addr }: { curr: string; next: string; addr?: string }): Turn {
if (addr === curr) {
return Turn.Yours;
} else if (addr === next) {
return Turn.Theirs;
} else {
return Turn.Spectating;
}
}

/**
* Figure out who won the game, out of the `curr`ent, and `next`
* players, relative to whose asking (`addr`). `turns` indicates the
* number of turns we've seen so far, which is used to determine which
* address corresponds to player X and player O.
*/
function whoWon({
curr,
next,
addr,
turn,
trophy,
}: {
curr: string;
next: string;
addr?: string;
turn: number;
trophy: Trophy;
}): Winner {
switch (trophy) {
case Trophy.None:
return Winner.None;
case Trophy.Draw:
return Winner.Draw;
case Trophy.Win:
// These tests are "backwards" because the game advances to the
// next turn after the win has happened. Nevertheless, make sure
// to test for the "you" case before the "them" case to handle a
// situation where a player is playing themselves.
if (addr === next) {
return Winner.You;
} else if (addr === curr) {
return Winner.Them;
} else if (turn % 2 === 0) {
return Winner.O;
} else {
return Winner.X;
}
}
}

function MoveIndicator({ turn }: { turn: Turn }): ReactElement {
switch (turn) {
case Turn.Yours:
return <Badge color="green">Your turn</Badge>;
case Turn.Theirs:
return <Badge color="orange">Their turn</Badge>;
case Turn.Spectating:
return <Badge color="blue">Spectating</Badge>;
}
}

function WinIndicator({ winner }: { winner: Winner }): ReactElement | null {
switch (winner) {
case Winner.None:
return null;
case Winner.Draw:
return <Badge color="orange">Draw!</Badge>;
case Winner.You:
return <Badge color="green">You Win!</Badge>;
case Winner.Them:
return <Badge color="red">You Lose!</Badge>;
case Winner.X:
return <Badge color="blue">X Wins!</Badge>;
case Winner.O:
return <Badge color="blue">O Wins!</Badge>;
}
}

/**
* "Delete" button with a confirmation dialog. On confirmation, the
* button calls `onDelete`, passing in an action to perform after
* deletion has completed (returning to the homepage).
*/
function DeleteButton({ onDelete }: { onDelete: (andThen: () => void) => void }): ReactElement {
const redirect = () => {
// Navigate back to homepage, because the game is gone now.
window.location.href = "/";
};

return (
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button color="red" size="1" variant="outline">
<TrashIcon /> Delete Game
</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>Delete Game</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this game? This will delete the object from the
blockchain and cannot be undone.
</AlertDialog.Description>
<Flex gap="3" mt="3" justify="end">
<AlertDialog.Cancel>
<Button variant="soft" color="gray">
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action onClick={() => onDelete(redirect)}>
<Button variant="solid" color="red">
Delete
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
);
}

첫 단계는 앞서 Game.admin에 기록한 다중 서명 공개 키를 가져오는 것이다. 그다음 두 개의 executor hook을 만든다: 첫 번째는 현재 플레이어로서 서명하고 실행하기 위한 것이고, 두 번째는 다중 서명/admin 계정으로서 서명하고 실행하기 위한 것이다. 지갑이 transaction을 직렬화하고 서명한 뒤 두 번째 executor는 지갑 서명으로부터 다중 서명을 만들고 다중 서명과 지갑을 대신하는 두 개의 서명으로 transaction을 실행한다.

두 개의 서명이 필요한 이유는 recv transaction의 구성을 보면 더 분명하다: 다중 서명은 Game에 대한 접근을 승인하고 지갑은 가스 object에 대한 접근을 승인한다. 이는 다중 서명 계정이 자체 코인을 보유하지 않기 때문에 플레이어 계정이 transaction을 스폰서해야 하기 때문이다.

게임의 multi-sig 변형과 shared 변형을 모두 지원하는 React front-end 예시는 ui directory에서 찾을 수 있고, Rust로 작성한 CLI는 cli directory에서 찾을 수 있다.