Play against yourself across browser tabs with a local chain, VRF node, and PvP host. Shake out multiplayer timing and lobby flows before inviting the competition.
chain dev pvp runs the PvP SDK simulator in your game’s repository. It mounts your game frontend
in the simulator page, which drives it through the same @chain-protocol/pvp-sdk bridge as the production
Chain.wtf host. Behind the page it runs its own chain, a minimal PvP deployment, a real Verify
Network VRF node and the contract your deployment.ts names. Everything runs on your machine; no
Chain.wtf account or backend access is needed.
Because PvP games are multiplayer, the simulator page is built to be opened in several browser tabs at once, each tab playing as a different wallet. If you can make your game fun and robust with two tabs racing each other in the simulator, it will behave the same way in production. The simulator page replicates the host’s quirks (strict manifest validation, the flashblock push layer over a lagging lobby feed, per-lobby routing) so you hit them on day one instead of after shipping.
Quick start
Section titled “Quick start”You need Node.js 22 or newer, or Bun 1.4.2 or newer. Create a game from one of the two examples, Bones or Rock Paper Scissors:
npx @chain-protocol/cli init pvp my-game --template bonesWith Bun, run bunx @chain-protocol/cli init pvp my-game --template bones. Use
--template rps for Rock Paper Scissors. Without --template, chain init pvp asks which
example to use. Expect Created my-game from the Bones example.
my-game/ holds the frontend in src/, the contract in contracts/src/BonesGame.sol, the
deployment.ts that names it and public/game.manifest.json. Install it and start the frontend:
cd my-gamenpm installnpm run devWith Bun, run bun install and bun run dev. Expect Vite to serve the game at
http://localhost:3200/ (Rock Paper Scissors: http://localhost:3500/).
Start the SDK simulator in a second terminal:
npm run simulatorWith Bun, run bun run simulator. The script runs chain dev pvp with the example’s frontend URL.
Expect Play your game at http://127.0.0.1:3400/?game=… when it is ready, and open that URL. Press
Ctrl-C to stop the chain, the VRF node and the simulator page.
To add the simulator to an existing project, install the same version of the SDK and the CLI, then
run chain dev pvp next to your deployment.ts:
npm install @chain-protocol/pvp-sdknpm install --save-dev @chain-protocol/clinpx chain dev pvp --frontend-url http://localhost:5173With Bun, run bun add @chain-protocol/pvp-sdk, bun add --dev @chain-protocol/cli and
bunx chain dev pvp --frontend-url http://localhost:5173.
The simulator page listens on port 3400 and the dev chain on 8545;
chain dev options lists the flags that change them.
No chain tooling is required. The dev chain is an in-memory EDR chain inside the simulator’s own process. If a chain already answers on the RPC port, the simulator deploys to it instead.
The game contract defaults to the one deployment.ts names. Query params override the saved
setup:
http://localhost:3400/?game=http://localhost:5173&gameAddress=0x…&rpc=…The simulator is the SDK’s internal ./simulator subpath. It sits outside semver; do not import
it from a game. Because chain dev runs the simulator of the SDK your project installed, the
simulator always matches the SDK version your game builds against.
Playing against yourself
Section titled “Playing against yourself”Every tab is its own player. The setup panel picks the tab’s wallet from the Hardhat/Anvil
default-mnemonic accounts (all of them hold 1,000,000 test chUSD; account #3 is reserved for the
VRF node) and Open a new tab as account #N opens the next one. The choice lives in the tab
(?player=<index> in the URL) while the game URL, contract addresses and lag setting are shared
by every tab, so a duel is:
- Tab A: create a table from the game. Once it is entered and indexed, the tab opens it
(
?lobby=<id>), exactly like production navigating to the lobby route. - Tab B (another account): join it from the game — the join enters the lobby and starts the duel, and the VRF node answers the coin toss for who rolls first.
- Roll and place from each tab in turn. Every tab reads the same chain, so all of them converge on each move: first through the flashblock push, then durably once the indexed feed catches up; let a turn timer run out to try the permissionless skip, and leave it idle for two more turn lengths to claim the win by forfeit.
A game created with --template rps runs the same way against RockPaperScissorsGame: both tabs seal a
pick, the guests reveal themselves, and the first to winsNeeded rounds takes the pot. It has no
skip path — nobody can play another player’s secret move — so let a window lapse to try the two
liveness endings: leave ONE tab idle and the other is offered Claim the match (a forfeit, the
whole pot), leave BOTH idle and either is offered Settle as a no-contest (each seat claims
half). Either tab can resign at any time.
What chain dev pvp runs
Section titled “What chain dev pvp runs”- A chain: the in-memory dev chain (EDR) on the RPC port, or the chain already answering there.
- Verify Network VRF: deploys the real router from
@verify-network/local, registers a local fulfilling node (dev account #3) and keeps answering randomness requests with real ECVRF proofs. Your contract’s randomness path runs for real, not mocked. - A minimal PvP host:
LocalTestToken: a freely mintable six-decimal chUSD stand-in, matching USDC.LocalPvpHost: a minimal stand-in for the production PvP facets that runs the fullIPvpGameV2lobby lifecycle (open, entries, start, actions and randomness, resolve or cancel, escrow, contribution ledger, the 5% default rake with the one-time developer rake and per-game overrides, the lobby gas tank and rake reserve, immediate and claimable settlement, refunds net of tank and reserve spend) and emits byte-identical events. No diamond and no game whitelist, as in production; the deployer account stands in for the security council and can deny a game.LocalGameBytecodeDeployer: the simulator’s copy of the protocol’s PvP bytecode deployer.createGame(initCode, salt, developer, authorshipId)names the game developer, and a zero developer reverts withLocalPvpHost__ZeroDeveloper.BonesGame,JackpotGameandRockPaperScissorsGame: the SDK’s reference games, listed after your game in the setup panel.
- Your game: the contract
deployment.tsnames, compiled, created through the PvP bytecode deployer from dev account #0, which the creation call names as its game developer, and listed first. - Funding and settings: every dev account gets test tokens. The host’s router balance starts
empty: every move that may request randomness pays for it, as on chain. The host starts with a
500 bps rake, a 3500 bps tank share and a 0.01 chUSD gas charge; change them in the setup
panel. The simulator page serves the deployment at
/__local-contracts.json.
Your game contract
Section titled “Your game contract”deployment.ts in the project’s root names the contract:
import type { DeploymentInput } from '@chain-protocol/pvp-sdk';
export default { kind: 'pvp', contract: { source: 'src/MyGame.sol', name: 'MyGame' },} satisfies DeploymentInput;contract.sourceis relative tocontracts/. Import the interfaces as@chain-protocol/pvp-sdk/contracts/IPvpGameV2.soland@chain-protocol/pvp-sdk/contracts/PvpLiveness.sol; they resolve to the Solidity of the SDK your project installed. Other imports resolve relative to the file or fromnode_modules.constructorArgslists constructor arguments.{{diamond}}and{{token}}stand for the addresses ofLocalPvpHostandLocalTestToken.- Keep
deployment.tsself-contained: import only types.
The simulator compiles with solc and watches deployment.ts and every file the build read. Save a
change and it redeploys the game at a fresh address within a couple of seconds, with no restart. A
compile error prints in the terminal, and the previous deployment keeps running.
To test a contract deployed some other way, deploy it with your own toolchain against
http://127.0.0.1:8545 and enter its address in the setup panel. As in production, any contract
may back a lobby; one created outside the PvP bytecode deployer has no game developer.
What the simulator page replicates from production
Section titled “What the simulator page replicates from production”- Strict frame validation — the manifest is required, fetched from the game origin, validated, and a game served from the host’s own origin is rejected. Failures show the exact reason instead of production’s generic screen.
- Two live data layers — every chain event reaches the host twice: near-instantly on a
flashblock push channel (in production the host observes flashblocks — sub-block
preconfirmations — and overlays them on the lobby list, the participant roster and the
contribution ledger through a forward-only patch layer) and after a configurable lag on the
indexed lobby feed the host reads lobbies from. Every participant’s events ride the fast
channel, so an opponent’s join, start, game state and result show up in this tab well before
the indexed feed delivers them. A call’s promise still resolves with the receipt, and the
snapshot may reflect the lobby before or after that, exactly like production: a game must
track the lobby it just created until it appears in
lobbies.items. - The same host methods — input validation, lobby/game ownership checks, approval batching
on
enterLobbyand on a creation with a firstentry, receipt parsing for lobby / contribution / claim results, and the feature-detectedclaimPayout(only when the manifest declares it). - The relayer’s policy and the gas tank — see Gas tank below: sponsored moves
while the lobby’s tank can pay, covered calls with the gas line item otherwise, the
PVP_CALL_UNAFFORDABLErefusal, the tank and reserve figures on the snapshot and refunds net of tank and reserve spend. - Randomness payments —
startLobbyandsubmitActionattach the ETH a randomness request owes, the host’srequiredPaymentplus a 10% buffer, starting from an empty router balance. What a request does not use stays in the balance for the next one, and a move whose step requests no randomness gets its ETH back in the same transaction. - One request per move — a step reached from
onRandomnessmay not request randomness again. The host refuses the fulfilment withLocalPvpHost__RandomnessRequestedFromCallback, the lobby stays inWAITING_RANDOMNESS, and only your game’scanCancelgets it out. On chain, the 48-hour liveness backstop also lets anyone cancel it; the simulator does not model that window. Derive every further random value from the oneonRandomnessreceived. - Spectator access —
snapshot.accesson every push, the read-only rejection of writes, the advertisedcapabilities.playAccessand simulatedrequestPlayAccessintent, and the sign-in placeholder for games withoutcapabilities.spectate. - The lobby route —
metadata.room.roomIdfollows the lobby the tab has open, and the lobby and match panels under the frame show what production renders around the game. Join, Watch and Replay in the match panel set the room the way production’s lobby route does; the extra Past games tab lists every finished lobby of the game (production’s history shows only your own), so any tab can replay a match it never sat at. Clicking the lobby the tab already has open reloads the frame, since the pushed room id cannot change — the same as reopening the lobby route. - The room browser — the Room browser tab above the frame is production’s PvP landing
page: every lobby of every game on the local host, railed as Open lobbies, Matches in
progress and Recently archived with the same cards chain.wtf renders (game cover or
initials, lobby id, game title, player count, the featured stake or pot, and the info popover
with phase, stake, pot, players and chain). It reads the same lagged indexed feed as the game,
so it shows exactly what a visitor browsing rooms sees while your lobby fills up, starts and
settles — open it in a second tab next to a playing tab to watch a room move between rails.
Clicking a card is production’s navigation to that lobby: the tab switches to the game page
with the lobby open (
?view=roomskeeps the browser open across reloads). A card the tab’s own account already sits at says you are seated — a table you opened yourself waits for another account, so join it from a tab opened as a different player. A card of another game switches the simulator page to that game contract; the game URL is shared, so point it at that game for the frame to mount. Titles come from the game’s manifest (and itsassets.coverUrlwhen it ships one) for the running game, and from the registered name for every other game. - Randomness verification —
getRandomnessVerificationruns the production checks against the local Verify Network router (real ECVRF proofs and enclave signatures read from the fulfillment transactions), so a provably-fair view works offline.
The setup panel
Section titled “The setup panel”The collapsible sidebar is your chaos-engineering console:
- Player — the tab’s wallet, plus a one-click way to open the next account in a new tab.
- Wallet status override — force
ready/disconnected/setup-required/session-key-mismatchper tab to exercise your game’s non-ready screens. The existing manual setup overlay button still changes the status toready. Any non-ready status also puts the tab inspectateaccess (seeCHAIN_WTF_PVP_GAMES.md); writes reject withPVP_SPECTATOR_READ_ONLY. - Simulated play-access intent — the bridge advertises
capabilities.playAccess: true. A guestrequestPlayAccess()records intent below the frame and in Bridge calls without changing readiness, authenticating, signing, funding, or betting. Resolution means acceptance only; use Wallet status override to change readiness and wait for the resulting snapshot. An already-ready request is a no-op; host-policy or unavailable handlers reject. - Spectate by host policy — keep the wallet as configured but mark the viewer read-only with
reason: 'host-policy'and no sign-in hand-off, the production behaviour for a view-only page. A game whose manifest lackscapabilities.spectategets the production sign-in placeholder instead of the iframe in both spectating cases. - Open lobby id — the room the tab has open, pushed as
metadata.room.roomId. - Flashblock / indexer lag sliders — tune both data layers live. Crank the indexer lag with a small flashblock lag to watch your game ride the flashblock layer (a second tab sees the opponent’s join, game state and result long before the indexed feed updates); raise the flashblock lag to watch it bridge the gap between a resolved call and the snapshot that reflects it. A well-built game shows no flicker and no duplicate rows either way.
- Sponsored minimum — the relayer’s PvP sponsored minimum in whole tokens (
0.5by default, live): a claim of winnings or a deferred payout while the pot is below it is a covered call. Entries, the creation with an entry and the cancel follow the rake reserve instead. - Gas tank (shown once the simulator page runs) — the default rake, this game’s own rake, the tank share and the gas charge every call is priced at, read from and written to the local host.
- Game URL + contract address — swap between the example and your own game without restarting anything.
Every snapshot pushed to the iframe is logged ([host-push #n] …, debug level) and checked
against the previous one; transitions a game could visually trip on (a lobby vanishing, a resolved
lobby mutating, a pot or count going backwards, the newest lobby regressing) log as console
warnings. The full history sits on window.__hostSnapshotTrace.
Gas tank
Section titled “Gas tank”Every lobby on chain.wtf carries a gas tank: a share of its rake that repays the gas of its participants’ moves, so a lobby’s moves cost its players nothing while the tank lasts. The simulator mirrors what a game sees of it:
- Sponsored moves. A participant’s
startLobby/submitActionis a sponsored call while the tank can pay its gas charge: the tab’s balance is untouched, the host emitsPvpLobbyTankPaid, andlobby.tankSpentrises by the charge whilelobby.tankRemainingfalls by it. The tank ispot × rake × tank share − spent, computed on the current pot, so a late entry grows it. On chain.wtf the tank also repays the ETH a move forwarded for its randomness, priced in the token; the simulator’s flat gas charge stands for both. - Covered calls. Once the tank cannot pay a whole move (or the caller has not entered), the
call is carried as a covered call: the gas charge is repaid from the tab’s token balance right
after the call and the production gas line item (
+ up to … for gas, the cover limit) shows over the frame. A creation without an entry and a refund claim are always covered calls; a creation with an entry, an entry and a cancel are covered calls when the rake reserve cannot pay for them (below); a claim of winnings is one while the pot is below the sponsored minimum. - Unaffordable calls. A covered call the balance cannot pay (stake plus cover limit) is not
sent: the host method rejects with an error starting with
PVP_CALL_UNAFFORDABLE, exported by@chain-protocol/pvp-sdk. - Settlement and refunds.
settlement.feeAmountstays the gross rake andsettlement.tankSpentsays how much of it the tank paid; winners sharepot − feeAmounteither way. A cancelled lobby refunds each participant their stake less their pro rata share oftankSpentplusreserveSpent(below), rounded against them (claimRefund, previewed by the host’sgetLobbyRefundand bylobbyNetRefundfrom the snapshot). The lobby panel under the frame shows the rake, the tank and the reserve left, and what each spent.
Rake reserve
Section titled “Rake reserve”The rest of the rake, the part the tank does not hold, is the lobby’s rake reserve. It repays the gas of the lobby’s open-and-enter, its entries and its cancel, the way the tank repays moves:
- Who it pays. Whoever sent the call (the tab’s own account here, a relayer wallet on
chain.wtf), credited as claimable, in full or not at all: the host emits
PvpLobbyReservePaid, andlobby.reserveSpentrises by the charge whilelobby.reserveRemainingfalls by it. A cancel draws on it whoever sends it; a loneopenLobbydraws nothing, because its pot is empty. - Its size.
pot × rake × (1 − tank share) − spent, measured after the call’s own stake lands, each multiplication truncated as on chain, so every entry grows it. A zero rake, or a tank share of the whole rake, leaves no reserve. - Where its spend goes. At resolution it comes out of the rake like the tank’s: the fee
recipients split
feeAmount − tankSpent − reserveSpentandsettlement.reserveSpentreports it. On a cancelled lobby it comes out of the refunds.
The reserve uses the same gas charge per call as the tank, and the simulator’s relay policy follows it the way the chain.wtf relayer does:
- Sponsored. A creation with an entry, an entry and a cancel are sponsored calls when the reserve, after the call’s own stake, holds the gas charge. At the defaults a stake leaves 3.25% of itself in the reserve, so a first stake of about 0.31 chUSD is the smallest the reserve pays for.
- Covered. Below that they are covered calls with the gas line item.
- Refund claims. A refund claim is always a covered call, repaid from the refund itself, so it goes through from an empty balance.
- Cancel and refund together. A player whose balance cannot cover a covered cancel, and who
has a stake in the lobby, cancels and claims their own refund as one covered call: the balance
and the net refund together must reach the cover limit.
lobby.viewer.refundedis then already true after the cancel.
The settings, all live and shared by every tab:
| Setting | Where | Default |
|---|---|---|
| Default rake | setup panel, setFeeConfig | 500 bps |
| This game’s rake | setup panel, setPvpGameRake or clearPvpGameRake | the default rake |
| Developer rake | setPvpDeveloperRake, within 60 s of creation | none |
| Tank share of the rake | setup panel, setPvpTankShare | 3500 bps |
| Gas charge per call | setup panel, setGasCharge | 0.01 chUSD |
| Sponsored minimum | setup panel (simulator page, persisted per browser) | 0.5 chUSD |
To force an exhausted tank quickly: with two 10 chUSD stakes the tank holds
20 × 5% × 35% = 0.35 chUSD, 35 moves at the default charge. Raise the gas charge to 0.1 and
the fourth move of the match becomes a covered call; set the tank share to 0 and every move is
a covered call from the start; set this game’s rake to 0 and every entry is too, since a lobby
without a rake has nothing to sponsor from. Raise the gas charge above a lobby’s reserve to see
its entries and cancel turn into covered calls.
Intentional differences from production
Section titled “Intentional differences from production”- Lobby calls are plain EOA transactions instead of the host’s gasless smart-vault signing flow.
The game-facing API and timing are unchanged;
wallet.addressis the tab’s local EOA andassets[0].balanceits token balance. - Every call is priced at one configurable gas charge instead of metered gas at the ETH price, so
the cover limit the line item shows is the charge plus the production headroom. A covered call
repays the charge to the host’s fee recipient in a transfer sent right after the call (in
production it is repaid inside the same Smart Vault batch), and a tank- or reserve-paid call
credits the tab’s own account, the way a relayer wallet is credited on chain.wtf;
claim(token, to)on the host collects it. A covered call the reserve could also pay is therefore repaid twice here, as it can be in production. - The host is its own VRF router client instead of going through a randomness-provider contract.