Why Build a Coinflip on Solana?
A coinflip is the "hello world" of on-chain gaming. It forces you to grapple with randomness, escrow patterns, and PDA-based state management — skills that transfer to every Solana program you will ever write. Plus, Solana's sub-second finality makes the UX feel instant compared to EVM-based alternatives.
In this tutorial you will build a complete coinflip program with Anchor 0.30+ and a minimal TypeScript client that calls it.
Prerequisites
- Rust toolchain (
rustup— stable channel) - Solana CLI v1.18+
- Anchor CLI v0.30+
- Node.js 18+ and
yarnornpm
# Verify your setup
solana --version
anchor --version
rustc --version1. Scaffold the Project
anchor init coinflip
cd coinflipAnchor generates:
programs/coinflip/src/lib.rs— your on-chain programtests/coinflip.ts— Mocha-based integration testsAnchor.toml— config (cluster, program ID, wallet)
2. Define the State Account
Every coinflip round needs persistent state. Create a PDA account that stores the wager, the players, and the result.
use anchor_lang::prelude::*;
declare_id!("YOUR_PROGRAM_ID_HERE");
#[program]
pub mod coinflip {
use super::*;
pub fn create_game(ctx: Context<CreateGame>, wager: u64) -> Result<()> {
let game = &mut ctx.accounts.game;
game.player_one = ctx.accounts.player.key();
game.wager = wager;
game.state = GameState::Waiting;
game.bump = ctx.bumps.game;
// Transfer wager from player to game vault
anchor_lang::system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
anchor_lang::system_program::Transfer {
from: ctx.accounts.player.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
},
),
wager,
)?;
Ok(())
}
pub fn join_game(ctx: Context<JoinGame>) -> Result<()> {
let game = &mut ctx.accounts.game;
require!(game.state == GameState::Waiting, CoinflipError::GameNotWaiting);
game.player_two = ctx.accounts.player.key();
game.state = GameState::Active;
// Player two deposits their wager
anchor_lang::system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
anchor_lang::system_program::Transfer {
from: ctx.accounts.player.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
},
),
game.wager,
)?;
Ok(())
}
pub fn resolve_game(ctx: Context<ResolveGame>) -> Result<()> {
let game = &mut ctx.accounts.game;
require!(game.state == GameState::Active, CoinflipError::GameNotActive);
// Pseudo-random coin flip using recent slot hashes
let clock = Clock::get()?;
let flip = clock.slot % 2;
let winner = if flip == 0 {
game.player_one
} else {
game.player_two
};
game.winner = winner;
game.state = GameState::Resolved;
// Transfer the full pot to the winner
let payout = game.wager * 2;
**ctx.accounts.vault.to_account_info().try_borrow_mut_lamports()? -= payout;
**ctx.accounts.winner_account.to_account_info().try_borrow_mut_lamports()? += payout;
Ok(())
}
}Using Clock::slot for randomness is not secure for production. Validators can manipulate slot ordering. Use a Verifiable Random Function (VRF) such as Switchboard VRF for real money games.
3. Accounts and State Definitions
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq)]
pub enum GameState {
Waiting,
Active,
Resolved,
}
#[account]
pub struct Game {
pub player_one: Pubkey,
pub player_two: Pubkey,
pub winner: Pubkey,
pub wager: u64,
pub state: GameState,
pub bump: u8,
}
impl Game {
pub const SIZE: usize = 8 + 32 + 32 + 32 + 8 + 1 + 1;
}
#[derive(Accounts)]
pub struct CreateGame<'info> {
#[account(
init,
payer = player,
space = Game::SIZE,
seeds = [b"game", player.key().as_ref()],
bump
)]
pub game: Account<'info, Game>,
/// CHECK: PDA vault holds the wagers
#[account(
mut,
seeds = [b"vault", game.key().as_ref()],
bump
)]
pub vault: UncheckedAccount<'info>,
#[account(mut)]
pub player: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct JoinGame<'info> {
#[account(mut)]
pub game: Account<'info, Game>,
/// CHECK: PDA vault
#[account(mut)]
pub vault: UncheckedAccount<'info>,
#[account(mut)]
pub player: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct ResolveGame<'info> {
#[account(mut)]
pub game: Account<'info, Game>,
/// CHECK: PDA vault
#[account(mut)]
pub vault: UncheckedAccount<'info>,
/// CHECK: Winner receives the payout
#[account(mut)]
pub winner_account: UncheckedAccount<'info>,
}
#[error_code]
pub enum CoinflipError {
#[msg("Game is not in Waiting state")]
GameNotWaiting,
#[msg("Game is not in Active state")]
GameNotActive,
}4. Build and Deploy
anchor build
anchor keys list # Copy the new program ID
# Update declare_id!() in lib.rs and Anchor.toml
anchor deploy --provider.cluster devnet5. TypeScript Client Integration
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Coinflip } from "../target/types/coinflip";
import { PublicKey, SystemProgram, LAMPORTS_PER_SOL } from "@solana/web3.js";
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Coinflip as Program<Coinflip>;
async function play() {
const player = provider.wallet.publicKey;
// Derive the game PDA
const [gamePda] = PublicKey.findProgramAddressSync(
[Buffer.from("game"), player.toBuffer()],
program.programId
);
// Derive the vault PDA
const [vaultPda] = PublicKey.findProgramAddressSync(
[Buffer.from("vault"), gamePda.toBuffer()],
program.programId
);
const wager = new anchor.BN(0.1 * LAMPORTS_PER_SOL);
// Create a new game
await program.methods
.createGame(wager)
.accounts({
game: gamePda,
vault: vaultPda,
player: player,
systemProgram: SystemProgram.programId,
})
.rpc();
console.log("Game created at:", gamePda.toBase58());
}
play();6. Writing Integration Tests
import { expect } from "chai";
describe("coinflip", () => {
it("creates a game with the correct wager", async () => {
// ... setup from above ...
await program.methods.createGame(wager).accounts({ /* ... */ }).rpc();
const gameAccount = await program.account.game.fetch(gamePda);
expect(gameAccount.wager.toNumber()).to.equal(wager.toNumber());
expect(gameAccount.state).to.deep.equal({ waiting: {} });
});
it("lets a second player join and resolves the flip", async () => {
// join and resolve ...
const gameAccount = await program.account.game.fetch(gamePda);
expect(gameAccount.state).to.deep.equal({ resolved: {} });
expect(gameAccount.winner.toBase58()).to.not.be.empty;
});
});anchor testProduction Considerations
| Concern | Solution |
|---|---|
| Randomness | Replace slot-based flip with Switchboard VRF or Chainlink VRF |
| Front-running | Use a commit-reveal scheme so players cannot see the result before submitting |
| Fee model | Add a small protocol fee deducted before payout |
| Account cleanup | Close the game PDA after resolution to reclaim rent |
| Multi-player | Extend to N-player lottery by generalizing the Game account |
Wrapping Up
You now have a working coinflip program on Solana Devnet. The pattern — PDA state, escrow vault, CPI transfers, pseudo-random resolution — is the foundation for on-chain games, DeFi vaults, and any program that needs trustless two-party settlements.
Check out the Anchor documentation and the Solana Cookbook for deeper dives into advanced patterns like VRF integration and cross-program invocations.