Turns
The turns kit is for games where players take turns: chess, checkers, connect four, reversi, tic-tac-toe, a card duel. You describe the game once as TurnRules. The kit then gives you a search that scores every move, a computer opponent at easy, medium or hard, and online play with a friend over a room. Chess comes built in as chessRules.
The kit has no drawing of its own. Draw the board with the 2D layer or React Native views. Moon Chess is a complete game on all of it.
Everything below is exported from @/engine.
Smallest working example
Tic-tac-toe rules (the same rules the engine's own search tests use) and a computer move.
import { createOpponent, searchMoves, type TurnRules } from '@/engine';
interface Ttt {
cells: number[]; // 0 empty, 1 X, -1 O
turn: 1 | -1;
}
const LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
const winner = (c: number[]) => {
for (const [a, b, d] of LINES) if (c[a] !== 0 && c[a] === c[b] && c[a] === c[d]) return c[a];
return 0;
};
export const tttRules: TurnRules<Ttt, number> = {
moves: (s) => (winner(s.cells) ? [] : s.cells.flatMap((c, i) => (c === 0 ? [i] : []))),
play: (s, m) => {
s.cells[m] = s.turn;
s.turn = s.turn === 1 ? -1 : 1;
},
undo: (s, m) => {
s.cells[m] = 0;
s.turn = s.turn === 1 ? -1 : 1;
},
outcome: (s, moves) => {
if (winner(s.cells)) return -1; // the player who just moved made the line
return (moves ?? tttRules.moves(s)).length === 0 ? 0 : null;
},
evaluate: () => 0,
order: (_s, m) => (m === 4 ? 2 : m % 2 === 0 ? 1 : 0), // centre, then corners
notation: (_s, m) => `${'abc'[m % 3]}${3 - Math.floor(m / 3)}`,
};
const state: Ttt = { cells: Array(9).fill(0), turn: 1 };
// Every legal move, best first, for the side to move.
const { ranked } = searchMoves(tttRules, state, { depth: 9 });
// Or let an opponent pick one at a level.
const opponent = createOpponent({ rules: tttRules, game: 'tic-tac-toe', level: 'easy' });
const turn = await opponent.takeTurn(state, []);
if (turn) tttRules.play(state, turn.move);
In a component, use useOpponent instead of createOpponent (it cancels the turn in progress on unmount).
TurnRules
The state is mutable. The search plays and undoes thousands of moves on one state object instead of copying a board per position, which is what makes it fast enough on a phone.
| Member | Required | What it does |
|---|---|---|
moves(state) | yes | Legal moves for the side to move. An empty list means the game is over. |
play(state, move) | yes | Plays the move in place. |
undo(state, move) | yes | Takes back exactly what play changed. |
outcome(state, moves?) | yes | Once over, the result for the side to move: 1 won, 0 draw, -1 lost. null while the game goes on. The search passes the moves it generated for that position; decide "no moves left" from them instead of generating again. |
evaluate(state) | yes | How good the position is for the side to move. 100 is about one "pawn", a modest edge. |
notation(state, move) | yes | The move as players read it: "Nf3", "c3-d4", "column 4". Unique per legal move, and readable back by your game. |
tacticalMoves(state) | no | Moves that change the position sharply (captures, promotions), searched past the depth limit. Leave it out for quiet games. |
order(state, move) | no | Higher is searched first. Better ordering means a deeper search in the same time. |
describe(state) | no | The position as text for the opponent's model: a FEN and a diagram, a hand, a score line. |
key(state) | no | A cheap key for the position (a hash or a short string). |
Test undo before anything else: play every legal move, undo it, and compare a serialised copy of the state with the one before.
Chess
chessRules is full chess, tested against the standard perft move counts: castling, en passant, promotion to all four pieces, check, checkmate, stalemate, the fifty-move rule, threefold repetition and dead positions.
import { chessRules, chessStatus, createChess, legalMovesFrom, moveFromSan, playMove, san, squareOf } from '@/engine';
const chess = createChess(); // or createChess(fen)
const moves = legalMovesFrom(chess, squareOf('e2'));
const e4 = moveFromSan(chess, 'e4');
if (e4) {
const text = san(chess, e4); // notation is read before the move is played
playMove(chess, e4);
}
const status = chessStatus(chess); // { over, result, reason, check, turn }
| Export | What it does |
|---|---|
createChess(fen?), toFen(state), cloneChess(state), START_FEN | A state from and to FEN. |
legalMoves(state), legalMovesFrom(state, sq) | ChessMove[]: { from, to, piece, color, captured?, promotion?, flags }. |
playMove(state, move), undoMove(state, move) | Play and take back in place. |
inCheck(state), pieceAt(state, sq) | Whether the side to move is in check; the ChessPiece ({ type, color }) on a square, or null. |
squareName(sq), squareOf(name) | Squares are 0 to 63: 0 is a1, 7 is h1, 56 is a8. |
san(state, move), moveFromSan(state, text) | Standard algebraic notation. Call san before playing the move. |
uci(move), moveFromUci(state, text) | UCI notation ("e2e4"). |
chessStatus(state) | { over, result, reason, check, turn }. result is '1-0', '0-1' or '1/2-1/2'; reason is 'checkmate', 'stalemate', 'fifty-move', 'repetition' or 'insufficient'. |
evaluateChess(state) | Evaluation in centipawns for the side to move. |
asciiBoard(state) | A text diagram, used by describe for the model. |
MOVE_FLAGS, CASTLING | Bits of ChessMove.flags and ChessState.castling. |
chessRules uses san as its notation, so the opponent and online play exchange moves in SAN.
Search
import { chessRules, createChess, pickMove, searchMoves, searchMovesAsync } from '@/engine';
const chess = createChess();
const { ranked, depth, nodes, ms } = searchMoves(chessRules, chess, { depth: 4, timeMs: 500 });
const pick = pickMove(ranked, { margin: 80, temperature: 30 });
// In a game: give the UI a frame between root moves so the board keeps animating.
const result = await searchMovesAsync(chessRules, chess, { depth: 4, timeMs: 700 });
The search is negamax with alpha-beta pruning, iterative deepening under a time budget (it keeps the last depth it finished), and a quiescence pass over tacticalMoves at the horizon. It scores every root move, not only the best, which is what an opponent at a level needs. Neither function leaves the state changed.
SearchOptions | Default | What it does |
|---|---|---|
depth | 4 | Deepest look-ahead in plies. |
timeMs | 500 | Time budget. Depth 1 always finishes. |
quiescence | 6 (0 without tacticalMoves) | Extra plies of tactical moves past the horizon. |
spread | Infinity | Root moves this far below the best are only proven worse, not scored exactly. Faster. |
maxNodes | none | Stop after this many positions (tests). |
now | Date.now | The clock. |
signal | none | { aborted }: set aborted = true to stop the async search at the next root move. |
sliceMs | 24 | searchMovesAsync only: how long it runs before giving the thread back for a frame. |
The result is { ranked, depth, nodes, ms, stopped }, where ranked is { move, score }[], best first.
A forced win scores WIN_SCORE - plies (WIN_SCORE is 1,000,000). isWinScore(score) tells a forced result from an evaluation, and pliesToEnd(score) how far away it is (positive when the mover wins).
pickMove(ranked, { margin, temperature, rng? }) picks the way a player at a level would: the best move at temperature 0, otherwise a weighted choice where a move temperature units worse is about 2.7 times less likely. Only moves within margin of the best count. It never picks a forced loss over a move that is not one.
In Hermes, chess searches about 70,000 positions a second (depth 4 in about 1.5 s from the opening). Use searchMovesAsync for anything that takes more than a few milliseconds, and do not change the state until it resolves.
The computer opponent
import { GAME_BACKEND_URL, chessRules, createChess, playMove, useOpponent } from '@/engine';
const chess = createChess();
const sans: string[] = []; // the game so far, in SAN
// Inside a component:
const opponent = useOpponent({
rules: chessRules,
game: 'chess',
level: 'medium',
persona: 'Vesper, a sly night fox who teases you when you slip, always fair.',
side: 'Black',
backendUrl: GAME_BACKEND_URL,
});
// On the computer's turn:
const turn = await opponent.takeTurn(chess, sans);
if (turn) {
sans.push(turn.notation);
playMove(chess, turn.move);
if (turn.say) console.log(turn.say); // show it in a speech bubble
}
// Undo, restart or exit while it thinks:
opponent.cancel();
Each turn the opponent:
- Searches the position on the device with
searchMovesAsync. - Keeps a shortlist of moves a player at its level would consider.
- With a backend, asks the backend's model to choose one of those moves in character and say a line to the player.
- Otherwise, or when the model is slow, busy or answers with a move not on the list, plays its own pick from the same shortlist, silently.
The model never sees an illegal move, and the level decides how strong the moves on the list are. The model only adds the personality and the talk. The backend runs the model on Cloudflare Workers AI behind a POST /ai/turn route with a per-player rate limit; see the backend overview. requestTurn(url, ask) calls that route directly if you write your own opponent loop.
OpponentConfig | Default | What it does |
|---|---|---|
rules | required | Your TurnRules. |
game | required | What is being played, for the model: 'chess'. |
level | required | 'easy', 'medium' or 'hard'. |
persona | none | A name and a personality in a sentence or two, for the model. |
side | none | The side it plays, for the model: 'Black'. |
backendUrl | none | GAME_BACKEND_URL. Without it, the opponent always plays its own pick and says nothing. |
unit | 100 | Evaluation units per "pawn". Scales the level's margin and temperature. |
tune | none | Overrides fields of the level's preset: { timeMs: 2500 }. |
timeoutMs | 8000 | How long to wait for the model. |
formatScore | defaultFormatScore(unit) | The note the model sees for each move: "+0.4", "wins in 2". |
rng | Math.random | Random source, for repeatable tests. |
OPPONENT_LEVELS holds the presets:
| Level | depth | timeMs | options | margin | temperature |
|---|---|---|---|---|---|
easy | 2 | 300 | 6 | 400 | 120 |
medium | 3 | 700 | 5 | 120 | 35 |
hard | 6 | 1500 | 3 | 30 | 0 |
margin and temperature are in hundredths of unit (centipawns in chess). The shortlist (shortlist(ranked, count, margin, rng)) takes the best half of options in order and draws the rest at random from what is within the margin. A move that loses by force never goes on the list while something else exists.
takeTurn(state, history) resolves to an OpponentMove, or null when cancel() or a newer turn dropped it:
| Field | What it is |
|---|---|
move, notation | The move and its notation. |
say | The line to show, or ''. At most 120 characters. |
source | 'model' or 'search'. |
reason | Why the model did not choose, when it did not: 'no backend', 'timed out', '429 ...'. |
score | The search's score for the move. |
depth, nodes, options, ms | How the turn went. |
Online turns
useTurnRoom builds turn-based play on a multiplayer room (Networking): seats, whose turn it is, the moves so far, resign and rematch.
import { useState } from 'react';
import { GAME_BACKEND_URL, displayName, useLaunchRoomId, useTurnRoom } from '@/engine';
const launchRoom = useLaunchRoomId();
const [code, setCode] = useState(launchRoom);
const table = useTurnRoom(GAME_BACKEND_URL, { roomId: code ?? '', name: displayName(), enabled: !!code });
// My move: pass the number of moves I saw when I chose it.
table.play(notation, table.moves.length);
table.finish(`resign:${table.mySeat}`); // or 'draw'
table.rematch(); // new game, seats rotated: the other side starts
UseTurnRoomOptions | Default | What it does |
|---|---|---|
roomId | required | The room code. Empty stays disconnected. |
name | the player's name | Display name. |
enabled | true | false stays disconnected. |
onMessage | none | Every server message, for emotes sent with room.sendEvent. |
seats | 2 | Players who take turns. |
TurnRoomView | What it is |
|---|---|
room | The RoomView underneath: status, presence, sendEvent. |
mySeat | This device's seat (seat 0 moves first), or null while waiting or watching. |
seated | Each seat's player while they are in the room, null for a free seat or one whose player stepped away. |
seatIds | Each seat's public id, kept while its player is away. |
full | Every seat has a player in the room. |
moves | Every move so far, in notation. Your own move shows at once. |
game | Game number in this room: 1, then 2 after a rematch. |
result | How the game ended outside its moves ('resign:1', 'draw'), or null. |
turn, myTurn | Whose seat moves now, and whether it is this device. |
play(move, ply) | Sends my move. Ignored, and returns false, when it is not my turn or ply is stale (a double tap). |
finish(result) | Ends the game for everyone. |
rematch() | A new game in the same room, seats rotated. |
The moves are the truth. The room's shared state holds seats, moves, game and result, and every device replays moves through its own rules, so the boards never disagree. When table.moves changes, apply the new moves; when they no longer extend yours (a rematch), rebuild the board from scratch. Each device takes the first free seat. A player who drops out keeps their seat and gets the whole game back when they reconnect. A third player watches.
readTurnRoom, claimSeat and turnSeat are the pure parts of useTurnRoom, exported for tests.
Without a backend, useTurnRoom stays in a room with only you, so offer pass-and-play or the computer instead.
Gotchas
undomust restore everythingplaychanged, including side to move, counters and caches. A leak here shows up as illegal moves deep in the search.- Do not touch the state while a search or
takeTurnruns. It plays and undoes moves on your object. Cancel first (opponent.cancel()), then undo or restart. - Notation is the wire format. The model answers with one of your notations and online play stores them, so each must be unique and parse back.
- Evaluate in pawns of 100. The level presets assume that scale. If your evaluation uses other units, pass
unit. - Online games are live. Both players have the game open. The scaffold's room keeps the game for 30 minutes after the last player leaves. There are no turns across days, no notifications and no clocks yet; keep a per-turn deadline in the room state if you need one.
- The strength comes from the search. Hard is a careful club player, not a grandmaster. Without a backend the opponent plays as well but says nothing.
See also
- Examples: Moon Chess, with three rivals who talk, a friend online from a link, pass-and-play, take-backs and a promotion picker.
- 2D layer: drawing the board and the pieces.
- Networking: rooms, room codes and invite links.
- Backend overview: the
POST /ai/turnroute and its limits.