Skip to content

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.

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.

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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

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

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:

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

  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.

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

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

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.

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:

SettingWhereDefault
Default rakesetup panel, setFeeConfig500 bps
This game’s rakesetup panel, setPvpGameRake or clearPvpGameRakethe default rake
Developer rakesetPvpDeveloperRake, within 60 s of creationnone
Tank share of the rakesetup panel, setPvpTankShare3500 bps
Gas charge per callsetup panel, setGasCharge0.01 chUSD
Sponsored minimumsetup 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.

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