⚠️ Alpha · testnets only. ChainRender is in active alpha and deployed on testnets only — Ethereum Sepolia, the Robinhood Chain Testnet, and the Circle Arc Testnet — not on any mainnet. Contracts, addresses, and APIs may change. Do not use with real funds or production assets.
Guides

Guide: Multiplayer Games, Waiting Rooms & On-Chain Settlement

Goal: a two-player game in which the waiting room, the stakes and the result live on-chain. Players move peer-to-peer for speed, and the chain refuses any result that does not follow the rules. A loser cannot walk away with a draw.

This page teaches the v2 game stack: GameLobbyV2, GameChannelV2, IGameRulesV2, and the example rules TicTacToeRules and RockPaperScissorsRulesV2, plus GameMoveBoard. Every behaviour described here is pinned by test/GameChannelV2.ts.

Deployment status. The v2 contracts are deployed on Ethereum Sepolia, Robinhood Chain Testnet and Circle Arc Testnet (2026-10-27). They are at different addresses on each chain: take every address from Deployed Addresses for the chain you are on, and never reuse one across chains.

The v1 contracts (GameLobby, GameChannel, RockPaperScissorsRules) are deployed on Sepolia, Robinhood and Arc. They have the limits listed under "The deployed v1 contracts" below. Use v1 only for unstaked demos.

What runs where

Part Where it runs What enforces it
Waiting room: post an offer, join it, cancel it on-chain (GameLobbyV2) the contract: stakes are escrowed, and exactly two players are matched
Entry conditions (invite list, NFT holders, skill rating…) on-chain (IJoinGuard, a contract you write) your guard's canJoin
The game's rules on-chain, as pure functions (IGameRulesV2, a contract you write) replayed by the channel at settlement
Playing moves peer-to-peer. Each move is an EIP-712 signature; no transaction is needed per move the signatures, which are checked at settlement
Moving messages between players your choice: any direct channel, or GameMoveBoard (events, no server) nothing. Clients verify every message they receive
Settling the result and paying the winner on-chain (GameChannelV2) the replay of signed moves, force-move, and equivocation proofs
The game screen (board, animations, input, signing) a client page with wallet access —

The game screen cannot be a rich-media sketch. A p5/three sketch runs sandboxed with no network and no wallet (see Rich Media), so it cannot sign moves or reach the other player. The on-chain floor can show the lobby (offers, stakes, results; see "The floor" below). Playing needs a client page. The quickest start is npm create chainrender@latest my-game -- --template game-tictactoe, a complete client for this stack that is tested end to end (create-chainrender). webapps/chainrender-rps.html is the older client for the v1 RPS game. Label that page as what it is: a client that settles on-chain. It is not "a fully on-chain game".

Which games fit

GameChannelV2 settles games whose every move the rules contract can check. That covers turn-based games with pure rules: board games (tic-tac-toe, connect-four, chess-like), card or hidden-hand games built on commit-reveal, dice built on commit-reveal, and auctions or bidding games.

It does not settle a real-time game the rules cannot replay, such as a racing game, a shooter or physics. There, the honest options are:

  • Unstaked play. The result is whatever both players co-sign (settleCooperative). If either refuses, there is no result. Record it as a leaderboard entry, not a payout.
  • Make the decisive part checkable. Turn the game into moves the rules can verify. For example, each lap is a move with a strategy choice (tyres, fuel, pit), and a deterministic race model in the rules contract decides the outcome from those choices and the shared seed. The 60 fps animation is then only a view of a result the chain computes.
  • Do not stake money on what the chain cannot check. No ChainRender contract attests a real-time result. A page that claims "trustless racing with prizes" would exceed what the code enforces.

Every game is two-player. There is no N-player channel. A lobby offer is one seat for one opponent.

Lifecycle

GameLobbyV2.createOffer(rules, stake, guard, window, signer) — creator escrows stake ─┐
GameLobbyV2.join(offerId) / joinWithSigner(offerId, signer) — joiner escrows stake   ├─ one tx opens GameChannelV2 game
                                                                                ┘  gameId = channel.gameIdOf(lobby, bytes32(offerId))
players sign moves peer-to-peer (EIP-712 "Move"), each sent to the other
        │
        ├─ game over → settleWithHistory(moves, sigs)          → Concluding (final after `window`)
        ├─ both agree → settleCooperative(state, validUntil, sig0, sig1) → Settled (final at once; unfinished = agreed draw)
        ├─ opponent stalls → challenge(history)                → Challenged: the due player must respond() on-chain
        │        ├─ respond(moves) in time → the game continues; the challenge passes to the next due player
        │        └─ no answer by the deadline → claimForfeit()  → Settled: the staller loses
        └─ someone signed two moves for one position → proveEquivocation(a, b) → Settled: the equivocator loses
