# Turns

> Turn-based games described once as TurnRules, with full chess, a look-ahead search, a computer opponent at three levels that can talk through the game's backend, and online turns over a room.

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](https://docs.moonspell.app/engine/2d.md) or React Native views. [Moon Chess](https://docs.moonspell.app/examples.md) 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.

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

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

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

```tsx
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:

1. Searches the position on the device with `searchMovesAsync`.
2. Keeps a shortlist of moves a player at its level would consider.
3. With a backend, asks the backend's model to choose one of those moves in character and say a line to the player.
4. 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](https://docs.moonspell.app/backend/overview.md). `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](https://docs.moonspell.app/engine/net.md)): seats, whose turn it is, the moves so far, resign and rematch.

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

- **`undo` must restore everything `play` changed**, 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 `takeTurn` runs.** 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](https://docs.moonspell.app/examples.md): Moon Chess, with three rivals who talk, a friend online from a link, pass-and-play, take-backs and a promotion picker.
- [2D layer](https://docs.moonspell.app/engine/2d.md): drawing the board and the pieces.
- [Networking](https://docs.moonspell.app/engine/net.md): rooms, room codes and invite links.
- [Backend overview](https://docs.moonspell.app/backend/overview.md): the `POST /ai/turn` route and its limits.
