Soroban: Tic-Tac-Toe

tl;dr: code repo is here .

Stellar has previously not supported open smart contracts, unlike most other notable L1s. That is about to change with Soroban, Stellar’s Smart Contract Platform. The platform is expected to co-exist with the current Stellar network, taking advantage of all Assets already issued on the network (yes including USDC).

In this tutorial we will go over how to create and test a smart contract for Soroban in rust. The example contract we will create is a simple game of tic-tac-toe.

Buckle up or you might end up losing some NFTs.

HappyTimes!
HappyTimes!

Environment Setup

For this tutorial you’ll need to:

  1. install rust,

  2. have a code-editor (vscode highly recommended)

  3. install the Soroban cli (for testing)

See this guide on how to do this.

Assuming you’ve configured those things we start our project by creating a new crate with cargo where we will write out our contract:

cargo new --lib tic-tac-toe
cd tic-tac-toe

Open the generated cargo.toml file. It should look something like this:

[package]
name = "tic-tac-toe"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Change its content to look like this:

[package]
name = "tic-tac-toe"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[features]
testutils = ["soroban-sdk/testutils"]

[dependencies]
soroban-sdk = "0.1.0"

[dev_dependencies]
soroban-sdk = { version = "0.1.0", features = ["testutils"] }

[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true

[profile.release-with-logs]
inherits = "release"
debug-assertions = true

Strictly speaking because we will not be releasing this contract to Soroban the .release keys are not necessary. But we’ll still leave them in just to be thorough.

Open src/lib.rs and remove all the code from that file. Start with a clean slate. In this file is where we will implement our contract.

Implementing Tic-Tac-Toe

Every lib.rs file that contains a smart contract for Soroban will start with the following line:

#![no_std]

This ensures that

the Rust standard library is not included in the build. The Rust standard library is large and not well suited to being deployed into small programs like those deployed to blockchains. - SorobanDocs

Because we don’t have the std library to lean on for types like strings and vectors we turn to the help of the Soroban Rust SDK and import the types we want to use in our contract by adding the following line to our lib.rs

#![no_std]

use soroban_sdk::{ contracterror, contractimpl, contracttype, panic_error, symbol, vec, Address, Env, Symbol, Vec,
};

This is enough tools for us to define how our contract shall work. To structure data / functions in rust we use a struct and to indicate where the implementation of our contract is implemented we use the attribute #[contractimpl] that we imported from the soroban sdk above. So we add to lib.rs

pub struct TicTacToeContract;

pub trait TicTacToeTrait {}   

#[contractimpl]
impl TicTacToeTrait for TicTacToeContract {}

Here we have created our Tic-Tac-Toe contract struct, a trait for the contract, and an implementation block. This is now an empty contract.

*Note: if you’re coming from some other language than rust this may be a little much to grasp (I recommend the rust-lang book to get the gist of how rust works) if you are a newbie in rust-land (like yours truly), but I’ll explain this code briefly.

First we define the empty struct TicTacToeContract that has no fields, this struct represents the Smart Contract people will be able to call. But what (who) do they call? Aha! We must define some functions so people can interact with our contract. Let’s do that shall we.

Create a Tic-Tac-Toe Game

Let’s begin with the most important function of our contract. Enable people to create a Tic-Tac-Toe game.

To do that we first have to know what data we want to store as part of each game. Therefore we begin by defining how our game data will look like using a, you guessed it, struct.

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Game {
    pub challenger: Address,
    pub opposition: Address,
    pub p_turn: Address, // Player's turn
    pub board: Vec<CellState>,
    pub game_state: GameState,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GameState {
    InPlay,
    Winner(Address),
    Draw,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CellState {
    Empty,
    X,
    O,
}
 
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
    GameDoesNotExist = 1,
    GameFinito = 2,
    NotYourTurn = 3,
    InvalidPlay = 4,
    NonPlayer = 5,
}

        

Here in our Game struct we have the challenger (the account that created the game, he will play as ‘X’) and the opposition (the account the challenger wants to play with, he will play as ‘O’). We also have a p_turn field that will always be in one of two states; either p_turn=challenger or p_turn=opposition. We’ll use it to know whose turn it is. The dataType of these fields is Address (imported from the rust-sdk) which is just the address for each player.

We also define how we will represent our board. A Vec<CellState> a vector of variants of the enum CellState where each variant is represents the cell’s state (::Empty, ::X and ::O). In our minds our board then looks like this:

[ idx0, idx1, idx2 ]
[ idx3, idx4, idx5 ]
[ idx6, idx7, idx8 ]

where idx* is the data in the index in the board-vector, board: Vec<CellState> . So we deduce that each board-vector will only be of length 9 , we therefore would’ve wanted to define our board like this board: [CellState; 9] but the #[contracttype] attribute prohibits us from having arrays in our ContractType structs, I don’t know why though... But regardless we can still use Vec to create our contract and will do so here and continue with our Game.

Finally, in our game struct we define the state of each game with game_state , ::InPlay, ::Winner and ::Draw.

Note: To define a custom type that we want to save / persist to the blockchain we need to give it the attribute #[contracttype] . Of course we want to persist each game of tic-tac-toe on the chain, therefore we give all our structs above this attribute.

Now that we have just started to break a sweat structuring all our data we can start defining our create function!

It’s really pretty simple. We start by adding the function to our trait.

pub trait TicTacToeTrait {
  fn create(env: Env, opposition: Address) -> u32;
}   

Lets go over what all this means.

  1. env: Env → When we create functions for our Smart contracts that clients will be able to invoke we have the option of putting as our first parameter an object or interface (i.e. env: Env) that we can use to interact with the host environment the contract will be running in (i.e. Soroban). This interface gives us many important methods. For this tutorial the methods we’ll use are env.invoker() , which gives us the id of the entity that invoked the function, env.data().set and env.data().get that let us read and write data to the blockchain.

  2. opposition: Address → When a client / challenger creates a game of tic-tac-toe he has to identify the opposition (‘O’ player) that he will play against.

  3. → u32 → If the function is successful in creating the game it will return an unsigned 32-bit integer. This integer is the id of the created game.

#[contractimpl]
impl TicTacToeTrait for TicTacToeContract {
    fn create(env: Env, opposition: Address) -> u32 {
        let game_id = 0;
        let new_board = get_empty_board(&env);

        // Save new game to the chain
        env.data().set(
            game_id,
            Game {
                challenger: env.invoker(),
                opposition: opposition.clone(),
                board: new_board,
                p_turn: env.invoker(),
                game_state: GameState::InPlay,
            },
        );

        return game_id;
    }

}

This is our “Soroban! Create a game of tic-tac-toe please!“ function. To quote myself

It’s really pretty simple

It:

  1. creates a game_id

  2. creates an empty board

  3. saves the Game into the Soroban state using the host environment’s key-value store (with game_id as key)

  4. returns the game_id

Pretty simple right? Now there is a small problem with this implementation, our game_id is always zero. Which means on each invocation of this function we are overwriting the previous created game with game_id zero. This is easy to fix. We create a helper function:

const GAME_COUNT: Symbol = symbol!("GAME_COUNT");
fn get_next_game_id(env: &Env) -> u32 {
    env
    .data()
    .get(GAME_COUNT)
    .unwrap_or(Ok(0))
    .unwrap()
}

Here we get the value of GAME_COUNT from our host environment (if it has not been defined we return 0) and that number will be the game_id of the next created game. And while we’re at it let’s create a function for creating a new empty board just to be more descriptive:

fn get_empty_board(env: &Env) -> Vec<CellState> {
    let mut new_board = vec![env];
    for _ in 1..10 {
        new_board.push_back(CellState::Empty);
    }
    new_board
}

Now we’re cooking and our create implementation looks like this:

#[contractimpl]
impl TicTacToeTrait for TicTacToeContract {
    fn create(env: Env, opposition: Address) -> u32 {
        let game_id = get_next_game_id(&env);
        let new_board = get_empty_board(&env);

        // Save new game to the chain
        env.data().set(
            game_id,
            Game {
                challenger: env.invoker(),
                opposition: opposition.clone(),
                board: new_board,
                p_turn: env.invoker(),
                game_state: GameState::InPlay,
            },
        );

        // increment num of games
        env.data().set(GAME_COUNT, game_id + 1);
        return game_id;
    }

}

You can also see we added an increment to the game_id just to make sure the next id is unique from the previous one.

YAY! Now people can create a game of Tic-Tac-Toe without relying on those pesky TicTacToe GateKeepers! Congratz🦥

Having this great create function is of course amazing but it feels like there is something missing…

Players gotta play!

Play a game of Tic-Tac-Toe

The play function is a bit more complex than the create one. Let’s start by adding the function to our trait:

pub trait TicTacToeTrait {
    fn create(env: Env, opposition: Address) -> u32;
    fn play(env: Env, game_id: u32, pos: u32) -> bool;
}

Now we’ll go over what this means like we did with the create function.

  1. env: Env → The injected host environment.

  2. game_id → the id of the game the invoker wants to make a move.

  3. pos → the position on the game board (values allowed 0-8) that the invoker wants to write his symbol (‘X’ if invoker is challenger, otherwise ‘O’)

  4. → bool → returns if the game is over or not (over = true)

Simple enough. Now the implementation:

impl TicTacToeTrait for TicTacToeContract {
    fn create(env: Env, opposition: Address) -> u32 {/* Create Implementation */}

    fn play(env: Env, game_id: u32, pos: u32) -> bool {
        assert!(pos <= 8, "Position supplied is not on board");

        match env.data().get::<_, Game>(game_id) {
            None => {
                panic_error!(&env, Error::GameDoesNotExist)
            }
            Some(w_game) => {
                let mut game = w_game.unwrap();

                // Assert the game is still "InPlay"
                if game.game_state != GameState::InPlay {
                    panic_error!(&env, Error::GameFinito)
                }

                // Assert the invoker has the next turn of the game
                if !env.invoker().eq(&game.p_turn) {
                    panic_error!(&env, Error::NotYourTurn)
                }

                let posit = game.board.get(pos).unwrap_or(Ok(CellState::X)).unwrap();

                if posit != CellState::Empty {
                    panic_error!(&env, Error::InvalidPlay);
                }

                if env.invoker().eq(&game.opposition) {
                    game.board.set(pos, CellState::O); // Opposition is 'O'
                    game.p_turn = game.challenger.clone();
                } else if env.invoker().eq(&game.challenger) {
                    game.board.set(pos, CellState::X); // Challenger is 'X'
                    game.p_turn = game.opposition.clone();
                } else {
                    panic_error!(&env, Error::NonPlayer)
                }

                game.game_state = get_current_state(&game);
                env.data().set(game_id, &game);

                return game.game_state != GameState::InPlay;
            }
        }
    }
}

This is our “Soroban! Make this move for me in Game → game_id, Please!” function. To unquote myself:

It’s really pretty complex

Nah ok, it’s not that complex. Let’s break it down.

  1. First it checks / asserts that the invoker is not trying to put his mark on a cell that is not a part of the board (i.e. cell.idx > 8)

  2. checks if a game corresponding to game_id exists in host state (if it doesn’t we panic with the corresponding error message!)

  3. unwrap the game struct and assert the game’s state is still in play (i.e. neither player has already won nor is the game in a draw state)

  4. assert / check that it is the invoker’s turn to make a move (we don’t want someone else messing with our game, especially not our opponent).

  5. assert / check that the cell on the board that the invoker is trying to mark with ‘X’ or ‘O’ is actually in an empty state

  6. If the invoker is

    1. challenger

      1. mark the cell with index pos with ‘X’

      2. change whose turn it is to opposition

    2. opposition

      1. mark the cell with index pos with ‘O’

      2. change whose turn it is to challenger

  7. get / set the new GameState of the game with respect to the newest play

  8. return whether or not the game is over.

As we can see most of the code regards asserting whether the invoker has permission to play the play he is trying to play (playx3 🥳). Mostly we are verifying the following:

  1. Is the game still InPlay (i.e. neither has won nor is there a draw)?

  2. Is the invoker’s turn to play?

  3. Is the cell he is trying to set his mark on in Empty state?

If any of these 3 conditions are not met the function invocation will fail. But if they are all met we check whether the invoker is the Challenger (‘X’) or if he is the Opposition (‘O’) and update the game board with this new play accordingly + then we swap whose player’s turn it is by setting the .p_turn field. Finally we check the state of the game and save it to the chain.

Wow! That is a bit more lines of code than the create function implementation. But most of it is checking that the play is valid and the player invoking the function is allowed to make the play. The other code is just updating the Game’s data. You still here? Respect.

We still have not defined how the get_current_state function works. Admittedly, it is probably not the cleanest way to check the state of a given game of tic-tac-toe but it is simple to understand. So what we want is that the function,get_current_state, takes in a game and checks if either player has won the game, if it is a draw or if it is still in play. We’ll implement this the easy way. We’ll

  1. check all configurations of winning moves (3 cells across, diagonal, down)

  2. If check 1 finds that a symbol (‘X’ or ‘O’) is in some winning triple of 3 cells the function returns the variant GameState::Winner with the winner’s Address as its data.

  3. If check 1 finds no winner the function checks if all cells are filled in

    1. If they are it returns a GameState::Draw

    2. If not it returns a GameState::InPlay

Simple enough. Let us implement.

fn get_cell_state(game: &Game, pos: u32) -> CellState {
    game.board.get_unchecked(pos).unwrap()
}

fn get_game_state(game: &Game, winner: CellState) -> GameState {
    match winner {
        CellState::X => return GameState::Winner(game.challenger.clone()),
        CellState::O => return GameState::Winner(game.opposition.clone()),
        _ => return GameState::InPlay,
    }
}

fn get_current_state(game: &Game) -> GameState {
    for tmp in 0..3 {
        if get_cell_state(&game, tmp) != CellState::Empty
            && get_cell_state(&game, tmp) == get_cell_state(&game, tmp + 3)
            && get_cell_state(&game, tmp) == get_cell_state(&game, tmp + 6)
        {
            return get_game_state(game, get_cell_state(&game, tmp));
        }

        let tmp = tmp * 3;

        if get_cell_state(&game, tmp) != CellState::Empty
            && get_cell_state(&game, tmp) == get_cell_state(&game, tmp + 1)
            && get_cell_state(&game, tmp) == get_cell_state(&game, tmp + 2)
        {
            return get_game_state(game, get_cell_state(&game, tmp));
        }
    }

    if (get_cell_state(&game, 4) != CellState::Empty
        && get_cell_state(&game, 0) == get_cell_state(&game, 4)
        && get_cell_state(&game, 0) == get_cell_state(&game, 8))
        || (get_cell_state(&game, 2) == get_cell_state(&game, 4)
            && get_cell_state(&game, 2) == get_cell_state(&game, 6))
    {
        return get_game_state(game, get_cell_state(&game, 4));
    }

    for tmp in 0..9 {
        if get_cell_state(game, tmp) == CellState::Empty {
            return GameState::InPlay;
        }
    }

    GameState::Draw
}

get_current_state just loops through the game board checking each cell-triple on their state. If they all have the same value (CellState::X vs CellState::O), and are not CellState::Empty, the function returns with the winner as the value of the GameState::Winner variant. Now if no winner is found the function checks if all cells are non-empty, if so the function returns GameState::Draw. If not it returns GameState::InPlay.

Thats it!

Nice! We now have a contract that people can use to create and play a game of Tic-Tac-Toe.

Just for show we want people to be able to query our contract for specific game data. So let’s add as a final addition to our contract a function that gets a game’s data from its game_id .

Get Game

We add another function to our trait.

pub trait TicTacToeTrait {
    fn create(env: Env, opposition: Address) -> u32;
    fn play(env: Env, game_id: u32, pos: u32) -> bool;
    fn get_game(env: Env, game_id: u32) -> Game;
}

Then we implement the trait with an easy, simple, soft and cuddly read function.

#[contractimpl]
impl TicTacToeTrait for TicTacToeContract {
    fn create(env: Env, opposition: Address) -> u32 {
        /* Create Implementation */
    }

    fn play(env: Env, game_id: u32, pos: u32) -> bool {
        /* Play Implementation */
    }

    fn get_game(env: Env, game_id: u32) -> Game {
        match env.data().get::<_, Game>(game_id) {
            None => {
                panic_error!(&env, Error::GameDoesNotExist)
            }
            Some(game) => game.unwrap(),
        }
    }
}

By now we know what all this does so we just add it to our code and we’re good to go. The resulting src/lib.rs then looks like this.

Grrrrreeeat! Nothing left for us to do but compile it, release it and watch all the millions of people onboarded to Web3 use our piping hot TicTacToe contract to play together. But no, wait! Wait a minute! STOP!

wut?
wut?

Now wait a minute. We don’t even know if our contract works… Maybe there is a bug in our code which will be exploited resulting in our millions of users being vulnerable to some black hat hacker manipulating their supposedly immutable tic-tac-toe victory! That can’t happen! It must not happen! This is why we have to test our contract first.

Testing the Contract

Our tic-tac-toe contract is ready. However.

All code is guilty, until proven innocent. - Code Constitution

We want to test if it works. Ok. Start by adding the following line to the bottom of your src/lib.rs file:

#[cfg(test)]
mod test;

We will make a minor test that we, or you, can later build on. So this will just be a single, minor, low hanging fruit kind of test. But that is ok for now 👌 Create the file src/test.rs and put the following code there:

#![cfg(test)]

extern crate std;

use super::*;
use soroban_sdk::{testutils::Accounts, Env, Address};

#[test]
fn test() {
    let env = Env::default();
    let contract_id = env.register_contract(None, TicTacToeContract);
    let client = TicTacToeContractClient::new(&env, &contract_id);
    let challenger = env.accounts().generate();
    let opposition = env.accounts().generate();
    std::println!("challenger: {:?}", challenger);
    std::println!("opposition: {:?}", opposition);

    let game_id = client.with_source_account(&challenger).create(&Address::Account(opposition.clone()));
    std::println!("Game Id: {}", game_id);
    for tmp in 0..3 {
        client.with_source_account(&challenger).play(&game_id, &tmp);
        if tmp == 2 {
            let game_result = client.get_game(&game_id);
            assert!(
                game_result.game_state != GameState::InPlay,
                "Game should be done"
            );
            assert_eq!(
                game_result.game_state,
                GameState::Winner(Address::Account(challenger.clone())),
                "Challenger should've won"
            );
            std::println!("{:?}", game_result.board.clone());
        } else {
            client.with_source_account(&opposition).play(&game_id, &(tmp + 3));
        }
    }
    let game = client.with_source_account(&opposition).get_game(&game_id);
    std::print!("{:?}", game.board);
}

Now we’ll not go through extensive explanation how everything up there works, for a simple example about testing Soroban contracts see the docs.

What we are basically doing here is registering our contract (TicTacToeContract) with the testing host-environment, which in turn creates a TicTacToeContractClient that we can use to interact with our contract in a similar way as it would be out in the real world (i.e. the jungle). We then generate two userAccounts, create a game and play the simplest / dumbest game of tic-tac-toe there is. The result-ing ending of the game being:

[ X,       X,     X   ]
[ O,       O,   EMPTY ]
[ EMPTY, EMPTY, EMPTY ]

To run the test execute:

cargo test -- --nocapture

** The --nocapture flag reverts the silencing of the printlns. **

You’ll see something similar to the following ouput.

running 1 test
challenger: AccountId(GCGH767LBWFD7NTYQUPOIYXANFIFAZT4ZPZ3UOKRQ3ESZ2U5JENIHUCG)
opposition: AccountId(GBO6FJBCMK5MYYWQ6Z7UJPMHYLVBGMVVJ346QQEFIRGCPL7QIXGA5KD5)
Game Id: 0
Vec(Ok(X), Ok(X), Ok(X), Ok(O), Ok(O), Ok(Empty), Ok(Empty), Ok(Empty), Ok(Empty))
Vec(Ok(X), Ok(X), Ok(X), Ok(O), Ok(O), Ok(Empty), Ok(Empty), Ok(Empty), Ok(Empty))
test test::test ... ok

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

   Doc-tests tic-tac-toe

running 0 tests

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

The end

Damn son! You made it! Very nice. Congratz on creating your (maybe) first Smart Contract for Soroban in rust! Respect ✊

Additional thoughts

This is a very nice start to our Tic-Tac-Toe contract but there are some things that could make it even cooler.

  1. Make it so that on each play a player nominates another address to make the next play.

  2. Solidify the end state of each game as an NFT. Maybe one for the victor and another for the loser.

  3. Advanced: Wager on the game. Both let 3rd parties bet on the outcome of the game and let the players themselves wager against each other before the game starts. Both simple ERC-20 like token wagers and NFTs.

Whatever you decide to do next I hope this tutorial was of some help and made writing “immutable” smart contracts a bit easier to understand 🐢 Now go make something cool!

🐢🐢🐢🐢🐢

Subscribe to Turtle
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
Author Address
0xfcd2E272798b5d9…5E8F22f5472cbB8
Content Digest
PoOA5ZmKakq2eNE…0CIaEgIYwhsrIOA