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