# Chain.wtf — building PvP games guide Build the contract policy, host bridge, and snapshot-driven frontend for a PvP game. Follow players from lobby entry through turns, settlement, claims, and refunds. :::note[Current SDK checkout] - `@chain-protocol/pvp-sdk@0.7.0` This guidance reflects this checkout and may include unreleased changes. [PvP changelog](/sdk/pvp/changelog/) ::: **Jump to:** 1. [Contract](#21-ipvpgamev2-full-solidity) 2. [Guest lifecycle](#32-guest-lifecycle-required-pattern) 3. [Snapshot](#4-using-pvphostsnapshot-in-the-game-ui) 4. [Manifest](#5-gamemanifestjson--host-validation) 5. [Frontend patterns](#6-frontend-patterns) Chain.wtf is a fully decentralized wagering platform (Sportsbook, Casino, fully on-chain **PvP games**, and prediction markets) built on Coinbase's Base L2. This document is **self-contained**: you can follow it from a separate repository (e.g. a Vite frontend + Forge/Hardhat) without internal Chain.wtf source access. Inline code blocks are the **canonical** types and patterns you need. The PvP model is the sibling of the casino model documented in `@chain-protocol/casino-sdk`. The bridge (host/guest over Penpal), the iframe/manifest model, and the snapshot-driven UI are the **same**. The difference is the economic model: - **Casino**: one player wagers against the **house liquidity pool**; the protocol backs winnings and prices risk via reserves. - **PvP**: players wager against **each other** into a shared **pot**. The protocol takes a **fee** and the game decides how the rest of the pot is **settled**. No house liquidity is ever at risk. API **v2** removes protocol-level assumptions that every participant pays the same buy-in, enters once, or fits in a bounded seat array. A lobby has one escrow asset; the game decides admission, stake validation, repeat entries, positions, lifecycle permissions, lobby keys, and settlement mode. --- ## 1. Platform model **Host** (Chain.wtf web app) 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`). **Guest** (your game UI): **does not** connect a wallet or send raw transactions. It only: - Encodes intent as ABI-encoded **`config`** / **`openData`** (at lobby creation), **`stake`** + **`entryData`** (per entry), and **`actionData`** (per action). - Calls **`hostApi.createLobby` / `enterLobby` / `startLobby` / `submitAction` / `cancelLobby` / `claimWinnings` / `claimRefund` / `claimPayout`** — each returns a **Promise** (host signs/broadcasts txs). `claimPayout` is optional on the host API — feature-detect it. - Pages large lists via **`getLobbyParticipants`** / **`getLobbyContributions`**. - Renders from **`PvpHostSnapshot`**, pushed by the host via **`guestApi.setState(snapshot)`** whenever wallet, balances, lobbies, host-provided metadata, or UI settings change. **Chain**: the protocol exposes a PvP facet on the diamond/proxy. Your deployed game contract implements **`IPvpGameV2`**. The protocol owns escrow, the contribution ledger, phase transitions, randomness requests, the protocol fee, and settlement transfers. It calls into your game's pure/view hooks for **policy**. **Turn order is owned by the game, not the protocol** (see §2.3) — which is what makes permissionless skipping of a slacking player possible. --- ## 2. On-chain architecture ### 2.1 `IPvpGameV2` (full Solidity) Your game contract implements this interface. Phases, context, ledger views, and step results are fixed by the platform. The authoritative source lives at [`../contracts/IPvpGameV2.sol`](/sdk-source/fb4e1ca5d757/IPvpGameV2.sol.txt). ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; enum LobbyPhase { NONE, WAITING_FOR_PLAYERS, IN_PROGRESS, WAITING_RANDOMNESS, WAITING_PLAYER_ACTION, RESOLVED, CANCELLED } enum SettlementMode { NONE, IMMEDIATE, CLAIMABLE } /// Bounded lobby summary. Unbounded collections stay behind ledger views. struct LobbyContext { uint256 lobbyId; bytes32 lobbyKey; address opener; address token; uint256 pot; uint256 participantCount; uint256 contributionCount; uint16 protocolFeeBps; uint32 step; LobbyPhase phase; bytes config; bytes gameState; } struct ParticipantContext { bool joined; uint256 totalStake; uint256 contributionCount; } struct ContributionRecord { uint256 contributionId; // dense 0-based per-lobby index in append order address participant; uint256 amount; bytes32 positionId; uint256 cumulativePot; uint64 contributedAt; } interface IPvpLobbyLedgerV2 { function getContribution(uint256 lobbyId, uint256 contributionId) external view returns (ContributionRecord memory); function participantAt(uint256 lobbyId, uint256 index) external view returns (address); function participantIndex(uint256 lobbyId, address participant) external view returns (bool joined, uint256 index); function getParticipantContext(uint256 lobbyId, address participant) external view returns (ParticipantContext memory); function getPositionStake(uint256 lobbyId, bytes32 positionId) external view returns (uint256); } struct LobbyOpenResult { bytes32 lobbyKey; // zero disables uniqueness; non-zero keys are unique per game bytes newGameState; } struct EntryResult { bytes32 positionId; bytes newGameState; } struct PvpStepResult { bytes newGameState; LobbyPhase nextPhase; bool requestRandomnessNow; address[] recipients; // used only on RESOLVED; the protocol derives the settlement mode: // exactly 1 → IMMEDIATE (whole pot - fee), 0 or 2+ → CLAIMABLE } struct ClaimQuote { address recipient; uint256 amount; bool valid; } interface IPvpGameV2 { function onLobbyOpen( LobbyContext calldata ctx, address opener, bytes calldata openData ) external view returns (LobbyOpenResult memory); function onEntry( LobbyContext calldata ctx, ParticipantContext calldata participant, address entrant, uint256 stake, bytes calldata entryData ) external view returns (EntryResult memory); function onLobbyStart( LobbyContext calldata ctx, address actor, bytes calldata actionData ) external view returns (PvpStepResult memory); function onPlayerAction( LobbyContext calldata ctx, address actor, bytes calldata actionData ) external view returns (PvpStepResult memory); function onRandomness( LobbyContext calldata ctx, bytes32 randomness ) external view returns (PvpStepResult memory); function canCancel( LobbyContext calldata ctx, address actor, bytes calldata cancelData ) external view returns (bool); function getClaim( LobbyContext calldata ctx, bytes32 claimId, bytes calldata claimData ) external view returns (ClaimQuote memory); } ``` **Semantics:** - **`onLobbyOpen`**: Validate asset/config/open data at creation. Optionally return a non-zero **`lobbyKey`** for game-scoped uniqueness (e.g. Jackpot keys by token + opener + config). The opener is recorded but carries **no implicit authority**. - **`onEntry`**: Accept or reject the caller's explicit stake (including zero) and return a **`positionId`**. Zero stake and repeated entry are valid protocol inputs; the game decides whether they are valid for that lobby. Revert to reject. Games that only allow entry while waiting for players must check `ctx.phase` themselves — the protocol does not invent a seat model. - **`onLobbyStart`**: Validate who may start and whether the lobby is ready, then return the first step result (initial `gameState`, phase, optional randomness request). - **`onPlayerAction`**: Called when a participant submits `actionData`. The protocol does **not** enforce turn order — it forwards the actor and your game decides what is valid (see §2.3). - **`onRandomness`**: Called when the randomness provider fulfills a request. Can route the next turn back to the **same** player, enabling `decide → randomness → decide again` loops. It may not request randomness again: see [One random value per request](#one-random-value-per-request). - **`canCancel`**: Own every normal and recovery cancellation permission. Cancellation refunds each stake net of its share of what the lobby's gas tank and rake reserve spent (§4). The protocol's only timeout is the liveness backstop: once a lobby has taken no step for **48 hours**, anyone may cancel it without asking `canCancel`. - **Liveness backstop**: a step is an open, an entry, or an applied start, action or randomness result. Never make a lobby wait 48 hours or more between steps (a jackpot window, a turn deadline), or anyone may cancel it mid-game. A claimable pot takes claims for 7 days after resolution; after that `refundUnclaimedWinnings` returns what is left to the participants by stake. See "Cancellation and refunds" in `PVP_CONTRACT_CONSTRAINTS.md`. - **`getClaim`**: For **claimable** settlement, compute one stable claim ID's recipient and amount from final state. The protocol records collected IDs and caps total transfers at the remaining distributable pot. Game hooks remain **`view`**. Read the contribution ledger through `msg.sender` as `IPvpLobbyLedgerV2` — do not maintain a parallel mutable copy of balances. #### One random value per request A game gets **one `bytes32`** per randomness request. When a step needs several random values, derive them all from that one value, for example `keccak256(abi.encode(randomness, i))` for the `i`-th value. Randomness is only requested from a participant's call (`onLobbyStart` or `onPlayerAction`), because the call that requests it pays its fee. `startLobby` and `submitLobbyAction` are `payable`: a call whose step requests randomness passes all the ETH it carries to the provider, and a call whose step requests none gets its ETH back in the same transaction. Your game contract never sees this ETH. If `onRandomness` returns **`WAITING_RANDOMNESS`**, the facet reverts the fulfilment with `PvpGameFacet__RandomnessRequestedFromCallback`: the result is never applied and the lobby stays waiting for randomness until `canCancel` lets someone cancel it or the 48-hour liveness backstop lets anyone cancel it. #### Consuming randomness: unbiased d6 When mapping facet **`bytes32`** randomness to six-sided dice via **raw bytes**, use **rejection sampling** (`byte < 252`, then `(byte % 6) + 1`) — not `randomness[i] % 6` alone. For many independent dice from one word, `keccak256(abi.encode(randomness, salt))` then `word % 6` is acceptable. Details: **[`RANDOMNESS_DICE.md`](/sdk/pvp/randomness-dice/)**. #### Pot accounting and settlement There is **no** `escrowDelta` / `reservedProfit` machinery. The lobby's **stake token** is chosen at open from a governance-controlled whitelist. Stakes are pulled by the protocol on each accepted entry. Your game never moves tokens; it only chooses settlement at resolution: The settlement mode is **derived from `recipients.length`** on the resolving step: | Mode | When | Who executes transfers | | ----------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `IMMEDIATE` | Exactly **1** recipient — it receives the whole pot − fee | Protocol, in the resolving transaction; a failed transfer parks the payout for `claimPayout(lobbyId)` | | `CLAIMABLE` | **0** recipients (not enumerable) or **2+**; `getClaim(claimId)` quotes payouts | Anyone may call claim; recipient is fixed by the game | | `CANCELLED` | Not a game result — each participant's aggregate stake refunded net of tank and reserve spend | Anyone may claim refund per participant | Because claim amounts are explicit base units (not basis-point shares), any scoring system still maps cleanly under claimable settlement: | Scoring | Claim quote idea (3 players, pot after fee = 100) | | ------------------------------ | ------------------------------------------------- | | Winner-take-all | `[100, —, —]` (omit zero seats) | | Fixed places 50/30/20 | `[50, 30, 20]` | | Points-based (scores 30/10/10) | `[60, 20, 20]` | | Draw / even split | `[34, 33, 33]` (assign dust so the sum is exact) | Immediate settlement is reserved for the single-winner case (1v1 duels, jackpots); every multi-recipient distribution settles via claims. ### 2.2 Protocol responsibilities (summary) Conceptual entrypoints (your deployment uses the protocol's verified ABI): | Entry | Role | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Open lobby | Whitelist game + token; call `onLobbyOpen`; store opener, config, optional lobby key; start in `WAITING_FOR_PLAYERS`. No stake yet. | | Enter lobby | Call `onEntry` with the caller's explicit stake; escrow funds; append a contribution ledger row; update participant/position totals. | | Open and enter | Open lobby followed by the opener's entry, in one call: the same events and state as the two separate calls; a rejected entry reverts the open too. | | Start lobby | Call `onLobbyStart(actor, …)`; game validates readiness and who may start. | | Submit action | Forward `msg.sender` to `onPlayerAction` **without** enforcing turn order. | | Randomness fulfilled | Run `onRandomness`. | | Cancel lobby | Require `canCancel` until 48 hours without a step; mark `CANCELLED`; each participant's stake becomes independently refundable, net of tank and reserve spend. | | Claim winnings / refund | Claimable payouts via `getClaim`; cancellation refunds via ledger totals less the participant's pro rata share of what the gas tank and the rake reserve spent. | | Ledger / participant views | Indexed reads for games; paginated reads for hosts. | On a step returning `nextPhase == RESOLVED`, the protocol validates the recipients (no zero addresses) and derives the mode. With exactly one recipient it deducts the fee and attempts the payout transfer — if it fails, the payout is parked and collected via the permissionless `claimPayout(lobbyId)`. Otherwise it records the remaining distributable pot and lets claims drain it. What the protocol **does not** own: player caps, fixed buy-ins, one-entry-per-address rules, opener privileges, or timeout cancellation. Those are game policy (or intentionally absent). The 48-hour liveness backstop is the one exception, a last resort that returns stakes when a game stalls. ### 2.3 Patterns: free-for-all, scheduled jackpot, turn-based **Fixed-stake duel (e.g. Bones)** — two seats, one exact stake each, per-roll randomness: - `onLobbyOpen` validates `requiredStake` (and the turn length) in `config`. - `onEntry` requires `WAITING_FOR_PLAYERS`, exact stake, single entry, and at most two seats. - `onLobbyStart` (either seated player, once both seats are filled) records the seats in ledger order in `gameState` and requests randomness for the coin toss → **`WAITING_RANDOMNESS`**. - `onRandomness` resolves the toss or the pending roll → **`WAITING_PLAYER_ACTION`**. - `onPlayerAction` validates the actor against the seat recorded in `gameState`; a `ROLL` requests randomness, a `PLACE` advances the turn or ends the match: **`RESOLVED`** with the winner as the single `recipient` (paid the whole distributable pot immediately) or both seats on a draw (each claims half with its address as the claim id). A per-turn `deadline` in `gameState` makes `SKIP` permissionless once it passes, and `FORFEIT` permissionless two turn lengths later — the idle seat loses and the other seat is the single `recipient`. Either seat may `RESIGN`. All three are the liveness standard's reserved payloads. **Scheduled weighted jackpot (e.g. Jackpot)** — variable stakes, repeat entries, one winner: - `onLobbyOpen` sets `lobbyKey = keccak256(abi.encode(token, opener, decodedConfig))` so distinct schedules/assets cannot collide and another opener copying the config cannot squat the key. - `onEntry` time-gates on `opensAt`/`closesAt`, enforces `minimumStake`, and reuses an address-derived `positionId` so repeats aggregate. - `onLobbyStart` (anyone, after close) requests randomness. - `canCancel` lets anyone void a contested round still unarmed a minute after close, so a lobby whose randomness provider cannot take requests never depends on the administrator key. - `onRandomness` rejection-samples a ticket in `[0, pot)`, binary-searches dense contribution IDs by `cumulativePot`, and pays the winner **immediately**. **Turn-based (e.g. heads-up poker, a board game)** — the game owns turn order in `gameState`: - `onLobbyStart` → **`WAITING_PLAYER_ACTION`**, record the current actor (via `participantAt` / indexes) in `gameState`. - `onPlayerAction` validates the actor against your own current actor, applies the move, and advances state. There is no protocol-level "next actor"; the guest reads it back from `raw.gameState`. - An action may go **`WAITING_RANDOMNESS`** (`requestRandomnessNow = true`); `onRandomness` may hand the turn **back to the same player**. **Skipping a slacking player** (no protocol support needed): store `deadline = block.timestamp + timeBank` in `gameState`. Define a `SKIP` action that checks `block.timestamp > deadline` (ignoring the caller) and forfeits the no-show — they get a **0 amount** at resolution or are dropped from the active set — then advances. Because the protocol forwards **any** caller, another player or a keeper can send the skip once the deadline passes. Your guest decodes the deadline from `raw.gameState` to render a countdown. Skipping alone still costs the present player a transaction (and a randomness round) per missed move, so an abandoned match can drag on for many minutes. Pair it with a forfeit. The optional **liveness standard** (`contracts/PvpLiveness.sol`) gives skip, forfeit, resign and advance reserved payloads and a `liveness` view that any host or keeper can use, for turn-based and simultaneous games alike — see [Liveness standard](/sdk/pvp/contract-constraints/#liveness-standard-optional). ```solidity // inside onPlayerAction; State holds currentActor + deadline + your game data (uint8 kind) = abi.decode(actionData, (uint8)); if (kind == SKIP) { require(block.timestamp > s.deadline, "not expired"); // caller irrelevant — anyone may poke // forfeit s.currentActor, advance turn, s.deadline = block.timestamp + timeBank } else { require(actor == s.currentActor, "not your turn"); // apply move; advance turn (or requestRandomnessNow = true); reset s.deadline } ``` ### 2.4 Sequence: open → enter → start → act → resolve ```mermaid sequenceDiagram participant Guest as Game_iframe participant Host as Host_app participant Facet as PvpFacet participant Game as IPvpGameV2 Guest->>Host: createLobby(asset, config, openData, entry?) Host->>Facet: openAndEnterLobby(...) or openLobby(...) Facet->>Game: onLobbyOpen(ctx, opener, openData) Facet->>Game: onEntry(ctx, opener, stake, entryData) — with entry Facet-->>Host: lobbyId (+ contributionId) Host->>Guest: setState(PvpHostSnapshot) Note over Guest,Facet: players enter with explicit stakes Guest->>Host: enterLobby(lobbyId, stake, entryData) Host->>Facet: enterLobby Facet->>Game: onEntry(ctx, participant, entrant, stake, entryData) Note over Facet: escrow stake#59; append contribution ledger Host->>Guest: setState(...) Guest->>Host: startLobby(lobbyId, actionData, ...) Host->>Facet: startLobby Facet->>Game: onLobbyStart(ctx, actor, actionData) Host->>Guest: setState(...) loop Player_actions Guest->>Host: submitAction(lobbyId, actionData, ...) Host->>Facet: submitAction Facet->>Game: onPlayerAction(ctx, actor, actionData) Game-->>Facet: PvpStepResult (maybe RESOLVED + settlement) Host->>Guest: setState(...) end Note over Facet,Game: IMMEDIATE pays the single winner#59; CLAIMABLE uses getClaim later ``` --- ## 3. Bridge SDK (guest + host) Install **`@chain-protocol/pvp-sdk`** from npm with `npm install @chain-protocol/pvp-sdk` or `bun add @chain-protocol/pvp-sdk`. A game imports `@chain-protocol/pvp-sdk`, `@chain-protocol/pvp-sdk/guest`, `@chain-protocol/pvp-sdk/liveness` and `@chain-protocol/pvp-sdk/spectator`. The bridge works like the one in `@chain-protocol/casino-sdk`; only the typed API and snapshot shapes differ. `./host`, `./simulator` and `./conformance` are internal subpaths. They serve the Chain.wtf host, `chain dev pvp` and `game-verify`, sit outside semver and can change in any release. Do not import them from a game. To start from a working game, run `npx @chain-protocol/cli init pvp my-game` (or `bunx @chain-protocol/cli init pvp my-game`) and see [Local Simulator](/sdk/pvp/local-simulator/). **Dependency:** `penpal` **^7.0.4**. ### 3.1 `guest.ts` (iframe / game side) The following are source excerpts; additional exports and helpers remain in `src/types.ts` and `src/guest.ts`. The guest contract includes optional panel behavior: ```typescript export type PvpGuestPanelName = 'info' | 'history'; export type PvpGuestCapabilities = { panels?: PvpGuestPanelName[] }; export type PvpGuestConnectOptions = { capabilities?: PvpGuestCapabilities }; export type PvpGuestApi = { /** Show or focus an advertised intent idempotently; resolve when accepted, not dismissed. Reject unsupported requests. */ openPanel?(input: { panel: PvpGuestPanelName }): Promise; setState(snapshot: PvpHostSnapshot | null): Promise; }; ``` ```typescript import { WindowMessenger, connect } from 'penpal'; import type { Connection } from 'penpal'; import { addHostCapabilities, createGuestMethods } from './capabilities'; import type { HostRpcApi } from './capabilities'; import type { PvpGuestApi, PvpGuestConnectOptions, PvpHostApi, PvpHostCapabilities } from './types'; export type { PvpGuestApi, PvpGuestCapabilities, PvpGuestConnectOptions, PvpGuestPanelName, PvpHostApi, PvpHostCapabilities, PvpHostMetadata, PvpHostSnapshot, PvpLobbyMetadata, PvpMetadataBag, PvpMetadataPrimitive, PvpMetadataValue, PvpPlayerMetadata, PvpRoomMetadata, PvpRoomParticipantMetadata, } 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 PvpGuestBridgeConnection = Connection & { capabilities: Promise; }; export type PvpContentSizeObserver = { 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: PvpGuestApi, options?: PvpGuestConnectOptions, ): PvpGuestBridgeConnection => { 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. For a guest-owned History panel, add an `openPanel` handler and declare it through 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; PvP supports `info` and `history`. Also export **`reportGameContentSize`** / **`observeGameContentSize`** so games with `capabilities.resize` can report dynamic iframe height to the host. ### 3.2 Guest lifecycle (required pattern) 1. On mount, call **`connectGameToHost`** with **`PvpGuestApi`** where **`setState`** stores the latest snapshot in component state; 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`** to receive **`PvpHostApi`** — enable buttons that call lobby/action methods only after it resolves. 3. Optionally call **`observeGameContentSize(hostApi)`** when resize is supported. 4. On unmount, call **`connection.destroy()`** (and disconnect the size observer). ```tsx import { useEffect, useState } from 'react'; import { connectGameToHost, observeGameContentSize, type PvpGuestApi, type PvpHostApi, type PvpHostSnapshot, } from '@chain-protocol/pvp-sdk/guest'; export function App() { const [hostApi, setHostApi] = useState(null); const [snapshot, setSnapshot] = useState(null); useEffect(() => { let mounted = true; const guestMethods: PvpGuestApi = { async setState(next) { if (mounted) setSnapshot(next); }, }; const connection = connectGameToHost(guestMethods); let sizeObserver: { disconnect(): void } | undefined; void connection.promise.then(parent => { if (!mounted) return; setHostApi(parent); sizeObserver = observeGameContentSize(parent); }); return () => { mounted = false; sizeObserver?.disconnect(); connection.destroy(); }; }, []); if (!hostApi || !snapshot) return
Connecting to host…
; return null; } ``` The host side mirrors `@chain-protocol/casino-sdk` — use `connectHostToGame({ iframe, childOrigin, methods })` from the internal `/host` subpath with stable method references that delegate to the latest closure, and call `guestApi.setState` on every snapshot change. The `src/host.ts` connection type uses SDK-owned `GuestRpcApi` (`src/capabilities.ts`): ```typescript import type { Connection } from 'penpal'; import type { GuestRpcApi } from './capabilities'; import type { PvpGuestCapabilities } from './types'; export type PvpHostBridgeConnection = Connection & { capabilities: Promise; }; ``` `GuestRpcApi` uses `Required` plus SDK-owned `getCapabilities`, preserving Penpal's callable remote proxy signatures and per-call `CallOptions`; this does not make `openPanel` required in guest implementations. Await `connection.capabilities` before enabling guest-panel controls; initial `setState` does not wait for discovery. 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. ```typescript // 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' }); } ``` --- ## 4. Using `PvpHostSnapshot` in the game UI | Field | Use | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wallet.status` | Enable lobby actions only when **`'ready'`**. | | `access` | `play` or `spectate` (with `reason` and `canRequestPlayAccess`). Read it through `getViewerAccess(snapshot)`; while spectating render read-only and call no write method — see §8. | | `assets[]` | Token list with optional `decimals` / `balance` / `iconUrl`. Format stakes with the lobby's own `asset`. | | `protocol.feeBps` | Preview the protocol cut; chain remains authoritative. | | `lobbies.items` | Each lobby: `phaseName`, `pot`, `opener`, `asset`, counts, optional `lobbyKey`, `viewer`, `settlement`, `raw.*`. | | `lobby.viewer` | Connected address's aggregate stake and contribution count if they have entered, plus winnings-claim state (`claimed`, `claimedAmount`, `claimedClaimIds`) and cancellation-refund state (`refunded`, `refundAmount`). | | `metadata.room` | Host-app room context: room id/slug/title, invite URL, optional participants for presence UI. | | `metadata.viewer` | Host-app profile for the connected viewer. | | `lobby.metadata` | Host-provided lobby labels, invite URL, or other presentation-only details. | **Gas tank**: a share of a lobby's rake pays the gas of its participants' moves (`startLobby`, `submitAction`). Two optional lobby fields, in the asset's base units, let a game show it: | Field | Meaning | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lobby.tankRemaining` | What the tank can still pay for moves. Present while the lobby is in play; absent once it is resolved or cancelled. It grows when a late entry raises the pot. | | `lobby.tankSpent` | What the tank has paid so far. Present for the whole life of a lobby that has a tank; a cancelled lobby's refunds are net of it. | Both are absent on hosts that predate them and on lobbies without a tank, so read them as optional and hide the figure when they are missing. They are informational: the relayer decides whether a move is sponsored, so a move can still be carried as a covered call (the player's Smart Vault repays its gas) while `tankRemaining` is above zero, when one move costs more than what is left. A game must never gate a move on them. **Rake reserve**: the rest of the rake, the part the gas tank does not hold, repays the gas of the lobby's open-and-enter, its entries and its cancel, each in full or not at all, sized on the pot after the call's own stake lands (a lone open draws nothing). Two more optional lobby fields, in the asset's base units, show it the same way: | Field | Meaning | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lobby.reserveRemaining` | What the reserve can still pay for entries and the cancel. Present while the lobby is open or in play; absent once it is resolved or cancelled. It grows with every entry. | | `lobby.reserveSpent` | What the reserve has paid so far. Present for the whole life of a lobby that has a gas tank; a cancelled lobby's refunds are net of it as well as of `tankSpent`. | They follow the tank fields' rules: optional, absent on older hosts and on lobbies without a gas tank, informational only. A lobby whose rake is zero, or whose tank share is the whole rake, has no reserve: its `reserveRemaining` stays `0`. **Identify yourself**: match `wallet.address` to participant pages, or use `lobby.viewer` / `isYou` on paged participants. Prefer `metadata.displayName` / `username` when present; fall back to shortened addresses. **Large lobbies**: snapshots carry **counts**, not full `players[]` arrays. Page with: ```ts const participants = await hostApi.getLobbyParticipants({ lobbyId, limit: 50 }); const contributions = await hostApi.getLobbyContributions({ lobbyId, limit: 100 }); ``` **Whose turn**: there is no `nextActor` in the snapshot — turn order is the game's own state. Decode `raw.gameState` to find the current actor and enable the action UI only on the connected wallet's turn. Treat the contract as authoritative; a submit may still revert if state moved. **Host metadata is display-only**: `snapshot.metadata`, `lobby.metadata`, and participant metadata are supplied by the parent app over the iframe bridge. They are useful for names, avatars, room titles, invite links, spectator lists, and feature flags. Do **not** use them for authoritative game rules, turn ownership, eligibility, scoring, payouts, or anything that must match contract state. Room membership is **not** on-chain admission — restricted games must verify signed `entryData`. Example host-provided metadata: ```typescript const metadata: PvpHostSnapshot['metadata'] = { viewer: { username: 'alice', displayName: 'Alice', avatarUrl: 'https://example.com/alice.png', }, room: { roomId: 'room_123', title: 'Friday table', inviteUrl: 'https://chain.wtf/rooms/room_123', participants: [ { address: '0x1111111111111111111111111111111111111111', username: 'alice', role: 'player', }, { address: '0x2222222222222222222222222222222222222222', username: 'bob', role: 'player', }, ], }, }; const lobbyMetadata: NonNullable = { title: 'Table 1', inviteUrl: 'https://chain.wtf/rooms/room_123/lobbies/1', }; ``` Example guest-side participant label: ```typescript function participantLabel(p: { address: `0x${string}`; metadata?: { displayName?: string; username?: string }; }) { return ( p.metadata?.displayName ?? p.metadata?.username ?? `${p.address.slice(0, 6)}...${p.address.slice(-4)}` ); } ``` **Skip a slacker**: decode the per-turn deadline you stored in `raw.gameState`; once it has passed, show a **Skip** button (to any player, not just the current actor) that submits the game's `SKIP` action, then a **Claim the win** button once the game's forfeit window opens. On games that implement the liveness standard, build a `PvpLivenessStatus` from your state and use `getDueWaits` and `encodeLivenessAction` from `@chain-protocol/pvp-sdk/liveness`. **Terminal phases**: - **`RESOLVED`** — read `lobby.settlement` (`IMMEDIATE` winner/amount or claimable remaining). Claims close 7 days after resolution; the remainder is then refunded by stake through `refundUnclaimedWinnings(lobbyId, participant)` on the diamond. - **`CANCELLED`** — stakes refundable via `claimRefund` per participant (not a batch payout), each net of its share of `lobby.tankSpent` plus `lobby.reserveSpent`; `lobbyNetRefund` computes it. ### 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/pvp-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/pvp/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/pvp-sdk@canary npm install --save-dev @chain-protocol/cli@canary ``` With Bun: ```sh bun add @chain-protocol/pvp-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 pvp`. 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/pvp-sdk@latest` and `npm install --save-dev @chain-protocol/cli@latest`, or `bun add` with `@latest`), remove the old path and ship again. --- ## 5. `game.manifest.json` — host validation The TypeScript type **`PvpGameManifest`** and the host validator **`validatePvpGameManifest`** require **`presentation`**, **`lobby`**, and **`capabilities`**. 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/pvp/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/pvp/visual-and-ux/). ```json { "schemaVersion": 2, "gameId": "BonesGame", "defaultLocale": "en", "locales": { "en": { "name": "Chain Bones", "description": "1v1 dice duel: roll, stack, smash the rival, highest score takes the pot." } }, "presentation": { "mode": "full-iframe", "hostPanels": { "lobby": false, "history": false, "status": false } }, "lobby": { "access": "public", "entries": "single", "defaultStake": "10", "usesLobbyKeys": false }, "capabilities": { "createLobby": true, "enterLobby": true, "startLobby": true, "submitAction": true, "cancelLobby": true, "claimWinnings": true, "claimRefund": true, "claimPayout": true, "spectate": true, "resize": true } } ``` `capabilities.spectate` declares that the game renders read-only for viewers without a wallet (§8); without it the host shows spectators a sign-in placeholder instead of the game. Manifest `lobby` fields are **catalog/UI hints only** — the contract remains authoritative: | Field | Meaning | | --------------- | ----------------------------------------------------------------------------------------------------------------- | | `access` | `public` or `game-defined` (e.g. signed invitation in `entryData`) | | `entries` | `single`, `repeatable`, or `game-defined` | | `defaultStake` | Optional whole-token decimal string (`"1"`, `"0.1"`) to prefill stake inputs; convert with the asset's `decimals` | | `usesLobbyKeys` | Whether the game may assign keyed canonical lobbies | `gameId` names the game inside its own frontend. The chain keeps no game name, so the host knows the game by its contract address and by the operator's catalog entry for it, and does not match `gameId` against either. Serve the manifest at the **same origin** as the game URL. --- ## 6. Frontend patterns Use `viem` for `encodeAbiParameters` / `decodeAbiParameters` / `formatUnits` / `parseUnits`. `EMPTY_HEX = '0x'` is valid for unused opaque fields. ### 6.1 Open a lobby (asset + game config; opener is not privileged) Bones config is `(uint256 requiredStake, uint16 turnSeconds)`: ```tsx const EMPTY_HEX = '0x' as const; async function handleCreate( hostApi: PvpHostApi, asset: `0x${string}`, requiredStake: bigint, turnSeconds: number, ) { const config = encodeAbiParameters( [{ type: 'uint256' }, { type: 'uint16' }], [requiredStake, turnSeconds], ); const { lobbyId, contributionId } = await hostApi.createLobby({ asset, config, openData: EMPTY_HEX, randomnessRequestData: EMPTY_HEX, entry: { stake: requiredStake.toString(), entryData: EMPTY_HEX }, }); // Track lobbyId until it appears in snapshot.lobbies.items } ``` **Pass the creator's first entry whenever the creator plays.** `entry` (optional: `stake` in base units, `entryData`) makes the host open the lobby and enter it in **one transaction**, and the call resolves with the `contributionId` of that entry beside the `lobbyId`. The entry follows the same rules as `enterLobby`: the game's `onEntry` and the invitee list apply (an invite-only lobby must list the creator), and an entry the game rejects fails the whole call, so no empty lobby is left behind. Creating and then entering in two calls still works, but costs the creator gas: the host relays a creation with an entry as a sponsored call when the rake reserve its stake creates can pay for it (see §6.3), while a creation **without** an entry is always a covered call the creator's Smart Vault pays for, so that nobody opens empty lobbies at the relayer's expense. Pass no `entry` only when the creator does not take part (opening a lobby for others). The host overlays flashblock-pushed lobby events onto its indexed feed, so the new lobby (and later every participant's entry, phase advance and result) usually reaches `lobbies.items` before the indexed feed has it — but the push is best-effort. Keep tracking the returned `lobbyId` until the lobby is listed; never assume it is there when the call resolves. For an invite-only lobby, pass `invitees` — the protocol then rejects `enterLobby` from any other address, before the game's own admission policy runs. The list is immutable after creation and the creator is **not** auto-invited, so include the creator when they intend to play. Omit the field (or pass an empty list) for a public lobby. A canonical use is a rematch between the same players: ```tsx const { lobbyId } = await hostApi.createLobby({ asset, config: oldLobby.raw.config!, // copy the finished lobby's config invitees: [myAddress, opponentAddress], }); ``` ### 6.2 Enter with an explicit stake ```tsx await hostApi.enterLobby({ lobbyId, stake: requiredStake.toString(), // base units; '0' is valid at the protocol boundary entryData: EMPTY_HEX, // or a signed invitation for restricted lobbies }); ``` ### 6.3 Start / act / cancel ```tsx await hostApi.startLobby({ lobbyId, actionData: EMPTY_HEX, randomnessRequestData: EMPTY_HEX }); await hostApi.submitAction({ lobbyId, actionData: encodeAbiParameters([{ type: 'uint8' }, { type: 'uint8' }], [ACTION_PLACE, col]), randomnessRequestData: EMPTY_HEX, }); await hostApi.cancelLobby({ lobbyId, cancelData: EMPTY_HEX }); // game decides who may cancel ``` **Who pays for a move.** A participant's `startLobby` / `submitAction` is a sponsored call while the lobby's gas tank can pay its gas charge: the player's balance is untouched and `lobby.tankSpent` rises by the charge. Once the tank cannot pay for the move (or the caller has not entered the lobby), the host carries it as a **covered call**: the gas charge is repaid from the player's Smart Vault balance in the lobby's asset, the host shows its own gas line item over the frame, and everything else is unchanged — the promise still resolves with the transaction and the snapshot updates as before. A game needs no code for either case. The one new failure: when the balance cannot pay the stake plus the cover limit, the host sends nothing and rejects with an error whose message starts with `PVP_CALL_UNAFFORDABLE` (exported by `@chain-protocol/pvp-sdk`); keep the move available to retry once the balance allows. **Who pays for the other calls.** The same two outcomes apply, and a game needs no code for them: | Call | Sponsored call when | Otherwise | | ---------------------------------- | ----------------------------------------------------------------------------------- | ------------ | | Creation with an entry, an entry | the lobby's rake reserve, after the call's own stake, can pay the call's gas charge | covered call | | Cancel | the rake reserve can pay the cancel's gas charge | covered call | | Creation without an entry | never | covered call | | Refund claim | never: it is repaid from the refund itself, so it works from an empty balance | covered call | | Claim of winnings, deferred payout | the lobby's pot reaches the relayer's sponsored minimum | covered call | - A lobby of a zero-rake game has no gas tank and no rake reserve, so every call in it is a covered call. - `lobby.reserveRemaining` and `lobby.reserveSpent` report the reserve; they are informational, like the tank's figures. The relayer decides, and the host re-signs once when it disagrees. - A player who cancels with a balance too small for a covered cancel still can, when they have a stake in the lobby: the host cancels and claims **their own refund** in one covered call, repaid from that refund. `cancelLobby` resolves as usual and `lobby.viewer.refunded` turns true, so read it before offering that player a refund claim. The SDK simulator (`chain dev pvp`) applies the same policy with the rake, the tank share, the gas charge and the sponsored minimum adjustable, so an exhausted tank or reserve is reproducible locally (see `LOCAL_SIMULATOR.md`). ### 6.4 Settle and claim ```tsx // IMMEDIATE: host shows lobby.settlement.immediate after resolution — no claim tx unless the // payout transfer failed and is parked (settlement.immediate.deferred). Collection is // permissionless and always pays the recorded winner. claimPayout is optional on the host API: if (lobby.settlement?.immediate?.deferred && hostApi.claimPayout) { await hostApi.claimPayout({ lobbyId }); } // CLAIMABLE: skip the tx when the snapshot already shows the claim as collected. A returning // player's `lobby.viewer` carries `claimed`, `claimedAmount`, and `claimedClaimIds` — check the // specific claimId when the game issues several claims per player, or `claimed` when it issues one. if (!lobby.viewer?.claimedClaimIds?.includes(claimId)) { await hostApi.claimWinnings({ lobbyId, claimId, claimData: EMPTY_HEX }); } // CANCELLED: use the projected refund state so a returning player can retry a failed refund // without attempting to cancel the lobby again. if (lobby.viewer?.refunded !== true) { await hostApi.claimRefund({ lobbyId, participant: walletAddress }); } ``` ### 6.5 Render the result When `lobby.phaseName === 'RESOLVED'`, read `lobby.settlement`: - **Immediate** — `winner` / `amount` (with a `deferred` flag while a failed transfer awaits `claimPayout`), plus `feeAmount`. - **Where the pot went** — `feeAmount` is the gross rake: winners share `pot - feeAmount` whatever the gas tank and the rake reserve spent. `settlement.tankSpent` and `settlement.reserveSpent` (optional, absent on older hosts) are the parts of that rake the tank paid for moves and the reserve paid for the open-and-enter and entries; the fee recipients split `feeAmount - tankSpent - reserveSpent`. - **Claimable** — remaining pot and per-claim quotes from the game. `lobby.viewer.claimed` / `claimedAmount` / `claimedClaimIds` tell a returning player their claim already went through, so render a "claimed" state instead of the claim button and skip the redundant transaction. - **Cancelled** — `lobby.viewer.refunded` / `refundAmount` tell a returning player whether its per-participant refund already went through. Keep a retry available while `refunded` is false. A refund is the participant's stake less its share, by stake, of `lobby.tankSpent` plus `lobby.reserveSpent`, rounded up against the participant. Show it before the claim with `lobbyNetRefund(lobby, lobby.viewer.totalStake)` from `@chain-protocol/pvp-sdk`, which returns exactly what `claimRefund` pays. The claim is always a covered call: its gas charge is repaid from the refund as it lands, and the host shows the gas line item. Decode `lobby.raw.gameState` for game-specific detail (scores, winner ticket, board). --- ## 7. Restricted lobbies For the simple case — a fixed list of on-chain addresses known at creation — use platform invitations: pass `invitees` to `createLobby` (§6.1). The protocol gates `enterLobby` to the list and the game needs no admission code at all. Invitations gate entry only — claims, refunds, and cancellation are untouched — and the list cannot change after creation. For dynamic or off-chain criteria (room membership, KYC, usernames, add/remove after creation), admission is entirely game-specific. A typical pattern: 1. Backend verifies external requirements (room membership, invite list, KYC flag, …). 2. Backend signs an address-bound credential scoped to chain ID, game, lobby, expiry, and nonce. 3. The entrant passes that signature as `entryData` on the first `enterLobby`. 4. `onEntry` recovers the signer, checks binding fields, and accepts or rejects. Host `room` metadata remains display-only. It must never substitute for on-chain game admission. --- ## 8. Spectator mode Anyone can open a lobby page without signing in, and the host still mounts the game: the snapshot carries every lobby with its `raw.gameState`, just no wallet. Spectator mode is the contract that turns that into a working screen. It needs no contract change — it is a bridge and UI standard. ### 8.1 Viewer access `snapshot.access` says what the viewer may do: | `access.mode` | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `play` | The connected wallet may call every write method. | | `spectate` | Read-only. `reason` is `disconnected`, `setup-required`, `session-key-mismatch`, `chain-mismatch` or `host-policy`. `canRequestPlayAccess` is true when the host can lead the viewer to `play` (sign-in, wallet setup) through `hostApi.requestPlayAccess()`. | Always read it through `getViewerAccess(snapshot)` from `@chain-protocol/pvp-sdk`: hosts older than this standard send no `access`, and the helper derives one from `wallet.status` (anything but `ready` is `spectate` with `canRequestPlayAccess: false`). `lobby.viewer` keeps answering a different question — whether the connected address holds a seat. A `play` viewer with no seat is a spectator of that lobby too, but may still use the permissionless calls (skip, forfeit, advance, `claimPayout`), which a wallet-less viewer cannot. ### 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 `PvpHostApi.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. PvP already exposed `requestPlayAccess` before Host capability discovery. When a Host omits `capabilities`, the SDK derives the declaration from its supplied methods: a callable `requestPlayAccess` advertises `{ playAccess: true }` and remains exposed; otherwise it advertises `{ playAccess: false }` and is not exposed. This preserves old guests and lets new guests discover existing Host support. Explicit `capabilities: {}` or `{ playAccess: false }` disables it. Discovery never overrides `access.canRequestPlayAccess`, spectator mode, lobby admission, chain restrictions or Host policy. Do not turn a policy-only spectator block into a wallet setup request. ### 8.2 What a game with `capabilities.spectate: true` must do 1. **Render any lobby without a wallet.** The routed lobby (`metadata.room.roomId`) and any lobby opened from a live list must draw board, seats, scores, timers and result from `raw.gameState` and the ledger pages alone. Pick a fixed perspective (seat 0 first) and never label a seat "you". This includes finished lobbies: the host's history and **Watch** actions route to resolved lobbies too, so a `RESOLVED` lobby opened cold — nothing seen live, no local state — must show its final board and who won as a replay of the match. 2. **Never write.** Show no write control and make no write call while `access.mode === 'spectate'` — including automatic ones (auto-reveal, auto-claim, start-when-full) and permissionless keeper actions. Every such call is rejected with `PVP_SPECTATOR_READ_ONLY` anyway; show it as "you are watching" (`isSpectatorReadOnlyError`) rather than as a retryable failure. 3. **Depend on nothing browser-local.** Sealed picks, salts and other local state belong to a player; a spectator sees only what is on chain. 4. **Offer the way in.** While `access.canRequestPlayAccess` is true and the guest connection has discovered `playAccess: true`, show a **Sign in to play** control that calls `hostApi.requestPlayAccess()`. A fabricated RPC proxy is not support evidence. The following snapshots carry the new `access`; do nothing else on resolve. Older PvP callers remain supported as described in [Requesting play access](#requesting-play-access). 5. **List live lobbies with a Watch action and finished ones with a Replay action** in the lobby browser, and hide create / join / balance UI while spectating. 6. **Follow the routed lobby.** Open `metadata.room.roomId` whenever it changes, whatever phase the lobby is in; it is the only signal a Watch or Replay click on the page around the iframe sends. ```tsx import { getViewerAccess, isSpectatorReadOnlyError } from '@chain-protocol/pvp-sdk'; const access = getViewerAccess(snapshot); const spectating = access.mode === 'spectate'; const hostCapabilities = await connection.capabilities; // Store per connection; hide on failure. const signIn = access.mode === 'spectate' && access.canRequestPlayAccess && hostCapabilities.playAccess === true ? hostApi.requestPlayAccess : undefined; // board: always from raw.gameState; controls: only when !spectating && lobby.viewer { signIn && ; } ``` ### 8.3 What the host does - Mounts the game and pushes snapshots without a wallet: `wallet.address` absent, `assets` listing the platform asset without `balance`, `lobbies.items` complete, `access` on every push. - Rejects every write with `Error('PVP_SPECTATOR_READ_ONLY')` while `access.mode === 'spectate'`, before any wallet check. Declares `playAccess` when it implements the UI request, and validates current `canRequestPlayAccess`/policy when invoked. A capability declaration does not grant play permission. - Shows a sign-in placeholder instead of the iframe to spectators when the manifest does not declare `capabilities.spectate`. - Keeps lobby chat readable and voice hidden for signed-out spectators. Reference: the Bones example, `chain init pvp --template bones` (turn-based, permissionless skip and forfeit hidden from wallet-less viewers), and the Rock Paper Scissors example, `chain init pvp --template rps` (commit–reveal, sealed hands shown as sealed, no automatic reveal for watchers). --- ## 9. Host integrator checklist (signing + snapshot) 1. **createLobby:** Call open with a game the guardian has not denied and a whitelisted asset and the guest-supplied `config` / `openData`, forwarding `invitees` verbatim (empty when omitted). Parse `lobbyId` from the creation log. With `entry`, call the open-and-enter entry point instead (prepend the ERC20 `approve` for the stake) and parse `contributionId` from the entry log of the same receipt; without it, opening pulls **no** stake. Include `invitees` on invite-only lobby rows in the snapshot so guests can gate their enter UI. 2. **enterLobby:** Prepend ERC20 `approve` for the explicit stake, then enter. Parse `contributionId` if the host surfaces ledger rows. 3. **submitAction:** Forward from the player's wallet. Turn ownership is **not** enforced by the protocol — the game validates on-chain; the guest decodes whose-turn / deadlines from `raw.gameState`. 4. **Pagination:** Snapshot only carries counts. Host APIs must page participants and contributions for the iframe. 5. **Snapshot freshness:** Merge indexer lobby list with on-chain reads so `raw.config` / `raw.gameState` / `settlement` match chain bytes for decoding in the iframe. 6. **Metadata:** Attach display-only host context to `snapshot.metadata`, `lobby.metadata`, and participant metadata. Common fields: username, display name, avatar, profile URL, room id/title, invite URL, participants/spectators, and app-specific JSON under `custom`. 7. **Push updates:** Call `guestApi.setState` whenever wallet, assets, lobby rows, fee, metadata, locale, theme, or viewport change. 8. **Spectators:** Send `access` on every snapshot, reject writes with `PVP_SPECTATOR_READ_ONLY` while spectating, expose `requestPlayAccess` when a sign-in or wallet flow can lead to `play`, and gate the iframe on `capabilities.spectate` for viewers who cannot play (§8). ### 9.1 Operators (not open yet) An **operator** is a frontend that hosts rooms on the protocol. It earns a share of the rake from the lobbies it opens. 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 lobby 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. - **Attribution is per room.** The host that opens the lobby names the operator with `openLobby(game, token, config, openData, invitees, operator)`. That operator earns its share of the whole lobby's rake, wherever the other players enter from. `enterLobby` and later actions carry no operator. The five-argument `openLobby` keeps working and means "no operator code". - **No code, or an unregistered code, never reverts.** The lobby is attributed to the protocol's default operator instead. - **The code cannot be swapped in transit.** It is part of the `openLobby` calldata, which the opener's Smart Vault signs. A relayer that changes it invalidates the signature. - **Reading it back:** `PvpLobbyOperatorAttributed(lobbyId, operator)` is emitted when the lobby opens, and `getLobbyOperator(lobbyId)` returns it. - **Players pay nothing extra.** The operator's share comes out of the existing protocol fee, so `distributable = pot - fee` is unchanged. - **Gas sponsorship:** the chain.wtf relayer pays gas only for lobbies 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 `LobbyContext` and does not reach the iframe. --- ## 10. Security notes - Penpal **origin allowlisting** works exactly as in `@chain-protocol/casino-sdk` (host passes `childOrigin`; guest allows the referrer origin, else `*` for development only). - **Never** trust client-only math for settlement — treat `gameState`, `pot`, and `settlement` as **hints**; the **protocol + game contract** are authoritative. Resolution recipients are validated (no zero addresses; exactly one means an immediate full-pot payout) before funds move. - Games must **phase-gate entries** and bound player-submitted numbers that feed arithmetic — see [`PVP_CONTRACT_CONSTRAINTS.md`](/sdk/pvp/contract-constraints/) and the reference games. - Treat host-provided metadata as untrusted presentation data. It may be missing, stale, or changed by the parent app; never encode assumptions from it into `config` / `entryData` / `actionData` unless the contract independently verifies the same rule. --- _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/pvp-sdk`: shared types such as `PvpHostSnapshot` and `PvpHostApi`, manifest validation, deployment helpers, liveness and spectator helpers. 2. `@chain-protocol/pvp-sdk/guest`: `connectGameToHost`, `reportGameContentSize`, `observeGameContentSize` and guest-facing types. 3. `@chain-protocol/pvp-sdk/liveness`: helpers including `getDueWaits` and `encodeLivenessAction`. 4. `@chain-protocol/pvp-sdk/spectator`: helpers including `getViewerAccess` and `isSpectating`. 5. `@chain-protocol/pvp-sdk/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.