This document describes the full design of the on-chain chess system — data encodings, contract roles, settlement flows, rating math, and the ZK path.
┌─────────────────────────────────────────────────────────────────────┐
│ chess.sol (core engine) │
│ Pure functions only. No storage. Validates any move sequence. │
│ Imported by: Chess960, ChessWager, DarkChess │
└───────────────────────┬─────────────────────────────────────────────┘
│ inherits / calls
┌─────────────┼────────────────────┐
│ │ │
┌─────▼──────┐ ┌────▼──────────┐ ┌──────▼─────┐
│ Chess960 │ │ ChessWager │ │ DarkChess │
│ (variant) │ │ (wager hub) │ │ (fog-of-war│
└────────────┘ └──┬──┬──┬──┬──┘ └────────────┘
│ │ │ │
┌───────────┘ │ │ └────────────────┐
│ │ └──────────┐ │
┌─────▼──────┐ ┌────▼───────┐ ┌─▼───────┐ │
│ ChessRating│ │ Treasure │ │ChessCoin│ │
│ (Glicko-2) │ │ (ERC-721) │ │(ERC-20) │ │
└────────────┘ └─────┬──────┘ └─────────┘ │
│ │
┌─────▼──────┐ │
│ ChessSVG │ ┌──────▼──────────┐
│ (SVG gen) │ │ChessProofVerify │
└────────────┘ │ (RISC Zero ZK) │
└─────────────────┘
▲
chess-guest/ (Rust)
off-chain prover
Dependency rules:
chess.soldepends on nothing (pure functions, no imports)Chess960andDarkChessinheritChessto reuse move validationChessWagercalls: validator,ChessRating,Treasure,ChessCoinTreasuredelegates tokenURI toChessSVGwhen renderer is setChessProofVerifieris a thin wrapper — it verifies then callsChessWager.settleFromVerifier
The entire 8×8 board fits in one 256-bit word: 4 bits per square × 64 squares = 256 bits.
Bit layout of each 4-bit nibble:
bit 3 = color (0 = white, 1 = black)
bits 2-0 = piece (0 = empty, 1 = pawn, 2 = bishop, 3 = knight,
4 = rook, 5 = queen, 6 = king)
Square index: a1=0, b1=1, …, h1=7, a2=8, …, h8=63
Nibble position in uint256: bits [ (sq*4)+3 : (sq*4) ]
Initial position:
0xcbaedabc99999999000000000000000000000000000000001111111143265234
╰──────────────╯ ╰──────────────╯
black (rank 7,6) white (rank 0,1)
Reading square sq from gameState:
uint8 piece = uint8((gameState >> (uint256(sq) * 4)) & 0xF);
bool isBlack = (piece & 0x8) != 0;
uint8 pieceType = piece & 0x7;This encoding means the full board is one cold SLOAD (2100 gas) or one warm SLOAD (100 gas). There is no cheaper representation for a validator that needs per-square random access.
Each player's castling rights, king position, and en-passant target are packed into a 32-bit word:
bits 0- 7: en-passant target square (0xFF = none)
bits 8-15: king position (0x00–0x3F)
bits 16-23: king-side rook position (0x80 = has moved / castling unavailable)
bits 24-31: queen-side rook position (0x80 = has moved / castling unavailable)
White initial: 0x000704FF (king=e1=4, K-rook=h1=7, Q-rook=a1=0)
Black initial: 0x383F3CFF (king=e8=60, K-rook=h8=63, Q-rook=a8=56)
Chess960 games use different initial rook/king positions but the same encoding. After any castling or rook move, the relevant field is set to 0x80 to permanently disable that side.
bits 15-12: promotion piece type (0 = no promotion; 1=pawn,2=bishop,3=knight,
4=rook,5=queen,6=king)
bits 11- 6: from position (0x00–0x3F)
bits 5- 0: to position (0x00–0x3F)
Special moves (consumed by verifyExecuteMove before piece validation):
0x1000 = request draw
0x2000 = accept draw
0x3000 = resign
Encoding e2→e4: (12 << 6) | 20 = 0x0314
Encoding e7→e8=Q: (5 << 12) | (52 << 6) | 60 = 0x5D3C
Design: Pure Solidity chess validator. No storage reads or writes. Every function is pure or view. The contract is designed to be called once per move submission — not as a real-time move generator.
Core loop (checkGame):
for each move in moves[]:
1. verifyExecuteMove(gameState, move, playerState, opponentState, isBlack)
├─ validates piece ownership, move legality
├─ handles castling, en-passant, promotion
└─ returns (newGameState, newPlayerState, newOpponentState)
2. check 50-move rule (halfmoveClock >= 150 → draw)
3. check fivefold repetition (keccak256 of position hash → draw)
4. swap player/opponent for next turn
5. checkEndgame(newState, ...)
├─ checkInsufficientMaterial → draw
├─ searchPiece → any legal moves exist?
└─ checkForCheck → if in check AND no legal moves → checkmate (return 2/3)
→ if not in check AND no legal moves → stalemate (return 1)
searchPiece() — flat loop (iterative)
Replaced a recursive binary search tree with a flat 64-iteration loop. The recursive version used ~60 gas/frame × 8 recursion levels = ~480 gas of overhead before any actual piece checking. The flat loop iterates all 64 positions with a single continue for empty/wrong-color squares:
for (uint8 pos = 0; pos < 64; pos++) {
uint8 piece = uint8((gameState >> (uint256(pos) * 4)) & 0xF);
if (piece == 0 || (piece & color_const) != color) continue;
// dispatch by piece type ...
if (pt == king_const && checkKingValidMoves(...)) return true;
...
}
return false;Gas reduction: ~3840 gas (recursive) → ~192 gas (iterative) per full board scan. ~20× improvement.
Draw detection:
| Rule | Implementation |
|---|---|
| 50-move rule | halfmoveClock incremented each half-move; resets on pawn move or capture; draw at 150 (FIDE 75-move article 9.6) |
| Fivefold repetition | keccak256(abi.encodePacked(gameState, whiteState, blackState, isBlack)) stored per position; draw on 5th occurrence |
| Insufficient material | K vs K; K+minor vs K; K+B vs K+B same-color squares |
Why keccak256 for repetition, not Zobrist hashing?
Zobrist uses XOR of random values — fast in C++ chess engines but unsafe on-chain. An adversary who knows the Zobrist keys can craft a position with a deliberate hash collision to falsely claim repetition. keccak256 is collision-resistant by construction; the preimage attack cost exceeds any conceivable reward.
Inherits Chess. Adds deterministic back-rank generation from SP number (0–959) using the FIDE Scharnagl encoding:
n1 = positionId % 4 → dark-squared bishop column (1,3,5,7)
n2 = (positionId / 4) % 4 → light-squared bishop column (0,2,4,6)
n3 = (positionId / 16) % 6 → queen on n3-th empty square
n4 = positionId / 96 (0–9) → knight pair (10 combinations of C(5,2))
Remaining 3 empty squares: → queen-side rook, king, king-side rook
SP 518 produces the standard chess starting position. Black's back rank is white's mirrored to rank 7. Player states encode the actual rook/king positions for the random placement.
The hub contract. Manages staking (ETH or CHSC), coordinates the three settlement paths, distributes payouts, mints rewards, updates ratings, and optionally mints NFTs.
Slot 0: white (160 bits) | stakeAmount (96 bits)
Slot 1: black (160 bits) | createdAt (64 bits) | acceptDeadline (32 bits)
Slot 2: gameDeadline (64) | disputeWindowEnd (64) | state (8) | currency (8) |
whiteOfferedDraw (8) | blackOfferedDraw (8) | submittedOutcome (8)
Slot 3: movesHash (256 bits)
Accessing any game field only costs 4 SLOADs max. allowedOpponent lives in a separate mapping to avoid a 5th slot for the rare private-challenge case.
Active
│
├─── settleWithSignatures() ──────────────────────────→ Resolved
│ Both players sign GameResult off-chain (EIP-712) ~60k gas
│ No validator call. No dispute window.
│
├─── settleFromVerifier() ──────────────────────→ Submitted
│ Called by ChessProofVerifier after ZK proof ~30k gas
│ → finalizeGame() after dispute window ~20k gas
│
├─── submitGame(moves[]) ─────────────────────→ Submitted
│ On-chain validator. Full move array. ~200k–800k gas
│ │ (move count dependent)
│ ├─── resubmitGame() during dispute window → Submitted (outcome may change)
│ └─── finalizeGame() after dispute window → Resolved
│
└─── claimGameTimeout() ──────────────────────────────→ Resolved
After 7 days with no submission.
Both players sign a GameResult struct off-chain:
DOMAIN_SEPARATOR = keccak256(
EIP712Domain(name="ChessWager", version="1", chainId, verifyingContract)
)
GameResult(uint256 gameId, bytes32 movesHash, uint8 outcome)
digest = keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash)
settleWithSignatures() recovers both signers via ECDSA.recover(digest, sig) and requires they match game.white and game.black. If valid: immediate payout, no validator, no dispute window. The movesHash in the struct lets the game be minted as an NFT with a verifiable move record even without on-chain validation.
Gas breakdown (60-move game):
| Path | Approx. gas |
|---|---|
settleWithSignatures |
~60,000 |
finalizeGame (after ZK) |
~30,000 |
submitGame (60 moves) |
~350,000 |
submitGame (120 moves) |
~650,000 |
All three settlement paths converge into the same internal function:
1. Mark state = Resolved ← CEI: state changes first
2. Clear playerActiveGame for both players ← prevents reentrancy lock-in
3. Calculate fee (feeBasisPoints / 1000)
4. Distribute pot (ETH low-level call or CHSC.transfer)
5. Mint CHSC rewards (WIN_REWARD=10, DRAW_REWARD=5)
6. Call ChessRating.recordResult()
ETH transfers use call{value}("") not transfer to avoid the 2300 gas stipend breaking with smart contract wallets.
ELO assumes rating uncertainty is constant — a 5-game player and a 500-game player get the same K-factor adjustment. Glicko-2 adds:
- Rating Deviation (RD) — confidence interval. New player: RD=350. Established: RD≈50.
- Volatility (σ) — how consistently the player performs. Adjusts after every game.
Used by Lichess, Chess.com, CS2, and Dota 2.
uint16 rating (display rating, default 1500)
uint16 rd (RD×10, default 3500 = RD 350.0)
uint16 sigma (σ×10000, default 600 = σ 0.06)
uint16 gamesPlayed
uint16 wins
uint16 losses
uint16 draws
uint16 peakRating
─────────────────
128 bits used, 128 bits free
All 8 fields fit in one storage slot → a recordResult() call costs exactly 2 SLOADs and 2 SSTOREs (one per player), regardless of game history length.
Step 1: Convert to Glicko-2 scale
μ = (r - 1500) / 173.7178
φ = RD / 173.7178
Step 2: g(φ) = 1 / sqrt(1 + 3φ²/π²)
Babylonian sqrt, π² = 9.8696 (scaled)
Step 3: E(μ, μⱼ, φⱼ) = 1 / (1 + exp(-g(φⱼ)(μ - μⱼ)))
7-term Taylor series for exp()
Step 4: v = 1 / Σ[ g(φⱼ)² · E·(1-E) ]
Step 5: Δ = v · Σ[ g(φⱼ) · (sⱼ - E) ]
Step 6: σ' = Illinois algorithm (bisection, 10 iterations)
Finds root of f(x) = 0 where:
f(x) = [exp(x)(Δ²-φ²-v-exp(x))] / [2(φ²+v+exp(x))²] - (x-ln(σ²))/τ²
Step 7: φ* = sqrt(φ²+σ'²) (pre-rating RD)
φ' = 1 / sqrt(1/φ*² + 1/v)
μ' = μ + φ'² · Σ[g(φⱼ)(sⱼ-E)]
Convert back: r' = 173.7178·μ' + 1500, RD' = 173.7178·φ'
Compilation note: The Glicko-2 update function has many local variables. Compile with:
# foundry.toml
[profile.default]
via_ir = trueGenerates a complete ERC-721 tokenURI entirely in Solidity — no IPFS, no external URLs.
tokenURI = data:application/json;base64,{base64(json)}
json = {
"name": "Chess Game #N",
"description": "...",
"image": "data:image/svg+xml;base64,{base64(svg)}"
}
<svg viewBox="0 0 360 360">
<!-- 64 <rect> elements: alternating #769656 / #EEEED2 -->
<!-- Up to 32 <text> elements: Unicode chess symbols -->
<!-- Rank/file labels: a-h, 1-8 -->
</svg>Piece symbols:
| Type | White | Black |
|---|---|---|
| King | ♔ U+2654 | ♚ U+265A |
| Queen | ♕ U+2655 | ♛ U+265B |
| Rook | ♖ U+2656 | ♜ U+265C |
| Bishop | ♗ U+2657 | ♝ U+265D |
| Knight | ♘ U+2658 | ♞ U+265E |
| Pawn | ♙ U+2659 | ♟ U+265F |
Unicode chess symbols are in the Basic Multilingual Plane — supported by all modern browsers and SVG renderers without custom fonts.
Square extraction from gameState:
uint8 nibble = uint8((gameState >> (pos * 4)) & 0xF);
// bit 3 = color, bits 2-0 = piece typeflipped = true renders from black's perspective (rank 8 at bottom).
function tokenURI(uint256 tokenId) public view override returns (string memory) {
if (svgRenderer != address(0) && games[tokenId].gameState != 0) {
return IChessSVG(svgRenderer).tokenURIFromGameState(
games[tokenId].gameState, tokenId, games[tokenId].color
);
}
return super.tokenURI(tokenId); // fallback to stored URI string
}Backwards compatible — NFTs minted before setSvgRenderer() fall back to their stored URI.
- Standard chess move validation (reuses
chess.solengine) - Win condition: capture the opponent's king — not checkmate
- Moving into check is legal (you may not know you're in check)
- You see only: your own pieces + all squares any of your pieces can reach
computeVisibility(gameState, playerState, isBlack) → uint64 bitmask
For each own piece at position pos:
set bit pos in mask (own piece is always visible)
pawn: forward square(s), diagonal captures if occupied or en-passant
knight: all 8 jump targets within board
king: all 8 adjacent squares
rook: 4 rays — extend until hitting any piece (blocker visible, beyond invisible)
bishop: 4 diagonal rays — same as rook
queen: rook rays ∪ bishop rays
After every move, MoveMade emits:
event MoveMade(
uint256 indexed gameId,
uint16 move,
uint64 whiteVisibility, // bitmask: which 64 squares white can see
uint64 blackVisibility, // bitmask: which 64 squares black can see
uint256 gameState // full board (for replay / dispute)
);Frontends use their own visibility mask to filter what to render. The full gameState in the event enables trustless reconstruction and dispute resolution by any third party.
True trustless fog (hiding gameState from the chain entirely) requires ZK — a player proves "my move is legal given my hidden board" without revealing the full state. That is a future extension leveraging the ZK infrastructure already built (see below).
After executing a move, the contract checks if the piece on the destination square was a king before the move:
uint8 capturedPiece = uint8((gameState >> (toPos * 4)) & 0xF);
bool kingCaptured = (capturedPiece & 0x7) == 6; // king_constIf captured: game immediately resolved — no checkmate detection needed.
Off-chain On-chain
───────────────────────────────── ──────────────────────────────────
chess-guest/src/main.rs ChessProofVerifier.sol
Input (private): verifyAndSettle(seal, journal)
- gameId: u64 │
- moves: Vec<u16> ├─ IRiscZeroVerifier.verify(
Logic: │ seal, IMAGE_ID, sha256(journal))
- Replay via shakmaty │ → revert if proof invalid
- Determine outcome │
Journal (public): ├─ decode(journal)
- ABI.encode(gameId, outcome, │ → (gameId, outcome, movesHash)
movesHash) │
└─ ChessWager.settleFromVerifier(
Proving via RISC Zero Bonsai gameId, outcome, movesHash)
(cloud) or self-hosted prover
// Solidity decoder (in ChessProofVerifier):
(uint256 gameId, uint8 outcome, bytes32 movesHash) =
abi.decode(journal, (uint256, uint8, bytes32));
// Rust encoder (in chess-guest/src/main.rs):
let mut journal = [0u8; 96];
journal[24..32].copy_from_slice(&game_id.to_be_bytes()); // uint256 = 32 bytes, game_id fits in last 8
journal[63] = outcome_code; // uint8 right-padded in 32 bytes
journal[64..96].copy_from_slice(&moves_hash); // bytes32 exact
env::commit_slice(&journal);The IMAGE_ID is a deterministic 32-byte hash of the compiled guest binary (ELF). It is set immutably at ChessProofVerifier deployment. If the guest program is changed (even one byte), the IMAGE_ID changes and old receipts become invalid against the new verifier.
| Network | Address |
|---|---|
| Mainnet | 0x8EaB2D97Dfce405A1692a21b3ff3A172d593D319 |
| Sepolia | 0x925d8331ddc0a1F0d96E68CF073DFE1d92b69187 |
| Base | 0x0b144e07A0826182176AB3e27021fA07F9EDE7Aa |
| Arbitrum | 0x0b144e07A0826182176AB3e27021fA07F9EDE7Aa |
cd chess-guest
cargo build --target riscv32im-risc0-zkvm-elf --release
# IMAGE_ID printed in build output or via: cargo run --bin compute-image-idSimple reward token with controlled minting:
mintCap — immutable. No address can ever mint beyond this total.
totalMinted — tracks cumulative minted supply (not just current balance).
minters — mapping(address → bool). Only ChessWager is authorized.
addMinter() / removeMinter() — owner-only.
Rewards:
- Win a wager game →
10 CHSC(WIN_REWARDin ChessWager) - Draw a wager game →
5 CHSC(DRAW_REWARDin ChessWager, both players)
Thin marketplace wrapping the Treasure ERC-721:
- List by token ID at fixed price (ETH or any ERC-20)
- Instant buy:
tokenInstantBuy()— protected bynonReentrant - Royalty payment to original minter (
originalPlayers[id]) - ERC-2771 meta-transaction support (same forwarder pattern as ChessWager)
CEI fix applied: seller[_id] is captured before zeroing, and emit precedes external calls to prevent reentrancy and event-ordering bugs.
| Property | Mechanism |
|---|---|
| Trustless settlement | Move array is self-validating — loser cannot block by withholding |
| Reentrancy | nonReentrant on all value-transferring functions; CEI ordering throughout |
| No fund lock | If both players disappear: accept timeout (3 days), game timeout (7 days) |
| EIP-712 replay protection | DOMAIN_SEPARATOR includes chainId and verifyingContract — sigs non-portable across chains or contract upgrades |
| ZK trust model | trustedVerifiers mapping — owner explicitly whitelists verifier contracts; no implicit trust |
| Mint inflation | ChessCoin.mintCap is immutable — enforced even if a minter contract is compromised |
| ETH transfer | call{value}("") not transfer — compatible with smart contract wallets and future gas repricing |
| Dispute window | 1-hour window after any submission — anyone can resubmit a different (correct) move sequence |
1. ChessCoin(initialSupply, mintCap)
2. Chess()
3. ChessSVG()
4. Treasure() -- UUPS proxy
5. TreasureMarket(treasure, forwarder) -- UUPS proxy
6. ChessRating()
7. ChessWager(chess, chessRating, chessCoin, treasure, forwarder)
8. Chess960()
9. DarkChess()
10. ChessProofVerifier(risc0Verifier, chessWager, imageId)
Post-deploy authorizations:
chessCoin.addMinter(chessWager)
chessRating.authorizeCaller(chessWager)
treasure.addAdmin(chessWager)
treasure.setSvgRenderer(chessSVG)
chessWager.setTrustedVerifier(chessProofVerifier, true)
Each contract is independently deployable. ChessWager is the only contract that requires all others to be set — the setters are owner-callable post-deploy, so partial deployments are safe.
| Operation | Gas (approx.) |
|---|---|
checkGameFromStart (20 moves) |
~80,000 |
checkGameFromStart (60 moves) |
~300,000 |
checkGameFromStart (120 moves) |
~600,000 |
searchPiece (full board, iterative) |
~200 |
searchPiece (full board, old recursive) |
~3,840 |
settleWithSignatures |
~60,000 |
submitGame (60 moves) + finalizeGame |
~350,000 |
verifyAndSettle (ZK proof) |
~3,000–5,000 |
ChessRating.recordResult (Glicko-2, 2 players) |
~50,000 |
ChessSVG.tokenURIFromGameState (view) |
~800,000 (off-chain only) |
DarkChess.computeVisibility (view) |
~15,000 |