Skip to main content

Intro to Solana Development - Lesson 6 - Game API

Updated on
Mar 18, 2025

25 min read

Getting started

As you recall from your testing in the previous lessons, Anchor makes interacting with Solana programs much more accessible. In this lesson, you will go a step further and create a client-side API to interact with the game program you created in the previous lesson. This will allow you to create a game client to interact with the program from your front end. We will use that to create a useGame hook for easy interactions from our React components. Let's jump in!

Create two new files in the ./utils/game/ directory:

  1. gameClient.ts to create the client-side API, and
  2. useGame.tsx to create our context and hook. Note: be careful of file types.

Game Client

The game client will be a simple class that will wrap the Anchor program client. It will have a constructor that takes in an Anchor Program object, and it will have several methods for interacting with our program.

Setup Client

Let's start by importing the necessary dependencies and creating a class for our game client. We will also create a static method that will allow us to create a new instance of our game client.

Open ./utils/game/gameClient.ts and add the following code:

import * as anchor from "@coral-xyz/anchor";
import { Keypair, PublicKey, TransactionInstruction } from "@solana/web3.js";
import { SEEDS } from "../constants";
import { TicTacToe } from "./idl/idl";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
import { PlayerInfo, Game, GameAndPda, Record, Square } from "./types";

export class TicTacToeClient {
private readonly program: anchor.Program<TicTacToe>;
public playerInfo: PlayerInfo | undefined;
private constructor(program: anchor.Program<TicTacToe>) {
if (!program) {
throw new Error("Program must be provided.");
}
this.program = program;
}

public static from(program: anchor.Program<TicTacToe>): TicTacToeClient {
return new TicTacToeClient(program);
}

public isReady(): boolean {
return this.program !== undefined;
}

private setPlayerInfo(newInfo: PlayerInfo): void {
this.playerInfo = newInfo;
}

// TODO: Add Private Methods

// TODO: Add Public Methods
}

Make sure you install the new dependency, @coral-xyz/anchor. In your terminal, run:

yarn add @coral-xyz/anchor # or npm install @coral-xyz/anchor

We are importing much of what we created in our previous lesson: the IDL, seeds, and types. We are also importing a few elements from the Solana Web3.js and the SPL Token libraries. We will use these to create a few helper methods.

The TicTacToeClient class has a private constructor, so we will use the static from method to create a new instance of our game client. This will allow us to create a new instance of our game client from our GameProgramProvider (we will make this in a bit). We have also added an isReady method that will allow us to check if our game client is ready to be used.

We are making a playerInfo property public so that we can access it from our hook. We will use this to store a player's info (if they have registered) so they can easily access it from our hook. We have included a setter method that will allow us to update the player's info.

Private Methods

Let's add a few private methods to our game client. These will be helper methods that will make it easier to interact with our program. Add the following methods to your TicTacToeClient:

    private getProgramStatePda(): PublicKey {
const [programStatePda, _programStateBump] = PublicKey.findProgramAddressSync(
[SEEDS.programState],
this.program.programId
);
return programStatePda;
}

private getPlayerPda(playerPubKey: PublicKey): PublicKey {
const [playerPda, _playerBump] = PublicKey.findProgramAddressSync(
[SEEDS.player, playerPubKey.toBuffer()],
this.program.programId
);
return playerPda;
}

private getPlayTokenMint(): PublicKey {
const [mintPda, _mintBump] = PublicKey.findProgramAddressSync(
[SEEDS.mint],
this.program.programId
);
return mintPda;
}

private getPlayerTokenAccount(playerPubKey: PublicKey): PublicKey {
const mint = this.getPlayTokenMint();
return getAssociatedTokenAddressSync(mint, playerPubKey);
}

private getGamePda(gameId: number): PublicKey {
function numberBuffer(value: bigint): Uint8Array {
const bytes = new Uint8Array(8);
for (let i = 0; i < 8; i++) {
bytes[i] = Number(value & BigInt(0xff));
value = value >> BigInt(8);
}
return bytes;
}
const [gamePda, _gameBump] = PublicKey.findProgramAddressSync(
[SEEDS.game, numberBuffer(BigInt(gameId))],
this.program.programId
);
return gamePda;
}

