Skip to main content

Intro to Solana Development - Lesson 5 - Game Instructions

Updated on
Mar 18, 2025

26 min read

Introduction

GM! Congrats if you have made it this far, you are crushing it, and you are almost done! Today we are going to finish up our program by creating the instructions that will govern how your program works. If you recall from the previous lesson, we created a lib.rs that called six different instructions:

  1. Initialize the Program
  2. Create a New Player
  3. Create a New Game
  4. Join a Game
  5. Play/Make a Move
  6. Claim Reward

Today, we are going to create those instructions. You will recall that instructions have two main components:

  1. Context: This is the list of accounts that must be passed in to the instruction.
  2. Instruction: this is the code that will be executed when the instruction is called. Since we did a great job with our state, we will be able to utilize that state to make our instructions very simple.

Let's jump right in!

Initialize the Program

Open your program in Solana Playground and navigate to the instructions folder we created earlier. Go ahead and create a new file, init.rs. This file will contain the code for the initialize instruction.

use anchor_lang::prelude::*;
use anchor_spl::token::{Token,Mint};
use crate::state::program_state::*;

pub fn init(ctx: Context<Init>) -> Result<()> {
let program_state = &mut ctx.accounts.program_state;
program_state.init(ctx.bumps.program_state);
Ok(())
}

#[derive(Accounts)]
pub struct Init<'info> {
#[account(
init,
payer = payer,
mint::decimals = 0,
mint::authority = mint,
seeds = [b"play_token_mint"],
bump
)]
pub mint: Account<'info, Mint>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub rent: Sysvar<'info, Rent>,
#[account(
init,
payer = payer,
space = 200,
seeds = [b"program_state"],
bump
)]
pub program_state: Account<'info, ProgramState>,
}

Our init instruction is going to do two things:

  1. Create a new program state account that will be used to store the state of our program. This is done inside the instruction, calling our init function from our program_state state. Notice in our context that we are initializing this with 200 bytes instead of calculating the size of our state. This is sometimes done to account for the future growth of the state.
  2. Create a new token mint that will be used to mint tokens for our game. Anchor takes a lot of the hassle out of creating a new token mint, and we can do it with just a few lines of code. Note, here that we are setting the token mint's authority to itself, mint. This handy trick will allow us not to have to pass in an additional account each time we want to mint a new token. This is possible because we are using the seeds parameter to create a PDA for our token mint (therefore controlled by our program). Because we are doing everything with the anchor init constraint, we do not even need to include any additional code to create the token mint. Anchor will do it for us!

A couple of notes:

  • we are using b"program_state" and b"play_token_mint" as seeds for our PDAs (remember the 'b' is for byte string or buffer). These are just arbitrary seeds that we are using to create our PDAs. You can use whatever you want, but you must use the same seeds when calling the instructions that use these PDAs. It is not uncommon for people to save these seeds as constants in their program.
  • we need to pass in the system_program and rent accounts to our instruction. This is because we are creating new accounts and need to pay rent on those accounts. We must also pass in the token_program account because we use the token program to make a new token mint.

Great job! When we call this from our client, we will effectively initiate our program state and have a new token mint. This instruction, as designed, can only be called once (because we are using init constraints on our accounts). This is a good thing because we only want to initialize our program once. We did not do it here, but you might consider in the future adding an address constraint on the payer account to make sure that only the program owner can initialize the program.

Create a New Player

Next, we will create the instruction to create a new player. The new player instruction will have three components:

  1. Create a new player PDA using the .init() function from our player state.
  2. Mint and distribute new play tokens to the player.
  3. Update the player state to reflect that the player has received their airdrop.

Open up a new file, create_player.rs in the instructions folder.

use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token::{mint_to, Mint, Token, MintTo, TokenAccount},
};
use crate::state::player::*;

pub fn create_player(ctx: Context<CreatePlayer>) -> Result<()> {
// Initialize the Player
let new_player = &mut ctx.accounts.player_pda;
new_player.init(ctx.accounts.player.key(),ctx.bumps.player_pda);

// Airdrop 10 Game Tokens
let signer_seeds: &[&[&[u8]]] = &[&[b"play_token_mint", &[ctx.bumps.mint]]];
let airdrop_amount: u64 = 10;

mint_to(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.player_token_account.to_account_info(),
authority: ctx.accounts.mint.to_account_info(),
},
)
.with_signer(signer_seeds), // using PDA to sign
airdrop_amount
)?;
new_player.airdrop_received = true;

Ok(())
}

