# 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 |