public async getPlayerIfRegistered(playerPubKey: PublicKey): Promise<PlayerInfo | undefined> {
const playerPda = this.getPlayerPda(playerPubKey);

try {
// @ts-ignore This works -- need to research type issue w/ IDL
const accountInfo = await this.program.account.player.fetch(playerPda);
const tokenAccount = this.getPlayerTokenAccount(playerPubKey);
const accountBalance = await this.program.provider.connection.getTokenAccountBalance(tokenAccount);
const playerInfo: PlayerInfo = {
playerPda,
playerTokenAccount: this.getPlayerTokenAccount(playerPubKey),
playerTokenBalance: accountBalance.value.uiAmount ?? 0,
record: accountInfo.record as unknown as Record,
rewardClaimed: accountInfo.rewardClaimed,
}
this.setPlayerInfo(playerInfo);
return playerInfo;
} catch (err) {
// Expected error - player not registerred yet
return undefined;
}
}

private async getCurrentGameId(): Promise<number> {
const programStatePda = this.getProgramStatePda();
//@ts-ignore This works -- need to research type issue w/ IDL
const state = await this.program.account.programState.fetch(programStatePda);
return state.currentGameId.toNumber();
}

private isPlayerInGame(game: Game, player: PublicKey): boolean {
if (!game) throw new Error("Game must be provided.");
if (!player) throw new Error("Player must be provided.");
return game.playerX.toBase58() === player.toBase58() ||
game.playerO.toBase58() === player.toBase58();
}

Let's walk through these methods:

  • The first five methods are helper methods that will allow us to get the PDAs for our program state, player, token mint, player token account, and game. These are the same methods used in our program tests, so they should look familiar.
  • The getPlayerIfRegistered method will allow us to get a player's info if they have registered and call our setter method to update the player's info.
  • The getCurrentGameId method will allow us to get the current game ID from our program state.
  • The isPlayerInGame method will allow us to check if a player is in a game. We will check to see if the player's PublicKey matches either the playerX or playerO PublicKey in the game.

Public Methods - State

Let's add a few public methods we will use in our front end to help us manage the UI/UX based on the game state. Inside of your TicTacToeClient class, add the following methods:

    public async fetchAllGames(): Promise<GameAndPda[]> {
// @ts-ignore This works
const games = await this.program.account.game.all() as unknown as GameAndPda[];
return games;
}

public async fetchGame(gamePda: PublicKey): Promise<Game> {
// @ts-ignore This works
const game = await this.program.account.game.fetch(gamePda) as unknown as Game;
return game;
}

public canPlay(game: Game, player: PublicKey | null): boolean {
if (!player) return false;
if (!game) return false;
if (!game.state) return false;

const isGameActive = 'active' in game.state;
const isPlayerInGame = game.playerX.toBase58() === player.toBase58() ||
game.playerO.toBase58() === player.toBase58();
return isGameActive && isPlayerInGame
}

public canJoin(game: Game, player: PublicKey | null): boolean {
if (!player) return false;
if (!game) return false;
if (!game.state) return false;

const isGameNotStarted = 'notStarted' in game.state;
const isPlayerNotInGame = game.playerX.toBase58() !== player.toBase58() &&
game.playerO.toBase58() !== player.toBase58();
return isGameNotStarted && isPlayerNotInGame
}

public lookingForOpponent(game: Game): boolean {
if (!game) throw new Error("Game must be provided.");
return 'notStarted' in game.state;
}

public isParticipating(game: Game, player: PublicKey | null): boolean {
if (!game) throw new Error("Game must be provided.");
if (!player) return false;
return game.playerX.toBase58() === player.toBase58() ||
game.playerO.toBase58() === player.toBase58();
}

