# Chain.wtf — building casino games guide
Wire your casino game's contracts, iframe frontend, manifest, and host bridge together. Follow the session lifecycle from wager to outcome reveal.
:::note[Current SDK checkout]
- `@chain-protocol/casino-sdk@0.7.0`
This guidance reflects this checkout and may include unreleased changes.
[Casino changelog](/sdk/casino/changelog/)
:::
**Jump to:**
1. [Contract](#21-icasinogamev2-full-solidity)
2. [Guest lifecycle](#34-guest-lifecycle-required-pattern)
3. [Snapshot](#4-using-hostsnapshot-in-the-game-ui)
4. [Manifest](#5-gamemanifestjson--host-validation)
5. [Frontend patterns](#6-frontend-patterns-reference-implementations)
Chain.wtf is a fully decentralized wagering platform that consists of Sportsbook, Casino, fully on chain PvP games and predcition markets. It's built on top of Coinbase's Base L2 network and a unified liquidity pool that is used to back the winnings of the players.
This document is **self-contained**: you can follow it from a separate repository (e.g. Vite frontend + Forge/Hardhat) without any access to internal Chain.wtf source trees. Inline code blocks are the **canonical** types and patterns you need. To start from a working game, follow [Getting Started](/sdk/casino/getting-started/).
---
## 1. Platform model
**Host** (Chain.wtf web application) owns the user’s wallet, Smart Vault, session keys, and all transaction signing. It loads your game in an **iframe** and talks to it over a **promise-based bridge** (Penpal over `postMessage`). The iframe is typically sandboxed with `allow-scripts allow-same-origin` so the child origin is stable and the bridge can connect.
**Guest** (your game UI): **does not** connect a wallet or send raw transactions. It only:
- Encodes user intent as ABI-encoded **`gameData`** (at session open) and **`actionData`** (per player step).
- Calls **`hostApi.openSession`**, **`hostApi.submitAction`**, and optionally **`hostApi.cancelStuckRandomness`** — each returns a **Promise** (host signs/broadcasts txs).
- Calls **`hostApi.revealOutcome`** once its result presentation has finished, so the host credits withheld winnings to its balance displays (see §3.4).
- Renders from **`HostSnapshot`**, pushed by the host via **`guestApi.setState(snapshot)`** whenever wallet, balances, sessions, or UI settings change.
**Chain**: the protocol exposes **`CasinoGameFacet`** on the diamond/proxy. Your deployed game contract implements **`ICasinoGameV2`**. The facet orchestrates escrow, vault liquidity caps, randomness requests, and calls into your game’s pure/view step handlers.
---
## 2. On-chain architecture
### 2.1 `ICasinoGameV2` (full Solidity)
Your game contract must implement this interface. Phases, context, and step results are fixed by the platform.
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
enum SessionPhase {
NONE,
WAITING_RANDOMNESS,
WAITING_PLAYER_ACTION,
SETTLED,
FORFEITED,
CANCELLED
}
struct SessionContext {
uint256 sessionId;
address player;
address vault;
uint256 wagerBase;
uint256 escrowedStake;
uint256 reservedProfit;
uint32 step;
bytes gameData;
bytes gameState;
}
struct StepResult {
bytes newGameState;
int256 escrowDelta;
int256 reservedProfitDelta;
SessionPhase nextPhase;
bool requestRandomnessNow;
uint256 payout;
}
interface ICasinoGameV2 {
function quoteCaps(
uint256 wager,
bytes calldata gameData
) external view returns (uint256 maxEscrowStake, uint256 maxReservedProfit);
function quoteRiskParams(
uint256 wager,
bytes calldata gameData
)
external
view
returns (
uint256 maxPayout,
uint256 probabilityWad,
uint256 expectedPayout,
uint256 bodyVarianceScaled
);
function onSessionStart(
SessionContext calldata ctx
) external view returns (StepResult memory);
function onPlayerAction(
SessionContext calldata ctx,
bytes calldata actionData
) external view returns (StepResult memory);
function onRandomness(
SessionContext calldata ctx,
bytes32 randomness
) external view returns (StepResult memory);
function quoteForfeitPayout(SessionContext calldata ctx) external view returns (uint256 cashoutValue);
}
```
**Semantics:**
- **`quoteCaps`**: Before `openSession`, the facet requires `maxEscrowStake >= wager` and holds `maxReservedProfit`, the session's largest net win, to the single-win cap.
- **`quoteRiskParams`**: Supplies the four figures the facet prices the session on: its fees, its bet-size cap and its share of the vault reserve (§2.1.1). `expectedPayout` is RTP × `wager`, the mean total payout, **under the player's best policy** when the game has player choices. `maxPayout` is the largest total player payout. `probabilityWad` is the probability of **exactly `maxPayout`** in WAD (`1e18 = 100%`), not the chance of any win. `bodyVarianceScaled` is the **body variance**: the variance of the round's payout with the top tier excluded, per bet, in wei² × 1e18. It is counted for every bet; only a game whose sole winning outcome is the top tier returns `0`. See `SLOTS_RISK_AND_RESERVES.md`.
- **`onSessionStart`**: Called immediately after the session is created with the player’s wager pulled in; you return the next phase and updated opaque **`newGameState`**.
- **`onPlayerAction`**: Only when the facet is in **`WAITING_PLAYER_ACTION`** and the player calls **`submitAction`** in time (by the session's `deadline`, a block timestamp).
- **`onRandomness`**: Called when the randomness provider fulfills a request; your game consumes **`bytes32`** RNG and returns the next step or terminal settlement. That step waits for the player or settles; it may not request randomness again. See [One random value per request](#one-random-value-per-request).
- **`quoteForfeitPayout`**: The current cash-out value (stake + accrued winnings) derived from `ctx.gameState`; return `0` when nothing is cashable mid-round. When an abandoned session is forfeited after its `deadline`, the facet pays the player this quote (clamped to `escrowedStake + reservedProfit`) **minus a 10% cut** instead of confiscating the whole stake. The call is defensive: if it reverts or returns malformed data — or the game predates the method — the forfeit pays `0`. Implement it faithfully **only** in games with a true anytime cash-out whose value is fully determined by already-revealed state (mines-style). Games whose mid-round value depends on unresolved randomness or hidden state (blackjack-style) **must return `0`**: any concrete quote there is an adverse-selection exploit — the player abandons exactly when the real continuation value is below the quote (a bad blackjack hand can be worth ~0.3× stake while a stake-based quote pays out 0.9×), turning forfeiture into a free put option against the vault.
#### One random value per request
A game gets **one `bytes32`** per randomness request. When a step needs several random values (two dice, a hand of cards, a mine layout), derive them all from that one value, for example `keccak256(abi.encode(randomness, i))` for the `i`-th value, or the byte cursor in [`RANDOMNESS_DICE.md`](/sdk/casino/randomness-dice/).
Randomness is only requested from a player's call (`onSessionStart` or `onPlayerAction`), because the call that requests it pays its fee. If `onRandomness` returns **`WAITING_RANDOMNESS`**, the facet reverts the callback with `CasinoGameFacet__RandomnessRequestedFromCallback`: the delivery fails and the result is never applied. Once the randomness deadline passes, anyone can call `cancelStuckRandomness`, which refunds the session's whole remaining escrow and takes no casino fee.
#### Consuming randomness: unbiased d6
Games that map facet **`bytes32`** randomness to six-sided dice **must** use **rejection sampling** — accept only bytes `< 252`, then `(byte % 6) + 1`. **Do not** use raw `randomness[i] % 6` on a byte (modulo bias: faces 1–4 are slightly more likely than 5–6).
Full algorithm, Solidity/TypeScript reference implementations, and an agent DO/DON'T checklist: **[`RANDOMNESS_DICE.md`](/sdk/casino/randomness-dice/)**.
**Escrow / profit:**
- **`escrowDelta`**: positive = extra tokens pulled from the player into facet escrow (e.g. double down); negative = release to player. Only `onSessionStart` and `onPlayerAction` may change the escrow. If `onRandomness` returns a nonzero `escrowDelta`, the facet reverts the callback with `CasinoGameFacet__EscrowChangedFromCallback`, and the session can only be cancelled with `cancelStuckRandomness`.
- **`reservedProfitDelta`**: commits vault “reserved profit” (usually the full `maxReservedProfit` in `onSessionStart`). The win is capped at `escrowedStake + reservedProfit`, so return `0` on the settling step. The facet releases the reserve at settlement, and releasing it yourself reverts every win above 1x. See [`CONTRACT_CONSTRAINTS.md` § Payout cap](/sdk/casino/contract-constraints/#payout-cap).
### 2.1.1 How the facet uses your quotes
Your two quote functions are read once, at `openSession`. They decide the session's **fees**, the **largest wager** the vault accepts and the **reserve** the session needs:
- `quoteCaps` answers: "What is the largest escrow / reserved-profit this single session may need?"
- `quoteRiskParams` answers: "What does this session pay on average, what is the most it pays, how likely is that, and how much does the rest vary?"
The facet uses both during `openSession`, in this order:
1. Require `maxEscrowStake >= wager`.
2. Check the quote against the guardrails: RTP inside the council-set RTP band (60% to 99.9% by default), `maxPayout` at least `expectedPayout`, a body variance above zero when outcomes below `maxPayout` pay, and a positive plain variance. A failing quote reverts with `QuoteFiguresLib__RtpOutsideBand`, `QuoteFiguresLib__MaxPayoutBelowExpectedPayout`, `QuoteFiguresLib__BodyVarianceMissing` or `QuoteFiguresLib__VarianceNotPositive`.
3. Set the session's fees from its edge, `wager − expectedPayout`.
4. Check the bet-size cap: the single-win cap on `maxReservedProfit`, and the risk budget and the break-even rule on the wager.
5. Pin the session's per-bet variance — quoted body variance plus the top-tier binary term from `(maxPayout, probabilityWad)` — add it into the vault aggregate and recompute the required casino reserve.
6. Revert if the reserve does not fit in the vault's available liquidity.
**Fees.** The edge is split in order: your game dev fee first (10% of the edge by default), then the LP cut and the protocol fee from the net edge that is left, and the operator fee is the remainder. A \$100 wager at 96% RTP has an edge of \$4.00, so the default cut pays you \$0.40. Fees are paid when the session settles or is forfeited, whether the player won or lost. A cancelled session pays no fee. The full split with a worked example, and how to read and claim your fees, is in [`CONTRACT_CONSTRAINTS.md` § Fee split](/sdk/casino/contract-constraints/#fee-split).
**Largest wager.** The largest wager the vault accepts falls as your game's variance rises and as its edge falls. Never compute it in the frontend: ask the host (§4, [Asking the host for the maximum wager](#asking-the-host-for-the-maximum-wager)).
What each figure must mean, the formulas and every error are in [`CONTRACT_CONSTRAINTS.md` § What your quotes decide](/sdk/casino/contract-constraints/#what-your-quotes-decide).
For the current built-in games:
- **Coinflip** returns the exact combinatorial win probability in WAD.
- **Mines** returns the probability of the full-board cashout path `1 / C(25, mineCount)` in WAD.
- **Blackjack** returns the probability of its 5x round under the best policy: split, double both hands, win both. Its `maxPayout` of 5x is net of the stake the split and the doubles add after opening.
- **Slots and any multi-tier paytable** must return the body variance in `bodyVarianceScaled`; a quote with several winning tiers and a body variance of `0` is refused at whitelisting and at `openSession`.
### 2.2 `CasinoGameFacet` responsibilities (summary)
The facet (names only — your deployment uses the protocol’s verified ABI):
| Entry | Role |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openSession(game, vault, wager, gameData)` | Validates whitelist, vault, caps from `quoteCaps` and the quote from `quoteRiskParams` (guardrails, bet-size cap), sets the session's fees, updates the casino portfolio reserve, transfers wager in, creates session, runs `onSessionStart`, then applies `StepResult` (may request RNG, wait for player, or finalize). An overload takes a trailing `operator` address (§7.1). |
| `submitAction(encodedSession, actionData)` | Requires phase `WAITING_PLAYER_ACTION`, player `msg.sender`, not past action deadline; runs `onPlayerAction`. `encodedSession` is the latest committed snapshot (see below). |
| `onCasinoRandomnessFulfilled(requestId, randomness, clientData)` | Randomness provider callback; `clientData` is the committed snapshot the facet handed to the provider; runs `onRandomness` on the game. |
| `CasinoConfigFacet.getSessionCommitment(sessionId)` / `decodeSession(bytes)` | Sessions are **not stored** on chain — only `keccak256(encodedSession)`. Every non-terminal step emits `CasinoSessionAdvanced(sessionId, step, requestId, randomness, session)` with the full encoded snapshot (`gameData`, `gameState`, phase, deadline, stake, caps…), and every later call echoes the latest one back. |
| `forfeitExpiredSession(bytes)` / `forfeitExpiredSessions(bytes[])` | If phase `WAITING_PLAYER_ACTION` and block > action deadline → forfeit. |
| `cancelStuckRandomness(bytes)` | If phase `WAITING_RANDOMNESS` and randomness deadline passed → cancel and refund per facet rules. |
Randomness is requested **by the facet** from its configured `IRandomnessProvider` (in production a Verify Network adapter dedicated to the casino vertical). `openSession` and `submitAction` are `payable`: a call whose step requests randomness passes all the ETH it carries to the provider as the request's fee, and a call whose step requests none gets its ETH back in the same transaction. Your game contract never sees this ETH. The bridge's `randomnessRequestData` field is deprecated and ignored by the host. The game iframe **never** receives the RNG directly from a wallet; settlement is on-chain.
### 2.3 Contract patterns: instant vs multi-action
**Instant game (e.g. coinflip)** — session resolves without `submitAction`:
- `onSessionStart` → **`nextPhase = WAITING_RANDOMNESS`**, **`requestRandomnessNow = true`**.
- `onPlayerAction` **reverts** (no player steps).
- `onRandomness` → **`SETTLED`** with payout (encode the result in `newGameState`).
Illustrative excerpt:
```solidity
function onSessionStart(
SessionContext calldata ctx
) external pure returns (StepResult memory stepResult) {
// ... decode ctx.gameData, compute maxReservedProfit ...
stepResult.newGameState = abi.encode(/* opaque state */);
stepResult.escrowDelta = 0;
stepResult.reservedProfitDelta = int256(maxReservedProfit);
stepResult.nextPhase = SessionPhase.WAITING_RANDOMNESS;
stepResult.requestRandomnessNow = true;
stepResult.payout = 0;
}
function onPlayerAction(
SessionContext calldata,
bytes calldata
) external pure returns (StepResult memory) {
revert CoinflipGame__NoPlayerAction();
}
function onRandomness(
SessionContext calldata ctx,
bytes32 randomness
) external pure returns (StepResult memory stepResult) {
// ... compute payout, set stepResult.nextPhase = SessionPhase.SETTLED, etc.
}
```
**Multi-action game (e.g. mines)** — opens on player’s turn:
- `onSessionStart` → **`WAITING_PLAYER_ACTION`**, **`requestRandomnessNow = false`**.
- `onPlayerAction` decodes `actionData` (reveal tile, cash out, etc.); may transition to **`WAITING_RANDOMNESS`** with **`requestRandomnessNow = true`** for a single tile reveal, then `onRandomness` returns to **`WAITING_PLAYER_ACTION`** or settles.
Illustrative excerpt:
```solidity
function onSessionStart(
SessionContext calldata ctx
) external pure returns (StepResult memory stepResult) {
uint8 mineCount = _decodeAndValidateGameData(ctx.gameData);
uint256 maxPayout = _cashoutPayout(ctx.wagerBase, mineCount, TILE_COUNT - mineCount);
uint256 maxReservedProfit = maxPayout > ctx.wagerBase ? maxPayout - ctx.wagerBase : 0;
stepResult.newGameState = abi.encode(/* initial MinesState */);
stepResult.escrowDelta = 0;
stepResult.reservedProfitDelta = int256(maxReservedProfit);
stepResult.nextPhase = SessionPhase.WAITING_PLAYER_ACTION;
stepResult.requestRandomnessNow = false;
stepResult.payout = 0;
}
```
### 2.4 Sequence: open session → chain → snapshot back to iframe
```mermaid
sequenceDiagram
participant Guest as Game_iframe
participant Host as Host_app
participant Facet as CasinoGameFacet
participant Game as ICasinoGameV2
Guest->>Host: openSession(wager, gameData)
Host->>Facet: openSession(game, vault, wager, gameData)
Facet->>Game: onSessionStart(ctx)
Game-->>Facet: StepResult
Facet-->>Host: tx receipt / logs
Host->>Guest: setState(HostSnapshot)
loop Player_actions
Guest->>Host: submitAction(sessionId, actionData, ...)
Host->>Facet: submitAction
Facet->>Game: onPlayerAction(ctx, actionData)
Game-->>Facet: StepResult
Host->>Guest: setState(...)
end
Note over Facet,Game: Randomness provider fulfills RNG off diagram
Facet->>Game: onRandomness(ctx, randomness)
Host->>Guest: setState(...)
```
---
## 3. Bridge SDK (guest + host)
Install **`@chain-protocol/casino-sdk`** from npm with `npm install @chain-protocol/casino-sdk` or `bun add @chain-protocol/casino-sdk`. A game imports `@chain-protocol/casino-sdk` (types, manifest validation, `hostSupports`) and `@chain-protocol/casino-sdk/guest` (`connectGameToHost`). The listings below are bridge source excerpts; omitted helpers and additional exports remain in `src/types.ts`, `src/guest.ts`, and `src/host.ts`.
`./host`, `./simulator` and `./conformance` are internal subpaths. They serve the Chain.wtf host, `chain dev casino` and `game-verify`, sit outside semver and can change in any release. §3.3 shows the host side so you know what the parent page does; do not import it from a game.
**Dependency:** the SDK depends on `penpal` **^7.0.4** (the child connects to `window.parent`; the host connects to `iframe.contentWindow`).
### 3.1 `types.ts`
```typescript
export type HexString = `0x${string}`;
export type CasinoGameManifest = {
schemaVersion: 1;
gameId: string;
defaultLocale: string;
locales: Record<
string,
{
name: string;
description?: string;
}
>;
assets?: {
iconUrl?: string;
coverUrl?: string;
};
};
export type HostSnapshot = {
/** Version of the SDK the host was built with; branch on it with `hostSupports`. */
sdkVersion: string;
integration: {
chainId: number;
slug: string;
gameAddress: `0x${string}`;
manifest: CasinoGameManifest;
};
wallet: {
address?: `0x${string}`;
smartVaultAddress?: `0x${string}`;
status: 'ready' | 'disconnected' | 'setup-required' | 'session-key-mismatch';
};
token: {
symbol?: string;
decimals?: number;
};
balances: {
smartVaultBalance?: string;
};
sessions: {
items: Array<{
sessionId: string;
sessionKey: string;
gameAddress: `0x${string}`;
// Compare against the exported `SessionPhase`, e.g. `phase === SessionPhase.SETTLED`.
phase?: number;
phaseName?: SessionPhaseName; // 'NONE' | 'WAITING_RANDOMNESS' | … | 'CANCELLED'
wager?: string;
// Total escrowed stake — wager plus mid-session increases (e.g. a
// blackjack double), from the committed session snapshot. Absent on
// older hosts; fall back to `wager`.
stake?: string;
payout?: string;
isSettled: boolean;
openedAt?: number;
settledAt?: number;
lastEventTimestamp: number;
raw: {
gameData?: HexString;
gameState?: HexString;
randomness?: HexString;
requestId?: HexString;
};
}>;
};
ui: {
locale: string;
theme: 'light' | 'dark' | 'system';
viewport?: {
// Height in px from the top of the game's iframe to the bottom edge of
// the visible screen, before the user scrolls. To place your primary
// action (e.g. the bet button) exactly at the screen edge, size the
// content above it to `availableHeight` minus the action's own height.
// Do not use CSS viewport units for this: the host grows the iframe to
// fit your content, so inside the iframe 100vh equals the full content
// height, not the visible screen.
availableHeight: number;
};
};
};
export type MaxWagerResult =
| { kind: 'limit'; maxWager: string }
| { kind: 'no-limit' }
| { kind: 'unknown' };
export type HostCapabilities = { playAccess?: boolean };
export type HostApi = {
/** Optional UI request; acknowledge presentation, never readiness. Discover playAccess first. */
requestPlayAccess?(): Promise;
reportContentSize?(input: { minHeight: number }): Promise;
getMaxWager(input?: { gameData?: HexString }): Promise;
openSession(input: {
wager: string;
gameData: HexString;
/** @deprecated Ignored: the casino facet requests randomness itself. */
randomnessRequestData?: HexString;
}): Promise<{ sessionKey: string; transactionHash: HexString }>;
submitAction(input: {
sessionId: string;
actionData: HexString;
/** @deprecated Ignored: the casino facet requests randomness itself. */
randomnessRequestData?: HexString;
approvalAmount?: string;
}): Promise<{ transactionHash: HexString }>;
cancelStuckRandomness(input: { sessionId: string }): Promise<{ transactionHash: HexString }>;
revealOutcome(input: { sessionId: string }): Promise;
};
export type GuestPanelName = 'info' | 'history' | 'audio';
export type GuestCapabilities = {
panels?: GuestPanelName[];
};
export type GuestConnectOptions = {
capabilities?: GuestCapabilities;
};
export type GuestApi = {
/** Show or focus an advertised intent idempotently; resolve when accepted, not dismissed. Reject unsupported requests. */
openPanel?(input: { panel: GuestPanelName }): Promise;
setState(snapshot: HostSnapshot | null): Promise;
};
export type CasinoGameManifestValidationResult =
| {
ok: true;
manifest: CasinoGameManifest;
}
| {
ok: false;
reason: string;
};
export type GameManifestLocale = {
locale: string;
name: string;
description?: string;
};
export type GameManifestMetadata = {
locale: string;
name: string;
description?: string;
iconUrl?: string;
coverUrl?: string;
};
```
### 3.2 `guest.ts` (iframe / game side)
```typescript
import { WindowMessenger, connect } from 'penpal';
import type { Connection } from 'penpal';
import { addHostCapabilities, createGuestMethods } from './capabilities';
import type { HostRpcApi } from './capabilities';
import type { GuestApi, GuestConnectOptions, HostApi, HostCapabilities } from './types';
export type {
GuestPanelName,
GuestApi,
GuestCapabilities,
GuestConnectOptions,
HostApi,
HostCapabilities,
HostSnapshot,
MaxWagerResult,
SessionPhaseName,
} from './types';
export { SessionPhase } from './types';
/**
* Peer capabilities are discovered independently of snapshots and ordinary RPC.
* Penpal fabricates callable proxies for missing methods: await capabilities and
* require playAccess === true before using the access intent. Proxy presence is
* never evidence of support. SDK destruction rejects pending discovery/calls.
*/
export type GuestBridgeConnection = Connection & {
capabilities: Promise;
};
export type ContentSizeObserver = {
disconnect(): void;
report(): void;
};
const getAllowedParentOrigins = (): string[] => {
if (typeof document === 'undefined' || !document.referrer) {
return ['*'];
}
try {
return [new URL(document.referrer).origin];
} catch {
return ['*'];
}
};
export const connectGameToHost = (
methods: GuestApi,
options?: GuestConnectOptions,
): GuestBridgeConnection => {
const guestMethods = createGuestMethods(methods, options?.capabilities);
return addHostCapabilities(
connect({
messenger: new WindowMessenger({
remoteWindow: window.parent,
allowedOrigins: getAllowedParentOrigins(),
}),
methods: guestMethods,
}),
);
};
```
`createGuestMethods` (`src/capabilities.ts`) validates the optional declaration and supplies SDK-owned `getCapabilities`; games implement behavior, never discovery. `connectGameToHost(guestMethods)` remains valid for guests without panels. To advertise a guest-owned History panel, supply an `openPanel` handler and the optional second argument:
```typescript
const connection = connectGameToHost(
{
setState: guestMethods.setState.bind(guestMethods),
async openPanel({ panel }) {
if (panel !== 'history') throw new Error('Unsupported panel.');
await showOrFocusHistory(); // Game-owned UI handler; repeated calls preserve panel state.
},
},
{ capabilities: { panels: ['history'] } },
);
```
`presentation.hostPanels.history` in the manifest requests host-drawn chrome; connection `panels: ['history']` advertises a guest-owned panel, and does not add panels to the manifest. Declare only implemented intents; Casino supports `info`, `history`, and `audio` (game audio controls, which may focus an existing Settings surface).
### 3.3 `host.ts` (parent page)
```typescript
import { WindowMessenger, connect } from 'penpal';
import type { Connection } from 'penpal';
import type { GuestCapabilities, HostApi, HostCapabilities } from './types';
import { addCapabilities, createHostMethods } from './capabilities';
import type { GuestRpcApi } from './capabilities';
import { version } from '../package.json';
export const SDK_VERSION: string = version;
export type {
GuestApi,
GuestCapabilities,
GuestPanelName,
HostApi,
HostCapabilities,
HostSnapshot,
} from './types';
// Penpal fabricates callable proxies even for missing methods. Discovery, not
// property presence, determines support; missing RPCs reject METHOD_NOT_FOUND.
export type HostBridgeConnection = Connection & {
capabilities: Promise;
};
export const connectHostToGame = (options: {
iframe: HTMLIFrameElement;
childOrigin: string;
methods: HostApi;
capabilities?: HostCapabilities;
}): HostBridgeConnection => {
const remoteWindow = options.iframe.contentWindow;
if (!remoteWindow) {
throw new Error('Iframe contentWindow is not available.');
}
const hostMethods = createHostMethods(options.methods, options.capabilities);
return addCapabilities(
connect({
messenger: new WindowMessenger({
remoteWindow,
allowedOrigins: [options.childOrigin],
}),
methods: hostMethods.methods,
}),
hostMethods.destroy,
);
};
```
The imported `GuestRpcApi` and `addCapabilities` (`src/capabilities.ts`) keep discovery inside the SDK. `GuestRpcApi` uses `Required` plus SDK-owned `getCapabilities`, so Penpal's remote proxy retains callable `openPanel` signatures and per-call `CallOptions`; this does not make `openPanel` required in guest implementations. Hosts use `connection.capabilities` to determine support: Penpal can fabricate a callable proxy for a missing method, so `typeof guestApi.openPanel` is not feature detection. A legacy guest without the discovery RPC yields `{}`; other discovery failures reject. Keep controls unavailable when discovery or a panel request fails, and destroy the connection on teardown.
The guest connection also discovers Host capabilities. The optional `playAccess` intent requests existing Host access UI; it does not authenticate, sign or place a bet. See [Requesting play access](#requesting-play-access) for declaration, lifecycle and unsupported-peer behavior. Guest panels and Host access are independent directions on the same bridge.
### Requesting play access
A game may ask the Host to present its existing login, vault setup or signing-key recovery UI. It grants no account access or transaction authority. On the connection returned by `connectGameToHost`, read `await connection.capabilities`; offer the new action only when `playAccess === true`. Missing discovery on an older Host resolves to `{}`; other discovery failures disable this affordance without blocking normal snapshots or gameplay RPCs.
```ts
const capabilities = await connection.capabilities;
const hostApi = await connection.promise;
// Call only from an explicit player action, after checking the game's access gates.
async function requestAccessFromClick() {
if (capabilities.playAccess !== true) return;
await hostApi.requestPlayAccess();
// Acknowledges UI presentation, not login success. Wait for authoritative snapshots.
}
```
The Host opts in with `connectHostToGame({ iframe, childOrigin, methods, capabilities: { playAccess: true } })`, implementing `HostApi.requestPlayAccess`. The SDK owns `getCapabilities`; do not implement that reserved RPC. An affirmative declaration without a callable implementation throws at connection creation. Capability discovery is per connection, not a readiness or permission signal.
The request takes no provider, account, route or transaction arguments and resolves once the current Host flow has opened/focused, or when the account is already ready. The Host must check its current player/game/deployment context, preserve unrelated modal and recovery ownership, reject an unavailable flow and coalesce or focus repeated UI requests. Concurrent pending requests share one invocation; destroying the connection rejects pending requests and stops queued ones. It cannot undo UI already opened or cancel an operation the player has separately authorized. Hosts must fence their own asynchronous continuations against context changes.
Keep betting disabled until a new Host snapshot permits it, and require a fresh bet action. Rejections and timeouts never imply readiness or prove that remote UI did not open; do not automatically retry. Use localized fallback guidance rather than parsing error text. The SDK adds no stable policy-error taxonomy, wallet kit, signing call, funding request or automatic bet. Existing Host entry points remain available on unsupported peers.
Casino exposes the new RPC only with `playAccess: true`; supplying the method alone does not enable it. This is separate from guest panel declarations and from game manifests.
### 3.4 Guest lifecycle (required pattern)
1. On mount, call **`connectGameToHost`** with **`GuestApi`** where **`setState`** stores the latest snapshot in React state (or your framework); guests with panels also implement optional **`openPanel`** and declare those intents in the optional second argument. The SDK owns **`getCapabilities`** for that connection.
2. **`await connection.promise`** (or `.then`) to receive **`HostApi`** — enable buttons that call **`openSession` / `submitAction`** only after this resolves.
3. When your result presentation finishes (reels stopped, bonus round played out), you **must** call **`hostApi.revealOutcome({ sessionId })`** so the host credits the payout to its balance UI. Sessions settle on-chain well before your animation ends; from bet placement until your reveal, the host **clamps its balance displays so they can move down but never up** — debits show, winnings stay hidden — so the top-bar balance never spoils the outcome. It is the reverse of an optimistic update: the payout is final, only its display is delayed until you reveal it. The guard only exists while your game is mounted: refreshing the page or navigating away shows the real balance immediately. Losses need no reveal (the wager left at open, so settlement moves no balance and the host releases the guard itself), but calling it after a loss is a harmless no-op.
4. On unmount, call **`connection.destroy()`**.
### 3.5 Host lifecycle (minimal pattern)
The host must:
1. Create an iframe with `src` pointing at your deployed game URL (HTTPS origin = **`childOrigin`**).
2. Call **`connectHostToGame({ iframe, childOrigin, methods })`** where **`methods`** implements **`HostApi`** (real tx signing).
3. When **`connection.promise`** resolves, call **`guestApi.setState(snapshot)`** once with the current snapshot.
4. On every snapshot update (wallet, sessions, balances), call **`guestApi.setState`** again.
5. Independently await **`connection.capabilities`** before enabling guest-panel controls; initial **`setState`** does not wait for discovery. Dispatch only advertised intents through **`guestApi.openPanel`** in response to the user's action.
Use **stable** function references for `getMaxWager` / `openSession` / `submitAction` / `cancelStuckRandomness` / `revealOutcome` that **delegate** to the latest closure (so reconnects do not stale). Minimal illustration:
```typescript
const latestMethodsRef = useRef(hostMethods);
latestMethodsRef.current = hostMethods;
const stableMethods: HostApi = {
getMaxWager: input => latestMethodsRef.current.getMaxWager(input),
openSession: input => latestMethodsRef.current.openSession(input),
submitAction: input => latestMethodsRef.current.submitAction(input),
cancelStuckRandomness: input => latestMethodsRef.current.cancelStuckRandomness(input),
revealOutcome: input => latestMethodsRef.current.revealOutcome(input),
};
const connection = connectHostToGame({
iframe,
childOrigin: new URL(gameUrl).origin,
methods: stableMethods,
});
void connection.promise.then(async guestApi => {
await guestApi.setState(currentSnapshot);
});
// Call from the host's History control; handle rejection in the control's error UI.
async function openGuestHistory() {
const capabilities = await connection.capabilities;
if (!capabilities.panels?.includes('history')) return;
const guestApi = await connection.promise;
await guestApi.openPanel({ panel: 'history' });
}
```
**Teardown:** destroy the Penpal connection when the iframe is removed. Avoid **`setState(null)`** racing iframe teardown (can throw if the connection is already destroyed).
---
## 4. Using `HostSnapshot` in the game UI
| Field | Use |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `wallet.status` | Only enable betting when **`'ready'`**; show setup prompts for **`setup-required`** / **`session-key-mismatch`**. |
| `token.decimals` | Parse human wager input with **`viem`** **`parseUnits`**. |
| `token.iconUrl` | Optional absolute URL of the token icon for balance / wager displays. |
| `balances.smartVaultBalance` | Display spendable balance (host-defined). |
| `sessions.items` | Each row: **`sessionId`**, **`sessionKey`**, **`phaseName`**, **`wager`**, **`payout`**, **`raw.gameData`**, **`raw.gameState`**. |
**`sessionKey` format** (host convention): **`${chainId}:${sessionIdDecimal}`** — use it to match a session after `openSession` returns until the indexer + on-chain view catch up.
**Terminal phases:** `SETTLED`, `FORFEITED`, `CANCELLED` — treat as ended for UX.
### SDK versions and breaking releases
`snapshot.sdkVersion` is the version of the SDK the host was built with, for example `0.6.0` or
`0.7.0-canary.1`. Hosts older than `0.5.0` leave it out. Branch on it with `hostSupports`, never by
parsing the string:
```ts
import { hostSupports } from '@chain-protocol/casino-sdk';
if (hostSupports(snapshot, '0.6.0')) {
// the 0.6.0 behavior
} else {
// the behavior of older hosts
}
```
`hostSupports` compares major, minor and patch and ignores the prerelease tag, so a canary host
answers as the version it previews. It answers `false` when `sdkVersion` is absent.
The casino SDK, the PvP SDK and `@chain-protocol/cli` publish together at one version. In `0.x`, a
minor bump such as `0.5.x` to `0.6.0` is breaking and a patch is not, so a `^0.5.0` range takes
patches and never the next breaking release. Install the SDK and the CLI at the same version.
`chain init` writes both as a `^` range of the CLI version that created the game. The
[Changelog](/sdk/casino/changelog/) has an entry for every release.
A patch publishes straight to the `latest` dist-tag. A breaking release goes through a canary:
1. The change is announced, and its notice period starts.
2. The new version publishes to npm under the `canary` dist-tag, as `0.6.0-canary.N`, and the
`staging` host runs it. `latest` and the production host stay on the old version.
3. Game devs ship builds that handle both hosts.
4. On switch day, `0.6.0` publishes under `latest` and the production host switches to it.
5. Game devs remove the old path.
During a notice period, do this:
1. Install the canary of the SDK and the CLI:
```sh
npm install @chain-protocol/casino-sdk@canary
npm install --save-dev @chain-protocol/cli@canary
```
With Bun:
```sh
bun add @chain-protocol/casino-sdk@canary
bun add --dev @chain-protocol/cli@canary
```
2. Branch with `hostSupports(snapshot, '0.6.0')` wherever the release changes what your game
sends or reads. Keep the old path in the `else` branch.
3. Play the new path with `chain dev casino`. The SDK simulator is the canary's, so it answers as
the new host. It does not emulate older hosts. To play the old path, submit the build on
production and open its test environment, which runs production's SDK version.
4. Ship the build before switch day. Until the switch, the production host reports the old version
and your game takes the old path.
5. After the switch, install the release (`npm install @chain-protocol/casino-sdk@latest` and
`npm install --save-dev @chain-protocol/cli@latest`, or `bun add` with `@latest`), remove the old
path and ship again.
### Snapshot update semantics (read this before animating anything)
`guestApi.setState` fires on **every** host-side state change — balances, locale,
viewport, session rows — not only on your session's
transitions. The host deduplicates byte-identical snapshots, but distinct
snapshots where nothing about _your_ session changed are normal. Treat
`setState` as "here is the current state", never as "something happened":
- **Derive, don't accumulate.** Render each session from its current
`raw.gameState` (plus `phaseName` / `payout`). Compare against what you last
rendered for that `sessionId` and animate only the difference. Never replay
a session's presentation from scratch because a new snapshot arrived.
- **A session is new only when its `sessionId` is new.** Phase changes within
a known session are progress, never a fresh round.
- **Mid-round `WAITING_RANDOMNESS` is normal for multi-action games.** Every
action that consumes randomness (blackjack hit/double/split/stand, a mines
reveal) moves the session `WAITING_PLAYER_ACTION → WAITING_RANDOMNESS →
WAITING_PLAYER_ACTION | SETTLED`. Only the session's _first_
`WAITING_RANDOMNESS` (before any `gameState` you have rendered) is the
opening deal. Do **not** reset the table when a round you are already
rendering enters `WAITING_RANDOMNESS` — show a waiting affordance (dealer
thinking, tile flipping) and keep the rendered state. The `gameState` bytes
accompanying that transition already include the accepted action.
- **`isSettled` flips once, atomically** with `payout` and the final
`gameState`; present the outcome from that final state, then call
`revealOutcome`.
> Compatibility note: the production host currently suppresses mid-round
> `WAITING_RANDOMNESS` states (a round appears to sit in
> `WAITING_PLAYER_ACTION` until it settles). This is legacy behavior slated
> for removal; the local simulator already delivers the full stream. Build and
> test against the simulator — a game correct there is correct on both.
### Asking the host for the maximum wager
`openSession` rejects a wager above the vault's **bet-size cap** for that bet configuration, and
one the vault's reserve cannot take right now. The cap moves with the vault and follows from your
game's quotes, so do not compute it in the game: ask the host, and pass the game data you are
about to open the session with.
```ts
const answer = await hostApi.getMaxWager({ gameData });
// 'limit' → cap the bet input / "Max" button at min(BigInt(answer.maxWager), balance)
// 'no-limit' → the host caps nothing for this bet; balance is the bound
// 'unknown' → the host could not work a maximum out; fall back to your own limits, never to unlimited
```
- `maxWager` is a decimal string in token base units, like the `wager` of `openSession`.
- A `limit` from the production host is the bet-size cap of that bet configuration, lowered to
what the vault's reserve can take with the sessions open now. What sets the cap is in
[`CONTRACT_CONSTRAINTS.md` § Bet-size cap](/sdk/casino/contract-constraints/#bet-size-cap).
- The production host answers 1% under that figure, rounded down, because the cap falls a little
every second and a wager at the exact figure would be refused a block later.
- Without game data (`hostApi.getMaxWager()`) the host answers for empty game data, your game's
base configuration. A game with one bet configuration needs nothing more. A game whose
quotes reject empty game data gets `unknown`, and so does a game whose quote fails a guardrail.
- The answer belongs to one bet configuration. Ask again whenever the player changes it, and on
an interval, because the answer moves with the vault.
- Keep the bet button disabled until the host has answered for the selected configuration.
- The host answers for the game it serves. The request names no game.
- Treat a rejected call as `unknown`.
- The answer is an estimate. Admission on chain is the authority.
The coinflip example (`chain init casino`) does this in `src/lib/useMaxWager.ts`. The local simulator answers
with the fixed cap you set in its setup panel; see [Local Simulator](/sdk/casino/local-simulator/).
---
## 5. `game.manifest.json` — host validation
The TypeScript type **`CasinoGameManifest`** is minimal; the **host validator** requires extra fields: **`presentation`** and **`capabilities`**. Use the validator below when building your static manifest.
Each `locales[].description` is the catalog's game description: optional, at most 500 characters per locale, and shown in the player's locale with a fallback to `defaultLocale`. The platform reads it from your build's preview deployment; see "Game description" in [Visual & UX](/sdk/casino/visual-and-ux/).
`assets.coverUrl` is the catalog cover image: a path relative to the manifest or an absolute `https://` URL to a PNG, JPEG or WebP image of at most 2 MB, 2:3 portrait and at least 230×345. The platform copies it into its storage at build time; see "Cover image" in [Visual & UX](/sdk/casino/visual-and-ux/).
### 5.1 `canonicalCasinoGameId` and `validateCasinoGameManifest`
```typescript
import type {
CasinoGameManifest,
CasinoGameManifestValidationResult,
GameManifestMetadata,
} from './types';
const isRecord = (value: unknown): value is Record =>
typeof value === 'object' && value !== null;
const hasString = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
/** Matches on-chain normalizeGameName / manifest gameId conventions (strip trailing "Game", lowercase, alnum only). */
export const canonicalCasinoGameId = (value: string | undefined | null): string => {
if (!value) {
return '';
}
let trimmed = value.trim();
if (trimmed.match(/Game$/i)) {
trimmed = trimmed.replace(/Game$/i, '');
}
return trimmed.toLowerCase().replace(/[^a-z0-9]/g, '');
};
const hasOptionalString = (value: unknown): value is string | undefined =>
value === undefined || typeof value === 'string';
/** Minimal validation for routing + catalog; full shape still typed as CasinoGameManifest. */
export const validateCasinoGameManifest = (value: unknown): CasinoGameManifestValidationResult => {
if (!isRecord(value)) {
return { ok: false, reason: 'Manifest must be an object.' };
}
const { schemaVersion, gameId, defaultLocale, locales, presentation, capabilities } = value;
if (schemaVersion !== 1) {
return { ok: false, reason: 'Unsupported manifest schemaVersion.' };
}
if (!hasString(gameId) || !hasString(defaultLocale)) {
return { ok: false, reason: 'Manifest gameId and defaultLocale are required.' };
}
if (!isRecord(locales) || Object.keys(locales).length === 0) {
return { ok: false, reason: 'Manifest locales must contain at least one locale.' };
}
const defaultLocaleEntry = locales[defaultLocale];
if (!isRecord(defaultLocaleEntry) || !hasString(defaultLocaleEntry.name)) {
return { ok: false, reason: 'Manifest defaultLocale must exist in locales.' };
}
if (
!isRecord(presentation) ||
(presentation.mode !== 'full-iframe' && presentation.mode !== 'embedded') ||
!isRecord(presentation.hostPanels) ||
typeof presentation.hostPanels.openSession !== 'boolean' ||
typeof presentation.hostPanels.history !== 'boolean' ||
typeof presentation.hostPanels.status !== 'boolean'
) {
return { ok: false, reason: 'Manifest presentation is invalid.' };
}
if (
!isRecord(capabilities) ||
capabilities.openSession !== true ||
typeof capabilities.submitAction !== 'boolean' ||
typeof capabilities.forfeitExpiredSession !== 'boolean' ||
typeof capabilities.cancelStuckRandomness !== 'boolean' ||
typeof capabilities.resize !== 'boolean'
) {
return { ok: false, reason: 'Manifest capabilities are invalid.' };
}
const assets = value.assets;
if (assets !== undefined) {
if (
!isRecord(assets) ||
!hasOptionalString(assets.iconUrl) ||
!hasOptionalString(assets.coverUrl)
) {
return { ok: false, reason: 'Manifest assets are invalid.' };
}
}
return {
ok: true,
manifest: value as CasinoGameManifest,
};
};
export const resolveManifestMetadata = (
manifest: CasinoGameManifest,
requestedLocale: string,
): GameManifestMetadata => {
const locale =
manifest.locales[requestedLocale] !== undefined ? requestedLocale : manifest.defaultLocale;
const localized = manifest.locales[locale] ?? manifest.locales[manifest.defaultLocale];
return {
locale,
name: localized.name,
description: localized.description,
iconUrl: manifest.assets?.iconUrl,
coverUrl: manifest.assets?.coverUrl,
};
};
export const assertSameOriginUrls = (manifestUrl: string, iframeUrl: string): boolean => {
try {
const manifestOrigin = new URL(manifestUrl).origin;
const iframeOrigin = new URL(iframeUrl).origin;
return manifestOrigin === iframeOrigin;
} catch {
return false;
}
};
```
### 5.2 Example manifests
**Blackjack** — multi-action, forfeit + cancel RNG:
```json
{
"schemaVersion": 1,
"gameId": "BlackjackGame",
"defaultLocale": "en",
"locales": {
"en": {
"name": "Blackjack",
"description": "Render and control the active blackjack table using host-provided on-chain session state."
}
},
"presentation": {
"mode": "full-iframe",
"hostPanels": {
"openSession": false,
"history": false,
"status": false
}
},
"capabilities": {
"openSession": true,
"submitAction": true,
"forfeitExpiredSession": true,
"cancelStuckRandomness": true,
"resize": true
}
}
```
**Mines** — multi-action, cancel stuck randomness:
```json
{
"schemaVersion": 1,
"gameId": "MinesGame",
"defaultLocale": "en",
"locales": {
"en": {
"name": "Mines",
"description": "Reveal safe tiles on a 5x5 grid and cash out before the next reveal hits a mine."
}
},
"presentation": {
"mode": "full-iframe",
"hostPanels": {
"openSession": false,
"history": false,
"status": false
}
},
"capabilities": {
"openSession": true,
"submitAction": true,
"forfeitExpiredSession": false,
"cancelStuckRandomness": true,
"resize": true
}
}
```
**Coinflip** — instant round (no `submitAction` in the iframe):
```json
{
"schemaVersion": 1,
"gameId": "CoinflipGame",
"defaultLocale": "en",
"locales": {
"en": {
"name": "Coinflip",
"description": "Flip one or more coins and settle the round from host-provided on-chain state."
}
},
"presentation": {
"mode": "full-iframe",
"hostPanels": {
"openSession": false,
"history": false,
"status": false
}
},
"capabilities": {
"openSession": true,
"submitAction": false,
"forfeitExpiredSession": false,
"cancelStuckRandomness": false,
"resize": true
}
}
```
---
## 6. Frontend patterns (reference implementations)
Use **`viem`** for **`encodeAbiParameters` / `decodeAbiParameters` / `formatUnits` / `parseUnits`**. The bridge's `randomnessRequestData` field is deprecated and ignored — omit it.
### 6.1 Shared bridge bootstrap (all games)
```tsx
import { useEffect, useState } from 'react';
import {
connectGameToHost,
type GuestApi,
type HostApi,
type HostSnapshot,
} from '@chain-protocol/casino-sdk/guest';
export function App() {
const [hostApi, setHostApi] = useState(null);
const [snapshot, setSnapshot] = useState(null);
useEffect(() => {
let mounted = true;
const guestMethods: GuestApi = {
async setState(nextSnapshot) {
if (!mounted) {
return;
}
setSnapshot(nextSnapshot);
},
};
const connection = connectGameToHost(guestMethods);
void connection.promise
.then(parent => {
if (mounted) {
setHostApi(parent);
}
})
.catch(() => {
/* handle */
});
return () => {
mounted = false;
connection.destroy();
};
}, []);
if (!hostApi || !snapshot) {
return ;
}
return null;
}
```
### 6.2 Coinflip (instant — `openSession` only)
Encode **`gameData`** to match your **`CoinflipGame`** ABI, e.g. `(bool pickHeads, uint8 coinCount, uint8 minWins)`:
```tsx
async function handleOpenSession(
hostApi: HostApi,
snapshot: HostSnapshot,
wagerInput: string,
side: 'heads' | 'tails',
coinCount: number,
minWins: number,
) {
const decimals = snapshot.token.decimals ?? 6;
const wager = parseUnits(wagerInput, decimals).toString();
const gameData = encodeAbiParameters(
[{ type: 'bool' }, { type: 'uint8' }, { type: 'uint8' }],
[side === 'heads', coinCount, minWins],
);
const { sessionKey } = await hostApi.openSession({
wager,
gameData,
});
// Track sessionKey until snapshot lists the session and outcome is known
}
```
Decode **`raw.gameData`** / **`raw.gameState`** for display; for coinflip, randomness often appears inside **`gameState`** after the contract encodes it (game-specific ABI).
### 6.3 Blackjack (multi-action + `approvalAmount`)
Open with rules packed in **`gameData`** (example: `uint8` blackjack payout mode + `bool` dealer hits soft 17):
```tsx
const PAYOUT_THREE_TO_TWO = 0;
const DEFAULT_DEALER_HITS_SOFT_17 = false;
async function handleOpenSession(hostApi: HostApi, snapshot: HostSnapshot, wagerInput: string) {
const wager = parseUnits(wagerInput, snapshot.token.decimals ?? 6).toString();
const gameData = encodeAbiParameters(
[{ type: 'uint8' }, { type: 'bool' }],
[PAYOUT_THREE_TO_TWO, DEFAULT_DEALER_HITS_SOFT_17],
);
await hostApi.openSession({
wager,
gameData,
});
}
async function handleAction(
hostApi: HostApi,
activeSession: HostSnapshot['sessions']['items'][number],
actionCode: number,
requiresExtraStake: boolean,
) {
await hostApi.submitAction({
sessionId: activeSession.sessionId,
actionData: encodeAbiParameters([{ type: 'uint8' }], [actionCode]),
approvalAmount: requiresExtraStake ? activeSession.wager : undefined,
});
}
```
**`approvalAmount`**: when the player **doubles** or **splits**, the facet may need an additional **`escrowDelta`**; the host batches **ERC20 `approve(proxy, amount)`** + **`submitAction`**. Pass the **base wager string** as `approvalAmount` when your UI enables those actions.
### 6.4 Mines (multi-action + maximum wager UX)
Open round with mine count:
```tsx
async function handleOpenSession(
hostApi: HostApi,
snapshot: HostSnapshot,
wagerInput: string,
mineCount: number,
) {
const wager = parseUnits(wagerInput, snapshot.token.decimals ?? 6).toString();
const gameData = encodeAbiParameters([{ type: 'uint8' }], [mineCount]);
await hostApi.openSession({
wager,
gameData,
});
}
const ACTION_REVEAL = 0;
const ACTION_CASHOUT = 1;
async function handleReveal(
hostApi: HostApi,
activeSession: HostSnapshot['sessions']['items'][number],
tileIndex: number,
) {
await hostApi.submitAction({
sessionId: activeSession.sessionId,
actionData: encodeAbiParameters(
[{ type: 'uint8' }, { type: 'uint8' }],
[ACTION_REVEAL, tileIndex],
),
});
}
async function handleCashout(
hostApi: HostApi,
activeSession: HostSnapshot['sessions']['items'][number],
) {
await hostApi.submitAction({
sessionId: activeSession.sessionId,
actionData: encodeAbiParameters([{ type: 'uint8' }, { type: 'uint8' }], [ACTION_CASHOUT, 0]),
});
}
```
**Maximum wager:** ask the host with **`hostApi.getMaxWager({ gameData })`** for the chosen mines count and disable **Open** above a `limit` answer before the user hits chain. A wager above its bet-size cap reverts with **`CasinoGameFacet__WagerAboveRiskBudget`**, **`CasinoGameFacet__WagerAboveBreakEvenRule`** or **`CasinoGameFacet__NetWinAboveSingleWinCap`**. A wager the vault's reserve cannot take reverts with **`CasinoGameFacet__InsufficientPortfolioReserve`**. Map each to a friendly message.
**Stuck randomness:** optional **`cancelStuckRandomness({ sessionId })`** when `phaseName === 'WAITING_RANDOMNESS'` and the host exposes recovery (mines manifest sets **`cancelStuckRandomness: true`**).
---
## 7. Host integrator checklist (signing + snapshot)
If you implement the parent app (not the iframe), you must:
1. **Approve + openSession:** Encode **`CasinoGameFacet.openSession(gameAddress, vault, wager, gameData)`**. Prepend **ERC20 `approve(facetOrProxy, wager)`** from the player’s funding path (EOA or Smart Vault batch).
2. **Parse `sessionId` and the snapshot:** From **`CasinoSessionOpened`** logs after the transaction, build **`sessionKey = \`\${chainId}:\${sessionId}\`**`; keep the encoded `session` bytes of the newest **`CasinoSessionAdvanced`** log per session — the facet stores only their hash.
3. **submitAction:** Optionally prepend **`approve`** when the guest passes **`approvalAmount`**; then **`submitAction(encodedSession, actionData)`** with the latest committed snapshot (own receipts, flashblock push or indexed feed, whichever is newest).
4. **Snapshot freshness:** Decode the committed `session` bytes (`CasinoSessionCodec`, or `CasinoConfigFacet.decodeSession`) so **`raw.gameData`** and **`raw.gameState`** match on-chain bytes for decoding in the iframe.
5. **Push updates:** Call **`guestApi.setState`** whenever wallet, token balances, session rows, locale, theme, or viewport change.
6. **Maximum wager:** Answer **`getMaxWager`** from **`CasinoConfigFacet.getMaximumWager(vault, gameAddress, gameData)`** for the game you serve, with empty game data when the guest passes none. The view returns `maximumWager` in token base units, the limit that binds (`0` risk budget, `1` break-even rule, `2` single-win cap, `3` the reserve, when it can take less than the bet-size cap) and the current LP cut; answer with `maximumWager` less a headroom of 1%, rounded down, because the figure falls every second. Answer **`unknown`** when the view reverts.
### 7.1 Operators (not open yet)
An **operator** is a frontend that serves players on the shared vaults. It earns the **operator
fee** of every session attributed to it: what is left of the session's net edge after the LP cut and the
protocol fee (§2.1.1). The operator code is simply the operator's address.
**Status: operator registration is closed.** Only the protocol's own operator is registered today,
so every session is attributed to it and nothing changes for your game or your host. Registration
will open to other frontends in a future release; this section describes the interface that is
already live so hosts can be built against it.
- **Naming the operator:** the facet has a second entry point,
`openSession(game, vault, wager, gameData, operator)`. The four-argument call keeps working and
means "no operator code".
- **No code, or an unregistered code, never reverts.** The session is attributed to the protocol's
default operator instead, so a stale host config cannot block a bet.
- **The code cannot be swapped in transit.** It is part of the `openSession` calldata, which the
player's Smart Vault signs as one batch. A relayer that changes it invalidates the signature.
- **Gas sponsorship:** the chain.wtf relayer pays gas only for sessions that name no operator or
chain.wtf's own. An operator sponsors its own players by running its own relayer.
- **Games see nothing.** The operator is not part of `SessionContext` and does not reach the
iframe. It is committed inside the encoded session (20 bytes after the risk flags), followed by
the session's edge and the three rates its fees are computed from. The fixed part of the encoded
session is 295 bytes, so a host that decodes session bytes itself must use the current
`CasinoSessionCodec` layout.
---
## 8. Appendix
### 8.1 Guest `package.json` dependencies (example)
```json
{
"dependencies": {
"@chain-protocol/casino-sdk": "0.5.0",
"react": "^19.0.0",
"viem": "^2.x"
},
"devDependencies": {
"@chain-protocol/cli": "0.5.0"
}
}
```
Keep the SDK and the CLI on the same version range, as `chain init` writes them. The SDK brings `penpal` as its own dependency. See
[SDK versions and breaking releases](#sdk-versions-and-breaking-releases) for what a version range
takes.
### 8.2 Security notes
- Penpal **origin allowlisting**: the host passes **`childOrigin`**; the guest allows the **referrer** origin when **`document.referrer`** is parseable, else **`*`** (development only — production games should be served from a stable origin behind HTTPS).
- **Never** trust client-only math for payouts — **always** treat **`gameState`** and balances as **hints**; the **facet + game contract** are authoritative.
---
_End of document._
## SDK exports at a glance
The examples above illustrate selected interfaces; they are not complete signatures. Consult the declarations installed with the SDK for the full exports and signatures for your installed version.
1. `@chain-protocol/casino-sdk`: shared types such as `HostSnapshot` and `HostApi`, manifest validation, deployment helpers and `hostSupports`.
2. `@chain-protocol/casino-sdk/guest`: `connectGameToHost`, `reportGameContentSize`, `observeGameContentSize` and guest-facing types.
3. `@chain-protocol/casino-sdk/slot-engine` and `/graph-engine`: engine helpers; `/slot-engine/threshold` exposes threshold helpers.
4. `@chain-protocol/casino-sdk/contracts/*`, `/slot-engine/contracts/*` and `/graph-engine/contracts/*`: Solidity contracts.
The host, simulator and conformance entry points are internal and outside semver. See [package entry points](/sdk/#package-entry-points) for context.