# Smart contract constraints (IPvpGameV2 + protocol ledger)
Define admission, turn order, cancellation, and claims through the PvP contract interface. Keep game policy aligned with the protocol's escrow and contribution ledger.
:::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/)
:::
PvP games wager players against each other into a shared pot — **no house liquidity is at risk**, so
there is no `quoteCaps` / `quoteRiskParams` / reserved-profit machinery (contrast `@chain-protocol/casino-sdk`).
The protocol's job is escrow, the contribution ledger, the protocol fee, and settlement transfers.
**Turn order is enforced by the game, not the protocol** (see "Turn order & skipping").
API v2 separates **protocol-owned money/accounting** from **game-owned policy**. The protocol does
not assume a fixed buy-in, a unique seat per address, a creator privilege, a player cap, or a timeout
cancellation policy.
The canonical interface lives at [`../contracts/IPvpGameV2.sol`](/sdk-source/fb4e1ca5d757/IPvpGameV2.sol.txt).
---
## Game interface
Your game **must** implement `IPvpGameV2`. All hooks are **`view`** (stateless policy). The protocol
records contributions, claims, and phase; games read a bounded `LobbyContext` plus ledger views
through `msg.sender` as `IPvpLobbyLedgerV2`.
| Hook | Responsibility |
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
| `onLobbyOpen` | Validate asset/config/open data. Optionally assign a non-zero `lobbyKey` unique within this game's namespace. |
| `onEntry` | Accept or reject the caller's explicit stake (including zero) and return a `positionId`. Revert to reject. |
| `onLobbyStart` | Validate the actor and readiness; return the first `PvpStepResult` (state, phase, optional randomness request). |
| `onPlayerAction` | Apply `actionData` for the forwarded actor. The protocol does **not** check turn order. |
| `onRandomness` | Consume fulfilled randomness; may return the turn to the same actor. May not request randomness again. |
| `canCancel` | Own every normal and recovery cancellation permission until the 48-hour liveness backstop applies. |
| `getClaim` | For claimable settlement, quote one stable `claimId` → recipient + amount from final state. |
There is **no** `quoteLobby`, **no** protocol `canStart` boolean separate from `onLobbyStart`, and
**no** separate Smart Vault field in the participant model — the submitting address is the participant.
### Unbiased d6 from `bytes32` randomness (MUST)
If you map facet bytes to faces `1..6` via a **byte walk**, use rejection sampling (`byte < 252`
then `(byte % 6) + 1`). **Do not** use raw `(randomness[i] % 6) + 1`. For many dice per fulfillment,
you may instead use `keccak256(abi.encode(randomness, uniqueSalt))` then `word % 6`. See
[`RANDOMNESS_DICE.md`](/sdk/pvp/randomness-dice/).
---
## Protocol responsibilities
- Refuse a denied game, and whitelist the lobby's single immutable ERC20 escrow asset.
- Store lobby lifecycle state, the game-defined `lobbyKey`, opaque `config`, and opaque `gameState`.
- Enforce non-zero lobby-key uniqueness within the game contract's namespace.
- Transfer only the explicit stake authorized by the entering address.
- Append every accepted entry to the contribution ledger and maintain participant/position aggregates.
- Expose indexed ledger reads to games and paginated reads to host applications.
- Request randomness and validate phase transitions.
- Deduct the protocol fee once at resolution:
`fee = pot * protocolFeeBps / 10_000`, `distributable = pot - fee`.
- Derive the settlement mode from the recipients returned at resolution: exactly **one** recipient
is paid the whole distributable pot immediately (a failed transfer is parked for `claimPayout`);
zero or several recipients settle via claims.
- Track collected claim IDs and cap claimable transfers at the remaining distributable pot.
- Fund each lobby's gas tank from a share of its rake and pay participants' moves from it, so a
move is a sponsored call while the tank can pay for it; the tank's spend comes out of the rake
before the fee split, never out of what winners share.
- Repay the gas of a lobby's open-and-enter, entries and cancel from its rake reserve, the part of
the rake the tank does not hold; like the tank's, the reserve's spend comes out of the rake before
the fee split.
- On cancellation, make each participant's aggregate stake independently refundable, **net of
their pro rata share of what the tank and the reserve spent** (rounded against the participant).
- Enforce the liveness backstop: a lobby that takes no step for `LIVENESS_WINDOW` (48 hours) can be
cancelled by anyone, and a claimable pot stops taking claims `CLAIM_WINDOW` (7 days) after
resolution (see
"Cancellation and refunds").
A lobby's `protocolFeeBps` is its game's rake, fixed when the lobby opens. The game contract
never sets it and only decides who receives the distributable pot and how. The rake is the
council's figure for the game when it has one, otherwise the game's developer rake, otherwise the
protocol-wide default of 5%.
The developer rake is a one-time choice. Within 60 seconds of the game's registration, its game
developer or the wallet that sent its creation call may call
`setPvpDeveloperRake(game, rakeBps)` on the diamond with any value from 100 to 500 bps (1% to 5%).
After that minute, or after the first successful call, it never changes. The window runs on time
from registration, not until the first lobby, so no one can lock the game at the default by
opening a lobby first. A lobby opened before the choice keeps the rake it opened with.
`getPvpDeveloperRake(game)` returns `(set, rakeBps)`; `getPvpGameRake(game)` returns the rake the
next lobby opens with.
The rake is split inside the protocol among the game developer, the treasury and the lobby's
operator. A game created through the PvP bytecode deployer has the game developer its creation
call names. Anyone may name any developer, since the name only directs the game dev fee. An
authorship is an optional way for a developer's wallet to authorize a specific creator wallet; a
creation call that consumes one must name the authorship's writer as the developer. A game created
any other way has none and pays no game dev fee. The operator fee goes to the frontend that opened
the lobby; operator registration is not open yet, so today it resolves to the protocol's own
operator. None of this changes `distributable`. See "Operators" in `CHAIN_WTF_PVP_GAMES.md`.
---
## Game responsibilities (money-safety checklist)
- **Phase-gate entries** when your arrays or rules assume a closed set of participants after start
(Bones reverts unless `ctx.phase == WAITING_FOR_PLAYERS`). The protocol does not invent seats.
- **Bound player-submitted numbers** that feed sums or multiplications (a score-submission game
should cap scores, e.g. at `type(uint64).max`) so resolution cannot overflow under Solidity 0.8
checked math and freeze the pot. A score cap bounds sums of scores only: products of the pot and
a score can still overflow, because the stake is unbounded. Compute them with OpenZeppelin
`Math.mulDiv(distributable, score, total)`, or bound the stake as well.
- **Return recipients deliberately**: exactly one recipient means that address is paid the whole
distributable pot immediately; return several (or none, when not enumerable) for claimable
distributions.
- **Assign rounding dust deliberately** in claimable splits so claim quotes sum exactly to the
distributable pot (the Bones draw split gives the dust to seat 0).
- **Derive lobby keys** from everything that should make a lobby unique, including the opener, and
hash the decoded config rather than the raw bytes (e.g. Jackpot:
`keccak256(abi.encode(token, opener, decodedConfig))`). A key without the opener lets anyone who
copies the config take it first, and raw bytes let trailing data mint extra keys.
- **Treat contribution IDs as dense** `0 .. contributionCount - 1` in append order when walking or
binary-searching the ledger (documented on `getContribution`).
- **Take a step at least every 48 hours.** Keep every wait between two steps (a jackpot's entry
window, a turn deadline, a challenge window) under the 48-hour liveness window, or anyone may
cancel the lobby while it is still live. Expect claims to close 7 days after resolution.
---
## Entry and position accounting
Each accepted entry becomes one ledger record:
| Field | Meaning |
| ---------------- | ---------------------------------------------------------------------------- |
| `contributionId` | Dense per-lobby 0-based index in append order (`0 .. contributionCount - 1`) |
| `participant` | Entering address |
| `amount` | Accepted stake for this entry |
| `positionId` | Game-defined position key |
| `cumulativePot` | Pot total after this entry (enables weighted ticket search) |
| `contributedAt` | Timestamp |
Returning the **same** `positionId` aggregates repeated entries into one position. Returning
**different** IDs keeps them independent.
Examples:
- **Bones** — rejects `ParticipantContext.joined == true`; `positionId` is address-derived; fixed
exact stake; exactly two seats.
- **Jackpot** — accepts repeats; same address-derived `positionId`; minimum stake; time window.
`IPvpLobbyLedgerV2.getContribution(lobbyId, contributionId)` therefore supports ordered index walks
and binary search over cumulative pots (see `JackpotGame`).
---
## Settlement invariants (enforced by the protocol)
When a step returns `nextPhase == RESOLVED`, the protocol validates settlement before moving funds.
A violation reverts and the lobby stays unresolved.
### Immediate settlement (derived: exactly one recipient)
- The game returns `recipients` with a single non-zero address, which receives the whole
distributable pot (`distributable = pot - fee`).
- The payout transfer is attempted without reverting: if it fails (e.g. a blacklisted recipient),
the payout is parked on the lobby and the winner collects it later via the permissionless
`claimPayout(lobbyId)`.
Reference pattern (Jackpot): draw a stake-weighted ticket, return the drawn participant as the only
recipient.
### Claimable settlement (derived: zero or several recipients)
- No payout loop at resolution; the returned recipients (if enumerated) only select the mode and
must contain no zero addresses.
- Anyone may submit a game-defined `claimId`; the protocol calls `getClaim`, records the ID, and
transfers to the returned recipient.
- The caller **cannot** redirect payment. Multiple claims may share a recipient.
- Total paid across all claims is capped at the remaining distributable pot.
- Claims close `CLAIM_WINDOW` (7 days) after resolution. Anyone may then call
`refundUnclaimedWinnings(lobbyId, participant)`, which pays that participant
`unclaimed * stake / pot`, rounded down, from what claims left of the distributable pot. This
returns a pot whose `getClaim` reverts, or whose claims add up to less than the pot.
### Cancellation
`CANCELLED` is **not** a game result. It means a **refund** of each participant's aggregate stake,
less their pro rata share of what the lobby's gas tank spent on moves and its rake reserve spent on
the open-and-enter, entries and cancel, claimed independently via the host API / protocol refund
path — never a penalty or forfeiture, and the whole stake when neither paid anything. Who may
cancel is `canCancel`, until the liveness backstop or the guardian's rescue of a denied game
applies (see "Cancellation and refunds").
---
## Protocol orchestration notes
- **No game whitelist**: any contract may back a lobby. The stake token must be
governance-whitelisted so pot accounting stays exact against standard ERC20s. A game the guardian
denies accepts no new lobbies or entries (`PvpGameFacet__GameDenied`); only the security council
lifts a denial. Lobbies already staked in still start, take actions and randomness, cancel and
pay out, unless the guardian or the council cancels one at once with
`cancelDeniedGameLobby(lobbyId)`.
- **No stake on open**: opening records config and optional lobby key only. Value moves on
**entry**.
- **Turns are not protocol-enforced.** Submit forwards the actor to `onPlayerAction` and lets the
game decide validity. Intentional — see "Turn order & skipping".
- **Randomness from a participant's call only**: `onLobbyStart` or `onPlayerAction` may return
`requestRandomnessNow = true` with `nextPhase = WAITING_RANDOMNESS`. The call that requests it
pays its fee. If `onRandomness` returns `WAITING_RANDOMNESS`, the facet reverts the fulfilment
with `PvpGameFacet__RandomnessRequestedFromCallback` and the lobby stays waiting for randomness
until `canCancel` lets someone cancel it or the liveness backstop applies.
- **One random value per request (MUST)**: a game gets one `bytes32` per request. Derive every
random value a step needs from it, for example `keccak256(abi.encode(randomness, i))` for the
`i`-th value.
- **Phases**: invalid transitions revert. You cannot resolve from a terminal phase or request
randomness inconsistently.
- **View safety**: hooks must be deterministic and view-safe for a given context (the protocol may
simulate).
- **Reentrancy**: value-moving entrypoints are non-reentrant and finalize pot/phase before
transfers, layered on the token whitelist.
Exact selector names and error codes live on the deployed facet ABI; treat verified bytecode as
authoritative if this document drifts.
---
## Turn order & skipping (game-enforced)
Because the protocol does not enforce turns, **the game owns all turn logic** in `onPlayerAction`:
- A game with turns tracks its current actor (and any sub-turn structure) in `gameState`, and a normal
move must `require(actor == currentActor)` itself.
- This is what lets an action trigger randomness and then route the **same** player to act again
(`decide → WAITING_RANDOMNESS → onRandomness keeps currentActor → that player decides again`) — a
shape a fixed protocol-level turn-guard could not express.
**Skipping a slacking player** falls straight out of this and needs **no protocol support**:
1. When the game hands a turn to a player, it writes a deadline into `gameState`
(`deadline = block.timestamp + timeBank`).
2. It defines a `SKIP` action whose handler checks `block.timestamp > deadline` (and ignores the
caller), then forfeits the no-show — they simply receive a **0 amount** at resolution (or are
dropped from the active set) — and advances the turn.
3. Since the protocol forwards **any** caller to `onPlayerAction`, **anyone** (another player, or a
keeper bot) can send the `SKIP` tx once the deadline passes; the laggard cannot stall the game.
```solidity
// inside onPlayerAction
(uint8 kind) = abi.decode(actionData, (uint8));
if (kind == SKIP) {
require(block.timestamp > s.deadline, "not expired"); // caller is irrelevant
// forfeit s.currentActor (0 amount), advance turn, set s.deadline = block.timestamp + timeBank
} else {
require(actor == s.currentActor, "not your turn");
require(block.timestamp <= s.deadline, "timed out"); // optional hard cutoff
// apply move, advance turn, reset deadline (and/or requestRandomnessNow = true)
}
```
The guest decodes the deadline from `raw.gameState` (its own state) to render a countdown and a
"Skip" button. A game that does **not** want permissionless callers may additionally require
membership for non-skip actions — but the skip path should stay open to keep the game live.
Skips alone make an abandoned match slow to finish: every missed move costs the present player a
transaction. Pair them with a forfeit, preferably through the liveness standard below.
### Liveness standard (optional)
[`../contracts/PvpLiveness.sol`](/sdk-source/748fbc286302/PvpLiveness.sol.txt) standardizes what a lobby is blocked on
and how anyone unblocks it, so a host, keeper or guest can drive skip / forfeit / resign / advance on
any game without knowing its action encoding. It is not a protocol rule: games may skip it or handle
timeouts their own way, and the protocol never calls it.
**Detect**: `supportsInterface(0xae5c86f9)` (ERC-165 id of `IPvpLiveness`) returns `true`. Treat a
revert or `false` as unsupported.
**Read**: `liveness(uint8 phase, bytes config, bytes gameState)` takes the values every lobby read
model carries and returns `supported` (a capability bitmask) plus the pending `waits`. Each wait is
one thing the lobby is blocked on:
| Field | Meaning |
| ----------- | ---------------------------------------------------------------------------------- |
| `kind` | `PLAYER_ACTION`, `PLAYER_REVEAL`, `TIMED_ADVANCE` or `CHALLENGE_WINDOW` |
| `actor` | The player who owes the step; zero for a wait nobody personally owes |
| `subject` | Game-defined handle (round id, claim id, …); zero when the game does not need one |
| `dueAt` | `SKIP` (or `ADVANCE`, when `actor` is zero) is accepted once past it; 0 = no path |
| `forfeitAt` | `FORFEIT` of `actor` is accepted once past it; 0 = this wait cannot cost the match |
Turn-based games publish one wait at a time. Simultaneous games publish one per player who still
owes the step, which is what makes "everyone is late" distinguishable from "one player is late".
**Act**: submit `abi.encodePacked(tag, bytes32 subject)` as `actionData` through
`submitLobbyAction`. A zero subject means "whichever wait qualifies".
| Action | Tag | Subject | Who | Effect |
| --------- | ------------ | -------------- | ------------------------------------------ | ------------------------------------------------------------------------- |
| `SKIP` | `0x021033a8` | wait `subject` | Anyone, after `dueAt` | The game plays or passes the missing input; the match continues |
| `FORFEIT` | `0xe04af859` | target player | Anyone, after `forfeitAt` | That `actor` loses; in a two-seat game the other seat takes the pot |
| `RESIGN` | `0x1fbe2408` | unused | A player still in contention | The caller loses as if forfeited |
| `ADVANCE` | `0x46c0809d` | wait `subject` | Anyone, after an actor-less wait's `dueAt` | The game closes the round, settles the challenge, or ends a stalled match |
Each tag is `bytes4(keccak256('PvpLiveness.'))`. The payload is 36 bytes, never a whole
number of ABI words, so it cannot collide with a game's own actions; games using packed encodings
must avoid 36-byte payloads. The facet only accepts actions in `IN_PROGRESS` /
`WAITING_PLAYER_ACTION`, so none of them work while randomness is pending.
**Implement**: inherit `PvpLiveness`, implement `_livenessCapabilities()` and
`_pendingWaits(config, gameState)`, and at the top of `onPlayerAction`:
```solidity
(LivenessAction action, bytes32 subject) = _livenessAction(actionData);
if (action != LivenessAction.NONE) {
// waits built from the state you already decoded; reverts unless the action is due
PvpWait memory wait = _requireLivenessAction(action, subject, _waitsOf(s));
if (action == LivenessAction.SKIP) return _skip(s);
if (action == LivenessAction.FORFEIT) return _concede(s, _seatOf(s, wait.actor));
if (action == LivenessAction.RESIGN) return _concede(s, _seatOf(s, actor));
return _advance(s); // actor-less wait: close the round, or end a stalled match
}
```
TypeScript hosts and guests get the tags, the ABI, `encodeLivenessAction` and
`getDueWaits(status, nowSeconds)` from `@chain-protocol/pvp-sdk/liveness`.
**Reference games**: `BonesGame` (turn-based: one `PLAYER_ACTION` wait, skip then forfeit) and
`RockPaperScissorsGame` (simultaneous: a wait per player who owes a commit or a reveal, no skip
path, and an actor-less `TIMED_ADVANCE` while both seats are still silent so an empty window ends as
a no-contest instead of punishing whoever is named first).
---
## Cancellation and refunds
V1 had per-lobby fill and action timeouts (`actionTimeoutBlocks`) that auto-cancelled lobbies. V2
removed them: a protocol timeout short enough to matter in play overrides the game's own turn and
round rules. Recovery is game policy via `canCancel`:
| Concern | v2 approach |
| ------------------------ | ------------------------------------------------------------------------------------------------------- |
| Lobby never starts | Game may allow the administrator, opener, or anyone to cancel while waiting |
| Mid-game abandon / stall | Liveness skip, then forfeit against the idle player; optional `canCancel` for full unwind |
| Stuck randomness | Protocol/provider operational concern; games should not strand funds on unfulfillable requests |
| Who gets money on cancel | Each participant's aggregate stake, net of their share of tank and reserve spend — never a game penalty |
**Liveness backstop.** Any contract may back a lobby, so a game can refuse every cancel and revert
every step, through malice or a bug. The protocol keeps two last-resort windows, constants in
`PvpFacetBase`: `LIVENESS_WINDOW = 48 hours` for a live lobby and `CLAIM_WINDOW = 7 days` for a
claimable pot.
- The lobby records `lastProgressAt` on open, on every entry and on every applied start, action or
randomness step. A reverted call is not a step. `getLobby` returns it.
- Once `block.timestamp >= lastProgressAt + 48 hours`, `cancelLobby` skips `canCancel`: anyone may
cancel the lobby in any cancellable phase, `WAITING_RANDOMNESS` included. The pending request is
unregistered, so a late fulfilment reverts with `PvpGameFacet__UnknownRandomnessRequest`.
Participants claim net refunds through `claimRefund` as on any cancel.
- A claimable pot takes claims until `lastProgressAt + 7 days`, measured from the resolving step.
After that `claimWinnings` reverts with `PvpGameFacet__LobbyNotClaimable` and
`refundUnclaimedWinnings` returns what is left by stake, as described under "Claimable
settlement".
- A lobby with no entry for 48 hours while waiting for players is cancellable the same way.
- The guardian or the security council may cancel a lobby of a denied game at once with
`cancelDeniedGameLobby(lobbyId)`. That covers a game that keeps a lobby inside the window by
stepping it itself.
The windows are constants, not council settings, so a game author can rely on them when choosing
deadlines, and no governance call can shorten them under a live lobby. Changing them takes a facet
upgrade through the upgrade timelock. The reference games' fixed deadlines are under a day. A
Jackpot's entry window comes from its config, so a Jackpot round that takes no entry for 48 hours
becomes cancellable by anyone. The claim window is longer than the liveness window because a
winner who has not claimed loses the unclaimed amount to the other participants.
There is no protocol `leaveLobby` seat model. Leaving before start is either a game that allows
cancel/refund patterns or simply not entering again for single-entry games.
---
## Reference implementations
SDK reference games:
| Contract | Where | Demonstrates |
| ----------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `PvpLiveness` | `@chain-protocol/pvp-sdk/contracts/PvpLiveness.sol` | Optional base for the liveness standard: detection, wait view, reserved skip / forfeit / resign / advance payloads |
| `BonesGame` | `contracts/src/BonesGame.sol` in a game created with `chain init pvp --template bones` | Turn-based: fixed stake, two seats, per-roll VRF, one liveness wait (skip then forfeit), immediate winner / claimable draw |
| `RockPaperScissorsGame` | `contracts/src/RockPaperScissorsGame.sol` from `chain init pvp --template rps` | Simultaneous: commit–reveal windows, a liveness wait per player who owes a step, no-contest when both miss one |
| `JackpotGame` | Deployed by `chain dev pvp`; not a template | Variable/repeat stakes, lobby keys, weighted ticket via ledger binary search |
Full protocol facets, when deployed in this monorepo, live under `packages/contracts`. Treat the
deployed facet's verified ABI as authoritative if this doc drifts.
---
## Related docs
- [`CHAIN_WTF_PVP_GAMES.md`](/sdk/pvp/building-games/) — bridge, snapshot, manifest, frontend patterns
- [`RANDOMNESS_DICE.md`](/sdk/pvp/randomness-dice/) — rejection sampling for d6
- [`VISUAL_AND_UX.md`](/sdk/pvp/visual-and-ux/) — iframe UX expectations