#[derive(Accounts)]
pub struct CreatePlayer<'info> {
#[account(mut)]
pub player: Signer<'info>,
#[account(
init,
payer = player,
space = Player::calculate_account_space(),
seeds = [b"player", player.key().as_ref()],
bump
)]
pub player_pda: Account<'info, Player>,
#[account(
mut,
seeds = [b"play_token_mint"],
bump
)]
pub mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = player,
associated_token::mint = mint,
associated_token::authority = player,
)]
pub player_token_account: Account<'info, TokenAccount>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>
}

Let's walk through the context first. Here's a breakdown of the accounts we will need to pass in:

  • player: this is the user's account that is signing and creating the new player account.
  • player_pda: the account the instruction will create. It will be a PDA of type Player. We are seeding the account using the literal string b"player" and the player's public key. This will create a PDA that is unique to each player.
  • mint: this is the token mint we created in the init instruction. We need to pass this in so that we can mint new tokens.
  • player_token_account: this is the token account that will be created for the player. Much like creating a new mint, Anchor abstracts quite a bit of complexity here. We just need to pass in the associated_token_program and the token_program accounts, and Anchor will create the new token account for the specified authority (in this case, the player) and mint (in this case, the mint we created in the init instruction).
  • system_program: we need to pass in the system program so that we can create the new PDA and token account.

Now, let's walk through the instruction.

  • First, we are going to initialize the player PDA. We are using the .init() function from our player state. This will create a new player PDA and set the player's public key as the authority.
  • Next, we use the anchor_spl mint_to function to mint and distribute 10 new tokens to the player. To do this, we need to make a call to the Token Program using a Cross Program Invocation (CPI). A CPI is a fancy way of saying that we are calling a function from another program. We are using the CpiContext to call the mint_to function from the token_program account. We are using the signer_seeds to sign the transaction with the PDA. We are not going to get too deep into CPIs in this course. If you would like to learn more, you can check out our guide on CPIs, here.
  • Finally, after the tokens are minted to the user, we set the airdrop_received flag to true.

Create a New Game

Next, we will create the instruction to create a new game. The new game instruction will have three components:

  1. Burn a play token from the player's account using another CPI to the token program.
  2. Create a new game PDA using the .create() function from our game state.
  3. Update the program state to reflect that increment the game count.

Structurally, this will be pretty similar to our previous instruction. Create a new file, create_game.rs in the instructions folder, and add the following:

use anchor_lang::prelude::*;
use anchor_spl::token::{burn, Burn, Mint, Token, TokenAccount};
use crate::state::game::*;
use crate::state::player::*;
use crate::state::program_state::*;

pub fn create_game(ctx: Context<CreateGame>, game_id: u64) -> Result<()> {
// Burn Payment Token
let play_fee: u64 = 1;
let cpi_accounts = Burn {
mint: ctx.accounts.mint.to_account_info(),
from: ctx.accounts.player_token_account.to_account_info(),
authority: ctx.accounts.player_x.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
burn(cpi_ctx, play_fee)?;

// Create Game
let game = &mut ctx.accounts.game;
let program_state = &mut ctx.accounts.program_state;
game.create(
ctx.accounts.player_x.key(),
ctx.bumps.game,
program_state.current_game_id,
);

// Update Program State

program_state.increment_game_id();

Ok(())
}

#[derive(Accounts)]
#[instruction(game_id: u64)]
pub struct CreateGame<'info> {
// Game Account
#[account(
init,
payer = player_x,
space = Game::calculate_account_space(),
seeds = [
b"new_game",
&(program_state.current_game_id).to_le_bytes()
],
bump
)]
pub game: Account<'info, Game>,

// Player/Signer Wallet
#[account(mut)]
pub player_x: Signer<'info>,

// Player PDA (record)
#[account(
seeds = [b"player", player_x.key().as_ref()],
bump = player_pda.bump
)]
pub player_pda: Account<'info, Player>,

// Fee Mint
#[account(
mut,
seeds = [b"play_token_mint"],
bump
)]
pub mint: Account<'info, Mint>,

// Players Token Account
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = player_x,
)]
pub player_token_account: Account<'info, TokenAccount>,

pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,

#[account(
mut,
seeds = [b"program_state"],
bump = program_state.bump
)]
pub program_state: Account<'info, ProgramState>,
}

