Step 2 - Game engine

5 min read

Game engine

The engine is the heart of the system and the only part with a formally fixed API. Everything else — AI, UI, persistence — is built on top of it. It contains no DOM code, no randomness and no I/O.

Types (src/engine/types.ts)

/** The two marks. X always moves first in a fresh game unless told otherwise. */
export type Player = 'X' | 'O';

/** A single square: a mark, or null when empty. */
export type Cell = Player | null;

/** Nine cells in row-major order, index 0 = top-left, 8 = bottom-right. */
export type Board = readonly Cell[];

export type GameStatus = 'in-progress' | 'win' | 'draw';

export interface GameState {
  readonly board: Board;
  /** Whose turn it is. Meaningless when status !== 'in-progress'. */
  readonly currentPlayer: Player;
  readonly status: GameStatus;
  /** The winner, or null on draw or while in progress. */
  readonly winner: Player | null;
  /** The three indices that won, or null. */
  readonly winningLine: readonly [number, number, number] | null;
  /** Cell indices in the order they were played. */
  readonly moveHistory: readonly number[];
}

export class InvalidMoveError extends Error {
  constructor(public readonly index: number, public readonly reason: string) { … }
}

Constants (src/engine/constants.ts)

export const BOARD_SIZE = 3;
export const CELL_COUNT = 9;

/** All eight winning triples: three rows, three columns, two diagonals. */
export const WIN_LINES: readonly (readonly [number, number, number])[] = [
  [0, 1, 2], [3, 4, 5], [6, 7, 8],
  [0, 3, 6], [1, 4, 7], [2, 5, 8],
  [0, 4, 8], [2, 4, 6],
];

WIN_LINES is written out literally rather than generated. It is the specification of the rules and must be readable at a glance.

Functions (src/engine/game.ts)

createGame(startingPlayer: Player = 'X'): GameState

Returns a state with nine null cells, currentPlayer set to startingPlayer, status 'in-progress', no winner, no winning line and an empty history.

evaluateBoard(board: Board): { status: GameStatus; winner: Player | null; winningLine: readonly [number, number, number] | null }

Scans WIN_LINES. If any line holds three identical non-null marks, returns a win for that mark with that line. Otherwise, if no cell is null, returns a draw. Otherwise 'in-progress'.

If two winning lines exist for the same player (possible only in positions the engine cannot itself produce, but reachable if a board is constructed directly in tests), return the first match in WIN_LINES order — the result must be deterministic.

applyMove(state: GameState, index: number): GameState

  1. Throw InvalidMoveError if state.status !== 'in-progress' (reason 'game-over').
  2. Throw InvalidMoveError if index is not an integer in 0..8 (reason 'out-of-range').
  3. Throw InvalidMoveError if state.board[index] !== null (reason 'occupied').
  4. Otherwise produce a new board with state.currentPlayer placed at index, run evaluateBoard, append index to moveHistory, and set currentPlayer to the opposite mark. The turn still flips on the winning move, so currentPlayer after a win is the loser; consumers must read winner, never infer it from currentPlayer.

The input state is never mutated. applyMove returns a structurally new object every time.

undoLastMove(state: GameState): GameState

Replays the game from createGame using moveHistory minus its last entry. Returns the state unchanged when the history is empty. Replaying, rather than patching the board, guarantees status and winner fields are always consistent.

The starting player used for the replay is derived from history length parity and the current currentPlayer; simpler still, store nothing extra and recompute by replaying from a createGame whose starting player equals the mark of the first move. When history is empty there is nothing to undo, so this is always well defined.

availableMoves(state: GameState): number[]

Ascending indices of empty cells. Returns an empty array when the game is over, so callers cannot accidentally continue a finished game.

otherPlayer(player: Player): Player

Trivial helper, exported because both the AI and the UI need it and neither should re-implement it.

Test requirements (src/engine/game.test.ts)

  • A fresh game has nine empty cells, X to move, status in progress.
  • applyMove places the mark, flips the player and grows the history by one.
  • applyMove does not mutate its input: the original state is deep-equal to a snapshot taken before the call.
  • Each of the eight WIN_LINES is detected as a win for X and, separately, for O, with the correct winningLine.
  • A full board with no line is a draw.
  • Playing into an occupied cell, an out-of-range index, a non-integer index and a finished game each throw InvalidMoveError with the documented reason.
  • undoLastMove after a winning move returns a state with status 'in-progress' and the winner cleared.
  • undoLastMove on a fresh game is a no-op.
  • availableMoves returns [] for a won game even when empty cells remain.

Acceptance criteria

  • src/engine/ imports nothing from src/ai/, src/ui/ or src/state/, and references no DOM or browser global.
  • All exported symbols match the signatures above exactly, including readonly modifiers.
  • applyMove and undoLastMove return new objects and leave their arguments untouched, proven by a deep-equality snapshot test.
  • All eight winning lines are detected for both players, with the correct winningLine triple.
  • A drawn board reports status 'draw' and winner: null.
  • Every invalid-move path throws InvalidMoveError carrying the documented reason.
  • Statement coverage of src/engine/ is at least 95%.
  • Every exported function has a TSDoc comment stating its behaviour on invalid input.
  • npm run verify passes.

Discussion

0 comments

No comments yet. Start the discussion.