Published on

Tic-tac-toe, Your Sixth Seahorse Program

Authors

Welcome back. Let's write some more Seahorse! This tutorial is part 6 of a series and follows on from the previous calculator example. Start here if you're an absolute beginner.

Today we'll be creating a classic 2 player game of Tic-tac-toe, also known as noughts and crosses wikipedia. The entire state of the game will be stored on Solana including the grid, the current player's turn and eventually the winner. This will require 2 wallets, 1 for each player, to take turns interacting with the program. We'll be using Solana Playgrounds for this tutorial.

The Game State

Okay, so let's start with how to represent the classic tic-tac-toe 3x3 grid on Solana. For the sake of simplification we'll be taking the 9 boxes and flattening them into a traditional zero indexed array of length 9.

tic-tac-toe tutorial 2

We'll also need to keep track of who the players are. Who's turn it is and if the game is active or finished. These are all represented in the Game account as such below.

TicTacToe.py

class Game(Account):
  grid: Array[u8,9]
  players: Array[Pubkey,2]
  curr_player: u8
  game_status: u8

Let's create an instruction to initialize a new game. The function will take 4 arguments, the signer, a Game account instance and the 2 public addresses of the 2 players, 1 of which will be the signer. The game instance is initialized the the seeds of a String ttt and the owner's public key address. We set the game status to 0 which means active, when the game is finished this will turn to 1. The current player is set to Player 1.

TicTacToe.py

@instruction
def init_game(owner: Signer, player1: Pubkey, player2: Pubkey, game: Empty[Game]):
  game = game.init(
    payer = owner,
    seeds = ['ttt', owner]
  )
  game.players[0] = player1
  game.players[1] = player2
  game.game_status = 0
  game.curr_player = 1

Next we'll set up some Enums that allow us to express the 4 possible winning states of a game. Either the game is still in progress, which we'll refer to as Game, or the game was a draw with neither player winning, a fairly common outcome with Tic-Tac-Toe. Or a player has won, which could be player 1 or player 2. We'll place this towards the top of the file before the instructions.

TicTacToe.py

class GameState(Enum):
  Game = 0
  Player1Wins = 1
  Player2Wins = 2
  Draw = 3

Now for our second instruction which allows for a player to take their turn. The function takes 4 arguments, the Signer, the Game instance, the player and their move. The first part of this instruction is input validation. We do 5 checks to ensure that the instruction is being called in a correct way that fits the rules of the game. Each of these checks is commented below.

TicTacToe.py

@instruction
def play_game(player:Signer, game_data:Game, played_by:u8, move_position:u8):
   
  # check the game is active
  assert game_data.game_status == 0, 'This game is already finished'

  # check for valid signer
  assert game_data.players[played_by-1] == player.key(), 'Invalid Signer'
   
  # check the correct player is taking their turn
  assert played_by == game_data.curr_player, 'Invalid Player'

  # check that move is possible
  assert move_position > 0 and move_position < 10, 'Invalid move, off the grid'
   
  # check that grid position is unoccupied
  assert game_data.grid[move_position - 1] == 0, 'Invalid move, position occupied'

After the input validation we move to writing our game logic. Firstly we decrement the move_position by 1 so it fits the zero indexing of the array. Then we mark the grid with the player's number. Next we check if the player has won. This logic will be separated out into a separate function win_check which we'll come to later. We switch the current player. Lastly we print the status of the game based on the result of the win_check function.

TicTacToe.py

move_position -= 1
 
  game_data.grid[move_position] = game_data.curr_player
   
  game_status = win_check(Array(game_data.grid, len = 9), game_data.curr_player)

  if game_data.curr_player == 2:
    game_data.curr_player = 1
  else:
    game_data.curr_player = 2

  if(game_status == GameState.Game):
    print("Ready for next move")
   
  if(game_status == GameState.Player1Wins):
    game_data.game_status=1
    print("Player1 wins")
   
  if(game_status == GameState.Player2Wins):
    game_data.game_status=2
    print("Player2 wins")
   
  if(game_status == GameState.Draw):
    game_data.game_status=3
    print("Draw")

Next we want to create our win_check function which checks the grid to see if a player has won the game. There are 8 possible lines which can be drawn on a 3x3 grid which gives us 8 possible win conditions in a game of Tic-Tac-Toe. We check each one in turn and if any one of the conditions is true we return a game state indicating the current player has won. If no one has won, we also check to see if the board is full. If we find it is full, we return a game state of Draw otherwise we return Game which indicates the game is still in play.

TicTacToe.py

def win_check(grid: Array[u8,9], player: u8)-> GameState:

  # check for 8 possible win conditions
  if((grid[0] == player and grid[1] == player and grid[2] == player) or
    (grid[0] == player and grid[3] == player and grid[6] == player) or
    (grid[6] == player and grid[7] == player and grid[8] == player) or
    (grid[2] == player and grid[5] == player and grid[8] == player) or
    (grid[0] == player and grid[4] == player and grid[8] == player) or
    (grid[2] == player and grid[4] == player and grid[6] == player) or
    (grid[1] == player and grid[4] == player and grid[7] == player) or
    (grid[3] == player and grid[4] == player and grid[5] == player)):
 
    if player == 1:
      return GameState.Player1Wins
    else:
      return GameState.Player2Wins

  # check for full board i.e. draw
  for i in range(9):
    if grid[i] == 0:
        return GameState.Game

  return GameState.Draw