Let's walk through our accounts in the context:

  • game is the account the instruction will create. It will be a PDA of type Game. We are seeding the account using the literal string b"new_game" and the game's id (remember, we are tracking this in our program state, so each time we create a new game, we can reference program_state.current_game_id to make sure we do not accidentally try to create the same game twice). This will create a PDA that is unique to each game.
  • player_x: is the user's account signing and creating the new game account. We are setting this up such that the player who makes the game will always play the 'X' sign.
  • player_pda: this is the account of the player PDA. We need this here to verify that the signer (player_x) has already created a player account. Note that we use player_x's public key to seed the PDA. This will ensure the instruction can only be called by a player who has already created a player account.
  • mint: this is the token mint we created in the init instruction. We need to pass this in so that we can burn tokens.
  • player_token_account: this is the token account from which we will burn the play fee.
  • token_program: we need to pass in the token program to interact with the Token Program.
  • system_program: we need to pass in the system program to create the new PDA.
  • program_state: we need to pass in the program state to update the game count.
  • Note that we have included a new parameter in our #[derive(Accounts)] macro: #[instruction(game_id: u64)]. This tool allows us to use instruction parameters within our contexts (e.g., to validate accounts or specify seeds). Since we are utilizing the program_state account to track the current game ID, we do not need this but have included it so you do not get surprised when you see it elsewhere.

Now, let's walk through the instruction.

  • First, we are setting a play_fee of 1 token. This fee will be burned from the player's account to create a new game. You can change this if you like.
  • Next, we use the anchor_spl burn function to burn the play fee from the player's account. To do this, we need to make a call to the Token Program using a CPI. We are using the player_x account as the authority to sign the transaction. We are burning the play_fee amount from the player_token_account account.
  • Finally, we are creating a new game PDA using the .create() function from our game state. This will create a new game PDA and set the player's public key as the authority. We are also incrementing the program_state current_game_id to reflect that we have created a new game.

Join a Game

Next, we will create the instruction for another player to join an open game (one that has yet to start). The join game instruction will have three components:

  • Verification checks to ensure that the game is open and the player has not already joined the game.
  • Burn a play token from the player's account using another CPI to the token program.
  • Update the game state to add the player to the game and to start the game.

Create a new file, join_game.rs in the instructions folder, and add the following:

use anchor_lang::prelude::*;
use anchor_spl::token::{burn, Burn, Mint, Token, TokenAccount};
use crate::errors::TicTacToeError;
use crate::state::game::*;
use crate::state::player::*;

pub fn join_game(ctx: Context<JoinGame>) -> Result<()> {
let game = &mut ctx.accounts.game;
let new_player = &ctx.accounts.player_o;

// Make sure not same as player 1
require!(
game.player_x != new_player.key(),
TicTacToeError::InvalidPlayer
);
// Make sure game hasn't started yet
require!(
game.state == GameState::NotStarted,
TicTacToeError::GameAlreadyStarted
);

// Burn Payment Token
let play_fee: u64 = 1;
let cpi_accounts = Burn {
mint: ctx.accounts.mint.to_account_info(),
from: ctx.accounts.player_token_account.to_account_info(),
authority: ctx.accounts.player_o.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
burn(cpi_ctx, play_fee)?;

// Join/Start the game
game.start(new_player.key());
Ok(())
}

#[derive(Accounts)]
pub struct JoinGame<'info> {
// Game Account
#[account(
mut,
seeds = [
b"new_game",
&(game.id).to_le_bytes()
],
bump = game.bump
)]
pub game: Account<'info, Game>,

// Player/Signer Wallet
#[account(mut)]
pub player_o: Signer<'info>,


// Player PDA (record)
#[account(
seeds = [b"player", player_o.key().as_ref()],
bump = player_pda.bump
)]
pub player_pda: Account<'info, Player>,

// Fee Mint
#[account(
mut,
seeds = [b"play_token_mint"],
bump
)]
pub mint: Account<'info, Mint>,

// Players Token Account
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = player_o,
)]
pub player_token_account: Account<'info, TokenAccount>,

pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}

This should look very similar to the create_game instruction, so we will not cover everything in detail. Something new here, however, is that we are making use of the require! macro to validate that the game is in the correct state and that the player is not the same as the player who created the game. If either of these conditions are not met, the instruction will fail.

Play/Make a Move

Alright! You've set up your program, created a couple of players, and started a game. Now it's time to play! This instruction will be very simple since we built such a comprehensive state. We just need to run a couple of checks to ensure that the correct player is making the move and that the game is active. Then, we can call our play function from our game state.

Create a new file, play.rs in the instructions folder, and add the following:

use anchor_lang::prelude::*;
use crate::errors::TicTacToeError;
use crate::state::game::*;
use crate::state::player::*;