withdraw(gameId) — winner takes 2 × stake; a draw refunds each player's stake

The response window is chosen per offer: at least MIN_RESPONSE_WINDOW (5 minutes) and at most MAX_RESPONSE_WINDOW (3 days). A short window suits fast games. It also gives a player less margin if the chain is congested or their transaction is delayed, so prefer hours over minutes for staked games. The same window is how long a history-based settlement stays open for an equivocation proof.

1. Write the rules (IGameRulesV2)

Rules are a contract with pure functions. The channel calls them; they never hold funds or state.

interface IGameRulesV2 /* is IGameRules */ {
    function initialState() external pure returns (bytes memory);
    function moverAt(address[2] calldata players, uint64 moveNumber) external pure returns (address);
    function validateMove(bytes calldata state, uint64 moveNumber, address mover, bytes calldata moveData) external pure returns (bool);
    function applyMove(bytes calldata state, uint64 moveNumber, address mover, bytes calldata moveData) external pure returns (bytes memory);
    function isOver(bytes calldata state) external pure returns (bool);
    function winner(bytes calldata state) external pure returns (address); // only read when isOver; address(0) = draw
}

contracts/TicTacToeRules.sol is the reference implementation (about 70 lines). The rules every implementation must follow:

  1. isOver is the only way a game ends from history. Return true only for a final position. A draw is "over, and winner returns address(0)". An unfinished position returns false, and the channel refuses it.
  2. validateMove returns false after the game is over, for a move number out of range, and for malformed moveData. Check the length before you abi.decode, because a decode revert also rejects the move but costs the caller more gas.
  3. moverAt must return one of the two players. The channel reverts BadRules otherwise.
  4. winner must name an address, but rules are pure and do not know the players. Record each mover in the state on their first move, as TicTacToe and RPS do.
  5. Encode the state with a fixed shape (abi.encode of fixed fields). Its hash chains every move.
  6. Hidden information uses commit-reveal (contracts/CommitReveal.sol). Commit moves carry a hash; reveal moves open it. RockPaperScissorsRulesV2 is the reference: moves 0–1 are commits, moves 2–3 are reveals. If the player who must reveal last stalls, they are challenged and forfeit, so withholding a losing reveal does not help them. Use a fresh random salt for every commit, because a salt reused across games leaks the choice.
  7. Test the rules before deploying them. GameChannelV2.rulesUsable(rules) must return true: the contract has code, and isOver(initialState()) is false. The lobby refuses offers whose rules fail this check. That includes the v1 rules contracts, which have no isOver.

Rules contracts follow the project's usual contract rules: ownerless, immutable, no constructor secrets. See Writing an App Contract for the toolchain, testing and deployment.

2. The waiting room (GameLobbyV2)