Final code

Adding our usual seahorse import and ID declaration to the top, the final code should look something like this.

TicTacToe.py

from seahorse.prelude import *

declare_id('')

class GameState(Enum):
  Game = 0
  Player1Wins = 1
  Player2Wins = 2
  Draw = 3

class Game(Account):
  players: Array[Pubkey,2]
  grid: Array[u8,9]
  game_status: u8
  curr_player: u8


@instruction
def init_game(owner: Signer, player1: Pubkey, player2: Pubkey, game: Empty[Game]):
  game = game.init(
    payer = owner,
    seeds = ['ttt', owner]
  )
  game.players[0] = player1
  game.players[1] = player2
  game.game_status = 0
  game.curr_player = 1


def win_check(grid: Array[u8,9], player: u8)-> GameState:

  # check for 8 possible win conditions
  if((grid[0] == player and grid[1] == player and grid[2] == player) or
    (grid[0] == player and grid[3] == player and grid[6] == player) or
    (grid[6] == player and grid[7] == player and grid[8] == player) or
    (grid[2] == player and grid[5] == player and grid[8] == player) or
    (grid[0] == player and grid[4] == player and grid[8] == player) or
    (grid[2] == player and grid[4] == player and grid[6] == player) or
    (grid[1] == player and grid[4] == player and grid[7] == player) or
    (grid[3] == player and grid[4] == player and grid[5] == player)):
 
    if player == 1:
      return GameState.Player1Wins
    else:
      return GameState.Player2Wins

  # check for full board i.e. draw
  for i in range(9):
    if grid[i] == 0:
        return GameState.Game

  return GameState.Draw


@instruction
def play_game(player:Signer, game_data:Game, played_by:u8, move_position:u8):
   
  # check the game is active
  assert game_data.game_status == 0, 'This game is already finished'

  # check for valid signer
  assert game_data.players[played_by-1] == player.key(), 'Invalid Signer'
   
  # check the correct player is taking their turn
  assert played_by == game_data.curr_player, 'Invalid Player'

  # check that move is possible
  assert move_position > 0 and move_position < 10, 'Invalid move, off the grid'
   
  # check that grid position is unoccupied
  assert game_data.grid[move_position - 1] == 0, 'Invalid move, position occupied'

  move_position -= 1
 
  game_data.grid[move_position] = game_data.curr_player
   
  game_status = win_check(Array(game_data.grid, len = 9), game_data.curr_player)

  if game_data.curr_player == 2:
    game_data.curr_player = 1
  else:
    game_data.curr_player = 2

  if(game_status == GameState.Game):
    print("Ready for next move")
   
  if(game_status == GameState.Player1Wins):
    game_data.game_status=1
    print("Player1 wins")
   
  if(game_status == GameState.Player2Wins):
    game_data.game_status=2
    print("Player2 wins")
   
  if(game_status == GameState.Draw):
    game_data.game_status=3
    print("Draw")


Testing

Build and deploy the code using Solana Playgrounds. Create the IDL and do some testing of your own on the testing tab. To test properly you will need to input 2 player addresses, both of which you control. Fortunately Playgrounds makes this easy for us. Simply generate a new wallet address from the wallet tab and use the send function to send a small amount, 0.1 is more than enough, to the newly created wallet address. This could also be done from the command line without a GUI if you wish to practice your Solana CLI commands.

tic-tac-toe tutorial 4

Below is some Typescript to run automated tests. Copy and paste this into the seahorse.test.ts file which you can find in the tests folder on the explorer tab. Simply type test on the shell command prompt to run the tests. For simplicity the tests use the same wallet for player 1 and player 2 which is possible as there is no program requirement that these be different addresses.

tic-tac-toe tutorial 3
seahorse.test.ts

describe("TTT game", async () => {
  const systemProgram = anchor.web3.SystemProgram;
  const player1 = pg.wallet.publicKey;
  const player2 = pg.wallet.publicKey;

  function printgame(grid) {
    console.log(`${grid[0]} ${grid[1]} ${grid[2]}`);
    console.log(`${grid[3]} ${grid[4]} ${grid[5]}`);
    console.log(`${grid[6]} ${grid[7]} ${grid[8]}`);
  }

  let [game] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("ttt"), pg.wallet.publicKey.toBytes()],
    pg.program.programId
  );

  it("Create Game!", async () => {
    let tx = await pg.program.methods
      .initGame(player1, player2)
      .accounts({
        owner: pg.wallet.publicKey,
        game: game,
        systemProgram: systemProgram.programId,
      })
      .rpc();

    console.log("Create game tx signature", tx);
    console.log("Game address", game.toString());
  });

  it("Turn 1", async () => {
    let person = 1;
    let position = 5;

    let tx = await pg.program.methods
      .playGame(person, position)
      .accounts({
        player: player1,
        gameData: game,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log("Turn 1 signature", tx);

    const game_account = await pg.program.account.game.fetch(game);
    printgame(game_account.grid);
  });

  it("Turn 2", async () => {
    let person = 2;
    let position = 9;

    let tx = await pg.program.methods
      .playGame(person, position)
      .accounts({
        player: player2,
        gameData: game,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log("Turn 2 signature", tx);

    const game_account = await pg.program.account.game.fetch(game);
    printgame(game_account.grid);
  });
});

Running these tests should output something like this to the console.

tic-tac-toe tutorial 5