pub fn play(ctx: Context<Play>, square: Square) -> Result<()> {
let game = &mut ctx.accounts.game;
let player = &ctx.accounts.player;
let player_record = &mut ctx.accounts.player_record;
let other_player_record = &mut ctx.accounts.other_player_record;

require_keys_eq!(
game.current_player(),
player.key(),
TicTacToeError::NotPlayersTurn
);

require_keys_eq!(
game.other_player_pda(),
other_player_record.key(),
TicTacToeError::InvalidPlayer
);

require!(
game.is_active(),
TicTacToeError::GameNotActive
);

game.play(&square, player_record, other_player_record)
}

#[derive(Accounts)]
pub struct Play<'info> {
// Player/Signer Wallet
#[account(mut)]
pub player: Signer<'info>,

// Player PDA (record)
#[account(
mut,
seeds = [b"player", player.key().as_ref()],
bump = player_record.bump
)]
pub player_record: Account<'info, Player>,

// We verify this inside program.
#[account(mut)]
pub other_player_record: Account<'info, Player>,

// Game Account
#[account(
mut,
seeds = [
b"new_game",
&(game.id).to_le_bytes()
],
bump = game.bump
)]
pub game: Account<'info, Game>,

pub system_program: Program<'info, System>,
}

This should be looking familiar at this point. There's nothing particularly new here. I'll note that in our context, we are defining our player PDAs as player_record and other_player_record. I did this to help me track why we have to pass these PDAs and why they are mutable. We must pass them if play is the final move in a game. In that case, we must update the player's record to reflect that they have won, lost, or tied the game. Our instruction includes a few checks to ensure we have passed the correct players and that the game is active. The only other thing to note is that we must pass a Square as an instruction parameter. We will see this in action when we create our client.

Claim Reward

Finally, we will create the instruction to claim a reward. The purpose here is after a player wins X number of games, we allow them to mint an NFT prize! This instruction is a little more complicated than the others, but you are up to the task!

The claim reward instruction will have four elements:

  • Verification checks to ensure the player has won enough games and has yet to claim their reward.
  • Mint a new NFT using a CPI to the Token Program.
  • Create a new metadata and master addition account using a CPI to the Token Metadata Program.
  • Update the player's record to reflect that they have claimed their reward.

Add the following to a new file, claim_reward.rs in the instructions folder:

#![allow(clippy::result_large_err)]
use {
anchor_lang::prelude::*,
anchor_spl::{
associated_token::AssociatedToken,
metadata::{
create_master_edition_v3, create_metadata_accounts_v3, CreateMasterEditionV3,
CreateMetadataAccountsV3, Metadata,
},
token::{mint_to, Mint, MintTo, Token, TokenAccount},
},
mpl_token_metadata::{
accounts::{Metadata as MetadataAccount, MasterEdition},
types::DataV2
},
};
use crate::state::player::*;
use crate::errors::TicTacToeError;

// User must win this number of games before being able to claim
const CLAIM_THRESHOLD: u8 = 1;

pub fn claim_reward(ctx: Context<ClaimReward>) -> Result<()> {
// Replace with your NFT details
let nft_name: String = "Quick-Tac-Toe Trophy".to_string();
let nft_symbol: String = "QTT".to_string();
let nft_uri: String = "https://arweave.net/PkmMMr2GNK3eraWcat-pl7BwGGUQN5QLEyzDtIjbYWI".to_string();
let player_pda = &mut ctx.accounts.player_pda;

// Check that player has won enough games and not already claimed
require!(player_pda.record.wins >= CLAIM_THRESHOLD, TicTacToeError::NotEligibleForReward);
require!(!player_pda.reward_claimed, TicTacToeError::RewardAlreadyClaimed);

// Cross Program Invocation (CPI)
// Invoking the mint_to instruction on the token program
mint_to(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
MintTo {
mint: ctx.accounts.mint_account.to_account_info(),
to: ctx.accounts.associated_token_account.to_account_info(),
authority: ctx.accounts.player.to_account_info(),
},
),
1,
)?;

// Cross Program Invocation (CPI)
// Invoking the create_metadata_account_v3 instruction on the token metadata program
create_metadata_accounts_v3(
CpiContext::new(
ctx.accounts.token_metadata_program.to_account_info(),
CreateMetadataAccountsV3 {
metadata: ctx.accounts.metadata_account.to_account_info(),
mint: ctx.accounts.mint_account.to_account_info(),
mint_authority: ctx.accounts.player.to_account_info(),
update_authority: ctx.accounts.player.to_account_info(),
payer: ctx.accounts.player.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accou
nts.rent.to_account_info(),
},
),
DataV2 {
name: nft_name,
symbol: nft_symbol,
uri: nft_uri,
seller_fee_basis_points: 0,
creators: None,
collection: None,
uses: None,
},
false, // Is mutable
true, // Update authority is signer
None, // Collection details
)?;

