- Published on
Tic-tac-toe, Your Sixth Seahorse Program
- Authors
- Name
- Lostin
- @__lostin__
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.
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.
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.
@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.
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.
@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.
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.
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.
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.
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.
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.