# Visual & UX expectations for PvP iframe games Keep every player oriented as a lobby fills, turns change, and the pot settles. Drive controls from snapshots, page participant lists, and make claims and refunds clear. :::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/) ::: ## Runtime environment - The main app embeds your game in an **iframe** with `sandbox="allow-scripts allow-same-origin"`. Design for a **single-page guest** that does not navigate away or open new windows. - The host owns wallet, Smart Vault, and signing. Your UI assumes **no direct wallet access** — reflect state from **`PvpHostSnapshot`** only. ## Snapshot-driven, multi-player UI Unlike casino (one player, one session at a time), PvP UIs are inherently **multi-player and multi-phase**. Drive everything from `snapshot.lobbies.items`, then **page** participants and contributions when you need lists: ```ts const participants = await hostApi.getLobbyParticipants({ lobbyId, limit: 50 }); const contributions = await hostApi.getLobbyContributions({ lobbyId, limit: 100 }); ``` Do **not** assume a complete `players[]` array on the lobby snapshot — v2 only carries counts, pot, and the connected viewer's aggregate (`lobby.viewer`). ### Phase-by-phase UI - **Lobby browser / create** (`WAITING_FOR_PLAYERS`): list open lobbies with `asset`, `pot`, `participantCount` / `contributionCount`, optional `lobbyKey`, and an enter button. Offer a create form that picks the escrow **asset**, encodes game **`config`**, and collects any **`openData`**. Prefill stake inputs from the manifest's `lobby.defaultStake` when present (whole tokens; convert with the asset's `decimals` via `parseUnits`). Disable enter when the relevant `assets[]` balance is insufficient or the viewer cannot play (`getViewerAccess(snapshot)` is not `play`). List lobbies in progress with a **Watch** action. - **Waiting room**: show paged participants (use `isYou` / `lobby.viewer` to highlight self). Display `opener` only as **attribution** — never label it owner, host-of-record, or assume it can start or cancel. Start eligibility is game-defined (e.g. Bones: either seated player once both seats are filled; Jackpot's post-close anyone). Use participant `metadata.displayName` / `username` / `avatarUrl` when the host provides them; fall back to shortened addresses. - **In-game** (`IN_PROGRESS` / `WAITING_PLAYER_ACTION` / `WAITING_RANDOMNESS`): render the board/state from `raw.gameState`. For turn-based games, decode the current actor (and any per-turn deadline) from `raw.gameState` — there is no host-provided `nextActor` — and only enable the action UI when it is the connected wallet's turn; otherwise show "waiting for \" (and a Skip button once the deadline passes, then a Claim the win button once a forfeit is due — see the liveness standard in `PVP_CONTRACT_CONSTRAINTS.md`). While moves are being played, `lobby.tankRemaining` (when the host sends it) is what the lobby's gas tank can still pay for them; show it so a player knows when moves will start costing gas. - **Resolved** (`RESOLVED`): render `lobby.settlement`: - **Rake** — `feeAmount` is the gross rake; `tankSpent` and `reserveSpent` (when the host sends them) are the parts of it that paid for the lobby's moves and for its open-and-enter and entries. - **Immediate** — the `winner` and `amount`, plus `feeAmount`. If `deferred` is set the transfer failed at settlement and anyone may trigger `hostApi.claimPayout({ lobbyId })` — show a collect control to everyone (execution cannot redirect the recorded winner), and feature-detect the method since older hosts omit it. - **Claimable** — remaining pot and a claim control. A claim button may be shown to **anyone** because execution cannot redirect the recipient. - **Cancelled** (`CANCELLED`): show independently **claimable refunds** per participant (`claimRefund`; each is the stake less its share of `lobby.tankSpent` plus `lobby.reserveSpent`, which `lobbyNetRefund` computes), not a single batch refund animation that waits on other players. ### Explicit stakes Ask the user for an **explicit stake** on every entry and explain game validation before signing (exact amount, minimum, free entry, or open amount). `stake: '0'` is valid at the protocol boundary but may still be rejected by the game. Format amounts with the lobby's own `asset.decimals` — the escrow asset is **lobby-scoped**, not a single global snapshot token. ### Restricted lobbies For `lobby.access === 'game-defined'` (manifest hint), obtain signed admission data **before** calling `enterLobby`. Host room membership and `metadata.room.participants` are **not** authoritative for on-chain admission. ### Invite-only lobbies When `lobby.invitees` is present, only the listed addresses may enter. Hide or disable the enter button when the connected address (`snapshot.wallet.address`) is not on the list, and show an "invited" badge when it is. Never treat host `room` metadata as the invite list — `lobby.invitees` is the on-chain gate. Claims and refunds are unaffected: keep those controls visible regardless of the invite list. ### Spectators A viewer without a wallet (or in read-only access for another reason) still gets the game, with `access.mode === 'spectate'` on the snapshot. Render every lobby from `raw.gameState` with seats named by position — there is no "you" — and show no write control: no picker, no skip / forfeit / resign, no claim, and no automatic action either. Show a short "watching" state with the reason (`access.reason`) and, when `access.canRequestPlayAccess` is true and the connection has discovered `playAccess: true`, a **Sign in to play** control that calls `requestPlayAccess`. Do not detect support from RPC proxy presence; see [Requesting play access](/sdk/pvp/building-games/#requesting-play-access). Declare `capabilities.spectate: true` in the manifest once this works; without it the host shows spectators a sign-in placeholder instead of the game. ### Host metadata (display-only) Host-provided metadata is presentation-only. `snapshot.metadata` can include viewer profile data, room title/id/invite URL, and participants/spectators; `lobby.metadata` can include table labels; and participant metadata can include usernames, display names, avatars, profile URLs, and custom JSON-serializable presentation data. **Never** use these fields for game rules, turn ownership, scoring, eligibility, or payouts. Decode authoritative state from `raw.gameState`, `raw.config`, contract-backed lobby fields, and the ledger pages. Respect **`ui.theme`** (`light` | `dark` | `system`) and **`ui.locale`** where practical. ## Manifest (`game.manifest.json`) Served at **`{gameBaseUrl}/game.manifest.json`** (same origin as the game). Validated by `validatePvpGameManifest` in `@chain-protocol/pvp-sdk` (`src/manifest.ts`). Required shape: - **`presentation`**: `mode` (`full-iframe` | `embedded`), `hostPanels` toggles (`lobby`, `history`, `status`). - **`lobby`** (catalog hints only; contract is authoritative): - `access`: `public` | `game-defined` - `entries`: `single` | `repeatable` | `game-defined` - `defaultStake`: optional whole-token decimal string (`"1"`, `"0.1"`) for input prefills, converted by the game with the asset's `decimals` - `usesLobbyKeys`: whether the game can create keyed canonical lobbies - **`capabilities`**: `createLobby` must be `true`; booleans for `enterLobby`, `startLobby`, `submitAction`, `cancelLobby`, `claimWinnings`, `claimRefund`, `resize`; optional `claimPayout` and `spectate` (the game renders read-only for viewers who cannot play). **Game description**: each `locales[].description` is the catalog's game description for that locale. It is optional. When the platform builds your game, it reads the manifest from the build's preview deployment and copies every non-empty description of 500 characters or fewer into the catalog; a longer one is dropped with a warning in the build log. A player sees the description of their locale, a locale of the same language (`pt-BR` for `pt`), or else the one of `defaultLocale`. A game with no description shows none. A frontend-only update brings in the new build's descriptions. **Cover image**: `assets.coverUrl` is the game's catalog cover image. It is either a path relative to `game.manifest.json`, which points into the build's preview deployment (for example `cover.webp` in your frontend's public folder), or an absolute `https://` URL. Any other scheme is skipped. The image must be PNG, JPEG or WebP, at most 2 MB, 2:3 portrait (width to height, within about 2%) and at least 230×345. When the platform builds your game, it copies the image into platform storage, so the catalog keeps working if your origin moves. While the manifest names a usable cover image, the submit form shows it read-only and takes no upload. An unreachable or unusable image is a warning in the build log that gives the reason, and the submit form then asks for an upload. A frontend-only update brings in the new build's cover image, or keeps the current one when the new manifest has none. **`gameId`** names the game inside its own frontend. The host knows the game by its contract address and the operator's catalog entry, and does not match `gameId` against either. Height is a **runtime** concern: when `capabilities.resize` is true, call `observeGameContentSize(hostApi)` from `@chain-protocol/pvp-sdk/guest` after the bridge resolves so the host can size the iframe to the game's current content. There is no static `presentation.minHeight` in the manifest. ## Motion & accessibility - Prefer **reduced motion** via `prefers-reduced-motion`. - Ensure tap targets and contrast work at the height your game reports to the host. - Prefer progressive disclosure for large jackpots (page contributions; show cumulative pot and your ticket share without loading every ledger row on mount). ## Assets `manifest.assets.iconUrl` is optional; used in discovery when provided. `manifest.assets.coverUrl` is the catalog cover image; see "Cover image" above. ## Quick checklist | Do | Don't | | ------------------------------------------------------------ | ---------------------------------------------------------- | | Treat `PvpHostSnapshot` as a summary + page the rest | Expect a full `players[]` on every lobby | | Label `opener` as attribution only | Call the opener owner / assume start-cancel rights | | Collect explicit stakes; show game validation rules | Silent fixed buy-in UX when the game allows variable stake | | Render immediate vs claimable settlement differently | Assume every resolution auto-pays the connected wallet | | Show per-participant refund claims on cancel | Block UI waiting for a global refund batch | | Decode `raw.config` / `raw.gameState` with the game ABI only | Drive eligibility or payouts from host metadata | | Render lobbies read-only for spectators, seats by position | Offer a spectator any write, automatic or not |