Request randomness
One CPI call inside whatever instruction starts a round in your game. DICE takes over from there — commits, reveals, aggregation, callback.
The CPI call
dice::cpi::request_randomness_auto takes exactly three accounts — the caller (your player), the channel PDA, and the System program — plus one argument: how many devices to recruit for this round. Here's the full handler in context:
use anchor_lang::prelude::*;
// Your own program's pubkey — see Quickstart for why.
declare_id!("7xAbcYourProgramIdHereReplaceMePlease111111");
#[program]
pub mod my_game {
use super::*;
pub fn play(ctx: Context<Play>, bet: u8, wager: u64) -> Result<()> {
// 1. Game setup — record the bet and take the wager.
let g = &mut ctx.accounts.game;
g.player = ctx.accounts.player.key();
g.bet = bet;
g.wager = wager;
g.settled = false;
// 2. Fire the DICE CPI. The hardware round starts the moment this
// TX confirms; DICE will call us back on the same channel when done.
dice::cpi::request_randomness_auto(
CpiContext::new(
ctx.accounts.dice_program.to_account_info(),
dice::cpi::accounts::RequestRandomnessAuto {
authority: ctx.accounts.player.to_account_info(),
channel: ctx.accounts.dice_channel.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
4, // node_count — see below
)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Play<'info> {
#[account(mut)]
pub player: Signer<'info>,
/// The round's game state. Your own PDA.
#[account(
init,
payer = player,
space = 8 + GameState::INIT_SPACE,
seeds = [b"game", player.key().as_ref()],
bump,
)]
pub game: Account<'info, GameState>,
/// CHECK: the DICE channel PDA. DICE verifies it inside the CPI.
#[account(mut)]
pub dice_channel: UncheckedAccount<'info>,
/// CHECK: DICE program id. Anchor's CPI invocation cross-checks it.
pub dice_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}ℹ Atomic with your game logic
Because it's a CPI inside your instruction, the DICE request is atomic with the rest of your play() handler. If anything later in the TX reverts, the randomness request reverts too. You don't need a two-phase commit in your code.
The Accounts struct
Three accounts on the DICE side. Your struct adds its own — whatever your game needs (game PDA, player signer, etc.). Four details worth knowing:
authorityis your player, not an admin. DICE uses it only as the signer that pre-authorises the channel debit.channelis the PDA you created in channel setup. Passed asmutbecause the round fee is debited in place.dice_programis the DICE program itself. Passdice.programIdfrom the client.system_programis standard Solana System; Anchor requires it for the fee transfer.
Picking node_count
Minimum 4, maximum 50. The security property scales linearly — 1 of N honest nodes makes the output unbiasable, so higher N tolerates more Byzantine devices. The fee is flat (0.002 SOL) regardless of N; operators split it proportionally.
| node_count | Recommended for | Notes |
|---|---|---|
| 4 | Default · games · mints | Fastest path. Tolerates 3 faulty. |
| 8–16 | High-stakes rounds | More devices sign → more costly to collude against. |
| 32–50 | Regulated / RWA draws | Slight latency bump; maximum assurance. |
Failure modes
InsufficientNodes— fewer than 4 nodes were online at the moment of request. Retry with a smaller node_count or wait.InvalidNodeCount— you passed < 4 or > 50.EscrowInsufficient— channel is out of SOL. Fund it.
A full table of error variants lives on the errors reference.
Client-side invocation
Nothing DICE-specific for the caller — your player signs play() on your program and the CPI is internal. A minimal TS client:
// Your game's play() call from a TS client.
await myGame.methods
.play(3, new BN(100_000)) // bet = 3, wager = 0.0001 SOL
.accounts({
player: wallet.publicKey,
game: gamePda, // derived from ["game", player]
diceChannel: diceChannelPda, // created in init-dice-channel.ts
diceProgram: dice.programId,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc()
// That's it. ~4 seconds later your GameState will be settled.
// Poll or subscribe:
const sub = anchor.AccountClient.subscribe
? myGame.account.gameState.subscribe(gamePda)
: null✓ Subscribe, don't poll
Because the callback lands in a separate TX (a few slots later), an account subscription on your GameState is the cleanest way to get an instant UI update when settled flips to true.
Testing without hardware
tests/harness/mock_firmware_node in the DICE repo simulates 10 devices running the real CBOR protocol and ECDSA signing. Point your devnet coordinator at it and your integration tests will exercise every code path without touching a physical ESP32.