Skip to content

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.

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.


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.

HookResponsibility
onLobbyOpenValidate asset/config/open data. Optionally assign a non-zero lobbyKey unique within this game’s namespace.
onEntryAccept or reject the caller’s explicit stake (including zero) and return a positionId. Revert to reject.
onLobbyStartValidate the actor and readiness; return the first PvpStepResult (state, phase, optional randomness request).
onPlayerActionApply actionData for the forwarded actor. The protocol does not check turn order.
onRandomnessConsume fulfilled randomness; may return the turn to the same actor. May not request randomness again.
canCancelOwn every normal and recovery cancellation permission until the 48-hour liveness backstop applies.
getClaimFor 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)

Section titled “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.


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

Section titled “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.

Each accepted entry becomes one ledger record:

FieldMeaning
contributionIdDense per-lobby 0-based index in append order (0 .. contributionCount - 1)
participantEntering address
amountAccepted stake for this entry
positionIdGame-defined position key
cumulativePotPot total after this entry (enables weighted ticket search)
contributedAtTimestamp

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)

Section titled “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)

Section titled “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)

Section titled “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.

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


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


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

../contracts/PvpLiveness.sol 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:

FieldMeaning
kindPLAYER_ACTION, PLAYER_REVEAL, TIMED_ADVANCE or CHALLENGE_WINDOW
actorThe player who owes the step; zero for a wait nobody personally owes
subjectGame-defined handle (round id, claim id, …); zero when the game does not need one
dueAtSKIP (or ADVANCE, when actor is zero) is accepted once past it; 0 = no path
forfeitAtFORFEIT 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”.

ActionTagSubjectWhoEffect
SKIP0x021033a8wait subjectAnyone, after dueAtThe game plays or passes the missing input; the match continues
FORFEIT0xe04af859target playerAnyone, after forfeitAtThat actor loses; in a two-seat game the other seat takes the pot
RESIGN0x1fbe2408unusedA player still in contentionThe caller loses as if forfeited
ADVANCE0x46c0809dwait subjectAnyone, after an actor-less wait’s dueAtThe game closes the round, settles the challenge, or ends a stalled match

Each tag is bytes4(keccak256('PvpLiveness.<ACTION>')). 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:

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


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:

Concernv2 approach
Lobby never startsGame may allow the administrator, opener, or anyone to cancel while waiting
Mid-game abandon / stallLiveness skip, then forfeit against the idle player; optional canCancel for full unwind
Stuck randomnessProtocol/provider operational concern; games should not strand funds on unfulfillable requests
Who gets money on cancelEach 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.


SDK reference games:

ContractWhereDemonstrates
PvpLiveness@chain-protocol/pvp-sdk/contracts/PvpLiveness.solOptional base for the liveness standard: detection, wait view, reserved skip / forfeit / resign / advance payloads
BonesGamecontracts/src/BonesGame.sol in a game created with chain init pvp --template bonesTurn-based: fixed stake, two seats, per-roll VRF, one liveness wait (skip then forfeit), immediate winner / claimable draw
RockPaperScissorsGamecontracts/src/RockPaperScissorsGame.sol from chain init pvp --template rpsSimultaneous: commit–reveal windows, a liveness wait per player who owes a step, no-contest when both miss one
JackpotGameDeployed by chain dev pvp; not a templateVariable/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.