Skip to content

Local simulator: test games without the platform

Put your casino game through its paces with a local chain, VRF node, and SDK host bridge. Catch balance guards and timing quirks before players do.

chain dev casino runs the casino SDK simulator in your game’s repository. It mounts your game frontend in the simulator page, which drives it through the same @chain-protocol/casino-sdk bridge as the production Chain.wtf host. Behind the page it runs its own chain, a minimal casino 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.

If you can make your game fun and robust in the simulator, it will behave the same way in production. The simulator page replicates the host’s quirks (optimistic updates, lagging data feeds, balance guarding) so you hit them on day one instead of after shipping.

The simulator ships inside the SDK your project installed, and the chain command from @chain-protocol/cli starts it. A game created with chain init casino has both, plus a simulator script (see Getting Started). To add them to an existing project, install the same version of each:

Terminal window
npm install @chain-protocol/casino-sdk
npm install --save-dev @chain-protocol/cli

With Bun:

Terminal window
bun add @chain-protocol/casino-sdk
bun add --dev @chain-protocol/cli

Serve your frontend, then start the simulator in the project’s root, next to deployment.ts:

Terminal window
npx chain dev casino --frontend-url http://localhost:5173

With Bun, run bunx chain dev casino --frontend-url http://localhost:5173. Expect Play your game at http://127.0.0.1:3300/?game=… when it is ready. Open that URL. Press Ctrl-C to stop the chain, the VRF node and the simulator page. The simulator page listens on port 3300 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 player defaults to dev-mnemonic account #0, which holds 1,000,000 test chUSD. Query params override the saved setup:

http://localhost:3300/?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.

  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 casino:
    • LocalTestToken: a freely mintable six-decimal chUSD stand-in, matching USDC.
    • LocalCasinoHost and LocalCasinoVault: a minimal stand-in for the production CasinoGameFacet that runs the full ICasinoGameV2 session lifecycle (open, randomness or player actions, settle, escrow deltas, payout caps, forfeit and cancel-stuck-randomness) and emits byte-identical events. No diamond and no portfolio risk accounting. It keeps the diamond’s game registry: the slot title deployer, the graph title deployer and a casino bytecode deployer are registered on it, and dev account #0 is its security council. It checks no RTP band.
    • CoinflipGame: the reference coinflip contract, listed after your game in the game picker. When your game registers under the name CoinflipGame too, the picker lists only yours.
    • Each game in /__local-contracts.json carries the Game ID it took when it registered (ADR 0025), numbered as a fresh deployment of the diamond numbers them.
  4. Your game: the contract, slot title or graph title deployment.ts names, compiled and created through the registered deployer of its kind, from dev account #0, which the creation call names as its game developer. A title at or below the 98% approval ceiling registers whitelisted. A title above it, and every raw-SDK casino game, registers as a pending game: the terminal says so, and the simulator’s council account approves it so you can play it. On a live chain it takes no sessions until the security council approves it. The game is listed first in the game picker.
  5. Funding: player tokens and vault liquidity. The host’s router balance starts empty: every call that may request randomness pays for it, as on chain. 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/casino-sdk';
export default {
kind: 'casino',
contract: { source: 'src/MyGame.sol', name: 'MyGame' },
} satisfies DeploymentInput;
  • contract.source is relative to contracts/. Import the interface as @chain-protocol/casino-sdk/contracts/ICasinoGameV2.sol; it resolves 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 LocalCasinoHost and LocalTestToken.
  • For a slot title, write { kind: 'slot', title: 'my-slot/title.json' } instead. See Slot Engine.
  • For a graph-engine title, write { kind: 'graph', name: 'My Title', title: 'title.ts' }. See Graph titles.
  • For a slot threshold Game written in TypeScript, write { kind: 'threshold', name: 'DiceGame', title: 'title.ts' }. The module’s default export lists its ThresholdConfigurations by index. See Slot Engine.
  • Keep deployment.ts self-contained: import only types.

The graph kind deploys a graph-engine title:

import type { DeploymentInput } from '@chain-protocol/casino-sdk';
export default {
kind: 'graph',
name: 'My Title',
title: 'title.ts',
steering: { source: 'MySteering.sol', name: 'MySteering' },
} satisfies DeploymentInput;
  • title names a module whose default export is the GraphGameDefinition. It may import other modules. The simulator compiles it in a fresh Node or Bun process (Node 22.18 or later, which strips TypeScript types by default).
  • steering is optional. It names the title’s Steering contract, with source relative to contracts/. Its constructor takes no arguments. Import the interface as @chain-protocol/casino-sdk/graph-engine/contracts/IGraphSteering.sol.
  • The simulator deploys the Steering contract, uploads the chunks, creates the title, derives it to the end (in batches when one 14M-gas call cannot hold it) and registers it under name. Every bet configuration’s RTP must be below 100%.
  • A session picks its bet configuration with its game data. Empty game data opens configuration 0, and 0x00 ‖ uint16 configuration ‖ tail opens that configuration, with the 16-bit index big-endian. The engine ignores the tail, which can carry display-only choices such as a dice side. encodePlainGameData(configuration, tail) from @chain-protocol/casino-sdk/graph-engine writes it. Other leading bytes revert GraphEngine__InvalidGameData.
  • A change to the title, any module it imports (under Node), or the Steering contract redeploys the title. The simulator watches the title’s whole import graph, workspace packages outside the project included, and skips only installed packages under node_modules. A large title takes many transactions. See Graph Engine → Deploy cost.

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 built some other way, create it with your own toolchain against http://127.0.0.1:8545 through the casinoBytecodeDeployer that /__local-contracts.json names, naming its game developer, and paste its address into the setup panel. The page approves the pending game as the host’s council and names it under the configured name. A contract no registered deployer created takes no sessions, as on chain.

