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.
Runtime environment
Section titled “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
PvpHostSnapshotonly.
Snapshot-driven, multi-player UI
Section titled “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:
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
Section titled “Phase-by-phase UI”- Lobby browser / create (
WAITING_FOR_PLAYERS): list open lobbies withasset,pot,participantCount/contributionCount, optionallobbyKey, and an enter button. Offer a create form that picks the escrow asset, encodes gameconfig, and collects anyopenData. Prefill stake inputs from the manifest’slobby.defaultStakewhen present (whole tokens; convert with the asset’sdecimalsviaparseUnits). Disable enter when the relevantassets[]balance is insufficient or the viewer cannot play (getViewerAccess(snapshot)is notplay). List lobbies in progress with a Watch action. - Waiting room: show paged participants (use
isYou/lobby.viewerto highlight self). Displayopeneronly 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 participantmetadata.displayName/username/avatarUrlwhen the host provides them; fall back to shortened addresses. - In-game (
IN_PROGRESS/WAITING_PLAYER_ACTION/WAITING_RANDOMNESS): render the board/state fromraw.gameState. For turn-based games, decode the current actor (and any per-turn deadline) fromraw.gameState— there is no host-providednextActor— and only enable the action UI when it is the connected wallet’s turn; otherwise show “waiting for <player>” (and a Skip button once the deadline passes, then a Claim the win button once a forfeit is due — see the liveness standard inPVP_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): renderlobby.settlement:- Rake —
feeAmountis the gross rake;tankSpentandreserveSpent(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
winnerandamount, plusfeeAmount. Ifdeferredis set the transfer failed at settlement and anyone may triggerhostApi.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.
- Rake —
- Cancelled (
CANCELLED): show independently claimable refunds per participant (claimRefund; each is the stake less its share oflobby.tankSpentpluslobby.reserveSpent, whichlobbyNetRefundcomputes), not a single batch refund animation that waits on other players.
Explicit stakes
Section titled “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
Section titled “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
Section titled “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
Section titled “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. 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)
Section titled “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)
Section titled “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),hostPanelstoggles (lobby,history,status).lobby(catalog hints only; contract is authoritative):access:public|game-definedentries:single|repeatable|game-defineddefaultStake: optional whole-token decimal string ("1","0.1") for input prefills, converted by the game with the asset’sdecimalsusesLobbyKeys: whether the game can create keyed canonical lobbies
capabilities:createLobbymust betrue; booleans forenterLobby,startLobby,submitAction,cancelLobby,claimWinnings,claimRefund,resize; optionalclaimPayoutandspectate(the game renders read-only for viewers who cannot play).
Game description: each locales[<locale>].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
Section titled “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
Section titled “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
Section titled “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 |