// Cross Program Invocation (CPI)
// Invoking the create_master_edition_v3 instruction on the token metadata program
create_master_edition_v3(
CpiContext::new(
ctx.accounts.token_metadata_program.to_account_info(),
CreateMasterEditionV3 {
edition: ctx.accounts.edition_account.to_account_info(),
mint: ctx.accounts.mint_account.to_account_info(),
update_authority: ctx.accounts.player.to_account_info(),
mint_authority: ctx.accounts.player.to_account_info(),
payer: ctx.accounts.player.to_account_info(),
metadata: ctx.accounts.metadata_account.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
},
),
None, // Max Supply
)?;

player_pda.reward_claimed = true;
Ok(())
}

#[derive(Accounts)]
pub struct ClaimReward<'info> {
#[account(mut)]
pub player: Signer<'info>,

// Player PDA (record)
#[account(
mut,
seeds = [b"player", player.key().as_ref()],
bump = player_pda.bump
)]
pub player_pda: Account<'info, Player>,


/// CHECK: Address validated using constraint
#[account(
mut,
address=MetadataAccount::find_pda(&mint_account.key()).0
)]
pub metadata_account: UncheckedAccount<'info>,

/// CHECK: Address validated using constraint
#[account(
mut,
address=MasterEdition::find_pda(&mint_account.key()).0
)]
pub edition_account: UncheckedAccount<'info>,

// Create new mint account, NFTs have 0 decimals
#[account(
init,
payer = player,
mint::decimals = 0,
mint::authority = player.key(),
mint::freeze_authority = player.key(),
)]
pub mint_account: Account<'info, Mint>,

// Create associated token account, if needed
// This is the account that will hold the NFT
#[account(
init,
payer = player,
associated_token::mint = mint_account,
associated_token::authority = player,
)]
pub associated_token_account: Account<'info, TokenAccount>,

pub token_program: Program<'info, Token>,
pub token_metadata_program: Program<'info, Metadata>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}

There are some new things here, so let's walk through it. Let's start with the context:

  • player: this is the user's account that is signing and claiming the reward.
  • player_pda: this is the account of the player PDA. We need this to verify that the player has not claimed their reward. The PDA is mutable so that we can update the reward_claimed flag.
  • metadata_account: this account will be created by the instruction. It will be a PDA of a Metaplex Metadata account.
  • edition_account: this account will be created by the instruction. It will be a PDA of a Metaplex Master Edition account.
  • mint_account: this is the token mint that will be used to mint the NFT. We will just generate a new key on the client side and pass it in as a parameter.
  • associated_token_account: this is the token account that will be created for the user to hold the NFT.
  • token_program: we need to pass in the token program to interact with the Token Program.
  • token_metadata_program: we need to pass in the token metadata program to interact with the Metaplex Token Metadata Program.
  • associated_token_program: we need to pass in the associated token program to create the new token account for the NFT.
  • system_program: we need to pass in the system program to create the new PDAs.
  • rent: we need to pass in the rent sysvar so we can calculate and pay rent exemption on the new PDAs.

Now, let's walk through the instruction.

  • First, we are setting a CLAIM_THRESHOLD of 1. This is the number of games a player must win before claiming their reward. You can change this if you like.
  • Next, we are defining the name, symbol, and URI (which must comply with the metadata standard) for our NFT. You can change these if you like.
  • Next, we use the require! macro to validate that the player has won enough games and has not already claimed their reward. If either of these conditions are not met, the instruction will fail.
  • Next, we use the anchor_spl mint_to function to mint the NFT to the player. To do this, we need to make a call to the Token Program using a CPI. We use the player account as the authority to sign the transaction. We are minting one token to the associated_token_account account.
  • Next, we use the anchor_spl create_metadata_accounts_v3 function to create the metadata account for the NFT. To do this, we need to make a call to the Token Metadata Program using a CPI.
  • Next, we use the anchor_spl create_master_edition_v3 function to create the master edition account for the NFT. To do this, we need to make a call to the Token Metadata Program using a CPI.
  • Finally, we set the reward_claimed flag to true to ensure the player cannot claim the reward again.

GAME TIME!

Congrats! You have finished your program. Take a moment to pat yourself on the back. We just went through a lot of material, and you have done a great job! Make sure you take your time and go back and review anything that you are not clear on.

In the next lesson, we will write tests to ensure our program works as expected. In the next lesson, we will create a client-side API and front-end to interact with our program. Finally, we will deploy our program to the devnet and play a game of tic-tac-toe!


Sign in to track your course progress and interact with quizzes
Create a free Quicknode account to save your progress, complete knowledge checks, and mark lessons complete.
Create AccountSign In