public whoseTurn(game: Game): PublicKey | null {
if (!game) throw new Error("Game must be provided.");
if (!game.state) throw new Error("Game state must be provided.");
if ('notStarted' in game.state) return null;
if ('won' in game.state) return null;
if ('tie' in game.state) return null;
if ('active' in game.state) {
const isPlayerX = ((game.turn - 1) % 2) + 1 === 1;
return isPlayerX ? game.playerX : game.playerO;
}
return null;
}

public otherPlayer(game: Game, player: PublicKey): PublicKey {
if (!game) throw new Error("Game must be provided.");
if (!player) throw new Error("Player must be provided.");
if (!this.isPlayerInGame(game, player)) throw new Error("Player must be in game.");
return game.playerX.toBase58() === player.toBase58() ? game.playerO : game.playerX;
}

Let's walk through each of these methods:

  • The fetchAllGames method will allow us to fetch all the games from our program. We will use this to display all of the games in our UI.
  • The fetchGame method will allow us to fetch a specific game from our program. We will use this to fetch a game when we want to display it in our UI.
  • The canPlay method will allow us to check if a player can play in a game. We will use this to determine UI elements/messaging in our game board. We are checking to see if the game is active and if the player is in the game.
  • The canJoin method will allow us to check if a player can join a game. We will use this to determine UI elements/messaging in our game board. We are checking to see if the game has yet to start and if the player is not in the game.
  • The lookingForOpponent method will allow us to check if a game is looking for an opponent. We will use this to determine UI elements/messaging in our game board. We are checking to see if the game has yet to start.
  • The isParticipating method will allow us to check whether a player participates in a game regardless of game status.
  • The whoseTurn method will allow us to check whose turn it is in a game. We will use this to determine UI elements/messaging in our game board. We are checking to see if the game is active and if it is player X's or O's turn.
  • The otherPlayer method will allow us to get the other player in a game. We will use this to determine UI elements/messaging in our game board. We are checking to see if the player is in the game and returning the other player.

Public Methods - Instructions

Finally, if you recall, we created a reusable SendTransactionTemplate component in our Solana Wallet Adapter lesson a few lessons ago. Among other things, the component takes a TransactionInstruction[] as a prop. We can create methods in our game client that will return a TransactionInstruction that we can pass to our SendTransactionTemplate component. This is nearly identical to how we created and sent instructions in our tests, except instead of using .rpc() to send methods, we will use .instruction() to get the Instruction. Let's add a few methods to our game client that will allow us to create instructions for creating a new player, creating a new game, joining a game, and playing a square. Add the following methods to your TicTacToeClient:

    public async createNewPlayerInstruction(player: PublicKey): Promise<TransactionInstruction> {
if (!player) throw new Error("Player must be provided.");
const playerPda = this.getPlayerPda(player);
const playerTokenAccount = this.getPlayerTokenAccount(player);
const mint = this.getPlayTokenMint();
const instruction = await this.program.methods
.createPlayer()
.accounts({
player,
playerPda,
playerTokenAccount,
mint,
})
.instruction()
return instruction;
}

public async createNewGameInstruction(playerX: PublicKey): Promise<{ instruction: TransactionInstruction, game: string }> {
if (!playerX) throw new Error("Player must be provided.");
try {
const gameId = await this.getCurrentGameId();
const game = this.getGamePda(gameId);
const playerPda = this.getPlayerPda(playerX);
const playerTokenAccount = this.getPlayerTokenAccount(playerX);
const mint = this.getPlayTokenMint();
const programState = this.getProgramStatePda();
const instruction = await this.program.methods
.createGame(new anchor.BN(gameId))
.accounts({
game,
playerX,
playerPda,
playerTokenAccount,
mint,
programState,
})
.instruction()
return { instruction, game: game.toBase58() };
} catch (err) {
console.log(err); throw err;
}
}

