# Local simulator: test PvP games without the platform 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. :::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/) ::: `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 You need [Node.js](https://nodejs.org) 22 or newer, or [Bun](https://bun.sh) 1.4.2 or newer. Create a game from one of the two examples, Bones or Rock Paper Scissors: ```sh npx @chain-protocol/cli init pvp my-game --template bones ``` With 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: ```sh cd my-game npm install npm run dev ``` With 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: ```sh npm run simulator ``` With 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`: ```sh npm install @chain-protocol/pvp-sdk npm install --save-dev @chain-protocol/cli npx chain dev pvp --frontend-url http://localhost:5173 ``` With 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](https://www.npmjs.com/package/@chain-protocol/cli#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 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=` in the URL) while the game URL, contract addresses and lag setting are shared by every tab, so a duel is: 1. Tab A: create a table from the game. Once it is entered and indexed, the tab opens it (`?lobby=`), exactly like production navigating to the lobby route. 2. 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. 3. 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 1. **A chain**: the in-memory dev chain (EDR) on the RPC port, or the chain already answering there. 2. **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. 3. **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 full `IPvpGameV2` lobby 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 with `LocalPvpHost__ZeroDeveloper`. - `BonesGame`, `JackpotGame` and `RockPaperScissorsGame`: the SDK's reference games, listed after your game in the setup panel. 4. **Your game**: the contract `deployment.ts` names, compiled, created through the PvP bytecode deployer from dev account #0, which the creation call names as its game developer, and listed first. 5. **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 `deployment.ts` in the project's root names the contract: ```ts import type { DeploymentInput } from '@chain-protocol/pvp-sdk'; export default { kind: 'pvp', contract: { source: 'src/MyGame.sol', name: 'MyGame' }, } satisfies DeploymentInput; ``` - `contract.source` is relative to `contracts/`. Import the interfaces as `@chain-protocol/pvp-sdk/contracts/IPvpGameV2.sol` and `@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 from `node_modules`. - `constructorArgs` lists constructor arguments. `{{diamond}}` and `{{token}}` stand for the addresses of `LocalPvpHost` and `LocalTestToken`. - Keep `deployment.ts` self-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 - **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 `enterLobby` and on a creation with a first `entry`, receipt parsing for lobby / contribution / claim results, and the feature-detected `claimPayout` (only when the manifest declares it). - **The relayer's policy and the gas tank** — see [Gas tank](#gas-tank) below: sponsored moves while the lobby's tank can pay, covered calls with the gas line item otherwise, the `PVP_CALL_UNAFFORDABLE` refusal, the tank and reserve figures on the snapshot and refunds net of tank and reserve spend. - **Randomness payments** — `startLobby` and `submitAction` attach the ETH a randomness request owes, the host's `requiredPayment` plus 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 `onRandomness` may not request randomness again. The host refuses the fulfilment with `LocalPvpHost__RandomnessRequestedFromCallback`, the lobby stays in `WAITING_RANDOMNESS`, and only your game's `canCancel` gets 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 one `onRandomness` received. - **Spectator access** — `snapshot.access` on every push, the read-only rejection of writes, the advertised `capabilities.playAccess` and simulated `requestPlayAccess` intent, and the sign-in placeholder for games without `capabilities.spectate`. - **The lobby route** — `metadata.room.roomId` follows 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=rooms` keeps 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 its `assets.coverUrl` when it ships one) for the running game, and from the registered name for every other game. - **Randomness verification** — `getRandomnessVerification` runs 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 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-mismatch` per tab to exercise your game's non-ready screens. The existing manual setup overlay button still changes the status to `ready`. Any non-ready status also puts the tab in `spectate` access (see `CHAIN_WTF_PVP_GAMES.md`); writes reject with `PVP_SPECTATOR_READ_ONLY`. - **Simulated play-access intent** — the bridge advertises `capabilities.playAccess: true`. A guest `requestPlayAccess()` 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 lacks `capabilities.spectate` gets 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.5` by 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 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` / `submitAction` is a sponsored call while the tank can pay its gas charge: the tab's balance is untouched, the host emits `PvpLobbyTankPaid`, and `lobby.tankSpent` rises by the charge while `lobby.tankRemaining` falls by it. The tank is `pot × 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.feeAmount` stays the gross rake and `settlement.tankSpent` says how much of it the tank paid; winners share `pot − feeAmount` either way. A cancelled lobby refunds each participant their stake less their pro rata share of `tankSpent` plus `reserveSpent` (below), rounded against them (`claimRefund`, previewed by the host's `getLobbyRefund` and by `lobbyNetRefund` from the snapshot). The lobby panel under the frame shows the rake, the tank and the reserve left, and what each spent. ### 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`, and `lobby.reserveSpent` rises by the charge while `lobby.reserveRemaining` falls by it. A cancel draws on it whoever sends it; a lone `openLobby` draws 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 − reserveSpent` and `settlement.reserveSpent` reports 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.refunded` is 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 - 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.address` is the tab's local EOA and `assets[0].balance` its 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.