# 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](../reference/addresses.md)** 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](../concepts/rich-media-p5-three.md)**), 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](../reference/create-chainrender.md)**). `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.

```solidity
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](./app-contract.md)** 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:

```solidity
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_call`s 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:

```ts
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

- **[Deployed Addresses](../reference/addresses.md)** · **[Writing an App Contract](./app-contract.md)**
- **[Declare a Safe Action](./declare-action.md)** · **[Wallet & Signing](./wallet-and-signing.md)**
- **[Rich Media](../concepts/rich-media-p5-three.md)** — why the game screen is a client, not a sketch
- `docs/185` — why v2 exists, and the tests behind every statement on this page
