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.
Jump to:
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
Section titled “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), andactionData(per action). - Calls
hostApi.createLobby/enterLobby/startLobby/submitAction/cancelLobby/claimWinnings/claimRefund/claimPayout— each returns a Promise (host signs/broadcasts txs).claimPayoutis optional on the host API — feature-detect it. - Pages large lists via
getLobbyParticipants/getLobbyContributions. - Renders from
PvpHostSnapshot, pushed by the host viaguestApi.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
Section titled “2. On-chain architecture”2.1 IPvpGameV2 (full Solidity)
Section titled “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.
// SPDX-License-Identifier: MITpragma 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-zerolobbyKeyfor 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 apositionId. 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 checkctx.phasethemselves — 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 (initialgameState, phase, optional randomness request).onPlayerAction: Called when a participant submitsactionData. 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, enablingdecide → randomness → decide againloops. It may not request randomness again: see 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 askingcanCancel.- 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
refundUnclaimedWinningsreturns what is left to the participants by stake. See “Cancellation and refunds” inPVP_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
Section titled “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
Section titled “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.
Pot accounting and settlement
Section titled “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)
Section titled “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
Section titled “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:
onLobbyOpenvalidatesrequiredStake(and the turn length) inconfig.onEntryrequiresWAITING_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 ingameStateand requests randomness for the coin toss →WAITING_RANDOMNESS.onRandomnessresolves the toss or the pending roll →WAITING_PLAYER_ACTION.onPlayerActionvalidates the actor against the seat recorded ingameState; aROLLrequests randomness, aPLACEadvances the turn or ends the match:RESOLVEDwith the winner as the singlerecipient(paid the whole distributable pot immediately) or both seats on a draw (each claims half with its address as the claim id). A per-turndeadlineingameStatemakesSKIPpermissionless once it passes, andFORFEITpermissionless two turn lengths later — the idle seat loses and the other seat is the singlerecipient. Either seat mayRESIGN. All three are the liveness standard’s reserved payloads.
Scheduled weighted jackpot (e.g. Jackpot) — variable stakes, repeat entries, one winner:
onLobbyOpensetslobbyKey = keccak256(abi.encode(token, opener, decodedConfig))so distinct schedules/assets cannot collide and another opener copying the config cannot squat the key.onEntrytime-gates onopensAt/closesAt, enforcesminimumStake, and reuses an address-derivedpositionIdso repeats aggregate.onLobbyStart(anyone, after close) requests randomness.canCancellets 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.onRandomnessrejection-samples a ticket in[0, pot), binary-searches dense contribution IDs bycumulativePot, 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 (viaparticipantAt/ indexes) ingameState.onPlayerActionvalidates 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 fromraw.gameState.- An action may go
WAITING_RANDOMNESS(requestRandomnessNow = true);onRandomnessmay 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.
// 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
Section titled “2.4 Sequence: open → enter → start → act → resolve”3. Bridge SDK (guest + host)
Section titled “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.
Dependency: penpal ^7.0.4.
3.1 guest.ts (iframe / game side)
Section titled “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:
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<void>; setState(snapshot: PvpHostSnapshot | null): Promise<void>;};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<HostRpcApi> & { capabilities: Promise<PvpHostCapabilities>;};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<HostRpcApi>({ 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:
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)
Section titled “3.2 Guest lifecycle (required pattern)”- On mount, call
connectGameToHostwithPvpGuestApiwheresetStatestores the latest snapshot in component state; guests with panels also implement optionalopenPaneland declare those intents in the optional second argument. The SDK ownsgetCapabilitiesfor that connection. await connection.promiseto receivePvpHostApi— enable buttons that call lobby/action methods only after it resolves.- Optionally call
observeGameContentSize(hostApi)when resize is supported. - On unmount, call
connection.destroy()(and disconnect the size observer).
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<PvpHostApi | null>(null); const [snapshot, setSnapshot] = useState<PvpHostSnapshot | null>(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 <section>Connecting to host…</section>; 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):
import type { Connection } from 'penpal';import type { GuestRpcApi } from './capabilities';import type { PvpGuestCapabilities } from './types';
export type PvpHostBridgeConnection = Connection<GuestRpcApi> & { capabilities: Promise<PvpGuestCapabilities>;};GuestRpcApi uses Required<PvpGuestApi> 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.
// 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
Section titled “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:
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:
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<PvpHostSnapshot['lobbies']['items'][number]['metadata']> = { title: 'Table 1', inviteUrl: 'https://chain.wtf/rooms/room_123/lobbies/1',};Example guest-side participant label:
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— readlobby.settlement(IMMEDIATEwinner/amount or claimable remaining). Claims close 7 days after resolution; the remainder is then refunded by stake throughrefundUnclaimedWinnings(lobbyId, participant)on the diamond.CANCELLED— stakes refundable viaclaimRefundper participant (not a batch payout), each net of its share oflobby.tankSpentpluslobby.reserveSpent;lobbyNetRefundcomputes it.
SDK versions and breaking releases
Section titled “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:
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 has an entry for every release.
A patch publishes straight to the latest dist-tag. A breaking release goes through a canary:
- The change is announced, and its notice period starts.
- The new version publishes to npm under the
canarydist-tag, as0.6.0-canary.N, and thestaginghost runs it.latestand the production host stay on the old version. - Game devs ship builds that handle both hosts.
- On switch day,
0.6.0publishes underlatestand the production host switches to it. - Game devs remove the old path.
During a notice period, do this:
-
Install the canary of the SDK and the CLI:
Terminal window npm install @chain-protocol/pvp-sdk@canarynpm install --save-dev @chain-protocol/cli@canaryWith Bun:
Terminal window bun add @chain-protocol/pvp-sdk@canarybun add --dev @chain-protocol/cli@canary -
Branch with
hostSupports(snapshot, '0.6.0')wherever the release changes what your game sends or reads. Keep the old path in theelsebranch. -
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. -
Ship the build before switch day. Until the switch, the production host reports the old version and your game takes the old path.
-
After the switch, install the release (
npm install @chain-protocol/pvp-sdk@latestandnpm install --save-dev @chain-protocol/cli@latest, orbun addwith@latest), remove the old path and ship again.
5. game.manifest.json — host validation
Section titled “5. game.manifest.json — host validation”The TypeScript type PvpGameManifest and the host validator validatePvpGameManifest
require presentation, lobby, and capabilities.
Each locales[<locale>].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.
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.
{ "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
Section titled “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)
Section titled “6.1 Open a lobby (asset + game config; opener is not privileged)”Bones config is (uint256 requiredStake, uint16 turnSeconds):
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:
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
Section titled “6.2 Enter with an explicit stake”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
Section titled “6.3 Start / act / cancel”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 cancelWho 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.reserveRemainingandlobby.reserveSpentreport 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.
cancelLobbyresolves as usual andlobby.viewer.refundedturns 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
Section titled “6.4 Settle and claim”// 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
Section titled “6.5 Render the result”When lobby.phaseName === 'RESOLVED', read lobby.settlement:
- Immediate —
winner/amount(with adeferredflag while a failed transfer awaitsclaimPayout), plusfeeAmount. - Where the pot went —
feeAmountis the gross rake: winners sharepot - feeAmountwhatever the gas tank and the rake reserve spent.settlement.tankSpentandsettlement.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 splitfeeAmount - tankSpent - reserveSpent. - Claimable — remaining pot and per-claim quotes from the game.
lobby.viewer.claimed/claimedAmount/claimedClaimIdstell 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/refundAmounttell a returning player whether its per-participant refund already went through. Keep a retry available whilerefundedis false. A refund is the participant’s stake less its share, by stake, oflobby.tankSpentpluslobby.reserveSpent, rounded up against the participant. Show it before the claim withlobbyNetRefund(lobby, lobby.viewer.totalStake)from@chain-protocol/pvp-sdk, which returns exactly whatclaimRefundpays. 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
Section titled “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:
- Backend verifies external requirements (room membership, invite list, KYC flag, …).
- Backend signs an address-bound credential scoped to chain ID, game, lobby, expiry, and nonce.
- The entrant passes that signature as
entryDataon the firstenterLobby. onEntryrecovers 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
Section titled “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
Section titled “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
Section titled “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.
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
Section titled “8.2 What a game with capabilities.spectate: true must do”- 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 fromraw.gameStateand 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 aRESOLVEDlobby opened cold — nothing seen live, no local state — must show its final board and who won as a replay of the match. - 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 withPVP_SPECTATOR_READ_ONLYanyway; show it as “you are watching” (isSpectatorReadOnlyError) rather than as a retryable failure. - Depend on nothing browser-local. Sealed picks, salts and other local state belong to a player; a spectator sees only what is on chain.
- Offer the way in. While
access.canRequestPlayAccessis true and the guest connection has discoveredplayAccess: true, show a Sign in to play control that callshostApi.requestPlayAccess(). A fabricated RPC proxy is not support evidence. The following snapshots carry the newaccess; do nothing else on resolve. Older PvP callers remain supported as described in Requesting play access. - 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.
- Follow the routed lobby. Open
metadata.room.roomIdwhenever 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.
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 && <button onClick={() => void signIn()}>Sign in to play</button>;}8.3 What the host does
Section titled “8.3 What the host does”- Mounts the game and pushes snapshots without a wallet:
wallet.addressabsent,assetslisting the platform asset withoutbalance,lobbies.itemscomplete,accesson every push. - Rejects every write with
Error('PVP_SPECTATOR_READ_ONLY')whileaccess.mode === 'spectate', before any wallet check. DeclaresplayAccesswhen it implements the UI request, and validates currentcanRequestPlayAccess/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)
Section titled “9. Host integrator checklist (signing + snapshot)”- createLobby: Call open with a game the guardian has not denied and a whitelisted asset and the guest-supplied
config/openData, forwardinginviteesverbatim (empty when omitted). ParselobbyIdfrom the creation log. Withentry, call the open-and-enter entry point instead (prepend the ERC20approvefor the stake) and parsecontributionIdfrom the entry log of the same receipt; without it, opening pulls no stake. Includeinviteeson invite-only lobby rows in the snapshot so guests can gate their enter UI. - enterLobby: Prepend ERC20
approvefor the explicit stake, then enter. ParsecontributionIdif the host surfaces ledger rows. - 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. - Pagination: Snapshot only carries counts. Host APIs must page participants and contributions for the iframe.
- Snapshot freshness: Merge indexer lobby list with on-chain reads so
raw.config/raw.gameState/settlementmatch chain bytes for decoding in the iframe. - 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 undercustom. - Push updates: Call
guestApi.setStatewhenever wallet, assets, lobby rows, fee, metadata, locale, theme, or viewport change. - Spectators: Send
accesson every snapshot, reject writes withPVP_SPECTATOR_READ_ONLYwhile spectating, exposerequestPlayAccesswhen a sign-in or wallet flow can lead toplay, and gate the iframe oncapabilities.spectatefor viewers who cannot play (§8).
9.1 Operators (not open yet)
Section titled “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.enterLobbyand later actions carry no operator. The five-argumentopenLobbykeeps 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
openLobbycalldata, 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, andgetLobbyOperator(lobbyId)returns it. - Players pay nothing extra. The operator’s share comes out of the existing protocol fee, so
distributable = pot - feeis 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
LobbyContextand does not reach the iframe.
10. Security notes
Section titled “10. Security notes”- Penpal origin allowlisting works exactly as in
@chain-protocol/casino-sdk(host passeschildOrigin; guest allows the referrer origin, else*for development only). - Never trust client-only math for settlement — treat
gameState,pot, andsettlementas 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.mdand 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/actionDataunless the contract independently verifies the same rule.
End of document.
SDK exports at a glance
Section titled “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.
@chain-protocol/pvp-sdk: shared types such asPvpHostSnapshotandPvpHostApi, manifest validation, deployment helpers, liveness and spectator helpers.@chain-protocol/pvp-sdk/guest:connectGameToHost,reportGameContentSize,observeGameContentSizeand guest-facing types.@chain-protocol/pvp-sdk/liveness: helpers includinggetDueWaitsandencodeLivenessAction.@chain-protocol/pvp-sdk/spectator: helpers includinggetViewerAccessandisSpectating.@chain-protocol/pvp-sdk/contracts/*: Solidity contracts.
The host, simulator and conformance entry points are internal and outside semver. See package entry points for context.