> For the complete documentation index, see [llms.txt](https://docs.fast.poker/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.fast.poker/building-on-the-protocol/playing-a-hand.md).

# Playing a hand

> Verified against the public fastpoker IDL (PokerXYdXL2SKNnfGbv1WE7vJHipTpNsfZbZeVvoJLn) released 2026-07-17.

**Capability: Needs ER / delegated table; signs with the session key**

> Beta software on mainnet. Examples can fail. Test with small amounts.

## Purpose

This page is the in-hand write path. It covers the instructions that move a hand forward from a seated player: `player_action` (fold, check, call, bet, raise, all-in, plus the maintenance actions), `use_time_bank`, `sit_out`, `sit_in`, and `update_approved_signer` to rotate the session key.

Live gameplay does not run on Solana L1. While a hand is in progress the table and seat are delegated to a MagicBlock Ephemeral Rollup (ER). You build the same instructions, but you send them to the ER endpoint and sign `player_action` with the session key, not the wallet. The wallet only signs `update_approved_signer` to set or rotate that session key.

This page does not re-derive PDAs or restate the seat layout. The Table and `PlayerSeat` account model is in [../04-architecture/state-accounts.md](/architecture/state-accounts.md). Card access is in [tee-card-access.md](/building-on-the-protocol/tee-card-access.md). Who-signs-what is in [signer-matrix.md](/building-on-the-protocol/signer-matrix.md).

## When to use / who signs

Use this once a player is seated and a hand is live. Reading the live table to know whose turn it is and what is legal is covered in [reading-a-table.md](/building-on-the-protocol/reading-a-table.md).

Two keys act here, and they are not interchangeable.

| Instruction              | Signer                                 | Why                                                                                                          |
| ------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `player_action`          | Session / approved key (or the wallet) | Gasless in-hand play. The program accepts the signer when it equals `seat.wallet` or `seat.approved_signer`. |
| `sng_duel_action`        | Session / approved key (or the wallet) | PLAY (`1`) or NEXT CARDS (`2`) for a selected 6-max/9-max duelist.                                           |
| `use_time_bank`          | Session / approved key (or the wallet) | Same dual-signer check as actions.                                                                           |
| `sit_out`                | Session / approved key (or the wallet) | Same dual-signer check.                                                                                      |
| `sit_in`                 | Session / approved key (or the wallet) | Same dual-signer check.                                                                                      |
| `update_approved_signer` | Wallet only                            | Must equal `seat.wallet`. This is the one in-hand instruction that needs a wallet popup.                     |

The session key is the approved signer stored on the seat. It signs play; it never moves funds. The wallet sets it. For the full role map see [signer-matrix.md](/building-on-the-protocol/signer-matrix.md).

### Scheduled-duel branch

Before starting a normal hand from `Waiting`, read `["sng_duel", table]` on a 6-max/9-max table. If `duel_active` is true, render the duel branch and block normal betting actions. The two selected seats can submit `sng_duel_action`:

* `1` = PLAY the current private cards.
* `2` = NEXT CARDS for both players.

Both PLAY choices resolve immediately. NEXT CARDS or a 20-second timeout advances the round; the third deal resolves automatically. Neither action declines the symmetric stake. The blind clock stays paused until resolution.

## Inputs and constants

All accounts derive against the FastPoker program ID using the shared helper from [setup.md](/building-on-the-protocol/setup.md). These instructions touch only `table` and `seat`, plus the signer. `seat` is always writable. `table` is writable for the play and maintenance instructions and read-only for `update_approved_signer`.

| Instruction              | Accounts (in order)                             | Args                                       |
| ------------------------ | ----------------------------------------------- | ------------------------------------------ |
| `player_action`          | `signer` (signer), `table` (mut), `seat` (mut)  | `action: PokerAction`, `entropy: [u8; 32]` |
| `use_time_bank`          | `player` (signer), `table` (mut), `seat` (mut)  | none                                       |
| `sit_out`                | `player` (signer), `table` (mut), `seat` (mut)  | none                                       |
| `sit_in`                 | `player` (signer), `table` (mut), `seat` (mut)  | `post_missed_blinds: bool`                 |
| `update_approved_signer` | `player` (signer), `table` (read), `seat` (mut) | `new_approved_signer: pubkey`              |

### PokerAction variants

`PokerAction` is an Anchor enum. `Bet`, `Raise`, and `RebuyTopUp` carry a `u64 amount`; the rest carry no fields. The variant order below is the IDL discriminant order.

| Discriminant | Variant         | Field         | Use                                                          |
| ------------ | --------------- | ------------- | ------------------------------------------------------------ |
| 0            | `Fold`          | none          | Give up the hand.                                            |
| 1            | `Check`         | none          | Pass when there is nothing to call.                          |
| 2            | `Call`          | none          | Match the current bet.                                       |
| 3            | `Bet`           | `amount: u64` | Open a bet (only when `min_bet` is 0).                       |
| 4            | `Raise`         | `amount: u64` | Raise by `amount` over the call.                             |
| 5            | `AllIn`         | none          | Commit your whole stack.                                     |
| 6            | `SitOut`        | none          | Maintenance: leave the action without standing up.           |
| 7            | `ReturnToPlay`  | none          | Maintenance: come back from sitting out.                     |
| 8            | `LeaveCashGame` | none          | Maintenance: mark a cash seat Leaving for cashout.           |
| 9            | `RebuyTopUp`    | `amount: u64` | Maintenance: add chips from your seat reserve between hands. |

`amount` is in the table currency's base units (lamports for SOL tables). For `Bet` and `Raise`, `amount` must clear the table minimum; the program rejects an under-min bet or raise. The maintenance variants (`SitOut`, `ReturnToPlay`, `LeaveCashGame`, `RebuyTopUp`) do not require it to be your turn; the betting variants do.

### Entropy

The `entropy` argument is a required 32-byte array. When the `DeckState` PDA for the table is passed as an extra account, the program XORs each seat's non-zero entropy into the shared accumulator once per seat per hand (gated by a contributor bitmask), so each player contributes randomness to the next shuffle. Derive that PDA with `getDeckStatePda(table)` from [setup.md](/building-on-the-protocol/setup.md) and append it to the instruction accounts when you want to contribute. Generate fresh random bytes for every action. Passing all-zero bytes is accepted by the instruction but contributes nothing, so do not hard-code zeros.

## Steps

1. Read the live table from the ER to learn `phase`, `current_player`, and the legal action set. See [reading-a-table.md](/building-on-the-protocol/reading-a-table.md).
2. Confirm the table is delegated. If the L1 `owner` is the MagicBlock Delegation program, live state is on the ER. See [reading-accounts.md](/building-on-the-protocol/reading-accounts.md).
3. Build the instruction with the shared helper. For `player_action`, set `signer` to the session key (or the wallet) and pass the chosen `PokerAction` plus 32 random entropy bytes.
4. Get a recent blockhash from the ER connection, not from L1. An L1 blockhash on an ER transaction fails with `Blockhash not found`.
5. Sign with the session key for play, or the wallet for `update_approved_signer`. Send to the ER endpoint.
6. To rotate the session key, the wallet signs `update_approved_signer(new_approved_signer)`. After that the new key signs subsequent actions.

## Example

This example imports the shared helper from [setup.md](/building-on-the-protocol/setup.md), builds an Anchor `Program`, and submits a `player_action` to the ER. It signs with the session key, takes the blockhash from the ER connection, and includes the 32-byte entropy. It also shows the wallet-signed `update_approved_signer` rotation.

```ts
import { Connection, Keypair, PublicKey, Transaction } from '@solana/web3.js';
import { AnchorProvider, Program, Wallet, BN } from '@coral-xyz/anchor';
import {
  FASTPOKER_IDL,
  getTablePda,
  getSeatPda,
  getDeckStatePda,
} from './setup'; // shared helper from setup.md

// PokerAction builders. Anchor encodes an enum as { VariantName: { ...fields } }.
const A = {
  fold: () => ({ fold: {} }),
  check: () => ({ check: {} }),
  call: () => ({ call: {} }),
  bet: (lamports: bigint) => ({ bet: { amount: new BN(lamports.toString()) } }),
  raise: (lamports: bigint) => ({ raise: { amount: new BN(lamports.toString()) } }),
  allIn: () => ({ allIn: {} }),
  sitOut: () => ({ sitOut: {} }),
  returnToPlay: () => ({ returnToPlay: {} }),
  leaveCashGame: () => ({ leaveCashGame: {} }),
  rebuyTopUp: (lamports: bigint) => ({ rebuyTopUp: { amount: new BN(lamports.toString()) } }),
};

// 32 fresh random bytes per action. Required argument; do not hard-code zeros.
function freshEntropy(): number[] {
  return Array.from(Keypair.generate().publicKey.toBytes()); // 32 bytes
}

// Send a transaction to the ER. The blockhash MUST come from the ER connection.
async function sendToEr(
  erConnection: Connection,
  tx: Transaction,
  signers: Keypair[],
): Promise<string> {
  const { blockhash } = await erConnection.getLatestBlockhash('confirmed');
  tx.recentBlockhash = blockhash;
  tx.feePayer = signers[0].publicKey;
  tx.sign(...signers);
  const sig = await erConnection.sendRawTransaction(tx.serialize());
  await erConnection.confirmTransaction(sig, 'confirmed');
  return sig;
}

async function act(
  erConnection: Connection,
  sessionKey: Keypair, // seat.approved_signer
  tableId: Uint8Array,
  seatIndex: number,
  action: ReturnType<(typeof A)[keyof typeof A]>,
) {
  // Read-only provider is fine; we sign and send manually below.
  const provider = new AnchorProvider(
    erConnection,
    new Wallet(sessionKey),
    { commitment: 'confirmed' },
  );
  const program = new Program(FASTPOKER_IDL as any, provider);

  const [tablePda] = getTablePda(tableId);
  const [seatPda] = getSeatPda(tablePda, seatIndex);
  const [deckStatePda] = getDeckStatePda(tablePda);

  const ix = await program.methods
    .playerAction(action, freshEntropy())
    .accounts({
      signer: sessionKey.publicKey, // session/approved key, NOT the wallet
      table: tablePda,
      seat: seatPda,
    })
    // Optional: pass DeckState (writable) so this seat's entropy is mixed in.
    .remainingAccounts([{ pubkey: deckStatePda, isSigner: false, isWritable: true }])
    .instruction();

  const sig = await sendToEr(erConnection, new Transaction().add(ix), [sessionKey]);
  console.log('action sent on ER:', sig);
}

// Wallet-signed rotation of the session key (the only in-hand wallet popup).
async function rotateSessionKey(
  erConnection: Connection,
  wallet: Keypair, // must equal seat.wallet
  tableId: Uint8Array,
  seatIndex: number,
  newSessionKey: PublicKey,
) {
  const provider = new AnchorProvider(
    erConnection,
    new Wallet(wallet),
    { commitment: 'confirmed' },
  );
  const program = new Program(FASTPOKER_IDL as any, provider);

  const [tablePda] = getTablePda(tableId);
  const [seatPda] = getSeatPda(tablePda, seatIndex);

  const ix = await program.methods
    .updateApprovedSigner(newSessionKey)
    .accounts({ player: wallet.publicKey, table: tablePda, seat: seatPda })
    .instruction();

  const sig = await sendToEr(erConnection, new Transaction().add(ix), [wallet]);
  console.log('approved signer rotated:', sig);
}

// Usage sketch (fill in your own ER endpoint, keys, table id, and seat):
// const er = new Connection('https://your-er-endpoint.magicblock.app', 'confirmed');
// await act(er, sessionKey, tableId, seatIndex, A.call());
// await act(er, sessionKey, tableId, seatIndex, A.raise(2_000_000n));
// await act(er, sessionKey, tableId, seatIndex, A.fold());
```

`use_time_bank`, `sit_out`, and `sit_in` follow the same shape: build with `program.methods`, set `player` to the session key (or wallet), and send to the ER with an ER blockhash. `sit_in` takes one bool, `post_missed_blinds`. `use_time_bank` and `sit_out` take no args.

## Result

A confirmed `player_action` on the ER advances the hand. The program checks the signer against `seat.wallet` and `seat.approved_signer`, validates the action for the current phase and turn, applies it, XORs your entropy into `DeckState`, and rotates `current_player`. Maintenance actions (`SitOut`, `ReturnToPlay`, `LeaveCashGame`, `RebuyTopUp`) change seat status between hands without requiring your turn. A confirmed `update_approved_signer` writes the new session key onto the seat; the wallet stays in custody control and play continues gaslessly with the new key.

## Pitfalls

* L1 blockhash on an ER transaction. Live play is delegated to the ER. Take the recent blockhash from the ER connection. An L1 blockhash fails with `Blockhash not found`. See [errors-troubleshooting.md](/building-on-the-protocol/errors-troubleshooting.md).
* Wrong connection. Build and send in-hand instructions over the ER endpoint, not L1 RPC. The L1 copy is a stale delegated stub during a hand.
* Signing `player_action` with the wallet by reflex. Play is meant to be gasless: sign with the session key (`seat.approved_signer`). The program accepts the wallet too, but it is the slow, popup path. Only `update_approved_signer` requires the wallet.
* Missing or zero entropy. The 32-byte `entropy` array is a required argument. Pass fresh random bytes every action. All-zero bytes are accepted but contribute no randomness, so never hard-code them.
* Table not delegated. These instructions assume a live, delegated table. If the table is on L1 (not yet delegated, or already committed back), the action will not land on the ER. Check the L1 `owner` first; see [reading-accounts.md](/building-on-the-protocol/reading-accounts.md).
* Acting out of turn. Betting variants require `table.current_player` to equal your seat. Out-of-turn actions fail with `NotPlayersTurn`. Maintenance variants do not require your turn but still have phase rules (for example, `SitOut` only applies between hands).
* Under-minimum `Bet` or `Raise`. `Bet` is valid only when `min_bet` is 0, and both `Bet` and `Raise` must meet the table minimum, or the program rejects them.
* Rotating to the default pubkey. `update_approved_signer` rejects the all-zero pubkey. Pass a real session key.

## See also

* [signer-matrix.md](/building-on-the-protocol/signer-matrix.md)
* [tee-card-access.md](/building-on-the-protocol/tee-card-access.md)
* [reading-a-table.md](/building-on-the-protocol/reading-a-table.md)
* [errors-troubleshooting.md](/building-on-the-protocol/errors-troubleshooting.md)
* [setup.md](/building-on-the-protocol/setup.md)
* [../04-architecture/state-accounts.md](/architecture/state-accounts.md)
* [../04-architecture/ephemeral-rollups.md](/architecture/ephemeral-rollups.md)