public async joinGameInstruction(gameInfo: Game, gamePda: PublicKey, player: PublicKey): Promise<TransactionInstruction> {
if (!gameInfo) throw new Error("Game must be provided.");
if (!player) throw new Error("Player must be provided.");
if (!this.canJoin(gameInfo, player)) throw new Error("Player cannot join game.");
const playerPda = this.getPlayerPda(player);
const playerTokenAccount = this.getPlayerTokenAccount(player);
const mint = this.getPlayTokenMint();
const instruction = await this.program.methods
.joinGame()
.accounts({
game: gamePda,
playerO: player,
playerPda,
playerTokenAccount,
mint,
})
.instruction()
return instruction;
}

public async createPlayInstruction(params: {
gameInfo: Game;
currentPlayer: PublicKey;
gamePda: PublicKey;
row: number;
column: number;
}): Promise<TransactionInstruction> {
const square: Square = { row: params.row, column: params.column };
const playerRecord = this.getPlayerPda(params.currentPlayer);
const otherPlayerRecord = this.getPlayerPda(this.otherPlayer(params.gameInfo, params.currentPlayer));

const instruction = await this.program.methods
.play(square)
.accounts({
player: params.currentPlayer,
playerRecord,
otherPlayerRecord,
game: params.gamePda,
})
.instruction();

return instruction;
}

public async claimRewardInstruction(player: PublicKey): Promise<{ instruction: TransactionInstruction, extraSigner: Keypair }> {
const TOKEN_METADATA_PROGRAM_ID = new PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s");
const mintKeypair = new Keypair();

// Derive the PDA of the metadata account for the mint.
const [metadataAddress] = PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
TOKEN_METADATA_PROGRAM_ID.toBuffer(),
mintKeypair.publicKey.toBuffer(),
],
TOKEN_METADATA_PROGRAM_ID
);

// Derive the PDA of the master edition account for the mint.
const [editionAddress] = PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
TOKEN_METADATA_PROGRAM_ID.toBuffer(),
mintKeypair.publicKey.toBuffer(),
Buffer.from("edition"),
],
TOKEN_METADATA_PROGRAM_ID
);

// Derive the associated token address account for the mint and payer.
const associatedTokenAccountAddress = getAssociatedTokenAddressSync(
mintKeypair.publicKey,
player
);
const instruction = await this.program.methods
.claimReward()
.accounts({
player,
playerPda: this.getPlayerPda(player),
metadataAccount: metadataAddress,
editionAccount: editionAddress,
mintAccount: mintKeypair.publicKey,
associatedTokenAccount: associatedTokenAccountAddress,
tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID,
})
.instruction();
return { instruction, extraSigner: mintKeypair };

}

Because we created helpful private methods to fetch our PDAs, generating these instructions is pretty straightforward. Just a couple of things to note:

  • Our createNewGameInstruction method returns an object with the instruction and the game PDA. We use the PDA to route the user to the correct game page after they create a new game.
  • Our claimRewardInstruction method returns an object with the instruction and an extra signer. The extra signer will be the random key pair generated for the mint account. Signing is required to create the new account.

Game Context and Hook

Now that we have our game client, we can create a context and hook that will allow us to interact with our game client from our React components easily. We will create a GameProgramProvider component that will wrap our app and provide our game client to our components (you will recall that we added this wrapper in the last lesson). We will also create a useGame hook that will allow us to access our game client easily from our components.

Open ./utils/game/useGame.tsx, and let's jump in! Import the following dependencies:

import * as anchor from "@coral-xyz/anchor";
import { IDL, TicTacToe } from "./idl/idl";
import { PROGRAM_ID } from "../constants";
import { useAnchorWallet, useConnection } from "@solana/wallet-adapter-react";
import { createContext, ReactNode, useContext, useEffect, useState, useCallback } from "react";
import { TicTacToeClient } from "./gameClient";
import { GameAndPda, PlayerInfo } from "./types";

And then let's define an interface for our context:

export interface GameProgramContextState {
gameClient: TicTacToeClient;
activePlayer: PlayerInfo | undefined;
refreshActivePlayer: () => void;
games: GameAndPda[];
refreshGames: () => void;
}

