Skip to content

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.

  • 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.

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).

  • 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 <player>” (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.

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.

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.

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.

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

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[<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.

  • 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).

manifest.assets.iconUrl is optional; used in discovery when provided. manifest.assets.coverUrl is the catalog cover image; see “Cover image” above.

DoDon’t
Treat PvpHostSnapshot as a summary + page the restExpect a full players[] on every lobby
Label opener as attribution onlyCall the opener owner / assume start-cancel rights
Collect explicit stakes; show game validation rulesSilent fixed buy-in UX when the game allows variable stake
Render immediate vs claimable settlement differentlyAssume every resolution auto-pays the connected wallet
Show per-participant refund claims on cancelBlock UI waiting for a global refund batch
Decode raw.config / raw.gameState with the game ABI onlyDrive eligibility or payouts from host metadata
Render lobbies read-only for spectators, seats by positionOffer a spectator any write, automatic or not