@chain-protocol/casino-sdk/local-host runs the simulator’s host in your own tests, on Hardhat’s EDR or any dev chain, so a test can open a session, deliver randomness and check the escrow, the reserve and the payout transfer:

import {
deployLocalCasinoHost,
localCasinoHostAbi,
testRandomnessRouterAbi,
whitelistOnLocalHost,
} from '@chain-protocol/casino-sdk/local-host';
const { host, vault, token, router } = await deployLocalCasinoHost({
publicClient,
walletClient,
vaultLiquidity: 10n ** 12n,
});
await whitelistOnLocalHost({ publicClient, walletClient, host, games: [game] });
  • walletClient’s account deploys the contracts and governs the host as its security council.
  • Mint the test token to the player and approve the host, then call openSession on the host.
  • TestRandomnessRouter.fulfill(requestId, word) delivers the word your test picks, under the router’s 1M callback gas. requestId is in the host’s CasinoSessionAdvanced event, and decodeCasinoSession reads the session it carries.
  • The subpath ships ABIs and bytecode, not Solidity. The host follows the production facet’s session lifecycle and changes with it.

What the simulator page replicates from production

Section titled “What the simulator page replicates from production”

The host side behaves like the real Chain.wtf host, so your game faces the real integration quirks before it ever ships:

  • Optimistic sessions — a pending:<uuid> row at phase WAITING_RANDOMNESS is pushed the moment openSession is called, then confirmed to the real session id via the broadcast tx hash (both arrival orders handled, exactly like production).
  • 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 — merged through a forward-only patch layer) and after a configurable lag on the indexed session feed (including the intermediate phase-NONE update right after CasinoSessionOpened).
  • Monotonic settled results — the settled flip is atomic (payout + gameState together), and a published result never regresses.
  • Game-steered balance — the displayed balance follows betPlaced/revealOutcome with a delayed chain reconcile, never a raw poll.
  • Randomness payments — openSession 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. A call whose step requests no randomness gets its ETH back in the same transaction.
  • One request per player call — a step reached from onRandomness may not request randomness again. The host refuses it with LocalCasinoHost__RandomnessRequestedFromCallback, the router logs the failed delivery as RandomnessCallbackFailed, and the session waits for cancelStuckRandomness, which refunds the whole escrow. Derive every further random value from the one onRandomness received.
  • No escrow change from a callback — a step reached from onRandomness may not return a nonzero escrowDelta. The host refuses it with LocalCasinoHost__EscrowChangedFromCallback, and the session waits for cancelStuckRandomness as above.
  • Stuck-randomness path — an expired randomness deadline surfaces the permissionless cancelStuckRandomness banner. The VRF node in chain dev answers every request within a second, so a session reaches this path only if the node falls behind.

The collapsible sidebar is your chaos-engineering console:

  • Flashblock / indexer lag sliders — tune both data layers live. Crank the indexer lag to watch your game ride the optimistic layer; a well-built game shows no flicker and no duplicate results.
  • Wallet status override — force ready / disconnected / setup-required to exercise your game’s non-ready screens.
  • Max-wager answer — choose what hostApi.getMaxWager answers, live: a limit at a cap you set in whole tokens (1000 by default), no-limit, or unknown. The game sees a change at its next request. Use it to exercise your bet clamp and your fallback for an unknown answer.
  • Game URL + contract address — swap between the example and your own game without restarting anything.

The simulator advertises capabilities.playAccess: true. A guest requestPlayAccess() records a simulated play-access intent in the frame and Bridge calls log. Resolution accepts the intent; it does not make the wallet ready, authenticate, sign, fund, or bet. Change readiness explicitly with Wallet status override and wait for the resulting snapshot. An already-ready request is a no-op.

  • Bets are plain EOA transactions instead of the host’s gasless smart-vault signing flow. The game-facing API and timing are unchanged; wallet.address and wallet.smartVaultAddress are both the local EOA, which also sends the randomness payment. On chain.wtf a relayer sends it, and a covered call charges it to the player in the token.
  • getMaxWager answers the cap from the setup panel, whatever game data you pass. The production host reads the vault’s maximum wager for your game and that game data. The local host does not enforce the cap: it admits any wager the game’s quotes allow.
  • getRandomnessVerification runs the production checks against the local Verify Network router (real ECVRF proofs and enclave signatures), so the provably-fair view is testable locally. It is optional and feature-detected, so your game must still handle hosts without it.