Board, GameState, Move, Position, and WinCondition are frozen dataclasses. Applying a move creates a new Board and a new GameState; the previous objects remain unchanged.
That one invariant makes undo, replay, testing, and AI search straightforward. A state can be hashed, compared, saved, or passed into a search function without worrying that another part of the program changed it.
The rules live in tictactoe.engine:
make_move(state, position) -> GameStateavailable_moves(board) -> list[Position]check_winner(board) -> GameCheckundo(state, plies=1) -> GameStatereplay(history, size, k, misere) -> GameState
These functions do not read the terminal, write files, or mutate hidden state. Invalid moves raise the custom exception hierarchy from tictactoe.exceptions.
Agents share the same shape: choose_move(state: GameState) -> Position.
The CLI can compose any X agent with any O agent:
HumanAgent(optionalread_line/echofor tests or alternate UIs)RandomAgentHeuristicAgentMinimaxAgentMonteCarloRolloutAgent(CLI namemcts; flat rollout Monte Carlo, not full UCT). AliasMCTSAgent.
Because agents only receive immutable state and return positions, human and AI players are interchangeable from the engine's perspective.
Renderers take a GameState and return a string. The terminal-facing Display class owns printing and optional screen clearing.
Available renderers:
ClassicRendererCoordinateRendererMinimalRendererBigRenderer
Color is handled by a small ANSI helper that respects NO_COLOR and --no-color.
Since history is a tuple of Move objects and state transitions are pure, replay is just "start from an empty board and apply these moves again."
The analyze command uses the same fact. It walks through a saved history, asks minimax what it would play at each point, and compares that recommendation to the recorded move.