Call Who Effect
createOffer(rules, stake, guard, responseWindow, signer) payable, msg.value == stake the creator, who becomes player 0 escrows the stake; returns (offerId, gameId). The game id is known before anyone joins. signer = address(0) means the creator's own address
join(offerId) payable, msg.value == stake anyone except the creator, if the guard accepts them opens the channel game with both stakes, in the same transaction; the joiner's moves are signed by the joiner's own address
joinWithSigner(offerId, signer) payable as join as join, with a separate game key (it must differ from the creator's address and key)
cancelOffer(offerId) the creator, while the offer is Open the stake is refunded in the same call
openOffers(start, count) read the Open offer ids among start … start+count-1 (bounded; page by count)
offerCount(), getOffer(id), offerStatus/Stake/Creator/Joiner/Rules/GameId(id) read single-value views exist so a staticread slot can show any field

Offer status values: 0 None, 1 Open, 2 Filled, 3 Cancelled. Events: OfferCreated, OfferFilled, OfferCancelled.

Stakes are in the chain's native token: ETH on Sepolia and Robinhood, and USDC on Arc, where USDC is the gas token. Pick stake amounts for the chain you are on.

Entry conditions are an IJoinGuard you deploy and name in the offer:

interface IJoinGuard {
    function canJoin(uint256 offerId, address who, address rules, uint256 stake) external view returns (bool ok);
}

Examples are an invite list, NFT or token holders, or a minimum rating read from your own contract. A guard is a view. It cannot take funds, and a guard that reverts blocks joins to that offer only. RejectAllGuard in the repository is a test guard that rejects everyone.

3. Play peer-to-peer

Signing keys. Each player names, when the game opens, the key that signs for them. It can be the player's own address (the default) or a separate game key.

  • Game keys. The client generates a game key and keeps it, and signs every move with it, so play needs no wallet prompt per move. The key holds no funds; payouts always go to the player address.
  • Smart-account players. A smart account (Safe, ERC-4337) has no key of its own to sign with, so it plays through a game key. join and createOffer with signer = address(0) revert NeedsSigner for such an account; use joinWithSigner, or pass an explicit signer. An EIP-7702-delegated EOA still has its own key and can use either.
  • No contract validation. Only ecrecover against the registered key counts, never an ERC-1271 check. A contract's signature validity can be switched off and on again, and that would let a player hide or re-sign moves.
  • Guard the key. Whoever holds a game key can play, or equivocate, for that player in that game. Its loss costs at most that game. It is a hot key: treat it like one. Read the keys with gameSigners(gameId). A game opened directly with open (not through the lobby) has its keys chosen by the opener, so check them before you play.

Each move is an EIP-712 signature by the mover's key over:

domain  { name: "ChainRenderGameChannel", version: "2", chainId, verifyingContract: <GameChannelV2> }
Move    { bytes32 gameId, uint64 moveNumber, bytes32 prevStateHash, address mover, bytes moveData }
  • prevStateHash = keccak256(state before the move). Get the states from the rules contract itself, with free eth_calls to initialState and applyMove. Your client then cannot disagree with the chain about what a move does.
  • Before applying a received move, check it: it continues your current state hash, its mover is moverAt(players, moveNumber), the signature recovers to that mover's key (gameSigners), and validateMove is true. Discard anything that fails. GameChannelV2.moveDigest(move) returns the exact digest, which lets you test your signing code against the contract.
  • Keep every signed move, yours and your opponent's. They are your evidence for settling or answering a challenge.
  • Never sign two different moves for the same position. That is equivocation, and it loses the game (§5). Sign deterministically: the same move always gets the same moveData. After a crash, resend the move you already signed rather than signing a new one. That includes an RPS commit: a re-commit with a new salt is a second move for the same position.

Transport. The channel does not care how moves travel. Use any direct channel between the two clients. If you have none, use GameMoveBoard:

  • post(gameId, kind, payload) emits Posted(gameId, from, kind, payload) and stores nothing.
  • Each post costs one transaction, which is cheap on an L2. Read the other player's posts with eth_getLogs filtered by gameId.
  • Anyone can post anything under any id, so verify each payload as above. The test encodes payload as abi.encode(Move, signature) with kind = 1.

4. Settle

Path Use when Final
settleCooperative(gameId, state, validUntil, sig0, sig1), where both keys sign Final { bytes32 gameId, bytes32 finalStateHash, uint64 validUntil } the game ended and both agree, or both want to quit (an unfinished state is an agreed draw) at once; rejected after validUntil
settleWithHistory(gameId, moves, sigs) the game is over and you hold every signed move after the window (Concluding)
settleFromCheckpoint(gameId, checkpointState, moves, sigs) as above, replaying only the moves after a co-signed checkpoint after the window
checkpoint(gameId, moveNumber, stateHash, sig0, sig1), where both sign Checkpoint { bytes32 gameId, uint64 moveNumber, bytes32 stateHash } long games: bounds the gas of later replays (moveNumber = the next move) —

A draw offer is a signed Final over an unfinished state. It stays acceptable until its validUntil, so give it a short expiry, and do not make another move while an offer you signed is still valid. After it expires, the opponent cannot accept it to escape a loss.

A history that does not end the game reverts NotOver, including the empty history. While a challenge is open, a history settles only before the challenge deadline. After the deadline, the forfeit stands. Anyone may submit a valid history; the result is the same whoever sends it. Read the outcome with gameResult(gameId), which returns (isFinal, winner, finalStateHash, finalAt), or with gameWinner, then call withdraw(gameId).

5. When the other player stops: force-move

  1. Call challenge(gameId, fromCheckpoint, checkpointState, moves, sigs) with the moves so far. You must be a player, and it must be the other player's turn; otherwise the call reverts OwnTurn. If a checkpoint exists, the posted moves must reach at least the checkpoint (fromCheckpoint = true with no further moves is enough). If the posted moves already end the game, the call settles instead. If a checkpoint you co-signed is later than the challenged move (for example, it landed after a stale challenge), answer with respondFromCheckpoint(gameId, checkpointState, moves, sigs), which accepts zero moves.
  2. The due player has responseWindow seconds to call respond(gameId, state, moves, sigs) with one or more signed moves that continue the posted state. Anyone holding those signed moves may submit them. After an answer, the challenge moves to whoever is due next, with a fresh window, so a game can be finished entirely on-chain this way.
  3. No answer by the deadline: anyone calls claimForfeit(gameId) and the player who was due loses. The result is final at once.

Let any draw offer you signed expire before you challenge, because the challenged player may still accept it. Watch for GameChallenged(gameId, dueMover, moveNumber, stateHash, deadline) and answer in time. A client should alert its player or answer automatically from the moves it already holds.

Equivocation. Two valid signatures by one player for different moves at the same (moveNumber, prevStateHash) are proof that the player tried to rewrite the game. Anyone holding them calls proveEquivocation(gameId, moveA, sigA, moveB, sigB) while the game is Open, Challenged or Concluding, and the equivocator loses. This is why a history settlement waits one window before it becomes final.

The floor: a lobby board in a ChainRender app

The lobby's state can be shown on the Grade-A floor with staticread slots (CallResolver), and joining is a declared payable action:

import { compose, reads, checkNode, type Node } from "@chainrender/client";

const LOBBY = "0x…" as `0x${string}`;          // GameLobbyV2 on your chain (Deployed Addresses)
const CALL_RESOLVER = "0x…" as `0x${string}`;  // CallResolver on the same chain
const read = (signature: string, args: unknown[] = [], fmt = {}) =>
  reads.staticReadSlot(CALL_RESOLVER, { target: LOBBY, callData: reads.callData(signature, args), ...fmt }) as Node;

// a fixed-stake table: the offer's stake is part of the declared action, so the wallet shows exactly what is sent
const STAKE_WEI = "1000000000000000"; // 0.001 in the native token
const table = compose.box([
  compose.heading("Tic-tac-toe — 0.001 table"),
  compose.text("Offers posted so far:"), read("offerCount()"),
  compose.text("Offer #0 stake:"), read("offerStake(uint256)", [0n], { format: reads.FMT.FIXED, decimals: 18, fracDigits: 3 }),
  compose.input({ inputType: "number", placeholder: "Offer #", name: "offerId" }),
  compose.button("Join offer", { variant: "primary", action: { to: LOBBY, sig: "join(uint256)", value: STAKE_WEI, args: "$offerId" } }),
], { direction: "column" });
console.log(checkNode(table).ok);

A declared action's value is fixed when the app is compiled. That is what lets the decode panel promise "sends exactly 0.001", but it also means a floor button can only join offers at the stake it declares. Build one table per stake, and have the client page create offers at those stakes. The game itself is played in the client page (§3), not on the floor. Grade the app honestly: the lobby floor's reads and actions are on-chain, and the game screen is a client enhancement (⚙).

The deployed v1 contracts

The v1 GameChannel and GameLobby are deployed and immutable, so their behaviour cannot change. Each limit below is reproduced by test/GameChannel.known-limits.ts:

  1. Any account can end any open or disputed v1 game as a draw by calling settleWithHistory(gameId, [], []). The empty history is accepted, and a draw refunds both stakes. The same works with any unfinished prefix, so a player who is about to lose can always escape to a draw. This also defeats the v1 checkpoint-and-dispute forfeit.
  2. A v1 offer with maxPlayers > 2 locks the stakes of the third and later players permanently. The channel is two-player, only two stakes are forwarded, and a filled offer has no refund path. Never create one.
  3. A v1 offer's game id can be pre-opened by anyone, because the salt is public calldata. This makes the filling join revert. No funds are lost: the creator cancels, and players claim refunds.

What v1 still gives correctly: signatures, turn order and move legality are checked; a non-player winner is refused; withdrawals are pull-based. Use it for unstaked demos only. For anything with a stake, use v2.

Security notes for game builders

  • The rules contract is part of the game's security. A rules bug, such as accepting an illegal move or calling a game over too early, is paid out exactly as written. Test your rules with the channel, as test/GameChannelV2.ts does.
  • Replay gas grows with history length. Co-sign checkpoints in long games so that a challenge or settlement replays only recent moves.
  • Windows are wall-clock. An honest player who is offline for longer than the window during a challenge loses. Set windows the players can meet, and have clients watch for challenges.
  • Withdrawals are pulled. The winner calls withdraw; nothing is pushed. A smart-account player must be able to receive the native token.
  • The rules contract is trusted completely. rulesUsable checks only that the initial state is not over. A rules contract that is upgradeable, calls other contracts, or can revert in moverAt can decide or freeze the game. A player should join only games whose rules contract is immutable, pure and reviewed.
  • Keep every signature, including those from before a checkpoint. They are your evidence for answering challenges and proving equivocation.
  • The channel rejects any move after the game is over, even if a rules contract would accept it.
  • Commit-reveal commitments are not bound to a game id, because rules never see the id. Use a fresh random salt for every commit, and never reuse one.
  • The lobby holds funds only for open offers. Filled offers forward both stakes to the channel; cancelled offers refund the creator at once.

Related