> 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/reading-a-table.md).

# Reading a table and seats

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

**Capability: Wallet + RPC only**

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

## Purpose

This page reads one table and its seats. It decodes the `Table` account (occupancy, pot, phase, dealer button, current player) and each `PlayerSeat` account (player wallet, chips, bets, status). It is read-only: no transaction, no signing, no custody.

For 6-max/9-max SNGs, derive `["sng_duel", table]` and read the 415-byte `SngDuelState` beside the table. It is the source for each seat's fractional points, fold counts, final blind level, and any active duel. Native HU has no sidecar. A table's shadow `sng_setup_mask` indicates duel and settlement-record mode; preserve its high mode bits in any raw-layout tooling.

It does not reproduce the full account field layout. For account sizes, seeds, the shadow-field pattern, and field meanings, see [../04-architecture/state-accounts.md](/architecture/state-accounts.md).

## When to use / who signs

Use this to render a table view, a seat list, or a spectator surface. Nothing signs. Reads are unauthenticated RPC calls. A wallet pubkey is only an identity used to match a seat's `wallet` field, not a signer.

One caveat governs the whole page. While a table is delegated to an Ephemeral Rollup (ER) or its trusted execution environment (TEE), the live runtime fields (`phase`, `pot`, `current_player`, `dealer_button`, the per-hand masks) advance on the ER, not on Solana L1. An L1 read of a delegated table returns the last committed snapshot, which can lag the live hand. Read those fields from the ER endpoint when the table is delegated. The static fields (`table_id`, `game_type`, `max_players`, blinds, tier economics) are stable and safe to read from L1. See [Runtime fields and the ER/TEE overlay](#runtime-fields-and-the-ertee-overlay).

## Inputs and constants

| Input                | Value                                          | Notes                                         |
| -------------------- | ---------------------------------------------- | --------------------------------------------- |
| Program ID           | `PokerXYdXL2SKNnfGbv1WE7vJHipTpNsfZbZeVvoJLn`  | Owner of the table and seat accounts on L1.   |
| Delegated owner (ER) | `DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh` | L1 owner while the table is delegated.        |
| IDL                  | `target/idl/fastpoker.json`                    | Source of discriminators and account layouts. |

The shared helper derives every PDA and exports the program IDs. Import it from [setup.md](/building-on-the-protocol/setup.md). This page reuses `getTablePda`, `getSeatPda`, `FASTPOKER_PROGRAM_ID`, and `DELEGATION_PROGRAM_ID` from that module. Do not redefine seeds here.

### Account names and discriminators

Decode against the IDL account names, which are PascalCase. The seat struct is `PlayerSeat`, not `Seat`. The Anchor coder reads each 8-byte discriminator from the IDL `accounts[].discriminator` array, so you never recompute it.

| Account | IDL name     | Seeds                         | Data size                                   |
| ------- | ------------ | ----------------------------- | ------------------------------------------- |
| Table   | `Table`      | `["table", table_id]`         | 459 bytes (478 allocated with shadow bytes) |
| Seat    | `PlayerSeat` | `["seat", table, seat_index]` | 281 bytes (284 allocated)                   |

### Table fields this page reads

| Field             | Type           | Meaning                                      |
| ----------------- | -------------- | -------------------------------------------- |
| `max_players`     | u8             | Seats on the table (2, 6, or 9).             |
| `current_players` | u8             | Seats currently occupied.                    |
| `seats_occupied`  | u16 bitmask    | Bit `i` set means seat `i` is occupied.      |
| `pot`             | u64            | Pot lamports for the live hand.              |
| `phase`           | GamePhase enum | Hand phase (see below).                      |
| `current_player`  | u8             | Seat index whose turn it is to act.          |
| `dealer_button`   | u8             | Seat index of the dealer button.             |
| `is_delegated`    | bool           | True while the table is delegated to the ER. |

`seats_occupied` is a bitmask, not a count. Test occupancy with `(seats_occupied >> i) & 1`. The integer count is `current_players`.

### PlayerSeat fields this page reads

| Field                 | Type            | Meaning                                          |
| --------------------- | --------------- | ------------------------------------------------ |
| `wallet`              | pubkey          | The seated player. `Pubkey::default` when empty. |
| `chips`               | u64             | Stack in lamports.                               |
| `bet_this_round`      | u64             | Lamports committed in the current betting round. |
| `total_bet_this_hand` | u64             | Lamports committed across the whole hand.        |
| `status`              | SeatStatus enum | Seat state (see below).                          |
| `seat_number`         | u8              | The seat index, echoed in the account.           |

### GamePhase enum

| Value | Variant            |
| ----- | ------------------ |
| 0     | Waiting            |
| 1     | Starting           |
| 2     | Preflop            |
| 3     | Flop               |
| 4     | Turn               |
| 5     | River              |
| 6     | Showdown           |
| 7     | Complete           |
| 8     | FlopRevealPending  |
| 9     | TurnRevealPending  |
| 10    | RiverRevealPending |

The `*RevealPending` phases are transient states while the ER reveals board cards. Treat them as the betting street they precede.

### SeatStatus enum

| Value | Variant    | Meaning                               |
| ----- | ---------- | ------------------------------------- |
| 0     | Empty      | No player.                            |
| 1     | Active     | In the hand or able to act.           |
| 2     | Folded     | Folded this hand.                     |
| 3     | AllIn      | All chips committed.                  |
| 4     | SittingOut | Seated but not dealt in.              |
| 5     | Busted     | Out of chips (SNG elimination).       |
| 6     | Leaving    | Marked to leave; removed at hand end. |

## Steps

1. Derive the table PDA from `table_id` with `getTablePda`.
2. Read the table account with `getAccountInfo`. A `null` result means the table does not exist.
3. Confirm the `Table` discriminator, then decode from the IDL layout. Read `max_players`, `current_players`, `seats_occupied`, `pot`, `phase`, `current_player`, `dealer_button`, and `is_delegated`.
4. Check the account `owner`. If it equals `DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh`, the table is delegated; treat the runtime fields from L1 as a possibly stale snapshot and read live values from the ER.
5. Derive each seat PDA for `seat_index` in `0..max_players` with `getSeatPda`.
6. Batch-read the seats. A free public RPC caps `getMultipleAccounts` at 10 accounts, so chunk a 9-max table into groups of 10 or fewer. A `null` slot is an uninitialized seat, which is normal.
7. For each seat, confirm the `PlayerSeat` discriminator, then decode `wallet`, `chips`, `bet_this_round`, `total_bet_this_hand`, and `status`.

For the general derive-then-fetch read model, discriminator checks, and chunking, see [reading-accounts.md](/building-on-the-protocol/reading-accounts.md).

## Example

This example imports the shared helper from [setup.md](/building-on-the-protocol/setup.md), reads one table, decodes it from the IDL, reports the L1-vs-ER owner, then reads and decodes every seat within the 10-account batch cap.

```ts
import { Connection, PublicKey } from '@solana/web3.js';
import { BorshAccountsCoder } from '@coral-xyz/anchor';
import {
  FASTPOKER_IDL,
  FASTPOKER_PROGRAM_ID,
  DELEGATION_PROGRAM_ID,
  getTablePda,
  getSeatPda,
} from './setup'; // shared helper from setup.md

const coder = new BorshAccountsCoder(FASTPOKER_IDL as any);

const GAME_PHASE = [
  'Waiting', 'Starting', 'Preflop', 'Flop', 'Turn', 'River',
  'Showdown', 'Complete', 'FlopRevealPending', 'TurnRevealPending',
  'RiverRevealPending',
];

const SEAT_STATUS = [
  'Empty', 'Active', 'Folded', 'AllIn', 'SittingOut', 'Busted', 'Leaving',
];

// IDL account names are PascalCase: the seat struct is "PlayerSeat".
function decode<T = any>(name: string, data: Buffer): T | null {
  const want = coder.accountDiscriminator(name);
  if (data.length < 8 || !data.subarray(0, 8).equals(want)) return null;
  return coder.decode<T>(name, data);
}

function ownerLabel(owner: PublicKey): 'L1' | 'ER' | 'other' {
  if (owner.equals(FASTPOKER_PROGRAM_ID)) return 'L1';
  if (owner.equals(DELEGATION_PROGRAM_ID)) return 'ER';
  return 'other';
}

// Read up to 10 accounts per call; chunk anything larger (free-RPC WAF cap).
async function getMultipleChunked(connection: Connection, keys: PublicKey[]) {
  const out: Awaited<ReturnType<Connection['getMultipleAccountsInfo']>> = [];
  for (let i = 0; i < keys.length; i += 10) {
    out.push(...(await connection.getMultipleAccountsInfo(keys.slice(i, i + 10))));
  }
  return out;
}

// Anchor decodes enums as objects with one key, e.g. { active: {} }.
const enumIndex = (v: any, names: string[]): number => {
  if (typeof v === 'number') return v;
  const key = Object.keys(v ?? {})[0]?.toLowerCase();
  return names.findIndex((n) => n.toLowerCase() === key);
};

async function readTable(connection: Connection, tableId: Uint8Array) {
  const [tablePda] = getTablePda(tableId);

  const info = await connection.getAccountInfo(tablePda);
  if (!info) {
    console.log('table not found:', tablePda.toBase58());
    return;
  }

  const table = decode('Table', info.data as Buffer);
  if (!table) {
    console.log('not a Table account (discriminator mismatch)');
    return;
  }

  const where = ownerLabel(info.owner);
  const phaseName = GAME_PHASE[enumIndex(table.phase, GAME_PHASE)] ?? 'unknown';
  const occupied = Number(table.seatsOccupied);
  const maxPlayers = Number(table.maxPlayers);

  console.log('table:', tablePda.toBase58(), 'owner:', where);
  console.log(
    `phase=${phaseName} pot=${Number(table.pot) / 1e9} SOL ` +
      `players=${table.currentPlayers}/${maxPlayers} ` +
      `button=seat${table.dealerButton} toAct=seat${table.currentPlayer}`,
  );
  if (where === 'ER') {
    console.log(
      'note: table is delegated; phase/pot/current_player on L1 may be stale. ' +
        'Read live values from the ER endpoint.',
    );
  }

  // Occupancy mask: bit i set means seat i is occupied.
  const occupiedSeats = Array.from({ length: maxPlayers }, (_, i) => i)
    .filter((i) => (occupied >> i) & 1);
  console.log('occupied seats:', occupiedSeats.join(', ') || '(none)');

  // Derive every seat, then batch-read within the 10-account cap.
  const seatPdas = Array.from({ length: maxPlayers }, (_, i) => getSeatPda(tablePda, i)[0]);
  const seatInfos = await getMultipleChunked(connection, seatPdas);

  seatInfos.forEach((seatInfo, i) => {
    if (!seatInfo) {
      console.log(`seat ${i}: uninitialized`);
      return; // null is a normal result
    }
    const seat = decode('PlayerSeat', seatInfo.data as Buffer);
    if (!seat) {
      console.log(`seat ${i}: discriminator mismatch`);
      return;
    }
    const statusName = SEAT_STATUS[enumIndex(seat.status, SEAT_STATUS)] ?? 'unknown';
    const wallet = new PublicKey(seat.wallet);
    const empty = wallet.equals(PublicKey.default);
    console.log(
      `seat ${i}: status=${statusName} ` +
        (empty
          ? 'empty'
          : `player=${wallet.toBase58()} chips=${Number(seat.chips) / 1e9} SOL ` +
            `betRound=${Number(seat.betThisRound) / 1e9} ` +
            `betHand=${Number(seat.totalBetThisHand) / 1e9}`),
    );
  });
}

// Usage:
// const connection = new Connection('https://api.mainnet-beta.solana.com', 'confirmed');
// await readTable(connection, tableId);
```

## Result

You get a decoded `Table` (occupancy mask, count, pot, phase, dealer button, current player) and a decoded `PlayerSeat` for every seat (player wallet, chips, both bet totals, status), fetched without any program scan and within the public RPC batch cap. A `null` table means it does not exist. A `null` seat slot means that seat is uninitialized, which is normal. An `owner` of `DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh` flags a delegated table whose runtime fields you should read from the ER.

When `duel_active` is true, render the duel instead of a normal hand-action UI and pause the displayed blind clock from the sidecar's on-chain pause stamp.

## Runtime fields and the ER/TEE overlay

The static fields of a table are fixed at creation and stay correct on L1: `table_id`, `game_type`, `max_players`, blinds, `tier`, and the SNG economics. The runtime fields advance every action: `phase`, `pot`, `current_player`, `dealer_button`, `seats_occupied`, and the per-hand masks. Seat `chips`, `bet_this_round`, `total_bet_this_hand`, and `status` advance the same way.

While `is_delegated` is true and the L1 `owner` is the Delegation program, those runtime fields are authoritative on the ER, not on L1. The L1 copy is the last committed snapshot and can trail the live hand. Read live runtime values from the ER endpoint for a delegated table. Read static fields from L1 at any time. The card accounts (`SeatCards`, `DeckState`) are permission-gated and are not part of this read; see [reading-accounts.md](/building-on-the-protocol/reading-accounts.md) and [playing-a-hand.md](/building-on-the-protocol/playing-a-hand.md).

## Pitfalls

* Do not treat `seats_occupied` as a count. It is a u16 bitmask. Test seat `i` with `(seats_occupied >> i) & 1`. The count is `current_players`.
* Do not decode the seat as `Seat`. The IDL account name is `PlayerSeat`. The wrong name yields the wrong discriminator and a silent mismatch.
* Do not trust L1 runtime fields for a delegated table. If `owner` is `DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh` (or `is_delegated` is true), `phase`, `pot`, and `current_player` from L1 may be stale. Read them from the ER.
* Do not treat a `null` seat as an error. Seats are initialized lazily; a `null` slot is an empty seat.
* Do not exceed 10 accounts in one `getMultipleAccounts` batch on a free pool. A 9-max table needs two chunks. This is a WAF rule, so unchunked retries keep failing.
* Do not read a seat's `wallet` without checking `Pubkey::default`. A seat can be initialized but unoccupied, in which case `status` is `Empty` and `wallet` is the default key.
* Do not hand-roll byte offsets. Decode from the IDL layout. The table carries shadow bytes beyond its 459-byte base; the IDL coder handles them.

## See also

* [setup.md](/building-on-the-protocol/setup.md): the shared helper, program IDs, and PDA derivations.
* [deriving-tables.md](/building-on-the-protocol/deriving-tables.md): finding table addresses to read.
* [reading-accounts.md](/building-on-the-protocol/reading-accounts.md): the general account-read, discriminator, and chunking pattern.
* [playing-a-hand.md](/building-on-the-protocol/playing-a-hand.md): acting on a hand and reading live ER state.
* [../04-architecture/state-accounts.md](/architecture/state-accounts.md): account sizes, seeds, shadow fields, and full layouts.

```
```