We are including:

  • gameClient - our game client
  • activePlayer - the active player's info, if they have registered
  • refreshActivePlayer - a method to refresh the active player's info (we can pass this after we finish a game to update our record, for example)
  • games - an array of all of the games in our program (this can limit the number of times we need to fetch games from our program--which can be a heavy operation)
  • refreshGames - a method to refresh the games array (we can pass this after we create a new game, for example)

Next, let's create our context and hook. Add the following code to your useGame.tsx file:

export const GameProgramContext = createContext<GameProgramContextState>(
{} as GameProgramContextState
);

export function useGame(): GameProgramContextState {
return useContext(GameProgramContext);
}

We are creating a context and a hook that will allow us to access our game client from our components. We just now need a way to provide our game client to our components. We will do this by creating a GameProgramProvider component that will wrap our app. Add the following code to your useGame.tsx file:

export function GameProgramProvider(props: { children: ReactNode }): JSX.Element {
const [api, setApi] = useState<TicTacToeClient>();
const [activePlayer, setActivePlayer] = useState<PlayerInfo>();
const [games, setGames] = useState<GameAndPda[]>([]);

const anchorWallet = useAnchorWallet();
const { connection } = useConnection();

useEffect(() => {
const provider: anchor.AnchorProvider = new anchor.AnchorProvider(
connection,
// fallback value allows querying the program without having a wallet connected
anchorWallet ?? ({} as anchor.Wallet),
{ commitment: 'confirmed', preflightCommitment: 'confirmed' }
);
const program: anchor.Program<TicTacToe> = new anchor.Program(
IDL as unknown as TicTacToe,
PROGRAM_ID,
provider ?? ({} as anchor.AnchorProvider)
);
setApi(TicTacToeClient.from(program));
}, [anchorWallet, connection, setApi]);

const refreshActivePlayer = useCallback(() => {
if (!api || !anchorWallet || !api.isReady()) return;
api.getPlayerIfRegistered(anchorWallet.publicKey)
.then((player) => {
setActivePlayer(player);
})
.catch((error) => {
console.error('Error fetching active player:', error);
});
}, [api, anchorWallet]);

useEffect(() => {
refreshActivePlayer();
}, [refreshActivePlayer]);

const refreshGames = useCallback(() => {
if (!api) return;
api.fetchAllGames().then(setGames).catch(console.error);
}, [api]);

useEffect(() => {
refreshGames();
}, [refreshGames]);

const value: GameProgramContextState = {
gameClient: api as TicTacToeClient,
activePlayer,
refreshActivePlayer,
games,
refreshGames
};

return (
<GameProgramContext.Provider value={value}>
{props.children}
</GameProgramContext.Provider>
);
}

Let's walk through this code:

  • We are creating a GameProgramProvider component that will wrap our app and provide our game client to our components (we have already included it in our SolanaProviders component).
  • We are creating useState hooks to store our game client, active player, and games array.
  • We use the useAnchorWallet and useConnection hooks from the @solana/wallet-adapter-react library to get our wallet and connection.
  • We use the useEffect hook to create a new instance of our game client when our wallet or connection changes.
  • We use the useCallback hook to create a method to refresh the active player's info.
  • We use the useEffect hook to refresh the active player's info when the active player changes.
  • We use the useCallback hook to create a method to refresh the games array.
  • We use the useEffect hook to refresh the games array when the games array changes.
  • We create a value object that will be our context value. The value contains our game client, active player, games array, and methods to refresh the active player and games array.
  • We return our GameProgramContext.Provider component with our value object as the value prop.

Use Game!

Since we have already included our GameProgramProvider in our SolanaProviders component, we are ready to use our useGame hook in our components. In the next lesson, we will create a game landing page that will allow us to register a player, create a new game, and join an existing game. Since we have already built our game client and hook, we will be able to focus on the UI/UX of our game landing page. See you there!


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