21 min read
Introduction
In the last lesson, you created a new Solana program using the Anchor framework and Solana Playground. You defined the architecture of your arcade and game and created a state directory where you will define the data structure and tools for modifying state in your arcade and game. In this lesson you will define those state models and tools. Let's get started!
Arcade and Game State
If you recall from our previous lesson, we outlined three different types of data structures we will need in our program:
- Program State - This will be the state of our program. It will include the program's version and the game IDs.
- Game State - This will be the state of a game. It will include the game board, the players, and the game's status.
- Player State - This will be the state of a player. It will include the player's profile, wins, losses, and reward status.
These data will be stored on-chain in PDAs that we will define next lesson when we cover instructions.
Program State
Create a new file in your state called program_state.rs. This will be the state of our program. It will include the program's version and the game IDs. Add the following code to your program_state.rs file:
use anchor_lang::prelude::*;
#[account]
pub struct ProgramState {
pub current_version: u64,
pub current_game_id: u64, // Use this to init next game
pub bump: u8,
}
This is a simple data structure that will be stored on-chain. It includes the current version of the program (this will not be necessary for our demo, but generally, it is a good practice to track this on-chain so interactions can be handled appropriately), the current game ID (we will use this when creating a new game to ensure the new game gets a unique identifier), and a bump. We discussed the bump in the previous lesson--this helps us verify accounts during run-time and make sure they are the accounts we expect.
Next, add an impl block to our program_state.rs file. If you are new to Rust, impl blocks are used to define the implementation of a trait for a type--they are kind of like JavaScript classes. This will include the functions we need to initialize the program state, increment the version, and increment the game ID. Add the following code to your program_state.rs file:
impl ProgramState {
pub fn init(&mut self, bump: u8) {
self.current_version = 1;
self.current_game_id = 1;
self.bump = bump;
}
pub fn increment_version(&mut self) {
self.current_version += 1;
}
pub fn increment_game_id(&mut self) {
self.current_game_id += 1;
}
}
This is pretty straight forward. The init function will initialize the program state with the current version, current game ID, and bump. The increment_version function will increment the current version. The increment_game_id function will increment the current game ID.
Let's create our state for the game and player. These will be a bit more complex.
Player State
Our player state will serve as a simple profile for the player. Each player will be required to create a profile to play a game. This profile will include the player's public key, their record (wins, losses, and ties), a bump, and two booleans to track whether or not they have received play tokens or claimed their reward. Create a new file in your state directory called player.rs. Add the following code to your player.rs file:
use anchor_lang::prelude::*;
#[account]
pub struct Player {
auth: Pubkey,
pub record: Record,
pub airdrop_received: bool,
pub reward_claimed: bool,
pub bump: u8,
}
impl Player {
// TODO: Add functions
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default)]
pub struct Record {
pub wins: u8,
losses: u8,
ties: u8,
}
impl Record {
pub fn calculate_account_space() -> usize {
1 + // wins
1 + // losses
1 // ties
}
pub fn default() -> Self {
Record {
wins: 0,
losses: 0,
ties: 0,
}
}
}
This is a bit more complex than our program state. Let's break it down. First, we have a struct called Player. It includes the fields we discussed above.
- The
authfield is the player's public key. - The
recordfield is the player's record. - The
airdrop_receivedfield is a boolean that will track whether or not the player has received their play tokens. - The
reward_claimedfield is a boolean that will track whether or not the player has claimed their reward. - The
bumpused to derive the player's PDA.
You will notice that we defined record as a struct called Record. So we are defining another struct, Record, that includes three u8 fields: wins, losses, and ties. These will be used to track the player's record. This will be adequate for our simple game, but if you are building something more extensive, you may consider using a u32 or u64 to track the player's record.
We have also included an impl block for our Record struct. This consists of two functions: calculate_account_space and default. The calculate_account_space function will be used to calculate the space required to store the Record struct on chain (though this is relatively simple for our struct, it is a good practice to include some mechanism across your state variables for calculating bytes required. We will need this when initializing the PDA). The default function will initialize the Record struct with values of 0.
We have left a placeholder in our code for our player functions. Let's add those.
Player State Functions
Add the following code to your impl block for your Player struct:
impl Player {
pub fn calculate_account_space() -> usize {
8 + // discriminator
32 + // key
Record::calculate_account_space() + // record
1 + // airdrop received
1 + // reward_claimed
1 // bump
}
pub fn init(&mut self, player: Pubkey, bump: u8) {
self.auth = player;
self.record = Record::default();
self.reward_claimed = false;
self.bump = bump;
}
pub fn record_win(&mut self) {
self.record.wins += 1
}
pub fn record_lose(&mut self) {
self.record.losses += 1
}
pub fn record_tie(&mut self) {
self.record.ties += 1
}
pub fn claim_reward(&mut self) {
self.reward_claimed = true
}
}
Let's break this down.
- First, we have the
calculate_account_spacefunction. This will be used to calculate the space required to store thePlayerstruct on-chain. It includes the space required for thePlayerstruct, theRecordstruct (notice the use of ourRecord::calculate_account_space()function), and thebump. - Next, we have the
initfunction. This will be used to initialize thePlayerstruct. It includes the player's public key, the bump, and theRecord::default()function. Note we do not initialize theairdrop_receivedfield. We could do that here, but we will include it in our instruction after executing the airdrop code. - Next, we have the
record_win,record_lose, andrecord_tiefunctions. These will be used to increment the player's record. - Finally, we have the
claim_rewardfunction. This will set thereward_claimedfield totrue.
Great job! We now have our player state defined. Let's move on to our game state.
Game State
Alright! Great job to this point. So far we have defined some state for our Program and our Players. Let's pivot to our game state. This will be a bit more complex. What will we need to store for a game of Tic-Tac-Toe?
- A unique ID for the game. For simplicity, we can use a u64 (note: each game will also have a unique PDA derived from our u64- we will cover that in the next lesson).
- The players' public keys.
- The game board. We can use a 3x3 array of
Option<Sign>for simplicity. We will need to define a Sign enum that will include X and O. - The game state. For simplicity, we can use an enum that will include NotStarted, Active, Tie, and Won.
- Which turn it is. Let's use a u8 that will be used to track how many turns have been played. We can use this to determine which player's turn it is.
- A bump. This will be used to derive the game's PDA.
- The winner, if there is one. This will be a
Option<Pubkey>.
Create a new file in your state directory called game.rs. Add the following code to your game.rs file:
use anchor_lang::prelude::*;
use crate::errors::TicTacToeError;
use crate::state::Player;
use num_derive::*;
use std::str::FromStr;
#[account]
pub struct Game {
pub id: u64, // 8
pub player_x: Pubkey, // 32
pub player_o: Pubkey, // 32
pub board: [[Option<Sign>; 3]; 3], // 9 * (1 + 1) = 18
pub state: GameState, // 32 + 1
pub turn: u8, // 1
pub bump: u8, // 1
pub winner: Option<Pubkey> // 32
}
impl Game {
// TODO: Add functions
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug, PartialEq, Eq)]
pub enum GameState {
NotStarted,
Active,
Tie,
Won,
}
#[derive(
AnchorSerialize, AnchorDeserialize, FromPrimitive, ToPrimitive, Copy, Clone, PartialEq, Eq,
)]
pub enum Sign {
X, // Player 1
O, // Player 2
}
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct Square {
row: u8,
column: u8,
}
Here's what we are doing:
- Defining a new struct,
Game, will include the abovementioned fields. - Defining an
implblock for ourGamestruct. This will include the functions we need to initialize the game, play a turn, and update the game state. We will add that next. - Defining an enum,
GameState, that will be used to track the game state. - Defining an enum,
Sign, that will be used to track the sign of each player. For simplicity sake, we will assign the game's creator the X sign and the second player the O sign. - Defining a struct,
Square, that will be used to identify where a player is positioning their "X" or "O" on the board. Note the use of Serialize and Deserialize macros. When we store it on-chain, these will be used to serialize and deserialize our data. If we did not include these, we would be unable to store our data on-chain, and our program would error out.
Great! We have our structs and enums defined. Let's consider what functionality we will need to play a game of Tic-Tac-Toe.
- A function to initialize the game.
- A function to allow a player to join and start the game.
- A function to play a turn.
- A function to verify the game state (e.g., win, tie, or active).
- A function to update the state.
- Some helper functions to determine the current player, the current player's sign, and the other player's address.
There are about 200 lines of code to build out our game logic. Since it is just a simple game of Tic-Tac-Toe, we will not go through each line of code. However, we outline each function's purpose below. Add the following code to your impl block for your Game struct:
impl Game {
pub fn calculate_account_space() -> usize {
8 + // discriminator
8 + // id
32 + // player_x
32 + // player_o
18 + // board
33 + // game state
1 + // turn
1 + // bump + 20
32
}
pub fn create(&mut self, player_x: Pubkey, bump: u8, id: u64) {
self.id = id;
self.player_x = player_x;
self.player_o = player_x; // placeholder
self.board = [[None; 3]; 3];
self.state = GameState::NotStarted;
self.turn = 0;
self.bump = bump;
self.winner = None;
}
pub fn start(&mut self, player_o: Pubkey) {
self.player_o = player_o;
self.state = GameState::Active;
self.turn = 1;
self.log_board();
}
pub fn is_active(&self) -> bool {
self.state == GameState::Active
}
fn current_player_index(&self) -> u8 {
((self.turn - 1) % 2) + 1
}
pub fn current_player(&self) -> Pubkey {
if self.current_player_index() == 1 {
self.player_x
} else {
self.player_o
}
}
pub fn current_player_sign(&self) -> Sign {
if self.current_player() == self.player_x {
Sign::X
} else {
Sign::O
}
}
pub fn other_player_pda(&self) -> Pubkey {
// Replace with your program's ID
let program_id = Pubkey::from_str("YOUR_PROGRAM_ID_HERE").unwrap();
let other_player = if self.current_player_index() == 1 {
self.player_o
} else {
self.player_x
};
let (pda, _bump) = Pubkey::find_program_address(
&[
b"player".as_ref(),
other_player.key().as_ref(),
],
&program_id,
);
pda.key()
}
fn log_board(&self) {
for row in 0..=2 {
let mut row_representation = String::new();
for column in 0..=2 {
row_representation.push(match self.board[row][column] {
Some(Sign::X) => 'X',
Some(Sign::O) => 'O',
None => '_',
});
row_representation.push(' ');
}
row_representation.pop(); // Remove the extra space at the end of the row
msg!("Row {}: {}", row + 1, row_representation);
}
}
pub fn play(&mut self, square: &Square, player_record: &mut Player, other_player_record: &mut Player) -> Result<()> {
let sign = self.current_player_sign();
// Attempt to add Player's Sign to Target Square
match square {
square @ Square {
row: 0..=2,
column: 0..=2,
} => match self.board[square.row as usize][square.column as usize] {
Some(_) => return Err(TicTacToeError::SquareAlreadySet.into()),
None => self.board[square.row as usize][square.column as usize] = Some(sign),
},
_ => return Err(TicTacToeError::SquareOffBoard.into()),
}
// Check for Win & Tie.
self.update_state();
// Update Record.
if GameState::Won == self.state {
player_record.record_win();
other_player_record.record_lose();
}
if GameState::Tie == self.state {
player_record.record_tie();
other_player_record.record_tie();
}
if GameState::Active == self.state {
self.turn += 1;
}
self.log_board();
Ok(())
}
fn is_winning_trio(&self, trio: [(usize, usize); 3]) -> bool {
let [first, second, third] = trio;
self.board[first.0][first.1].is_some()
&& self.board[first.0][first.1] == self.board[second.0][second.1]
&& self.board[first.0][first.1] == self.board[third.0][third.1]
}
fn update_state(&mut self) {
for i in 0..=2 {
// three of the same in one row
if self.is_winning_trio([(i, 0), (i, 1), (i, 2)]) {
self.state = GameState::Won;
self.winner = Some(self.current_player());
msg!("Winner is: {}", self.current_player());
return;
}
// three of the same in one column
if self.is_winning_trio([(0, i), (1, i), (2, i)]) {
self.state = GameState::Won;
self.winner = Some(self.current_player());
msg!("Winner is: {}", self.current_player());
return;
}
}
// three of the same in one diagonal
if self.is_winning_trio([(0, 0), (1, 1), (2, 2)])
|| self.is_winning_trio([(0, 2), (1, 1), (2, 0)])
{
self.state = GameState::Won;
self.winner = Some(self.current_player());
msg!("Winner is: {}", self.current_player());
return;
}
//Reaching this code means the game has not been won,
// so if there are unfilled tiles left, it's still active
for row in 0..=2 {
for column in 0..=2 {
if self.board[row][column].is_none() {
return;
}
}
}
//The game has not been won
// game has no more free tiles
// -> game ends in a tie
self.state = GameState::Tie;
}
}
Make sure to replace your own program's ID in defining the other_player_pda function. You can find this in the Build & Deploy menu of your program in Solana Playground.
Here's an overview of each function:
calculate_account_spacecalculates the space required to store theGamestruct on-chain.createinitializes the game with a state of NotStarted (effectively waiting for another player). For simplicity, we will set the player's public key to both the X and O player. We will update the O player when they join the game.startwill be used to start the game. It will set the game state to Active and set the turn to 1. It will also update the O player's public key.is_activewill be used to check whether the game state is Active.current_player_indexwill determine the current player's index. This will be used to determine which player's turn it is.current_playerwill determine the current player's public key. We are deffining here that Player 1 will be the X player and Player 2 will be the O player.current_player_signwill be used to get the current player's sign (e.g., X or O) based on their public key.other_player_pdagets the other player's PDA. This will help run error checks to ensure the correct player is playing. Make sure to replace your own program's ID in defining theother_player_pdafunction.log_boardlogs the game board to our program logs so we can see it in Solana Explorer. This will render a simple board like this:
Row 1: X _ X
Row 2: _ O _
Row 3: _ _ _
playwill be used to play a turn. It will take aSquarestruct and first check if the square is already set. If it is, it will return an error. If not, it will set the square to the current player's sign. It will then check if the game has been won or tied. If it has, it will update the game state and the players' records and increment the turn.is_winning_triois a helper function that checks whether a player has won the game. It will take a trio of squares and check if they are all the same sign. If they are, it will returntrue. If not, it will returnfalse.update_statewill be used to update the game state. It will check if the game has been won or tied. If it has, it will update the game state and the winner.
Take some time to review this code. After you finish this Program, you can use this concept to implement your own game logic on-chain!
Stateful!
Great job! You have now defined the state of your arcade and game. In the next lesson we will cover instructions and how to interact with your program.