code.pablomurad.com

pablosgames

Ten small board, strategy and puzzle games played entirely in the terminal

first commit
author
Pablo Murad
date
commit
088cf562f3e64885c93fa35c8e5282606fce4636
changes
37 files +7294 −0
@@ -0,0 +1,33 @@+# Byte-compiled / cache
+__pycache__/
+*.py[cod]
+*$py.class
+*.pyo
+
+# Virtual environments
+.venv/
+venv/
+env/
+
+# Local game statistics (player data)
+stats.json
+stats.tmp
+
+# Test / coverage
+.pytest_cache/
+.coverage
+htmlcov/
+.mypy_cache/
+.ruff_cache/
+
+# OS
+.DS_Store
+Thumbs.db
+Desktop.ini
+
+# Editors / IDEs
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
@@ -0,0 +1,21 @@+MIT License
+
+Copyright (c) 2026 Pablo Murad
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
@@ -0,0 +1,194 @@+# Pablo's Terminal Games
+
+A collection of ten small board, strategy and puzzle games designed to
+be played entirely inside the terminal. No browser, no GUI, no
+accounts, no internet — just games.
+
+```
++================================+
+|                                |
+|     PABLO'S TERMINAL GAMES     |
+|                                |
+|         VERSION 0.0.1          |
+|                                |
++================================+
+
+          > 1. Tic-Tac-Toe
+            2. Connect Four
+            3. Checkers
+            4. Reversi
+            5. Naval Battle
+            6. Gomoku
+            7. Mancala
+            8. Minesweeper
+            9. 2048
+            0. Fifteen Puzzle
+
+ Arrows/W-S move - Enter select - H help - S stats - A about - Q quit
+```
+
+
+
+## Games included
+
+
+| #   | Game           | Modes                   |
+| --- | -------------- | ----------------------- |
+| 1   | Tic-Tac-Toe    | vs Computer, vs Player  |
+| 2   | Connect Four   | vs Computer, vs Player  |
+| 3   | Checkers       | vs Computer, vs Player  |
+| 4   | Reversi        | vs Computer, vs Player  |
+| 5   | Naval Battle   | vs Computer             |
+| 6   | Gomoku         | vs Computer, vs Player  |
+| 7   | Mancala        | vs Computer, vs Player  |
+| 8   | Minesweeper    | single player, 3 levels |
+| 9   | 2048           | single player           |
+| 0   | Fifteen Puzzle | single player           |
+
+
+Every game has its own help screen (press `H` in-game) explaining the
+objective, controls and rules.
+
+## Features
+
+- Retro terminal interface with arrow-key menus, ANSI colors and
+Unicode box drawing (with automatic ASCII fallback).
+- Cross-platform keyboard input: works on Windows (msvcrt) and
+Linux/Unix (termios) with no configuration.
+- Correct game rules kept separate from rendering, so they are fully
+unit-tested (236 tests).
+- Local JSON statistics: games played, won, lost and drawn, plus
+records (2048 high score and best tile, Minesweeper best times,
+Fifteen Puzzle best moves/time).
+- Graceful behavior: invalid input is explained and re-asked, the
+terminal is always restored on exit, and a corrupted statistics
+file never crashes the program.
+- Completely offline. No telemetry, no analytics, no network
+requests, no accounts. Everything stays on your computer.
+
+
+
+## Requirements
+
+- Python 3.8 or newer (standard library only — nothing to install).
+- A terminal of at least 80x24 characters.
+
+
+
+## Installation
+
+Clone or copy this directory anywhere. There is no build step and no
+dependency to install.
+
+## Running
+
+
+
+### Windows
+
+```
+python main.py
+```
+
+or double-click / run:
+
+```
+play.bat
+```
+
+Works in Windows PowerShell, cmd and Windows Terminal.
+
+### Linux
+
+```
+python3 main.py
+```
+
+or:
+
+```
+./play.sh
+```
+
+Works in any standard terminal emulator (Debian/Ubuntu included).
+
+## Controls
+
+- **Menus**: Arrow keys or `W`/`S` to move, `Enter` to select,
+number keys as shortcuts, `Q` or `ESC` to go back.
+- **Board games** (Tic-Tac-Toe, Connect Four, Checkers, Reversi,
+Naval Battle, Gomoku, Mancala): type coordinates or numbers
+(e.g. `b2`, `d3`, `b6-a5`, `E5`, `1-6`) and press Enter.
+- **Real-time games** (Minesweeper, 2048, Fifteen Puzzle): arrow
+keys and WASD. Minesweeper: `Enter`/`Space` reveals, `F` flags.
+- **Everywhere**: `H` for help, `Q`/`ESC` to return to the menu.
+
+Note: game 10 (Fifteen Puzzle) is selected with the `0` key.
+
+## Statistics
+
+Statistics are stored in a local `stats.json` file next to `main.py`
+and survive between executions. View them from the main menu with
+`S`. No data ever leaves your computer; delete the file to reset
+everything.
+
+## Project structure
+
+```
+terminal-games/
+|
+├── main.py               entry point, main menu, help/stats/about
+├── play.bat              Windows launcher
+├── play.sh               Linux launcher
+├── GAME_SPEC.md          internal contract for game modules
+│
+├── core/
+│   ├── __init__.py       app metadata (version lives here, once)
+│   ├── terminal.py       screen, colors, boxes, centering, prompts
+│   ├── input.py          cross-platform single-key and line input
+│   ├── menu.py           reusable arrow-key menu
+│   ├── storage.py        local JSON statistics
+│   └── utils.py          small shared helpers
+│
+├── games/                one module per game, all following
+│   ├── tic_tac_toe.py    GAME_SPEC.md (pure logic + UI layer)
+│   ├── connect_four.py
+│   ├── checkers.py
+│   ├── reversi.py
+│   ├── naval_battle.py
+│   ├── gomoku.py
+│   ├── mancala.py
+│   ├── minesweeper.py
+│   ├── game_2048.py
+│   └── fifteen.py
+│
+└── tests/                one unittest file per game (headless)
+```
+
+
+
+## Development
+
+Game rules live in pure logic classes with no printing or input,
+which is what the tests drive. The `Game` class in each module is the
+terminal UI on top. To add a new game, follow `GAME_SPEC.md` and
+register the module in `main.py`.
+
+## Running tests
+
+```
+python -m unittest discover -s tests
+```
+
+All 236 tests should pass in well under a second.
+
+## Author
+
+Pablo Murad
+[pablomurad@pm.me](mailto:pablomurad@pm.me)
+
+Made for people who still enjoy the command line.
+
+## License
+
+MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Pablo Murad.
@@ -0,0 +1,11 @@+"""PABLO'S TERMINAL GAMES - core package.
+
+Single source of truth for application metadata.
+"""
+
+APP_NAME = "PABLO'S TERMINAL GAMES"
+__version__ = "0.0.1"
+AUTHOR = "Pablo Murad"
+EMAIL = "pablomurad@pm.me"
+YEAR = "2026"
+TAGLINE = "Made for people who still enjoy the command line."
@@ -0,0 +1,78 @@+"""Cross-platform single-key input for Pablo's Terminal Games.
+
+The rest of the application never needs to know which operating
+system it runs on. Use read_key() for real-time games and menus,
+and read_line() for coordinate-style text input.
+"""
+from __future__ import annotations
+
+import sys
+
+
+class Key:
+    """Symbolic names for special keys."""
+
+    UP = "UP"
+    DOWN = "DOWN"
+    LEFT = "LEFT"
+    RIGHT = "RIGHT"
+    ENTER = "ENTER"
+    ESCAPE = "ESCAPE"
+    BACKSPACE = "BACKSPACE"
+
+
+if sys.platform == "win32":
+    import msvcrt
+
+    _WIN_ARROWS = {"H": Key.UP, "P": Key.DOWN, "K": Key.LEFT, "M": Key.RIGHT}
+
+    def read_key() -> str:
+        """Read one key press. Returns a Key constant or a single char."""
+        ch = msvcrt.getwch()
+        if ch in ("\x00", "\xe0"):  # special key prefix
+            code = msvcrt.getwch()
+            return _WIN_ARROWS.get(code, "")
+        if ch == "\r":
+            return Key.ENTER
+        if ch == "\x1b":
+            return Key.ESCAPE
+        if ch == "\x08":
+            return Key.BACKSPACE
+        return ch
+
+else:
+    import select
+    import termios
+    import tty
+
+    _UNIX_ARROWS = {"[A": Key.UP, "[B": Key.DOWN, "[C": Key.RIGHT, "[D": Key.LEFT}
+
+    def read_key() -> str:
+        """Read one key press. Returns a Key constant or a single char."""
+        fd = sys.stdin.fileno()
+        old = termios.tcgetattr(fd)
+        try:
+            tty.setraw(fd)
+            ch = sys.stdin.read(1)
+            if ch == "\x1b":
+                # Distinguish a lone ESC from an escape sequence.
+                if select.select([sys.stdin], [], [], 0.05)[0]:
+                    seq = sys.stdin.read(2)
+                    return _UNIX_ARROWS.get(seq, Key.ESCAPE)
+                return Key.ESCAPE
+            if ch in ("\r", "\n"):
+                return Key.ENTER
+            if ch == "\x7f":
+                return Key.BACKSPACE
+            return ch
+        finally:
+            termios.tcsetattr(fd, termios.TCSADRAIN, old)
+
+
+def read_line(prompt: str = "") -> str | None:
+    """Read a line of text. Returns None on EOF/Ctrl-C."""
+    try:
+        return input(prompt)
+    except (EOFError, KeyboardInterrupt):
+        print()
+        return None
@@ -0,0 +1,67 @@+"""Reusable arrow-key menu built on core.terminal and core.input."""
+from __future__ import annotations
+
+from core import terminal as term
+from core.input import Key, read_key
+
+
+def choose(title_lines, options, hotkeys=None, extra_keys="", hint=None):
+    """Interactive vertical menu.
+
+    title_lines : str or list of str shown at the top (centered).
+    options     : list of str, one per entry.
+    hotkeys     : optional list of single characters, one per option,
+                  shown as "<key>. <option>" and selectable directly.
+    extra_keys  : string of additional accepted characters; pressing one
+                  returns it (lowercased) instead of an index. Extra
+                  keys take precedence over W/S navigation.
+    hint        : optional gray footer line.
+
+    Returns the selected option index, an extra key character,
+    or None when the user presses ESC or Q.
+    """
+    if isinstance(title_lines, str):
+        title_lines = [title_lines]
+    index = 0
+    width = max(term.size()[0], 40)
+
+    while True:
+        term.clear()
+        print()
+        for line in title_lines:
+            print(term.center(line, width))
+        print()
+
+        block = []
+        for i, option in enumerate(options):
+            label = f"{hotkeys[i]}. {option}" if hotkeys else option
+            if i == index:
+                block.append(term.style(f"  > {label}", term.Color.CYAN, term.Color.BOLD))
+            else:
+                block.append(f"    {label}")
+        block_width = max(term.visible_len(b) for b in block)
+        pad = max(0, (width - block_width) // 2)
+        for b in block:
+            print(" " * pad + b)
+
+        if hint:
+            print()
+            print(term.center(term.style(hint, term.Color.GRAY), width))
+
+        key = read_key()
+        if isinstance(key, str) and key and key.lower() in extra_keys.lower():
+            return key.lower()
+        if key in (Key.UP,) or (isinstance(key, str) and key.lower() == "w"):
+            index = (index - 1) % len(options)
+        elif key in (Key.DOWN,) or (isinstance(key, str) and key.lower() == "s"):
+            index = (index + 1) % len(options)
+        elif key == Key.ENTER:
+            return index
+        elif key == Key.ESCAPE:
+            return None
+        elif isinstance(key, str) and key:
+            low = key.lower()
+            if low == "q":
+                return None
+            if hotkeys and low in [h.lower() for h in hotkeys]:
+                return [h.lower() for h in hotkeys].index(low)
@@ -0,0 +1,103 @@+"""Lightweight persistent local statistics (JSON only).
+
+Fully offline: no network, no accounts, no telemetry. Handles a
+missing or corrupted statistics file gracefully by starting fresh.
+"""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+DEFAULT_PATH = Path(__file__).resolve().parent.parent / "stats.json"
+
+OUTCOMES = ("win", "loss", "draw")
+
+_STATS_FIELDS = ("played", "won", "lost", "drawn")
+
+
+def _empty_stats() -> dict:
+    return {field: 0 for field in _STATS_FIELDS}
+
+
+def _default_data() -> dict:
+    return {"games": {}, "records": {}}
+
+
+class Store:
+    """JSON-backed statistics store."""
+
+    def __init__(self, path=DEFAULT_PATH):
+        self.path = Path(path)
+        self.data = _default_data()
+        self._load()
+
+    def _load(self) -> None:
+        try:
+            with open(self.path, "r", encoding="utf-8") as fh:
+                loaded = json.load(fh)
+            if not isinstance(loaded, dict):
+                return
+        except (OSError, ValueError):
+            return  # missing or corrupted file: start fresh
+        games = loaded.get("games")
+        if isinstance(games, dict):
+            for key, value in games.items():
+                if isinstance(value, dict):
+                    merged = self.data["games"].setdefault(key, _empty_stats())
+                    for field in _STATS_FIELDS:
+                        # type() is int, not isinstance(): JSON true/false
+                        # decode to bool, which is a subclass of int.
+                        if type(value.get(field)) is int:
+                            merged[field] = value[field]
+        records = loaded.get("records")
+        if isinstance(records, dict):
+            self.data["records"] = records
+
+    def save(self) -> None:
+        """Write statistics atomically (temp file + replace)."""
+        tmp = self.path.with_suffix(".tmp")
+        try:
+            with open(tmp, "w", encoding="utf-8") as fh:
+                json.dump(self.data, fh, indent=2)
+            os.replace(tmp, self.path)
+        except OSError:
+            pass  # statistics are nice to have, never fatal
+
+    def record_result(self, game_key: str, outcome: str) -> None:
+        """Record one finished game. outcome: 'win', 'loss' or 'draw'."""
+        if outcome not in OUTCOMES:
+            return
+        entry = self.data["games"].setdefault(game_key, _empty_stats())
+        entry["played"] += 1
+        entry[{"win": "won", "loss": "lost", "draw": "drawn"}[outcome]] += 1
+        self.save()
+
+    def game_stats(self, game_key: str) -> dict:
+        return dict(self.data["games"].get(game_key, {}))
+
+    def get_record(self, key: str, default=None):
+        return self.data["records"].get(key, default)
+
+    def set_record(self, key: str, value) -> None:
+        self.data["records"][key] = value
+        self.save()
+
+    def improve_record(self, key: str, value, lower_is_better: bool = False) -> bool:
+        """Store value if it beats the current record. Returns True if it did."""
+        current = self.data["records"].get(key)
+        if current is None or (value < current if lower_is_better else value > current):
+            self.set_record(key, value)
+            return True
+        return False
+
+
+_store: Store | None = None
+
+
+def get_store() -> Store:
+    """Return the process-wide statistics store."""
+    global _store
+    if _store is None:
+        _store = Store()
+    return _store
@@ -0,0 +1,215 @@+"""Terminal rendering helpers for Pablo's Terminal Games.
+
+Screen control, ANSI colors, text centering, boxes and small UI
+helpers. Everything degrades gracefully when ANSI colors or Unicode
+are not available. Game logic must never depend on this module.
+"""
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import sys
+
+MIN_COLS = 80
+MIN_ROWS = 24
+
+_ANSI_RE = re.compile(r"\033\[[0-9;?]*[a-zA-Z]")
+
+
+def _enable_windows_ansi() -> bool:
+    """Enable virtual terminal processing on Windows 10+ consoles."""
+    if sys.platform != "win32":
+        return False
+    try:
+        import ctypes
+
+        kernel32 = ctypes.windll.kernel32
+        handle = kernel32.GetStdHandle(-11)  # STD_OUTPUT_HANDLE
+        mode = ctypes.c_ulong()
+        if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
+            return False
+        ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
+        return bool(
+            kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
+        )
+    except Exception:
+        return False
+
+
+def _detect_ansi() -> bool:
+    if os.environ.get("NO_COLOR"):
+        return False
+    if sys.platform == "win32":
+        return _enable_windows_ansi()
+    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
+
+
+def _detect_unicode() -> bool:
+    encoding = (getattr(sys.stdout, "encoding", None) or "").lower()
+    return "utf" in encoding
+
+
+ANSI = _detect_ansi()
+UNICODE = _detect_unicode()
+
+
+class Color:
+    """Semantic terminal colors. Use sparingly."""
+
+    RESET = "\033[0m"
+    BOLD = "\033[1m"
+    DIM = "\033[2m"
+    RED = "\033[31m"
+    GREEN = "\033[32m"
+    YELLOW = "\033[33m"
+    CYAN = "\033[36m"
+    WHITE = "\033[37m"
+    GRAY = "\033[90m"
+
+
+STATUS_COLORS = {
+    "info": Color.CYAN,
+    "ok": Color.GREEN,
+    "warn": Color.YELLOW,
+    "error": Color.RED,
+    "dim": Color.GRAY,
+}
+
+
+def style(text: object, *codes: str) -> str:
+    """Wrap text in ANSI codes, or return it plain when unsupported."""
+    text = str(text)
+    if not ANSI or not codes:
+        return text
+    return "".join(codes) + text + Color.RESET
+
+
+def visible_len(text: str) -> int:
+    """Length of text ignoring ANSI escape sequences."""
+    return len(_ANSI_RE.sub("", text))
+
+
+# ---------------------------------------------------------------- screen
+
+def clear() -> None:
+    if ANSI:
+        sys.stdout.write("\033[2J\033[H")
+    else:
+        sys.stdout.write("\n" * 3)
+    sys.stdout.flush()
+
+
+def move_to(row: int, col: int) -> None:
+    if ANSI:
+        sys.stdout.write(f"\033[{row};{col}H")
+
+
+def hide_cursor() -> None:
+    if ANSI:
+        sys.stdout.write("\033[?25l")
+        sys.stdout.flush()
+
+
+def show_cursor() -> None:
+    if ANSI:
+        sys.stdout.write("\033[?25h")
+        sys.stdout.flush()
+
+
+def reset_style() -> None:
+    if ANSI:
+        sys.stdout.write(Color.RESET)
+
+
+def cleanup() -> None:
+    """Restore the terminal to a sane state before exiting."""
+    reset_style()
+    show_cursor()
+    sys.stdout.flush()
+
+
+def size() -> tuple[int, int]:
+    s = shutil.get_terminal_size((MIN_COLS, MIN_ROWS))
+    return s.columns, s.lines
+
+
+def too_small() -> bool:
+    cols, rows = size()
+    return cols < MIN_COLS or rows < MIN_ROWS
+
+
+# ---------------------------------------------------------------- text
+
+def center(text: str, width: int | None = None) -> str:
+    """Center text (ANSI-aware) within width, default terminal width."""
+    if width is None:
+        width = size()[0]
+    pad = max(0, (width - visible_len(text)) // 2)
+    return " " * pad + text
+
+
+def print_centered(lines, width: int | None = None) -> None:
+    for line in lines:
+        print(center(line, width))
+
+
+def status(message: str, kind: str = "info") -> None:
+    """Print a colored status line."""
+    print(style(message, STATUS_COLORS.get(kind, "")))
+
+
+# ---------------------------------------------------------------- boxes
+
+if UNICODE:
+    BOX = {
+        "tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
+        "h": "─", "v": "│",
+        "lt": "├", "rt": "┤", "tt": "┬", "bt": "┴", "x": "┼",
+    }
+    BOX_DOUBLE = {
+        "tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "h": "═", "v": "║",
+    }
+else:
+    BOX = {
+        "tl": "+", "tr": "+", "bl": "+", "br": "+",
+        "h": "-", "v": "|",
+        "lt": "+", "rt": "+", "tt": "+", "bt": "+", "x": "+",
+    }
+    BOX_DOUBLE = {
+        "tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "=", "v": "|",
+    }
+
+
+def box(lines, pad: int = 1, double: bool = False) -> list[str]:
+    """Frame a list of text lines in a box. Returns list of strings."""
+    chars = BOX_DOUBLE if double else BOX
+    width = max((visible_len(l) for l in lines), default=0) + pad * 2
+    out = [chars["tl"] + chars["h"] * width + chars["tr"]]
+    for line in lines:
+        space = width - pad - visible_len(line)
+        out.append(chars["v"] + " " * pad + line + " " * space + chars["v"])
+    out.append(chars["bl"] + chars["h"] * width + chars["br"])
+    return out
+
+
+# ---------------------------------------------------------------- prompts
+
+def confirm(prompt: str, default: bool = False) -> bool:
+    """Ask a [Y/N] question. Returns the boolean answer."""
+    suffix = "[Y/n]" if default else "[y/N]"
+    try:
+        answer = input(f"{prompt} {suffix} ").strip().lower()
+    except (EOFError, KeyboardInterrupt):
+        print()
+        return False
+    if not answer:
+        return default
+    return answer.startswith("y")
+
+
+def press_enter(message: str = "Press Enter to continue...") -> None:
+    try:
+        input(style(message, Color.GRAY))
+    except (EOFError, KeyboardInterrupt):
+        print()
@@ -0,0 +1,20 @@+"""Small shared utilities."""
+from __future__ import annotations
+
+
+def format_time(seconds) -> str:
+    """Format elapsed seconds as MM:SS (or H:MM:SS for long runs)."""
+    seconds = int(seconds or 0)
+    hours, rem = divmod(seconds, 3600)
+    minutes, secs = divmod(rem, 60)
+    if hours:
+        return f"{hours}:{minutes:02d}:{secs:02d}"
+    return f"{minutes:02d}:{secs:02d}"
+
+
+def safe_int(text, default=None):
+    """Parse an int without raising."""
+    try:
+        return int(str(text).strip())
+    except (TypeError, ValueError):
+        return default
@@ -0,0 +1,5 @@+"""Game modules for Pablo's Terminal Games.
+
+Each module exposes NAME, KEY, DESCRIPTION and a Game class with
+run(). See GAME_SPEC.md for the full contract.
+"""
@@ -0,0 +1,545 @@+"""Checkers - standard English/American 8x8 draughts.
+
+Pure game logic lives in ``Move``, ``CheckersState``, ``parse_move``
+and ``choose_computer_chain`` (no printing, no input) so unit tests
+can drive them headless. ``Game`` is the interactive UI layer on top.
+
+Board orientation: black ("b") starts at the top (rows 1-3), white
+("w") at the bottom (rows 6-8). White moves first and moves "up"
+(towards row 1). Kings are the uppercase letters "W"/"B". Only the
+dark squares are playable; row 1 is at the top of the screen.
+"""
+from __future__ import annotations
+
+import random
+import re
+import time
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+
+NAME = "Checkers"
+KEY = "checkers"
+DESCRIPTION = "Classic 8x8 draughts: jump all of the enemy pieces."
+
+SIZE = 8
+WHITE = "w"
+BLACK = "b"
+EMPTY = " "
+COLORS = (WHITE, BLACK)
+COLUMNS = "abcdefgh"
+
+_MOVE_RE = re.compile(
+    r"^\s*([a-hA-H])([1-8])\s*[-,;:]?\s*([a-hA-H])([1-8])\s*$"
+)
+
+
+def opponent(color):
+    """Return the other color."""
+    return BLACK if color == WHITE else WHITE
+
+
+def color_of(piece):
+    """Color ('w' or 'b') of a piece letter, or None for empty."""
+    if piece in (EMPTY, None):
+        return None
+    return piece.lower()
+
+
+def is_king(piece):
+    """True when the piece letter is a king (uppercase)."""
+    return piece.isupper()
+
+
+def in_bounds(row, col):
+    return 0 <= row < SIZE and 0 <= col < SIZE
+
+
+def is_playable(row, col):
+    """Only dark squares are used; on those (row + col) is even."""
+    return in_bounds(row, col) and (row + col) % 2 == 0
+
+
+def square_to_coords(text):
+    """Parse one square like 'b6' (case-insensitive) into (row, col).
+
+    Returns None when the text is not a valid on-board square.
+    """
+    if not text:
+        return None
+    text = text.strip().lower()
+    if len(text) != 2:
+        return None
+    col_char, row_char = text[0], text[1]
+    if col_char not in COLUMNS or row_char not in "12345678":
+        return None
+    return int(row_char) - 1, COLUMNS.index(col_char)
+
+
+def coords_to_square(row, col):
+    """Inverse of square_to_coords: (5, 1) -> 'b6'."""
+    return f"{COLUMNS[col]}{row + 1}"
+
+
+def parse_move(text):
+    """Parse 'b6-a5', 'b6 a5' or 'b6a5' into ((row, col), (row, col)).
+
+    Returns None when the text is not a well-formed move on the board.
+    """
+    if not text:
+        return None
+    match = _MOVE_RE.match(text)
+    if not match:
+        return None
+    src = square_to_coords(match.group(1) + match.group(2))
+    dst = square_to_coords(match.group(3) + match.group(4))
+    if src is None or dst is None:
+        return None
+    return src, dst
+
+
+class Move:
+    """One single step: a simple diagonal move or one jump."""
+
+    __slots__ = ("src", "dst")
+
+    def __init__(self, src, dst):
+        self.src = src
+        self.dst = dst
+
+    @property
+    def is_capture(self):
+        return abs(self.src[0] - self.dst[0]) == 2
+
+    @property
+    def captured(self):
+        """Square of the jumped piece (only for captures)."""
+        return ((self.src[0] + self.dst[0]) // 2,
+                (self.src[1] + self.dst[1]) // 2)
+
+    def notation(self):
+        sep = "x" if self.is_capture else "-"
+        return (coords_to_square(*self.src) + sep
+                + coords_to_square(*self.dst))
+
+    def __eq__(self, other):
+        return (isinstance(other, Move)
+                and self.src == other.src and self.dst == other.dst)
+
+    def __hash__(self):
+        return hash((self.src, self.dst))
+
+    def __repr__(self):
+        return f"Move({self.notation()})"
+
+
+class CheckersState:
+    """Pure checkers game state. No printing, no input.
+
+    Moves are applied one single step at a time. After a jump that
+    can be continued, ``must_continue`` holds the square of the piece
+    that has to keep jumping; only captures from that square are then
+    legal. Reaching the last row promotes immediately and ends the
+    turn (English rules), even in the middle of a jump sequence.
+    """
+
+    def __init__(self, setup=True):
+        self.board = [[EMPTY] * SIZE for _ in range(SIZE)]
+        if setup:
+            for row in range(SIZE):
+                for col in range(SIZE):
+                    if not is_playable(row, col):
+                        continue
+                    if row < 3:
+                        self.board[row][col] = BLACK
+                    elif row > 4:
+                        self.board[row][col] = WHITE
+        self.turn = WHITE
+        self.must_continue = None  # (row, col) of a piece mid-chain
+
+    # ---------------------------------------------------------- basics
+
+    def clone(self):
+        """A deep copy that can be moved on without touching this one."""
+        other = CheckersState(setup=False)
+        other.board = [row[:] for row in self.board]
+        other.turn = self.turn
+        other.must_continue = self.must_continue
+        return other
+
+    def piece_at(self, row, col):
+        return self.board[row][col]
+
+    def set_piece(self, row, col, piece):
+        """Place a piece (or EMPTY) on a square. Test/setup helper."""
+        self.board[row][col] = piece
+
+    def pieces(self, color):
+        """Dict of {(row, col): piece} for one color."""
+        return {(r, c): self.board[r][c]
+                for r in range(SIZE) for c in range(SIZE)
+                if color_of(self.board[r][c]) == color}
+
+    def last_row(self, color):
+        return 0 if color == WHITE else SIZE - 1
+
+    # ---------------------------------------------------- move lookup
+
+    def _directions(self, piece):
+        """Forward dr values: men move one way, kings move both."""
+        if is_king(piece):
+            return (-1, 1)
+        return (-1,) if piece == WHITE else (1,)
+
+    def _steps_from(self, row, col):
+        """Simple (non-capture) diagonal moves of the piece here."""
+        piece = self.board[row][col]
+        moves = []
+        for dr in self._directions(piece):
+            for dc in (-1, 1):
+                nr, nc = row + dr, col + dc
+                if in_bounds(nr, nc) and self.board[nr][nc] == EMPTY:
+                    moves.append(Move((row, col), (nr, nc)))
+        return moves
+
+    def _jumps_from(self, row, col):
+        """Single jumps of the piece here (ignores global legality)."""
+        piece = self.board[row][col]
+        enemy = opponent(color_of(piece))
+        moves = []
+        for dr in self._directions(piece):
+            for dc in (-1, 1):
+                mr, mc = row + dr, col + dc      # jumped square
+                lr, lc = row + 2 * dr, col + 2 * dc  # landing square
+                if (in_bounds(lr, lc)
+                        and self.board[lr][lc] == EMPTY
+                        and color_of(self.board[mr][mc]) == enemy):
+                    moves.append(Move((row, col), (lr, lc)))
+        return moves
+
+    def legal_moves(self):
+        """All single-step moves the player to move may play now.
+
+        Captures are mandatory: when any jump exists, only jumps are
+        legal. While ``must_continue`` is set, only jumps with that
+        same piece are legal.
+        """
+        if self.must_continue is not None:
+            return self._jumps_from(*self.must_continue)
+        jumps = []
+        steps = []
+        for row, col in self.pieces(self.turn):
+            jumps.extend(self._jumps_from(row, col))
+            steps.extend(self._steps_from(row, col))
+        return jumps if jumps else steps
+
+    def capture_chains(self, row, col):
+        """All maximal jump sequences for the piece at (row, col).
+
+        Returns a list of chains; each chain is a list of ``Move``.
+        An empty list means the piece cannot jump.
+        """
+        if color_of(self.board[row][col]) != self.turn:
+            return []
+        chains = []
+
+        def walk(state, pos, chain):
+            jumps = state._jumps_from(*pos)
+            if not jumps:
+                if chain:
+                    chains.append(chain)
+                return
+            for jump in jumps:
+                nxt = state.clone()
+                nxt.apply(jump)
+                if nxt.turn != state.turn:
+                    chains.append(chain + [jump])  # promotion ends it
+                else:
+                    walk(nxt, jump.dst, chain + [jump])
+
+        walk(self.clone(), (row, col), [])
+        return chains
+
+    # -------------------------------------------------------- applying
+
+    def apply(self, move):
+        """Play one single-step move. Raises ValueError if illegal."""
+        if move not in self.legal_moves():
+            raise ValueError(f"Illegal move: {move.notation()}")
+        piece = self.board[move.src[0]][move.src[1]]
+        self.board[move.src[0]][move.src[1]] = EMPTY
+        self.board[move.dst[0]][move.dst[1]] = piece
+        if move.is_capture:
+            cr, cc = move.captured
+            self.board[cr][cc] = EMPTY
+        promoted = False
+        if (not is_king(piece)
+                and move.dst[0] == self.last_row(self.turn)):
+            self.board[move.dst[0]][move.dst[1]] = piece.upper()
+            promoted = True
+        if move.is_capture and not promoted:
+            if self._jumps_from(*move.dst):
+                self.must_continue = move.dst
+                return
+        self.must_continue = None
+        self.turn = opponent(self.turn)
+
+    # --------------------------------------------------------- outcome
+
+    def winner(self):
+        """'w' or 'b' when the game is decided, else None.
+
+        A player wins when the opponent has no pieces left or no
+        legal move on their turn (while not mid-chain).
+        """
+        if self.must_continue is not None:
+            return None
+        if not self.pieces(self.turn):
+            return opponent(self.turn)
+        if not self.legal_moves():
+            return opponent(self.turn)
+        return None
+
+
+# ------------------------------------------------------------- computer
+
+
+def _hands_opponent_a_capture(state, move):
+    """True when playing ``move`` gives the opponent a direct jump."""
+    nxt = state.clone()
+    nxt.apply(move)
+    if nxt.turn == state.turn:
+        return False  # move continued a chain; opponent not to move
+    return any(m.is_capture for m in nxt.legal_moves())
+
+
+def _advance_score(move, color):
+    """How far towards the promotion row the destination lies."""
+    if color == WHITE:
+        return (SIZE - 1) - move.dst[0]
+    return move.dst[0]
+
+
+def choose_computer_chain(state, color, rng=None):
+    """Pick the computer's play as a list of single-step Moves.
+
+    Priority: 1. captures (most jumps win), 2. moves that do not
+    hand the opponent a capture, 3. promotions, 4. the most advanced
+    destination. Ties are broken at random.
+    """
+    rng = rng or random
+    chains = []
+    for row, col in state.pieces(color):
+        chains.extend(state.capture_chains(row, col))
+    if chains:
+        longest = max(len(chain) for chain in chains)
+        return rng.choice([c for c in chains if len(c) == longest])
+
+    moves = [m for m in state.legal_moves() if not m.is_capture]
+    if not moves:
+        return []
+    safe = [m for m in moves if not _hands_opponent_a_capture(state, m)]
+    pool = safe or moves
+    promoting = [m for m in pool
+                 if not is_king(state.piece_at(*m.src))
+                 and m.dst[0] == state.last_row(color)]
+    if promoting:
+        return [rng.choice(promoting)]
+    best = max(_advance_score(m, color) for m in pool)
+    return [rng.choice([m for m in pool
+                        if _advance_score(m, color) == best])]
+
+
+# ------------------------------------------------------------------- UI
+
+
+class Game:
+    """Interactive UI layer: menus, rendering, prompts, statistics."""
+
+    def run(self):
+        try:
+            term.hide_cursor()
+            while True:
+                mode = self._choose_mode()
+                if mode is None:
+                    return
+                self._play_match(mode)
+                if not term.confirm("Play again?"):
+                    return
+        finally:
+            term.show_cursor()
+
+    # ------------------------------------------------------------ menus
+
+    def _choose_mode(self):
+        title = term.style(NAME, term.Color.CYAN, term.Color.BOLD)
+        choice = choose(
+            [title, "", DESCRIPTION, "", "You play White and move first."],
+            ["Player vs Computer", "Player vs Player"],
+            hotkeys=["1", "2"],
+            extra_keys="h",
+            hint="H help - Q/ESC return to menu",
+        )
+        if choice == "h":
+            self._show_help()
+            return self._choose_mode()
+        return choice  # 0 (vs computer), 1 (vs player) or None (quit)
+
+    def _show_help(self):
+        term.clear()
+        print()
+        lines = [
+            term.style(f"HOW TO PLAY - {NAME}", term.Color.CYAN, term.Color.BOLD),
+            "",
+            term.style("Objective", term.Color.CYAN),
+            "Capture all enemy pieces, or leave your opponent with no",
+            "legal move. White moves first.",
+            "",
+            term.style("Controls", term.Color.CYAN),
+            "Type a move as FROM-TO, for example b6-a5 or b6 a5, and",
+            "press Enter. Columns are a-h, rows are 1-8.",
+            "  H  show this help screen",
+            "  Q  return to the main menu",
+            "",
+            term.style("Rules", term.Color.CYAN),
+            "Men (w/b) move one square diagonally forward; kings",
+            "(W/B) also move backwards. Captures are made by jumping",
+            "over an enemy piece and are MANDATORY: when a jump is",
+            "available, only jumps are legal. A jumped piece that can",
+            "jump again must keep jumping with the same piece. A man",
+            "reaching the last row is promoted to a king.",
+        ]
+        for line in term.box(lines, pad=2):
+            print(term.center(line))
+        print()
+        term.press_enter()
+
+    # ------------------------------------------------------------ match
+
+    def _play_match(self, mode):
+        state = CheckersState()
+        message = ""
+        while True:
+            winner = state.winner()
+            if winner is not None:
+                self._render(state, "")
+                self._announce_result(winner, mode)
+                return
+            if mode == 0 and state.turn == BLACK:
+                self._computer_turn(state)
+                message = ""
+            else:
+                action = self._human_turn(state, mode, message)
+                message = ""
+                if action == "quit":
+                    if term.confirm("Return to the main menu?"):
+                        return
+                elif action == "help":
+                    self._show_help()
+
+    def _computer_turn(self, state):
+        chain = choose_computer_chain(state, BLACK)
+        if not chain:
+            return
+        self._render(state, "The computer is thinking...")
+        time.sleep(0.6)
+        for move in chain:
+            state.apply(move)
+            self._render(state, f"Computer plays {move.notation()}.")
+            time.sleep(0.6)
+
+    def _human_turn(self, state, mode, message):
+        """Ask for a move. Applies it, or returns 'help' / 'quit'."""
+        label = self._turn_label(state, mode)
+        while True:
+            self._render(state, message)
+            print(term.center(label))
+            answer = read_line(term.center("Move (e.g. b6-a5, h help, q quit): "))
+            if answer is None:
+                return "quit"
+            answer = answer.strip().lower()
+            if answer == "q":
+                return "quit"
+            if answer == "h":
+                return "help"
+            parsed = parse_move(answer)
+            if parsed is None:
+                message = "Invalid move. Use something like b6-a5."
+                continue
+            move = Move(*parsed)
+            if state.must_continue is not None and move.src != state.must_continue:
+                square = coords_to_square(*state.must_continue)
+                message = f"You must keep jumping with the piece on {square}."
+                continue
+            if move not in state.legal_moves():
+                if any(m.is_capture for m in state.legal_moves()):
+                    message = "A capture is mandatory. You must jump."
+                else:
+                    message = "Illegal move. Try again."
+                continue
+            state.apply(move)
+            return "moved"
+
+    def _turn_label(self, state, mode):
+        side = "White" if state.turn == WHITE else "Black"
+        if mode == 0:
+            return f"Your move ({side})"
+        return f"{side} to move"
+
+    # ------------------------------------------------------------ render
+
+    def _render(self, state, message):
+        term.clear()
+        print()
+        term.print_centered([term.style(NAME, term.Color.CYAN, term.Color.BOLD), ""])
+        for line in self._board_lines(state):
+            print(term.center(line))
+        print()
+        if state.must_continue is not None:
+            square = coords_to_square(*state.must_continue)
+            print(term.center(term.style(
+                f"Multi-jump! Continue with {square}.", term.Color.YELLOW)))
+        if message:
+            print(term.center(term.style(message, term.Color.GRAY)))
+
+    def _board_lines(self, state):
+        lines = ["   " + " ".join(COLUMNS)]
+        for row in range(SIZE):
+            cells = [self._cell_text(state, row, col) for col in range(SIZE)]
+            lines.append(f" {row + 1} " + " ".join(cells))
+        return lines
+
+    def _cell_text(self, state, row, col):
+        piece = state.piece_at(row, col)
+        if piece == EMPTY:
+            if is_playable(row, col):
+                return " "
+            return term.style(".", term.Color.GRAY)
+        if term.UNICODE:
+            glyphs = {WHITE: "○", "W": "◎", BLACK: "●", "B": "◉"}
+            text = glyphs[piece]
+        else:
+            text = piece
+        if color_of(piece) == WHITE:
+            return term.style(text, term.Color.WHITE, term.Color.BOLD)
+        return term.style(text, term.Color.GRAY)
+
+    # ------------------------------------------------------------ result
+
+    def _announce_result(self, winner, mode):
+        store = get_store()
+        print()
+        side = "White" if winner == WHITE else "Black"
+        if mode == 0:
+            if winner == WHITE:
+                store.record_result(KEY, "win")
+                term.print_centered([term.style("You win! Congratulations!", term.Color.GREEN)])
+            else:
+                store.record_result(KEY, "loss")
+                term.print_centered([term.style("The computer wins. Try again!", term.Color.RED)])
+        else:
+            store.record_result(KEY, "win")
+            term.print_centered([term.style(f"{side} wins!", term.Color.GREEN)])
+        print()
@@ -0,0 +1,327 @@+"""Connect Four for Pablo's Terminal Games.
+
+Drop pieces into columns; the first player to connect four in a row
+(horizontally, vertically or diagonally) wins. Supports Player vs
+Computer and local Player vs Player.
+
+The rules live in the pure-logic classes ``Board`` and
+``ComputerPlayer`` (no printing, no input); ``Game`` is the UI layer.
+"""
+from __future__ import annotations
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+from core.utils import safe_int
+
+NAME = "Connect Four"
+KEY = "connect_four"
+DESCRIPTION = "Drop pieces and connect four in a row to win."
+
+EMPTY = 0
+PLAYER_ONE = 1
+PLAYER_TWO = 2
+
+COLUMN_COUNT = 7
+ROW_COUNT = 6
+CONNECT = 4
+
+# Search order for the AI's positional preference (center first).
+CENTER_ORDER = (3, 2, 4, 1, 5, 0, 6)
+
+_DIRECTIONS = ((0, 1), (1, 0), (1, 1), (1, -1))
+
+
+class Board:
+    """Pure Connect Four board state.
+
+    ``grid[row][col]`` holds EMPTY, PLAYER_ONE or PLAYER_TWO.
+    Row 0 is the TOP row; pieces fall towards the highest row index.
+    """
+
+    def __init__(self) -> None:
+        self.grid = [[EMPTY] * COLUMN_COUNT for _ in range(ROW_COUNT)]
+        self.moves = 0
+
+    def is_valid_column(self, column: int) -> bool:
+        """True when the column index is in range and not full."""
+        return 0 <= column < COLUMN_COUNT and self.grid[0][column] == EMPTY
+
+    def valid_columns(self) -> list[int]:
+        """Column indices that can still accept a piece."""
+        return [c for c in range(COLUMN_COUNT) if self.is_valid_column(c)]
+
+    def drop(self, column: int, player: int) -> int | None:
+        """Drop a piece. Returns the landing row, or None when invalid."""
+        if not self.is_valid_column(column):
+            return None
+        row = ROW_COUNT - 1
+        while self.grid[row][column] != EMPTY:
+            row -= 1
+        self.grid[row][column] = player
+        self.moves += 1
+        return row
+
+    def is_full(self) -> bool:
+        return self.moves >= COLUMN_COUNT * ROW_COUNT
+
+    def has_won(self, player: int) -> bool:
+        """True when player has CONNECT pieces in a row anywhere."""
+        for row in range(ROW_COUNT):
+            for col in range(COLUMN_COUNT):
+                if self.grid[row][col] != player:
+                    continue
+                for d_row, d_col in _DIRECTIONS:
+                    if self._count_run(row, col, d_row, d_col, player) >= CONNECT:
+                        return True
+        return False
+
+    def _count_run(self, row: int, col: int, d_row: int, d_col: int,
+                   player: int) -> int:
+        count = 0
+        while (0 <= row < ROW_COUNT and 0 <= col < COLUMN_COUNT
+               and self.grid[row][col] == player):
+            count += 1
+            row += d_row
+            col += d_col
+        return count
+
+    def winner(self) -> int:
+        """PLAYER_ONE, PLAYER_TWO or EMPTY when nobody has won yet."""
+        for player in (PLAYER_ONE, PLAYER_TWO):
+            if self.has_won(player):
+                return player
+        return EMPTY
+
+    def is_draw(self) -> bool:
+        """Full board with no winner."""
+        return self.is_full() and self.winner() == EMPTY
+
+
+class ComputerPlayer:
+    """Simple Connect Four AI.
+
+    Priority: win immediately, block the opponent's immediate win,
+    take the most central available column.
+    """
+
+    def __init__(self, player: int = PLAYER_TWO) -> None:
+        self.player = player
+        self.opponent = PLAYER_ONE if player == PLAYER_TWO else PLAYER_ONE
+
+    def choose_column(self, board: Board) -> int | None:
+        """Return the column index the computer wants to play."""
+        for candidate in (self.player, self.opponent):
+            move = self._find_winning_move(board, candidate)
+            if move is not None:
+                return move
+        for column in CENTER_ORDER:
+            if board.is_valid_column(column):
+                return column
+        # CENTER_ORDER covers every column; reaching here means the
+        # board is full, which the game loop never allows.
+        return None
+
+    @staticmethod
+    def _find_winning_move(board: Board, player: int) -> int | None:
+        """Column that completes a four-in-a-row for player, or None."""
+        for column in board.valid_columns():
+            probe = Board()
+            probe.grid = [row[:] for row in board.grid]
+            probe.moves = board.moves
+            probe.drop(column, player)
+            if probe.has_won(player):
+                return column
+        return None
+
+
+def other_player(player: int) -> int:
+    return PLAYER_TWO if player == PLAYER_ONE else PLAYER_ONE
+
+
+def player_name(player: int, vs_computer: bool) -> str:
+    if vs_computer:
+        return "You" if player == PLAYER_ONE else "Computer"
+    return f"Player {player}"
+
+
+def piece_symbol(player: int) -> str:
+    if player == EMPTY:
+        return " "
+    if term.UNICODE:
+        return "\u25cf" if player == PLAYER_ONE else "\u25cb"  # ● / ○
+    return "X" if player == PLAYER_ONE else "O"
+
+
+def colored_piece(player: int) -> str:
+    color = term.Color.YELLOW if player == PLAYER_ONE else term.Color.RED
+    return term.style(piece_symbol(player), color)
+
+
+class Game:
+    """Interactive Connect Four session (UI layer)."""
+
+    def run(self) -> None:
+        while True:
+            vs_computer = self._choose_mode()
+            if vs_computer is None:
+                return
+            self._play_match(vs_computer)
+            if not term.confirm("Play again?"):
+                return
+
+    # ------------------------------------------------------------ menus
+
+    def _choose_mode(self) -> bool | None:
+        """True for Player vs Computer, False for PvP, None to quit."""
+        title = [
+            term.style("CONNECT FOUR", term.Color.CYAN, term.Color.BOLD),
+            "",
+            term.style(DESCRIPTION, term.Color.GRAY),
+        ]
+        choice = choose(title, ["Player vs Computer", "Player vs Player"],
+                        hint="Enter select - Q/ESC back to menu")
+        if choice is None:
+            return None
+        return choice == 0
+
+    # ------------------------------------------------------------ match
+
+    def _play_match(self, vs_computer: bool) -> None:
+        board = Board()
+        computer = ComputerPlayer(PLAYER_TWO) if vs_computer else None
+        current = PLAYER_ONE
+        while True:
+            self._render(board)
+            if vs_computer and current == PLAYER_TWO:
+                term.status("Computer is thinking...", "dim")
+                column = computer.choose_column(board)
+            else:
+                column = self._ask_column(board, current, vs_computer)
+                if column is None:
+                    return  # player chose to leave the match
+            row = board.drop(column, current)
+            if row is None:
+                term.status("That column is full. Try another one.", "warn")
+                term.press_enter()
+                continue
+            if board.has_won(current):
+                self._render(board)
+                self._finish(board, current, vs_computer)
+                return
+            if board.is_full():
+                self._render(board)
+                self._finish(board, EMPTY, vs_computer)
+                return
+            current = other_player(current)
+
+    def _ask_column(self, board: Board, player: int, vs_computer: bool) -> int | None:
+        """Prompt for a column (1-7). None means the player quit."""
+        name = player_name(player, vs_computer)
+        prompt = f"{name} ({colored_piece(player)}), choose a column (1-{COLUMN_COUNT}), H for help, Q to quit: "
+        while True:
+            answer = read_line(prompt)
+            if answer is None:
+                return None
+            answer = answer.strip().lower()
+            if answer == "q":
+                if term.confirm("Return to the main menu?"):
+                    return None
+                self._render(board)
+                continue
+            if answer == "h":
+                self._show_help()
+                self._render(board)
+                continue
+            column = safe_int(answer)
+            if column is None or not 1 <= column <= COLUMN_COUNT:
+                term.status(f"Invalid move. Enter a number from 1 to {COLUMN_COUNT}.", "error")
+                continue
+            if not board.is_valid_column(column - 1):
+                term.status("That column is full. Try another one.", "warn")
+                continue
+            return column - 1
+
+    def _finish(self, board: Board, winner: int, vs_computer: bool) -> None:
+        store = get_store()
+        if winner == EMPTY:
+            term.status("The board is full. It's a draw!", "warn")
+            store.record_result(KEY, "draw")
+        elif vs_computer:
+            if winner == PLAYER_ONE:
+                term.status("You connected four. You win!", "ok")
+                store.record_result(KEY, "win")
+            else:
+                term.status("The computer connected four. You lose.", "error")
+                store.record_result(KEY, "loss")
+        else:
+            name = player_name(winner, vs_computer)
+            term.status(f"{name} connected four and wins the game!", "ok")
+            store.record_result(KEY, "win")
+        term.press_enter()
+
+    # ------------------------------------------------------------ render
+
+    def _render(self, board: Board) -> None:
+        term.clear()
+        print()
+        term.print_centered([
+            term.style("CONNECT FOUR", term.Color.CYAN, term.Color.BOLD),
+            "",
+        ])
+        cell = 4
+        width = COLUMN_COUNT * cell + 1
+        border = term.BOX
+        pad = max(0, (term.size()[0] - width) // 2)
+
+        def line(text: str) -> None:
+            print(" " * pad + text)
+
+        numbers = " ".join(f" {n} " for n in range(1, COLUMN_COUNT + 1))
+        line(term.style(numbers, term.Color.CYAN))
+        line(border["tl"] + border["tt"].join(border["h"] * (cell - 1)
+                                              for _ in range(COLUMN_COUNT)) + border["tr"])
+        for row in range(ROW_COUNT):
+            cells = border["v"].join(f" {colored_piece(board.grid[row][c])} "
+                                     for c in range(COLUMN_COUNT))
+            line(border["v"] + cells + border["v"])
+            if row < ROW_COUNT - 1:
+                line(border["lt"] + border["x"].join(border["h"] * (cell - 1)
+                                                     for _ in range(COLUMN_COUNT)) + border["rt"])
+        line(border["bl"] + border["bt"].join(border["h"] * (cell - 1)
+                                              for _ in range(COLUMN_COUNT)) + border["br"])
+        print()
+        legend = (f"{colored_piece(PLAYER_ONE)} Player 1   "
+                  f"{colored_piece(PLAYER_TWO)} Player 2")
+        print(term.center(legend))
+        print()
+
+    # ------------------------------------------------------------ help
+
+    def _show_help(self) -> None:
+        term.clear()
+        print()
+        lines = [
+            term.style("CONNECT FOUR - HELP", term.Color.CYAN, term.Color.BOLD),
+            "",
+            term.style("Objective:", term.Color.CYAN),
+            "Be the first to connect four of your pieces in a row:",
+            "horizontally, vertically or diagonally.",
+            "",
+            term.style("Controls:", term.Color.CYAN),
+            f"  1-{COLUMN_COUNT}  drop a piece into that column",
+            "  H    show this help screen",
+            "  Q    return to the main menu (asks for confirmation)",
+            "",
+            term.style("Rules:", term.Color.CYAN),
+            "Players take turns dropping one piece per turn.",
+            "Pieces fall to the lowest free cell of the column.",
+            "A column is full once it holds six pieces.",
+            "If the whole board fills with no four in a row,",
+            "the game is a draw.",
+        ]
+        for line in term.box(lines, pad=2):
+            print(term.center(line))
+        print()
+        term.press_enter()
@@ -0,0 +1,323 @@+"""Fifteen Puzzle - the classic 4x4 sliding tile puzzle.
+
+Tiles 1-15 and one empty cell sit on a 4x4 grid. Sliding tiles
+around, the player must restore the tiles to ascending order with
+the empty cell in the bottom-right corner.
+
+Control convention: a direction key names the direction in which a
+tile slides INTO the empty cell. Pressing UP (or W) slides the tile
+below the gap upward into it, and so on.
+"""
+from __future__ import annotations
+
+import random
+import time
+
+from core import terminal as term
+from core.input import Key, read_key
+from core.storage import get_store
+from core.utils import format_time
+
+NAME = "Fifteen Puzzle"
+KEY = "fifteen"
+DESCRIPTION = "Slide the numbered tiles into order."
+
+RECORD_MOVES = "fifteen_best_moves"
+RECORD_TIME = "fifteen_best_time"
+
+DIRECTIONS = ("UP", "DOWN", "LEFT", "RIGHT")
+OPPOSITE = {"UP": "DOWN", "DOWN": "UP", "LEFT": "RIGHT", "RIGHT": "LEFT"}
+
+SCRAMBLE_MOVES = 200
+CELL_WIDTH = 4
+
+
+class Board:
+    """Pure 4x4 sliding-puzzle logic. No printing, no input.
+
+    Tiles are stored row-major in a flat list of 16 integers; 0 is
+    the empty cell. A move is named after the direction the TILE
+    slides into the empty cell: "UP" moves the tile below the gap
+    up into it (the gap itself moves down).
+    """
+
+    SIZE = 4
+    BLANK = 0
+
+    def __init__(self, tiles=None):
+        if tiles is None:
+            tiles = self.solved_tiles()
+        tiles = list(tiles)
+        expected = list(range(self.SIZE * self.SIZE))
+        if len(tiles) != len(expected) or sorted(tiles) != expected:
+            raise ValueError("tiles must be a permutation of 0..15")
+        self.tiles = tiles
+        self.move_count = 0
+
+    # ---------------------------------------------------------- setup
+
+    @classmethod
+    def solved_tiles(cls) -> list[int]:
+        """Tiles of the goal state: 1..15 in order, blank last."""
+        return list(range(1, cls.SIZE * cls.SIZE)) + [cls.BLANK]
+
+    @classmethod
+    def shuffled(cls, rng=None, scramble_moves: int = SCRAMBLE_MOVES) -> "Board":
+        """Build a board by applying random legal moves from the goal.
+
+        Shuffling with legal moves guarantees a solvable position by
+        construction; immediate backtracking is avoided so the board
+        is genuinely mixed.
+        """
+        rng = rng or random
+        board = cls()
+        previous = None
+        for _ in range(scramble_moves):
+            options = [d for d in board.legal_moves() if d != OPPOSITE[previous]] \
+                if previous else board.legal_moves()
+            direction = rng.choice(options)
+            board.move(direction)
+            previous = direction
+        board.move_count = 0
+        if board.is_solved():  # astronomically unlikely, but never hand back a solved board
+            return cls.shuffled(rng, scramble_moves)
+        return board
+
+    # -------------------------------------------------------- queries
+
+    def blank_index(self) -> int:
+        return self.tiles.index(self.BLANK)
+
+    def blank_position(self) -> tuple[int, int]:
+        """(row, col) of the empty cell, 0-based."""
+        index = self.blank_index()
+        return divmod(index, self.SIZE)
+
+    def tile_at(self, row: int, col: int) -> int:
+        return self.tiles[row * self.SIZE + col]
+
+    def is_solved(self) -> bool:
+        return self.tiles == self.solved_tiles()
+
+    def legal_moves(self) -> list[str]:
+        return [d for d in DIRECTIONS if self.can_move(d)]
+
+    def can_move(self, direction: str) -> bool:
+        """True when a tile can slide `direction` into the empty cell."""
+        row, col = self.blank_position()
+        if direction == "UP":     # tile below the gap slides up
+            return row < self.SIZE - 1
+        if direction == "DOWN":   # tile above the gap slides down
+            return row > 0
+        if direction == "LEFT":   # tile right of the gap slides left
+            return col < self.SIZE - 1
+        if direction == "RIGHT":  # tile left of the gap slides right
+            return col > 0
+        return False
+
+    # -------------------------------------------------------- changes
+
+    def move(self, direction: str) -> bool:
+        """Slide a tile `direction` into the gap. False when impossible."""
+        if not self.can_move(direction):
+            return False
+        index = self.blank_index()
+        if direction == "UP":
+            other = index + self.SIZE
+        elif direction == "DOWN":
+            other = index - self.SIZE
+        elif direction == "LEFT":
+            other = index + 1
+        else:  # RIGHT
+            other = index - 1
+        self.tiles[index], self.tiles[other] = self.tiles[other], self.tiles[index]
+        self.move_count += 1
+        return True
+
+    # ---------------------------------------------------- solvability
+
+    @staticmethod
+    def count_inversions(tiles) -> int:
+        """Pairs (i < j) with tiles[i] > tiles[j]; the blank is ignored."""
+        sequence = [t for t in tiles if t != Board.BLANK]
+        inversions = 0
+        for i in range(len(sequence)):
+            for j in range(i + 1, len(sequence)):
+                if sequence[i] > sequence[j]:
+                    inversions += 1
+        return inversions
+
+    @staticmethod
+    def is_solvable(tiles) -> bool:
+        """Parity test for a 4-wide grid.
+
+        Solvable iff inversions + row-of-blank-from-bottom is odd.
+        """
+        tiles = list(tiles)
+        inversions = Board.count_inversions(tiles)
+        blank_row = tiles.index(Board.BLANK) // Board.SIZE
+        row_from_bottom = Board.SIZE - blank_row
+        return (inversions + row_from_bottom) % 2 == 1
+
+
+class Game:
+    """UI layer: rendering, input handling, statistics."""
+
+    def __init__(self):
+        self.store = get_store()
+
+    def run(self) -> None:
+        term.hide_cursor()
+        try:
+            while True:
+                if not self._play_round():
+                    break  # player quit mid-game
+                if not term.confirm("Play again?", default=True):
+                    break
+        finally:
+            term.show_cursor()
+
+    # -------------------------------------------------------- one game
+
+    def _play_round(self) -> bool:
+        """Play one board. True when it was solved, False when quit."""
+        board = Board.shuffled()
+        start = time.monotonic()
+        message = ""
+        while True:
+            self._draw(board, time.monotonic() - start, message)
+            message = ""
+            key = read_key()
+            direction = self._direction_for(key)
+            if direction is not None:
+                if board.move(direction):
+                    if board.is_solved():
+                        self._handle_victory(board, time.monotonic() - start)
+                        return True
+                else:
+                    message = "No tile can slide that way."
+            elif key in (Key.ESCAPE,) or (isinstance(key, str) and key.lower() == "q"):
+                if term.confirm("Return to the main menu?"):
+                    return False
+            elif isinstance(key, str) and key.lower() == "r":
+                board = Board.shuffled()
+                start = time.monotonic()
+                message = "Board reshuffled. Good luck!"
+            elif isinstance(key, str) and key.lower() == "h":
+                self._show_help()
+            # any other key is ignored
+
+    @staticmethod
+    def _direction_for(key) -> str | None:
+        """Map an arrow key or WASD letter to a slide direction."""
+        mapping = {
+            Key.UP: "UP", Key.DOWN: "DOWN", Key.LEFT: "LEFT", Key.RIGHT: "RIGHT",
+            "w": "UP", "s": "DOWN", "a": "LEFT", "d": "RIGHT",
+        }
+        if not isinstance(key, str):
+            return None
+        if key in mapping:
+            return mapping[key]
+        return mapping.get(key.lower())
+
+    def _handle_victory(self, board: Board, elapsed: float) -> None:
+        seconds = max(1, int(round(elapsed)))
+        self.store.record_result(KEY, "win")
+        best_moves = self.store.improve_record(RECORD_MOVES, board.move_count, True)
+        best_time = self.store.improve_record(RECORD_TIME, seconds, True)
+        self._draw(board, elapsed)
+        print()
+        term.print_centered([
+            term.style("PUZZLE SOLVED! Congratulations!", term.Color.GREEN, term.Color.BOLD),
+            f"You finished in {board.move_count} moves and {format_time(seconds)}.",
+        ])
+        if best_moves:
+            print(term.center(term.style("New record: fewest moves!", term.Color.YELLOW)))
+        if best_time:
+            print(term.center(term.style("New record: fastest time!", term.Color.YELLOW)))
+        print()
+
+    # -------------------------------------------------------- drawing
+
+    def _draw(self, board: Board, elapsed: float, message: str = "") -> None:
+        term.clear()
+        print()
+        print(term.center(term.style(NAME.upper(), term.Color.CYAN, term.Color.BOLD)))
+        print()
+        stats = (f"Moves: {board.move_count}    Time: {format_time(elapsed)}")
+        print(term.center(stats))
+        print(term.center(term.style(self._records_line(), term.Color.GRAY)))
+        print()
+        for line in self._board_lines(board):
+            print(term.center(line))
+        print()
+        controls = "Arrows/WASD slide a tile - R reshuffle - H help - Q/ESC menu"
+        print(term.center(term.style(controls, term.Color.GRAY)))
+        if message:
+            print(term.center(term.style(message, term.Color.YELLOW)))
+        else:
+            print()
+
+    def _records_line(self) -> str:
+        moves = self.store.get_record(RECORD_MOVES)
+        seconds = self.store.get_record(RECORD_TIME)
+        parts = []
+        if moves is not None:
+            parts.append(f"best {moves} moves")
+        if seconds is not None:
+            parts.append(f"best time {format_time(seconds)}")
+        return "Records: " + ", ".join(parts) if parts else "No records yet."
+
+    @staticmethod
+    def _board_lines(board: Board) -> list[str]:
+        """Render the grid using box-drawing chars (ASCII fallback)."""
+        b = term.BOX
+        size = Board.SIZE
+        segment = b["h"] * CELL_WIDTH
+        top = b["tl"] + b["tt"].join([segment] * size) + b["tr"]
+        middle = b["lt"] + b["x"].join([segment] * size) + b["rt"]
+        bottom = b["bl"] + b["bt"].join([segment] * size) + b["br"]
+        lines = [top]
+        for row in range(size):
+            cells = []
+            for col in range(size):
+                value = board.tile_at(row, col)
+                if value == Board.BLANK:
+                    cells.append(" " * CELL_WIDTH)
+                else:
+                    label = f"{value:^{CELL_WIDTH}}"
+                    in_place = value == row * size + col + 1
+                    color = term.Color.GREEN if in_place else term.Color.WHITE
+                    cells.append(term.style(label, color))
+            lines.append(b["v"] + b["v"].join(cells) + b["v"])
+            lines.append(middle if row < size - 1 else bottom)
+        return lines
+
+    # ----------------------------------------------------------- help
+
+    def _show_help(self) -> None:
+        term.clear()
+        print()
+        lines = [
+            term.style(f"{NAME} - HELP", term.Color.CYAN, term.Color.BOLD),
+            "",
+            term.style("OBJECTIVE", term.Color.CYAN),
+            "Order the tiles 1 to 15 from top-left to bottom-right,",
+            "leaving the empty cell in the bottom-right corner.",
+            "",
+            term.style("CONTROLS", term.Color.CYAN),
+            "Arrow keys or WASD : slide a tile into the empty cell",
+            "                     (the key names the direction the tile moves)",
+            "R                  : restart with a freshly shuffled board",
+            "H                  : show this help screen",
+            "Q or ESC           : return to the main menu",
+            "",
+            term.style("RULES", term.Color.CYAN),
+            "Only a tile directly next to the empty cell can slide.",
+            "Every starting board is guaranteed to be solvable.",
+            "Fewer moves and faster times set new records.",
+        ]
+        for line in lines:
+            print(term.center(line))
+        print()
+        term.press_enter()
@@ -0,0 +1,329 @@+"""2048 for Pablo's Terminal Games.
+
+Slide numbered tiles on a 4x4 board, merge equal values and build
+the 2048 tile. Real-time input: arrow keys or WASD.
+"""
+from __future__ import annotations
+
+import random
+
+from core import terminal as term
+from core.input import Key, read_key
+from core.storage import get_store
+
+NAME = "2048"
+KEY = "game_2048"
+DESCRIPTION = "Slide and merge numbered tiles to reach 2048."
+
+DIRECTIONS = ("left", "right", "up", "down")
+
+
+class Board:
+    """Pure 2048 logic on a 4x4 grid of ints (0 = empty). No I/O."""
+
+    SIZE = 4
+    TARGET = 2048
+
+    def __init__(self, grid=None, rng=None):
+        self.rng = rng if rng is not None else random.Random()
+        self.score = 0
+        if grid is not None:
+            self.grid = [list(row) for row in grid]
+        else:
+            self.grid = [[0] * self.SIZE for _ in range(self.SIZE)]
+            self.spawn_tile()
+            self.spawn_tile()
+
+    # ------------------------------------------------------- movement
+
+    @staticmethod
+    def merge_line(line):
+        """Slide one row/column toward index 0, merging equal neighbours.
+
+        Each tile merges at most once per move. Returns a tuple
+        (new_line, points_gained); new_line has the same length.
+        """
+        tiles = [value for value in line if value]
+        merged = []
+        points = 0
+        i = 0
+        while i < len(tiles):
+            if i + 1 < len(tiles) and tiles[i] == tiles[i + 1]:
+                value = tiles[i] * 2
+                merged.append(value)
+                points += value
+                i += 2
+            else:
+                merged.append(tiles[i])
+                i += 1
+        merged.extend([0] * (len(line) - len(merged)))
+        return merged, points
+
+    def move(self, direction):
+        """Slide all tiles in a direction. Returns True if the board changed.
+
+        A valid move adds the merged values to the score and spawns
+        one new tile. An invalid move (nothing slides) changes nothing.
+        """
+        if direction not in DIRECTIONS:
+            raise ValueError(f"unknown direction: {direction!r}")
+        lines = self._lines(direction)
+        changed = False
+        gained = 0
+        new_lines = []
+        for line in lines:
+            merged, points = self.merge_line(line)
+            if merged != line:
+                changed = True
+            gained += points
+            new_lines.append(merged)
+        if not changed:
+            return False
+        self._put_lines(direction, new_lines)
+        self.score += gained
+        self.spawn_tile()
+        return True
+
+    def _lines(self, direction):
+        """Rows or columns oriented so index 0 is the slide target."""
+        g, n = self.grid, self.SIZE
+        if direction == "left":
+            return [row[:] for row in g]
+        if direction == "right":
+            return [row[::-1] for row in g]
+        if direction == "up":
+            return [[g[r][c] for r in range(n)] for c in range(n)]
+        return [[g[r][c] for r in range(n - 1, -1, -1)] for c in range(n)]  # down
+
+    def _put_lines(self, direction, lines):
+        """Write lines produced by _lines back into the grid."""
+        n = self.SIZE
+        for i, line in enumerate(lines):
+            if direction == "left":
+                self.grid[i] = line
+            elif direction == "right":
+                self.grid[i] = line[::-1]
+            elif direction == "up":
+                for r in range(n):
+                    self.grid[r][i] = line[r]
+            else:  # down
+                for r in range(n):
+                    self.grid[n - 1 - r][i] = line[r]
+
+    # ---------------------------------------------------------- state
+
+    def empty_cells(self):
+        """List of (row, col) coordinates holding a zero."""
+        return [(r, c) for r in range(self.SIZE) for c in range(self.SIZE)
+                if self.grid[r][c] == 0]
+
+    def spawn_tile(self):
+        """Place a 2 (90% chance) or a 4 (10%) in a random empty cell.
+
+        Returns the (row, col) used, or None when the board is full.
+        """
+        cells = self.empty_cells()
+        if not cells:
+            return None
+        row, col = self.rng.choice(cells)
+        self.grid[row][col] = 2 if self.rng.random() < 0.9 else 4
+        return (row, col)
+
+    def highest_tile(self):
+        """Largest value currently on the board."""
+        return max(max(row) for row in self.grid)
+
+    def has_won(self):
+        """True once the 2048 tile (or bigger) exists."""
+        return self.highest_tile() >= self.TARGET
+
+    def has_moves(self):
+        """True while any slide would change the board."""
+        if self.empty_cells():
+            return True
+        n = self.SIZE
+        for r in range(n):
+            for c in range(n):
+                value = self.grid[r][c]
+                if r + 1 < n and self.grid[r + 1][c] == value:
+                    return True
+                if c + 1 < n and self.grid[r][c + 1] == value:
+                    return True
+        return False
+
+    def is_game_over(self):
+        """True when the board is full and no equal neighbours remain."""
+        return not self.has_moves()
+
+
+class Game:
+    """Interactive UI layer on top of Board."""
+
+    CELL = 7  # character width of one board cell
+
+    _MOVE_KEYS = {
+        Key.UP: "up", Key.DOWN: "down", Key.LEFT: "left", Key.RIGHT: "right",
+        "w": "up", "s": "down", "a": "left", "d": "right",
+    }
+
+    def __init__(self):
+        self.store = get_store()
+        self.board = Board()
+        self.message = ""
+        self._win_announced = False
+
+    def run(self):
+        """Play complete sessions of 2048 until the player leaves."""
+        term.hide_cursor()
+        try:
+            while self._play_one_game():
+                pass
+        finally:
+            term.show_cursor()
+
+    # ------------------------------------------------------ game loop
+
+    def _play_one_game(self):
+        """Play one board. Returns True to play again, False for the menu."""
+        self.board = Board()
+        self.message = ""
+        self._win_announced = False
+        while True:
+            self._render()
+            if self.board.is_game_over():
+                return self._game_over()
+            if not self._win_announced and self.board.has_won():
+                self._win_announced = True
+                self.message = "You reached 2048! Keep going for a higher score."
+                continue
+            action = self._handle_key(read_key())
+            if action == "quit":
+                return False
+            if action == "restart":
+                return True
+
+    def _handle_key(self, key):
+        """Handle one pressed key. Returns 'quit', 'restart' or None."""
+        low = key.lower() if isinstance(key, str) and len(key) == 1 else key
+        if low in self._MOVE_KEYS:
+            if self.board.move(self._MOVE_KEYS[low]):
+                self.message = ""
+                self._save_records()
+            else:
+                self.message = "Nothing can slide that way."
+        elif low == "r":
+            if term.confirm("Restart with a fresh board?"):
+                return "restart"
+        elif low == "h":
+            self._show_help()
+            self.message = ""
+        elif low in ("q", Key.ESCAPE):
+            if term.confirm("Return to the main menu?"):
+                self._quit_game()
+                return "quit"
+        else:
+            self.message = "Unknown key. Press H for help."
+        return None
+
+    def _game_over(self):
+        outcome = "win" if self.board.has_won() else "loss"
+        self.store.record_result(KEY, outcome)
+        self._save_records()
+        self._render()
+        print()
+        text = f"Game over! Final score: {self.board.score}."
+        term.print_centered([term.style(text, term.Color.YELLOW)])
+        print()
+        return term.confirm("Play again?", default=True)
+
+    def _quit_game(self):
+        """Leaving mid-game: keep the records; a made 2048 counts as a win."""
+        self._save_records()
+        if self.board.has_won():
+            self.store.record_result(KEY, "win")
+
+    def _save_records(self):
+        self.store.improve_record("2048_high_score", self.board.score)
+        self.store.improve_record("2048_best_tile", self.board.highest_tile())
+
+    # ------------------------------------------------------- rendering
+
+    def _render(self):
+        term.clear()
+        print()
+        title = term.style("2048", term.Color.CYAN, term.Color.BOLD)
+        best = self.store.get_record("2048_high_score", 0) or 0
+        info = (f"Score: {self.board.score}    Best: {best}    "
+                f"Highest tile: {self.board.highest_tile()}")
+        term.print_centered([title, "", info, ""])
+        for line in self._board_lines():
+            print(term.center(line))
+        print()
+        if self.message:
+            term.print_centered([term.style(self.message, term.Color.YELLOW)])
+        hint = "Arrows/WASD slide - R restart - H help - Q menu"
+        term.print_centered([term.style(hint, term.Color.GRAY)])
+
+    def _board_lines(self):
+        b = term.BOX
+        n = Board.SIZE
+        segment = b["h"] * self.CELL
+        top = b["tl"] + b["tt"].join([segment] * n) + b["tr"]
+        middle = b["lt"] + b["x"].join([segment] * n) + b["rt"]
+        bottom = b["bl"] + b["bt"].join([segment] * n) + b["br"]
+        lines = [top]
+        for r, row in enumerate(self.board.grid):
+            cells = b["v"].join(self._cell_text(value) for value in row)
+            lines.append(b["v"] + cells + b["v"])
+            lines.append(bottom if r == n - 1 else middle)
+        return lines
+
+    def _cell_text(self, value):
+        if not value:
+            return " " * self.CELL
+        text = str(value)
+        pad = self.CELL - len(text)
+        left = pad // 2
+        styled = term.style(text, *self._tile_colors(value))
+        return " " * left + styled + " " * (pad - left)
+
+    @staticmethod
+    def _tile_colors(value):
+        if value >= 512:
+            return (term.Color.RED, term.Color.BOLD)
+        if value >= 128:
+            return (term.Color.YELLOW,)
+        if value >= 32:
+            return (term.Color.GREEN,)
+        if value >= 8:
+            return (term.Color.CYAN,)
+        return (term.Color.WHITE,)
+
+    # ------------------------------------------------------------ help
+
+    def _show_help(self):
+        term.clear()
+        print()
+        term.print_centered([term.style("2048 - HELP", term.Color.CYAN, term.Color.BOLD), ""])
+        lines = [
+            term.style("Objective:", term.Color.CYAN),
+            "  Slide the numbered tiles and merge equal values",
+            "  to build a tile worth 2048 (then keep going!).",
+            "",
+            term.style("Controls:", term.Color.CYAN),
+            "  Arrow keys or WASD  slide every tile that way",
+            "  R                   restart with a fresh board",
+            "  H                   show this help screen",
+            "  Q or ESC            return to the main menu",
+            "",
+            term.style("Rules:", term.Color.CYAN),
+            "  Two equal tiles that collide merge into their double,",
+            "  once per move, and the merged value adds to your score.",
+            "  After every valid move a new tile appears: 90% a 2,",
+            "  10% a 4. The game ends when the board is full and no",
+            "  equal neighbours remain.",
+        ]
+        for line in term.box(lines, pad=2):
+            print(term.center(line))
+        print()
+        term.press_enter()
@@ -0,0 +1,380 @@+#!/usr/bin/env python3
+"""Gomoku (Five in a Row) on a 15x15 board.
+
+Two players alternate placing stones; the first to line up five or
+more consecutive stones (horizontally, vertically or diagonally) wins.
+Overlines (six or more) also count as a win.
+
+Pure game logic lives in GomokuBoard and GomokuAI (no printing, no
+input) so tests can drive them headless. Game is the terminal UI.
+"""
+from __future__ import annotations
+
+import random
+import re
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+
+NAME = "Gomoku"
+KEY = "gomoku"
+DESCRIPTION = "Five in a row on a 15x15 board, against the computer or a friend."
+
+SIZE = 15
+WIN_LENGTH = 5
+EMPTY, X, O = 0, 1, 2
+COLUMN_LETTERS = "ABCDEFGHIJKLMNO"
+DIRECTIONS = ((0, 1), (1, 0), (1, 1), (1, -1))
+
+# Values for one line of stones, keyed by (length, open ends).
+LINE_VALUES = {
+    (5, 2): 1_000_000, (5, 1): 1_000_000, (5, 0): 1_000_000,
+    (4, 2): 100_000, (4, 1): 10_000, (4, 0): 0,
+    (3, 2): 5_000, (3, 1): 500, (3, 0): 0,
+    (2, 2): 200, (2, 1): 50, (2, 0): 0,
+    (1, 2): 10, (1, 1): 2, (1, 0): 0,
+}
+
+
+class GomokuBoard:
+    """The 15x15 board and the rules of Gomoku. No UI in here."""
+
+    def __init__(self, size: int = SIZE):
+        self.size = size
+        self.grid = [[EMPTY] * size for _ in range(size)]
+        self.moves: list[tuple[int, int, int]] = []
+        self.current_player = X
+        self.winner = EMPTY
+
+    # ------------------------------------------------------------ queries
+
+    def in_bounds(self, row: int, col: int) -> bool:
+        return 0 <= row < self.size and 0 <= col < self.size
+
+    def is_empty(self, row: int, col: int) -> bool:
+        return self.in_bounds(row, col) and self.grid[row][col] == EMPTY
+
+    def is_full(self) -> bool:
+        return all(cell != EMPTY for row in self.grid for cell in row)
+
+    def is_over(self) -> bool:
+        return self.winner != EMPTY or self.is_full()
+
+    def count_direction(self, row: int, col: int, dr: int, dc: int, player: int) -> int:
+        """Consecutive stones of player starting one step from (row, col)."""
+        count = 0
+        r, c = row + dr, col + dc
+        while self.in_bounds(r, c) and self.grid[r][c] == player:
+            count += 1
+            r += dr
+            c += dc
+        return count
+
+    def wins_through(self, row: int, col: int) -> bool:
+        """True if the stone at (row, col) is part of five or more in a row."""
+        player = self.grid[row][col]
+        if player == EMPTY:
+            return False
+        for dr, dc in DIRECTIONS:
+            total = 1
+            total += self.count_direction(row, col, dr, dc, player)
+            total += self.count_direction(row, col, -dr, -dc, player)
+            if total >= WIN_LENGTH:
+                return True
+        return False
+
+    # ------------------------------------------------------------ moves
+
+    def place(self, row: int, col: int) -> bool:
+        """Place the current player's stone. False when the move is illegal."""
+        if self.winner != EMPTY or not self.is_empty(row, col):
+            return False
+        player = self.current_player
+        self.grid[row][col] = player
+        self.moves.append((row, col, player))
+        if self.wins_through(row, col):
+            self.winner = player
+        else:
+            self.current_player = O if player == X else X
+        return True
+
+    # ------------------------------------------------------------ coordinates
+
+    @staticmethod
+    def parse_coordinate(text: str, size: int = SIZE) -> tuple[int, int] | None:
+        """Parse 'h8' style input (column letter + row number) to (row, col)."""
+        match = re.fullmatch(r"([A-Za-z])\s*([0-9]{1,2})", (text or "").strip())
+        if not match:
+            return None
+        col = ord(match.group(1).upper()) - ord("A")
+        row = int(match.group(2)) - 1
+        if 0 <= row < size and 0 <= col < size:
+            return (row, col)
+        return None
+
+    @staticmethod
+    def format_coordinate(row: int, col: int) -> str:
+        return f"{COLUMN_LETTERS[col]}{row + 1}"
+
+
+class GomokuAI:
+    """Simple scored-search opponent. No minimax, just priorities:
+
+    1. complete its own five,
+    2. block an immediate opponent win,
+    3. extend useful sequences (open threes, fours) / block the opponent's,
+    4. otherwise play near existing stones with a slight center bias.
+    """
+
+    CANDIDATE_RANGE = 2
+
+    def __init__(self, player: int = O):
+        self.player = player
+
+    def opponent(self) -> int:
+        return X if self.player == O else O
+
+    def choose_move(self, board: GomokuBoard) -> tuple[int, int] | None:
+        """Pick a move for the AI on the given board."""
+        if board.is_over():
+            return None
+        move = self._winning_move(board, self.player)
+        if move:
+            return move
+        move = self._winning_move(board, self.opponent())
+        if move:
+            return move
+        candidates = self._candidates(board)
+        if not candidates:
+            center = board.size // 2
+            return (center, center)
+        best_score = max(self._cell_score(board, r, c) for r, c in candidates)
+        best = [(r, c) for r, c in candidates
+                if self._cell_score(board, r, c) == best_score]
+        return random.choice(best)
+
+    # ------------------------------------------------------------ helpers
+
+    def _winning_move(self, board: GomokuBoard, player: int) -> tuple[int, int] | None:
+        """First empty cell that immediately gives player five in a row."""
+        for row in range(board.size):
+            for col in range(board.size):
+                if board.grid[row][col] != EMPTY:
+                    continue
+                board.grid[row][col] = player
+                wins = board.wins_through(row, col)
+                board.grid[row][col] = EMPTY
+                if wins:
+                    return (row, col)
+        return None
+
+    def _candidates(self, board: GomokuBoard) -> list[tuple[int, int]]:
+        """Empty cells close to existing stones (worth evaluating)."""
+        if not board.moves:
+            return []
+        reach = self.CANDIDATE_RANGE
+        cells = set()
+        for row, col, _ in board.moves:
+            for r in range(max(0, row - reach), min(board.size, row + reach + 1)):
+                for c in range(max(0, col - reach), min(board.size, col + reach + 1)):
+                    if board.grid[r][c] == EMPTY:
+                        cells.add((r, c))
+        return sorted(cells)
+
+    def _cell_score(self, board: GomokuBoard, row: int, col: int) -> int:
+        """Attack value for the AI plus defensive value against the opponent,
+        with a small bonus for central cells."""
+        attack = self._line_score(board, row, col, self.player)
+        defense = self._line_score(board, row, col, self.opponent())
+        center = board.size // 2
+        center_bonus = board.size - (abs(row - center) + abs(col - center))
+        return attack + defense + center_bonus
+
+    def _line_score(self, board: GomokuBoard, row: int, col: int, player: int) -> int:
+        """Value of placing player's stone at (row, col), summed over all
+        four directions (open-ended lines are worth far more)."""
+        total = 0
+        for dr, dc in DIRECTIONS:
+            forward = board.count_direction(row, col, dr, dc, player)
+            backward = board.count_direction(row, col, -dr, -dc, player)
+            length = 1 + forward + backward
+            open_ends = 0
+            if board.is_empty(row + (forward + 1) * dr, col + (forward + 1) * dc):
+                open_ends += 1
+            if board.is_empty(row - (backward + 1) * dr, col - (backward + 1) * dc):
+                open_ends += 1
+            length = min(length, WIN_LENGTH)
+            total += LINE_VALUES.get((length, open_ends), 0)
+        return total
+
+
+class Game:
+    """Interactive terminal UI for Gomoku."""
+
+    def __init__(self):
+        self.store = get_store()
+        self.message = ""
+
+    # ------------------------------------------------------------ session
+
+    def run(self) -> None:
+        vs_computer = self._choose_mode()
+        if vs_computer is None:
+            return
+        while True:
+            self._play_match(vs_computer)
+            if not term.confirm("Play again?", default=True):
+                return
+
+    def _choose_mode(self) -> bool | None:
+        """True = Player vs Computer, False = Player vs Player, None = quit."""
+        title = [
+            term.style("GOMOKU", term.Color.CYAN, term.Color.BOLD),
+            term.style(DESCRIPTION, term.Color.GRAY),
+        ]
+        options = ["Player vs Computer", "Player vs Player"]
+        choice = choose(title, options, hint="Enter select - Q back to menu")
+        if choice is None:
+            return None
+        return choice == 0
+
+    # ------------------------------------------------------------ one match
+
+    def _play_match(self, vs_computer: bool) -> None:
+        board = GomokuBoard()
+        ai = GomokuAI(O) if vs_computer else None
+        self.message = ""
+        while not board.is_over():
+            self._render(board, vs_computer)
+            if vs_computer and board.current_player == O:
+                row, col = ai.choose_move(board)
+                board.place(row, col)
+                self.message = f"Computer plays {GomokuBoard.format_coordinate(row, col)}."
+                continue
+            if not self._human_turn(board, vs_computer):
+                return  # aborted by the player: no statistics
+        self._render(board, vs_computer)
+        self._finish(board, vs_computer)
+
+    def _human_turn(self, board: GomokuBoard, vs_computer: bool) -> bool:
+        """One human move. Returns False when the player quits the match."""
+        name = self._player_name(board.current_player, vs_computer)
+        stone = self._stone_char(board.current_player)
+        prompt = f"{name} ({stone}), your move [h=help, q=quit]: "
+        answer = read_line(prompt)
+        if answer is None:
+            answer = "q"
+        command = answer.strip().lower()
+        if command == "q":
+            if term.confirm("Return to the main menu?"):
+                return False
+            self.message = ""
+            return True
+        if command == "h":
+            self._show_help()
+            self.message = ""
+            return True
+        coord = GomokuBoard.parse_coordinate(answer)
+        if coord is None:
+            self.message = "Invalid move. Use a column letter and row number, e.g. h8."
+            return True
+        if not board.place(*coord):
+            self.message = "That square is already occupied."
+            return True
+        self.message = ""
+        return True
+
+    def _finish(self, board: GomokuBoard, vs_computer: bool) -> None:
+        if board.winner == EMPTY:
+            term.status("The board is full. It's a draw!", "warn")
+            self.store.record_result(KEY, "draw")
+        else:
+            name = self._player_name(board.winner, vs_computer)
+            stone = self._stone_char(board.winner)
+            term.status(f"{name} ({stone}) wins with five in a row!", "ok")
+            if vs_computer:
+                self.store.record_result(KEY, "win" if board.winner == X else "loss")
+            else:
+                self.store.record_result(KEY, "win")
+        term.press_enter()
+
+    # ------------------------------------------------------------ rendering
+
+    def _render(self, board: GomokuBoard, vs_computer: bool) -> None:
+        term.clear()
+        print()
+        mode = "Player vs Computer" if vs_computer else "Player vs Player"
+        print(term.center(term.style(f"GOMOKU - {mode}", term.Color.CYAN, term.Color.BOLD)))
+        print()
+        lines = self._board_lines(board)
+        width = max(term.visible_len(line) for line in lines)
+        pad = max(0, (term.size()[0] - width) // 2)
+        for line in lines:
+            print(" " * pad + line)
+        print()
+        if not board.is_over():
+            name = self._player_name(board.current_player, vs_computer)
+            stone = self._stone_char(board.current_player)
+            print(term.center(f"{name} to move ({stone})"))
+        if self.message:
+            kind = "info" if self.message.startswith("Computer") else "warn"
+            print(term.center(term.style(self.message, term.STATUS_COLORS[kind])))
+        print()
+
+    def _board_lines(self, board: GomokuBoard) -> list[str]:
+        header = "    " + " ".join(COLUMN_LETTERS[:board.size])
+        lines = [term.style(header, term.Color.GRAY)]
+        for row in range(board.size):
+            cells = []
+            for col in range(board.size):
+                player = board.grid[row][col]
+                if player == EMPTY:
+                    cells.append(term.style("·" if term.UNICODE else ".", term.Color.GRAY))
+                elif player == X:
+                    cells.append(term.style(self._stone_char(X), term.Color.CYAN, term.Color.BOLD))
+                else:
+                    cells.append(term.style(self._stone_char(O), term.Color.YELLOW))
+            lines.append(f"{row + 1:>3} " + " ".join(cells))
+        return lines
+
+    @staticmethod
+    def _stone_char(player: int) -> str:
+        if term.UNICODE:
+            return "●" if player == X else "○"
+        return "X" if player == X else "O"
+
+    @staticmethod
+    def _player_name(player: int, vs_computer: bool) -> str:
+        if vs_computer:
+            return "You" if player == X else "Computer"
+        return "Player 1" if player == X else "Player 2"
+
+    # ------------------------------------------------------------ help
+
+    def _show_help(self) -> None:
+        term.clear()
+        print()
+        lines = [
+            term.style("GOMOKU - HELP", term.Color.CYAN, term.Color.BOLD),
+            "",
+            term.style("OBJECTIVE", term.Color.CYAN),
+            "Be the first to line up FIVE or more of your stones",
+            "in a row: horizontally, vertically or diagonally.",
+            "Longer lines (overlines) also win.",
+            "",
+            term.style("CONTROLS", term.Color.CYAN),
+            "Type a coordinate as column letter + row number,",
+            "for example h8, and press Enter.",
+            "h - show this help     q - quit to the main menu",
+            "",
+            term.style("RULES", term.Color.CYAN),
+            "Players alternate placing one stone on an empty",
+            "intersection. Player 1 (X) always moves first.",
+            "If the board fills with no five in a row, it's a draw.",
+        ]
+        for line in term.box(lines, pad=2):
+            print(term.center(line))
+        print()
+        term.press_enter()
@@ -0,0 +1,393 @@+"""Mancala (Kalah) for Pablo's Terminal Games.
+
+Traditional Kalah rules: six pits per side with four stones each and
+one store per player. On a turn the player picks one of their pits and
+sows its stones counter-clockwise, one per pit, including their own
+store but skipping the opponent's store. A last stone in the own store
+grants an extra turn; a last stone in an empty own pit captures that
+stone plus the opposite pit's stones. The game ends when one side is
+empty; the remaining stones go to the other player's store and the
+higher store wins.
+
+Pure game logic lives in KalahBoard and ComputerPlayer (no printing,
+no input); the Game class is the interactive UI layer on top.
+"""
+from __future__ import annotations
+
+import random
+from typing import NamedTuple
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+from core.utils import safe_int
+
+NAME = "Mancala"
+KEY = "mancala"
+DESCRIPTION = "Classic Kalah: sow stones, earn extra turns and capture pits."
+
+PITS_PER_SIDE = 6
+INITIAL_STONES = 4
+
+HUMAN = 0      # bottom row, store on the right
+OPPONENT = 1   # top row, store on the left
+DRAW = -1
+
+SLOT_COUNT = (PITS_PER_SIDE + 1) * 2  # 6 pits + store, per player
+
+
+class MoveResult(NamedTuple):
+    """What happened during one sowing."""
+
+    pit: int          # pit number played (1..PITS_PER_SIDE)
+    extra_turn: bool  # last stone landed in the own store
+    captured: int     # stones captured into the store (0 = no capture)
+    store_gain: int   # total stones added to the own store this move
+    game_over: bool   # the move ended the game
+
+
+class KalahBoard:
+    """Pure Kalah game state. No printing, no input.
+
+    The board is a flat list of 14 slots:
+
+      0-5   HUMAN's pits (left to right as the human sees them)
+      6     HUMAN's store
+      7-12  OPPONENT's pits (in sowing order, i.e. reversed on screen)
+      13    OPPONENT's store
+
+    Sowing moves towards higher slot numbers and wraps around,
+    skipping the opponent's store. The slot opposite pit slot ``i``
+    is ``12 - i``. Pit numbers in the public API are 1-based (1..6),
+    matching what players type.
+    """
+
+    def __init__(self, slots=None, current: int = HUMAN):
+        if slots is None:
+            slots = ([INITIAL_STONES] * PITS_PER_SIDE + [0]) * 2
+        if len(slots) != SLOT_COUNT:
+            raise ValueError(f"slots must have {SLOT_COUNT} entries")
+        self.slots = list(slots)
+        self.current = current
+        self.game_over = False
+        self.winner = None  # HUMAN, OPPONENT or DRAW once game_over
+
+    # ------------------------------------------------------------ indexing
+
+    @staticmethod
+    def store_slot(player: int) -> int:
+        return PITS_PER_SIDE if player == HUMAN else SLOT_COUNT - 1
+
+    @staticmethod
+    def pit_slot(player: int, pit: int) -> int:
+        """Slot index of pit number ``pit`` (1..6) for ``player``."""
+        return pit - 1 if player == HUMAN else PITS_PER_SIDE + pit
+
+    @staticmethod
+    def opposite_slot(slot: int) -> int:
+        return (SLOT_COUNT - 2) - slot
+
+    def _is_own_pit(self, player: int, slot: int) -> bool:
+        if player == HUMAN:
+            return 0 <= slot < PITS_PER_SIDE
+        return PITS_PER_SIDE < slot < SLOT_COUNT - 1
+
+    # ------------------------------------------------------------ queries
+
+    def pits(self, player: int) -> list[int]:
+        """The player's six pits, in pit-number order (1..6)."""
+        start = 0 if player == HUMAN else PITS_PER_SIDE + 1
+        return self.slots[start:start + PITS_PER_SIDE]
+
+    def store(self, player: int) -> int:
+        return self.slots[self.store_slot(player)]
+
+    def legal_moves(self, player: int | None = None) -> list[int]:
+        """Pit numbers (1..6) that may be played: non-empty own pits."""
+        if player is None:
+            player = self.current
+        return [pit for pit, stones in enumerate(self.pits(player), start=1)
+                if stones > 0]
+
+    def clone(self) -> "KalahBoard":
+        copy = KalahBoard(self.slots, self.current)
+        copy.game_over = self.game_over
+        copy.winner = self.winner
+        return copy
+
+    # ------------------------------------------------------------ rules
+
+    def apply_move(self, pit: int) -> MoveResult:
+        """Sow the stones of pit ``pit`` (1..6) for the current player.
+
+        Raises ValueError for an illegal move. Returns a MoveResult.
+        """
+        if self.game_over:
+            raise ValueError("The game is over.")
+        if pit not in self.legal_moves():
+            raise ValueError(f"Illegal move: pit {pit}.")
+
+        player = self.current
+        own_store = self.store_slot(player)
+        opponent_store = self.store_slot(1 - player)
+
+        slot = self.pit_slot(player, pit)
+        stones = self.slots[slot]
+        self.slots[slot] = 0
+
+        store_gain = 0
+        while stones:
+            slot = (slot + 1) % SLOT_COUNT
+            if slot == opponent_store:
+                continue  # never sow into the opponent's store
+            self.slots[slot] += 1
+            stones -= 1
+            if slot == own_store:
+                store_gain += 1
+
+        extra_turn = slot == own_store
+
+        captured = 0
+        if (not extra_turn and self._is_own_pit(player, slot)
+                and self.slots[slot] == 1):  # was empty before the last stone
+            opposite = self.opposite_slot(slot)
+            if self.slots[opposite] > 0:
+                captured = self.slots[opposite] + 1
+                self.slots[own_store] += captured
+                store_gain += captured
+                self.slots[opposite] = 0
+                self.slots[slot] = 0
+
+        self._check_end()
+        if not extra_turn:
+            self.current = 1 - player
+        return MoveResult(pit, extra_turn, captured, store_gain, self.game_over)
+
+    def _check_end(self) -> None:
+        """End the game when a side is empty; collect remaining stones."""
+        human_empty = all(n == 0 for n in self.pits(HUMAN))
+        opponent_empty = all(n == 0 for n in self.pits(OPPONENT))
+        if not (human_empty or opponent_empty):
+            return
+        for player in (HUMAN, OPPONENT):
+            store = self.store_slot(player)
+            for slot in range(SLOT_COUNT):
+                if self._is_own_pit(player, slot) and self.slots[slot]:
+                    self.slots[store] += self.slots[slot]
+                    self.slots[slot] = 0
+        self.game_over = True
+        human_store, opponent_store = self.store(HUMAN), self.store(OPPONENT)
+        if human_store > opponent_store:
+            self.winner = HUMAN
+        elif opponent_store > human_store:
+            self.winner = OPPONENT
+        else:
+            self.winner = DRAW
+
+
+class ComputerPlayer:
+    """Heuristic opponent.
+
+    Scores every legal move on a cloned board and prefers, in order:
+    moves granting an extra turn, then captures, then the move putting
+    the most stones into its store. Ties are broken randomly.
+    """
+
+    def __init__(self, rng: random.Random | None = None):
+        self.rng = rng or random.Random()
+
+    def choose_move(self, board: KalahBoard) -> int:
+        """Return the pit number (1..6) the computer wants to play."""
+        best_score = None
+        best_moves: list[int] = []
+        for pit in board.legal_moves(OPPONENT):
+            clone = board.clone()
+            clone.current = OPPONENT  # simulate even if it is not its turn
+            result = clone.apply_move(pit)
+            score = (result.extra_turn, result.captured, result.store_gain)
+            if best_score is None or score > best_score:
+                best_score, best_moves = score, [pit]
+            elif score == best_score:
+                best_moves.append(pit)
+        return self.rng.choice(best_moves)
+
+
+# ------------------------------------------------------------------ UI
+
+HELP_LINES = [
+    "OBJECTIVE",
+    "  Collect more stones in your store than your opponent.",
+    "",
+    "CONTROLS",
+    f"  Type a pit number (1-{PITS_PER_SIDE}) and press Enter to sow it.",
+    "  H shows this help, Q (or ESC) returns to the main menu.",
+    "",
+    "RULES",
+    "  - Stones are sown counter-clockwise, one per pit, into your",
+    "    own store but never into the opponent's store.",
+    "  - Last stone in your own store: take another turn.",
+    "  - Last stone in one of your empty pits: capture that stone",
+    "    plus all stones in the opposite pit into your store.",
+    "  - The game ends when one side is empty; the stones left on",
+    "    the other side go to that player's store. Higher store wins.",
+]
+
+
+class Game:
+    """Interactive Mancala session: mode selection, play loop, replay."""
+
+    def __init__(self):
+        self.store = get_store()
+        self.computer = ComputerPlayer()
+
+    # ------------------------------------------------------------ session
+
+    def run(self) -> None:
+        title = [term.style("MANCALA", term.Color.CYAN, term.Color.BOLD),
+                 term.style(DESCRIPTION, term.Color.GRAY)]
+        options = ["Player vs Computer", "Player vs Player", "How to Play"]
+        while True:
+            choice = choose(title, options, hotkeys=["1", "2", "3"],
+                            hint="Enter select - Q/ESC back to the main menu")
+            if choice is None:
+                return
+            if choice == 2:
+                self._show_help()
+                continue
+            mode = "pvc" if choice == 0 else "pvp"
+            self._play_session(mode)
+
+    def _play_session(self, mode: str) -> None:
+        """Play matches of one mode until the player declines a replay."""
+        while self._play_match(mode):
+            if not term.confirm("Play again?"):
+                return
+
+    # ------------------------------------------------------------ match
+
+    def _play_match(self, mode: str) -> bool:
+        """Play one match. Returns False when the player quit mid-game."""
+        board = KalahBoard()
+        names = ("PLAYER", "COMPUTER") if mode == "pvc" else ("PLAYER 1", "PLAYER 2")
+        message = "New game. Good luck!"
+        while not board.game_over:
+            self._render(board, names, message)
+            player = board.current
+            if mode == "pvc" and player == OPPONENT:
+                pit = self.computer.choose_move(board)
+                result = board.apply_move(pit)
+                message = self._describe_move(names[player], result)
+                continue
+            pit = self._ask_move(board, names, message)
+            if pit is None:
+                if term.confirm("Return to the main menu?"):
+                    return False
+                message = "Game resumed."
+                continue
+            result = board.apply_move(pit)
+            message = self._describe_move(names[player], result)
+        self._render(board, names, message)
+        self._show_result(board, names, mode)
+        return True
+
+    def _ask_move(self, board: KalahBoard, names, message: str) -> int | None:
+        """Prompt for a pit number. Returns 1..6, or None to quit."""
+        player = board.current
+        prompt = (f"{names[player]}, choose a pit "
+                  f"(1-{PITS_PER_SIDE}, H help, Q quit): ")
+        while True:
+            raw = read_line(prompt)
+            if raw is None:
+                return None
+            text = raw.strip().lower()
+            if text in ("q", "\x1b"):
+                return None
+            if text == "h":
+                self._show_help()
+                self._render(board, names, message)
+                continue
+            pit = safe_int(text)
+            if pit is None or not 1 <= pit <= PITS_PER_SIDE:
+                term.status(f"Invalid move. Enter a number from 1 to {PITS_PER_SIDE}.",
+                            "warn")
+                continue
+            if pit not in board.legal_moves():
+                term.status("That pit is empty. Choose another one.", "warn")
+                continue
+            return pit
+
+    # ------------------------------------------------------------ screens
+
+    def _render(self, board: KalahBoard, names, message: str) -> None:
+        term.clear()
+        print()
+        colors = {board.current: term.Color.GREEN, 1 - board.current: term.Color.GRAY}
+        for line in self._board_lines(board, names, colors):
+            print(term.center(line))
+        print()
+        if message:
+            print(term.center(term.style(message, term.Color.CYAN)))
+        print()
+
+    def _board_lines(self, board: KalahBoard, names, colors) -> list[str]:
+        """Classic layout: opponent on top (reversed), stores on the sides."""
+        cell = "{:>3}".format
+        top = [cell(n) for n in reversed(board.pits(OPPONENT))]
+        bottom = [cell(n) for n in board.pits(HUMAN)]
+        labels = "".join(f"  ({i})" for i in range(1, PITS_PER_SIDE + 1))
+        row_indent = "      "
+        store_row = (f" {cell(board.store(OPPONENT))} "
+                     + " " * (PITS_PER_SIDE * 5)
+                     + f" {cell(board.store(HUMAN))} ")
+        return [
+            term.style(names[OPPONENT], colors[OPPONENT], term.Color.BOLD),
+            row_indent + labels,
+            row_indent + "".join(f"  {n}" for n in top),
+            store_row,
+            row_indent + "".join(f"  {n}" for n in bottom),
+            row_indent + labels,
+            term.style(names[HUMAN], colors[HUMAN], term.Color.BOLD),
+            term.style(f"{names[OPPONENT]}'s store is on the left, "
+                       f"{names[HUMAN]}'s store is on the right.", term.Color.GRAY),
+        ]
+
+    def _show_help(self) -> None:
+        term.clear()
+        print()
+        term.print_centered([term.style("MANCALA - HOW TO PLAY",
+                                        term.Color.CYAN, term.Color.BOLD), ""])
+        for line in HELP_LINES:
+            print(term.center(line if line else ""))
+        print()
+        term.press_enter()
+
+    def _show_result(self, board: KalahBoard, names, mode: str) -> None:
+        human_store = board.store(HUMAN)
+        opponent_store = board.store(OPPONENT)
+        score = f"Final score: {names[HUMAN]} {human_store} - " \
+                f"{opponent_store} {names[OPPONENT]}"
+        print()
+        if board.winner == DRAW:
+            term.status(f"It's a draw! {score}", "warn")
+            self.store.record_result(KEY, "draw")
+        elif mode == "pvp":
+            term.status(f"{names[board.winner]} wins! {score}", "ok")
+            self.store.record_result(KEY, "win")
+        elif board.winner == HUMAN:
+            term.status(f"You win! {score}", "ok")
+            self.store.record_result(KEY, "win")
+        else:
+            term.status(f"The computer wins. {score}", "error")
+            self.store.record_result(KEY, "loss")
+        print()
+        term.press_enter()
+
+    @staticmethod
+    def _describe_move(player_name: str, result: MoveResult) -> str:
+        parts = [f"{player_name} sows pit {result.pit}."]
+        if result.captured:
+            parts.append(f"Captures {result.captured} stones!")
+        if result.extra_turn:
+            parts.append("Extra turn!")
+        return " ".join(parts)
@@ -0,0 +1,394 @@+#!/usr/bin/env python3
+"""Minesweeper - clear the field without detonating a mine.
+
+Pure logic lives in Board (no printing, no input, no clock) so it can
+be tested headless. The Game class is the real-time terminal UI layer.
+"""
+from __future__ import annotations
+
+import random
+import time
+from dataclasses import dataclass
+
+from core import terminal as term
+from core.input import Key, read_key
+from core.menu import choose
+from core.storage import get_store
+from core.utils import format_time
+
+NAME = "Minesweeper"
+KEY = "minesweeper"
+DESCRIPTION = "Clear the field without detonating a mine."
+
+CELL_WIDTH = 3  # characters per board cell when rendering
+
+
+@dataclass(frozen=True)
+class Difficulty:
+    """Immutable board preset."""
+
+    name: str
+    rows: int
+    cols: int
+    mines: int
+    record_key: str
+
+
+DIFFICULTIES = {
+    "easy": Difficulty("Easy", 9, 9, 10, "minesweeper_best_time_easy"),
+    "medium": Difficulty("Medium", 16, 16, 40, "minesweeper_best_time_medium"),
+    "hard": Difficulty("Hard", 18, 22, 85, "minesweeper_best_time_hard"),
+}
+DIFFICULTY_ORDER = ("easy", "medium", "hard")
+
+
+class Board:
+    """Pure minesweeper logic: mine placement, counts, reveal, flags.
+
+    Mines are generated on the first reveal (or by an explicit
+    place_mines call); the first revealed cell and its neighbors are
+    guaranteed mine-free. No printing, no input, no clock.
+    """
+
+    def __init__(self, rows, cols, mine_count=0, mines=None, rng=None):
+        if rows < 2 or cols < 2:
+            raise ValueError("The board must be at least 2x2.")
+        self.rows = rows
+        self.cols = cols
+        self._rng = rng if rng is not None else random.Random()
+        if mines is not None:
+            mine_set = set(mines)
+            for cell in mine_set:
+                if not self._in_bounds(*cell):
+                    raise ValueError(f"Mine outside the board: {cell}")
+            self.mine_count = len(mine_set)
+            self.mines = mine_set
+            self.mines_placed = True
+        else:
+            if mine_count < 1 or mine_count >= rows * cols:
+                raise ValueError("Invalid mine count for this board size.")
+            self.mine_count = mine_count
+            self.mines = set()
+            self.mines_placed = False
+        self.revealed = set()
+        self.flagged = set()
+        self.lost = False
+        self.exploded_at = None
+
+    # ---------------------------------------------------------- helpers
+
+    def _in_bounds(self, r, c):
+        return 0 <= r < self.rows and 0 <= c < self.cols
+
+    def neighbors(self, r, c):
+        """Yield the in-bounds neighbors of cell (r, c)."""
+        for dr in (-1, 0, 1):
+            for dc in (-1, 0, 1):
+                if dr == 0 and dc == 0:
+                    continue
+                nr, nc = r + dr, c + dc
+                if self._in_bounds(nr, nc):
+                    yield (nr, nc)
+
+    def neighbor_mine_count(self, r, c):
+        """How many mines touch cell (r, c)."""
+        return sum(1 for nb in self.neighbors(r, c) if nb in self.mines)
+
+    # ---------------------------------------------------------- mines
+
+    def place_mines(self, safe_r, safe_c):
+        """Randomly place mines, avoiding the safe cell and its neighbors."""
+        if self.mines_placed:
+            raise RuntimeError("Mines have already been placed.")
+        if not self._in_bounds(safe_r, safe_c):
+            raise ValueError(f"Safe cell outside the board: {(safe_r, safe_c)}")
+        protected = {(safe_r, safe_c)} | set(self.neighbors(safe_r, safe_c))
+        candidates = [
+            (r, c)
+            for r in range(self.rows)
+            for c in range(self.cols)
+            if (r, c) not in protected
+        ]
+        if len(candidates) < self.mine_count:
+            raise ValueError("Not enough free cells to place all mines safely.")
+        self.mines = set(self._rng.sample(candidates, self.mine_count))
+        self.mines_placed = True
+        return self.mines
+
+    # ---------------------------------------------------------- actions
+
+    def reveal(self, r, c):
+        """Reveal a cell; flood-clears empty areas. Returns newly revealed cells.
+
+        Ignores flagged or already revealed cells. Revealing a mine ends
+        the game and exposes every mine.
+        """
+        if self.lost or self.is_won or not self._in_bounds(r, c):
+            return set()
+        if (r, c) in self.flagged or (r, c) in self.revealed:
+            return set()
+        if not self.mines_placed:
+            self.place_mines(r, c)
+        if (r, c) in self.mines:
+            self.lost = True
+            self.exploded_at = (r, c)
+            self.revealed.update(self.mines)
+            return set(self.mines)
+        newly = set()
+        stack = [(r, c)]
+        while stack:
+            cell = stack.pop()
+            if cell in self.revealed or cell in self.flagged:
+                continue
+            self.revealed.add(cell)
+            newly.add(cell)
+            if self.neighbor_mine_count(*cell) == 0:
+                for nb in self.neighbors(*cell):
+                    if nb not in self.revealed and nb not in self.flagged:
+                        stack.append(nb)
+        return newly
+
+    def toggle_flag(self, r, c):
+        """Flag/unflag a hidden cell. True=flagged, False=unflagged, None=refused."""
+        if self.lost or self.is_won or not self._in_bounds(r, c):
+            return None
+        if (r, c) in self.revealed:
+            return None
+        if (r, c) in self.flagged:
+            self.flagged.discard((r, c))
+            return False
+        self.flagged.add((r, c))
+        return True
+
+    # ---------------------------------------------------------- state
+
+    @property
+    def flags_remaining(self):
+        """Mines minus flags placed (may go negative)."""
+        return self.mine_count - len(self.flagged)
+
+    @property
+    def is_won(self):
+        """True once every non-mine cell is revealed."""
+        return (
+            self.mines_placed
+            and not self.lost
+            and len(self.revealed) == self.rows * self.cols - self.mine_count
+        )
+
+
+# ------------------------------------------------------------------ UI
+
+class Game:
+    """Real-time terminal interface for Minesweeper."""
+
+    def run(self):
+        term.hide_cursor()
+        try:
+            while True:
+                diff_key = self._choose_difficulty()
+                if diff_key is None:
+                    return
+                if not self._play(DIFFICULTIES[diff_key]):
+                    return
+        finally:
+            term.show_cursor()
+
+    # ---------------------------------------------------------- menus
+
+    def _choose_difficulty(self):
+        store = get_store()
+        title = [
+            term.style("MINESWEEPER", term.Color.CYAN, term.Color.BOLD),
+            term.style(DESCRIPTION, term.Color.GRAY),
+        ]
+        options = []
+        for key in DIFFICULTY_ORDER:
+            diff = DIFFICULTIES[key]
+            best = store.get_record(diff.record_key)
+            best_text = format_time(best) if best is not None else "--:--"
+            options.append(
+                f"{diff.name} - {diff.cols}x{diff.rows}, {diff.mines} mines"
+                f" (best {best_text})"
+            )
+        choice = choose(
+            title, options, hotkeys=["1", "2", "3"],
+            hint="Arrows/W-S move - Enter select - Q/ESC back",
+        )
+        if choice is None:
+            return None
+        return DIFFICULTY_ORDER[choice]
+
+    def _show_help(self):
+        term.clear()
+        print()
+        term.print_centered([
+            term.style("MINESWEEPER - HELP", term.Color.CYAN, term.Color.BOLD),
+            "",
+        ])
+        lines = [
+            "Objective:",
+            "  Reveal every safe cell without detonating a mine.",
+            "",
+            "Controls:",
+            "  Arrows / WASD  move the cursor",
+            "  Enter / Space  reveal the cell under the cursor",
+            "  F              place or remove a flag",
+            "  H              show this help",
+            "  Q / ESC        return to the main menu",
+            "",
+            "Rules:",
+            "  A number tells how many mines touch that cell.",
+            "  Revealing an empty cell clears the whole open area.",
+            "  The first reveal is always safe, and so are its neighbors.",
+            "  The counter shows mines minus the flags you placed.",
+            "  Win by revealing every cell that is not a mine.",
+            "",
+            "Quitting while a game is in progress asks for confirmation.",
+        ]
+        for line in lines:
+            print(term.center(line))
+        print()
+        term.press_enter()
+
+    # ---------------------------------------------------------- play
+
+    def _play(self, diff):
+        """Play one board. Returns True to pick another, False for the menu."""
+        board = Board(diff.rows, diff.cols, diff.mines)
+        cursor_r, cursor_c = diff.rows // 2, diff.cols // 2
+        start = None  # clock starts on the first reveal
+        message = ""
+        while True:
+            if not self._wait_for_fit(diff):
+                return False
+            seconds = int(time.monotonic() - start) if start is not None else 0
+            self._render(board, diff, cursor_r, cursor_c, seconds, message)
+            message = ""
+            key = read_key()
+            low = key.lower() if isinstance(key, str) and len(key) == 1 else ""
+            if key == Key.UP or low == "w":
+                cursor_r = max(0, cursor_r - 1)
+            elif key == Key.DOWN or low == "s":
+                cursor_r = min(diff.rows - 1, cursor_r + 1)
+            elif key == Key.LEFT or low == "a":
+                cursor_c = max(0, cursor_c - 1)
+            elif key == Key.RIGHT or low == "d":
+                cursor_c = min(diff.cols - 1, cursor_c + 1)
+            elif key == Key.ENTER or low == " ":
+                board.reveal(cursor_r, cursor_c)
+                if start is None and board.mines_placed:
+                    start = time.monotonic()
+            elif low == "f":
+                if board.toggle_flag(cursor_r, cursor_c) is None:
+                    message = "You cannot flag that cell."
+            elif low == "h":
+                self._show_help()
+            elif low == "q" or key == Key.ESCAPE:
+                if board.lost or board.is_won or not board.revealed:
+                    return False
+                if term.confirm("Return to the main menu?"):
+                    return False
+            # any other key is ignored
+            if board.lost or board.is_won:
+                break
+        elapsed = time.monotonic() - start if start is not None else 0
+        return self._finish(board, diff, elapsed)
+
+    def _finish(self, board, diff, elapsed):
+        """Result screen and statistics. Returns True to play again."""
+        seconds = int(elapsed)
+        store = get_store()
+        improved = False
+        if board.is_won:
+            store.record_result(KEY, "win")
+            improved = store.improve_record(diff.record_key, seconds,
+                                            lower_is_better=True)
+        else:
+            store.record_result(KEY, "loss")
+        self._render(board, diff, -1, -1, seconds, "")
+        print()
+        if board.is_won:
+            term.print_centered([term.style(
+                f"You cleared the field in {format_time(seconds)}!",
+                term.Color.GREEN, term.Color.BOLD)])
+            if improved:
+                term.print_centered([term.style(
+                    "New best time for this difficulty!", term.Color.YELLOW)])
+        else:
+            term.print_centered([term.style(
+                "BOOM! You hit a mine.", term.Color.RED, term.Color.BOLD)])
+        print()
+        return term.confirm("Play again?")
+
+    # ---------------------------------------------------------- render
+
+    def _board_fits(self, diff):
+        cols, rows = term.size()
+        return cols >= diff.cols * CELL_WIDTH + 2 and rows >= diff.rows + 5
+
+    def _wait_for_fit(self, diff):
+        """Block until the terminal fits the board. False means give up."""
+        while not self._board_fits(diff):
+            term.clear()
+            need_cols = diff.cols * CELL_WIDTH + 2
+            need_rows = diff.rows + 5
+            cols, rows = term.size()
+            print()
+            term.print_centered([
+                term.style("The board does not fit your terminal.", term.Color.YELLOW),
+                "",
+                f"{diff.name} needs at least {need_cols}x{need_rows};"
+                f" you have {cols}x{rows}.",
+                "",
+                term.style("Resize the window and press any key, or Q to go back.",
+                           term.Color.GRAY),
+            ])
+            key = read_key()
+            if key == Key.ESCAPE or (isinstance(key, str) and key.lower() == "q"):
+                return False
+        return True
+
+    def _render(self, board, diff, cursor_r, cursor_c, seconds, message):
+        term.clear()
+        cols, _ = term.size()
+        header = (f"{diff.name} - Time {format_time(seconds)}"
+                  f" - Flags left {board.flags_remaining}")
+        print(term.center(term.style(header, term.Color.CYAN), cols))
+        print()
+        pad = max(0, (cols - diff.cols * CELL_WIDTH) // 2)
+        for r in range(diff.rows):
+            line = "".join(
+                self._cell(board, r, c, r == cursor_r and c == cursor_c)
+                for c in range(diff.cols)
+            )
+            print(" " * pad + line)
+        print()
+        if message:
+            print(term.center(term.style(message, term.Color.YELLOW), cols))
+        else:
+            hint = "Arrows/WASD move - Enter/Space reveal - F flag - H help - Q quit"
+            print(term.center(term.style(hint, term.Color.GRAY), cols))
+
+    def _cell(self, board, r, c, cursor):
+        """One 3-character-wide board cell."""
+        flag_glyph = "⚑" if term.UNICODE else "F"
+        mine_glyph = "●" if term.UNICODE else "*"
+        codes = ()
+        if (r, c) in board.revealed:
+            if (r, c) in board.mines:
+                glyph, codes = mine_glyph, (term.Color.RED,)
+                if board.exploded_at == (r, c):
+                    codes = (term.Color.RED, term.Color.BOLD)
+            else:
+                count = board.neighbor_mine_count(r, c)
+                glyph = str(count) if count else " "
+                codes = (term.Color.WHITE,) if count else ()
+        elif (r, c) in board.flagged:
+            glyph, codes = flag_glyph, (term.Color.YELLOW,)
+        else:
+            glyph, codes = ".", (term.Color.GRAY,)
+        if cursor:
+            codes = codes + (term.Color.BOLD,)
+            return term.style(f"[{glyph}]", *codes)
+        return term.style(f" {glyph} ", *codes)
@@ -0,0 +1,459 @@+"""Naval Battle - Battleship-style game on a 10x10 ocean.
+
+Player vs Computer. Coordinates are A1..J10 (letter = row, number =
+column). The pure rules live in Ship, Board and ComputerAI, which
+never print or read input; the Game class is the terminal UI layer.
+"""
+from __future__ import annotations
+
+import random
+import re
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+
+NAME = "Naval Battle"
+KEY = "naval_battle"
+DESCRIPTION = "Sink the enemy fleet on a 10x10 ocean before yours goes down."
+
+BOARD_SIZE = 10
+FLEET = (
+    ("Carrier", 5),
+    ("Battleship", 4),
+    ("Cruiser", 3),
+    ("Submarine", 3),
+    ("Destroyer", 2),
+)
+
+# Shot results.
+MISS = "miss"
+HIT = "hit"
+SUNK = "sunk"
+ALREADY = "already"
+
+_DIRECTIONS = ("h", "v")
+_NEIGHBORS = ((1, 0), (-1, 0), (0, 1), (0, -1))
+
+_COORD_RE = re.compile(r"^([A-J])\s*([1-9]|10)$")
+_COORD_RE_REVERSED = re.compile(r"^([1-9]|10)\s*([A-J])$")
+
+
+def parse_coordinate(text):
+    """Parse 'E5' (or '5E') into a (row, col) tuple. None if invalid."""
+    if not text:
+        return None
+    text = text.strip().upper()
+    match = _COORD_RE.match(text) or _COORD_RE_REVERSED.match(text)
+    if not match:
+        return None
+    letter, number = match.groups()
+    if letter.isdigit():
+        letter, number = number, letter
+    return (ord(letter) - ord("A"), int(number) - 1)
+
+
+def format_coordinate(row, col):
+    """Format a (row, col) tuple as 'E5'."""
+    return f"{chr(ord('A') + row)}{col + 1}"
+
+
+class Ship:
+    """One vessel: a name, a size and the cells it occupies."""
+
+    def __init__(self, name, size, cells):
+        self.name = name
+        self.size = size
+        self.cells = frozenset(cells)
+        self.hits = set()
+
+    @property
+    def sunk(self):
+        return len(self.hits) >= self.size
+
+
+class Board:
+    """One player's ocean: placed ships plus incoming shots."""
+
+    def __init__(self, size=BOARD_SIZE):
+        self.size = size
+        self.ships = []
+        self.shots = {}  # (row, col) -> MISS / HIT / SUNK
+        self._occupied = {}  # (row, col) -> Ship
+
+    @staticmethod
+    def ship_cells(start, length, direction, size=BOARD_SIZE):
+        """Cells a ship would occupy. None if direction bad or off-board."""
+        if direction not in _DIRECTIONS or length < 1:
+            return None
+        row, col = start
+        d_row, d_col = (0, 1) if direction == "h" else (1, 0)
+        cells = [(row + i * d_row, col + i * d_col) for i in range(length)]
+        if any(not (0 <= r < size and 0 <= c < size) for r, c in cells):
+            return None
+        return cells
+
+    def can_place(self, cells):
+        """True when every cell is on-board and free. Adjacency is fine."""
+        if not cells:
+            return False
+        for r, c in cells:
+            if not (0 <= r < self.size and 0 <= c < self.size):
+                return False
+            if (r, c) in self._occupied:
+                return False
+        return True
+
+    def place_ship(self, name, size, cells):
+        """Place a ship on the given cells. False when illegal."""
+        if not cells or len(cells) != size or not self.can_place(cells):
+            return False
+        ship = Ship(name, size, cells)
+        self.ships.append(ship)
+        for cell in ship.cells:
+            self._occupied[cell] = ship
+        return True
+
+    def ship_at(self, cell):
+        return self._occupied.get(cell)
+
+    def receive_shot(self, row, col):
+        """Fire at a cell. Returns (result, ship or None)."""
+        cell = (row, col)
+        if cell in self.shots:
+            return ALREADY, None
+        ship = self._occupied.get(cell)
+        if ship is None:
+            self.shots[cell] = MISS
+            return MISS, None
+        ship.hits.add(cell)
+        result = SUNK if ship.sunk else HIT
+        self.shots[cell] = result
+        return result, ship
+
+    @property
+    def fleet_destroyed(self):
+        return bool(self.ships) and all(ship.sunk for ship in self.ships)
+
+    def afloat_ships(self):
+        return [ship for ship in self.ships if not ship.sunk]
+
+
+def random_fleet(rng=None):
+    """Build a board with the standard fleet placed randomly and legally."""
+    rng = rng or random.Random()
+    board = Board()
+    for name, length in FLEET:
+        placed = False
+        while not placed:
+            start = (rng.randrange(BOARD_SIZE), rng.randrange(BOARD_SIZE))
+            cells = Board.ship_cells(start, length, rng.choice(_DIRECTIONS))
+            if cells is not None and board.can_place(cells):
+                board.place_ship(name, length, cells)
+                placed = True
+    return board
+
+
+class ComputerAI:
+    """Computer targeting: random hunting, then pursue hits until sunk."""
+
+    def __init__(self, rng=None):
+        self._rng = rng or random.Random()
+        self._fired = set()
+        self._hunt = []  # candidate cells adjacent to unsunk hits
+
+    def next_shot(self):
+        """Pick the next cell to fire at (never repeats a cell)."""
+        while self._hunt:
+            cell = self._hunt.pop()
+            if cell not in self._fired:
+                self._fired.add(cell)
+                return cell
+        cell = self._rng.choice([
+            (r, c)
+            for r in range(BOARD_SIZE)
+            for c in range(BOARD_SIZE)
+            if (r, c) not in self._fired
+        ])
+        self._fired.add(cell)
+        return cell
+
+    def register_result(self, cell, result, ship=None):
+        """Learn the outcome of the shot fired at cell."""
+        if result in (HIT, SUNK):
+            row, col = cell
+            for d_row, d_col in _NEIGHBORS:
+                neighbor = (row + d_row, col + d_col)
+                if (0 <= neighbor[0] < BOARD_SIZE
+                        and 0 <= neighbor[1] < BOARD_SIZE
+                        and neighbor not in self._fired):
+                    self._hunt.append(neighbor)
+        if result == SUNK and ship is not None:
+            # The ship is gone: drop hunt candidates around its cells.
+            self._hunt = [
+                candidate for candidate in self._hunt
+                if not any((candidate[0] + d_row, candidate[1] + d_col) in ship.cells
+                           for d_row, d_col in _NEIGHBORS)
+            ]
+
+
+class Game:
+    """Terminal UI for Naval Battle (Player vs Computer)."""
+
+    def __init__(self):
+        if term.UNICODE:
+            self._glyphs = {"ship": "■", "hit": "●", "miss": "○", "water": "·"}
+        else:
+            self._glyphs = {"ship": "#", "hit": "X", "miss": "o", "water": "."}
+        self._messages = []  # list of (text, kind)
+
+    # ------------------------------------------------------------- session
+
+    def run(self):
+        term.hide_cursor()
+        try:
+            while True:
+                boards = self._setup_fleets()
+                if boards is None:
+                    return
+                outcome = self._play_match(*boards)
+                if outcome is None:
+                    return
+                get_store().record_result(KEY, outcome)
+                if not term.confirm("Play again?", default=True):
+                    return
+        finally:
+            term.show_cursor()
+
+    # -------------------------------------------------------------- setup
+
+    def _setup_fleets(self):
+        """Choose placement and build both boards. None means quit."""
+        enemy_board = random_fleet()
+        title = ["", term.style(NAME.upper(), term.Color.CYAN, term.Color.BOLD), ""]
+        hint = "Enter select - H help - Q/ESC back to menu"
+        while True:
+            choice = choose(title, ["Place ships manually",
+                                    "Place ships automatically"],
+                            hotkeys=["1", "2"], extra_keys="h", hint=hint)
+            if choice is None:
+                return None
+            if choice == "h":
+                self._show_help()
+                continue
+            if choice == 0:
+                player_board = self._place_manually()
+                if player_board is None:
+                    return None
+            else:
+                player_board = random_fleet()
+                term.clear()
+                print()
+                self._render_single_board(player_board, "YOUR FLEET IS READY")
+                print()
+                term.press_enter()
+            return player_board, enemy_board
+
+    def _place_manually(self):
+        """Let the player position every ship. None means quit."""
+        board = Board()
+        for name, length in FLEET:
+            while True:
+                term.clear()
+                print()
+                self._render_single_board(board, "DEPLOY YOUR FLEET")
+                print()
+                print(term.center(term.style(
+                    f"Place the {name} ({length} cells).", term.Color.CYAN)))
+                answer = read_line("Start + direction (e.g. 'b3 h'), H help, Q quit: ")
+                if answer is None or answer.strip().lower() == "q":
+                    return None
+                if answer.strip().lower() == "h":
+                    self._show_help()
+                    continue
+                error = self._try_place(board, name, length, answer)
+                if error is None:
+                    break
+                term.status(error, "warn")
+                term.press_enter()
+        return board
+
+    def _try_place(self, board, name, length, answer):
+        """Parse a placement line and place the ship. Returns error text."""
+        tokens = answer.lower().split()
+        if len(tokens) != 2:
+            return "Type a coordinate and a direction, like 'b3 h' or 'b3 v'."
+        start = parse_coordinate(tokens[0])
+        if start is None:
+            return "Invalid coordinate. Use A1..J10 (letter = row)."
+        direction = tokens[1][0]
+        if direction not in _DIRECTIONS:
+            return "Direction must be H (horizontal) or V (vertical)."
+        cells = Board.ship_cells(start, length, direction)
+        if cells is None:
+            return "The ship would leave the ocean. Try another spot."
+        if not board.can_place(cells):
+            return "Ships cannot overlap. Try another spot."
+        board.place_ship(name, length, cells)
+        return None
+
+    # -------------------------------------------------------------- battle
+
+    def _play_match(self, player_board, enemy_board):
+        """Run the battle. Returns 'win', 'loss', or None (quit to menu)."""
+        ai = ComputerAI()
+        self._messages = []
+        while True:
+            self._render_battle(player_board, enemy_board)
+            answer = read_line("Fire at (e.g. E5), H help, Q quit: ")
+            if answer is None:
+                if term.confirm("Return to the main menu?"):
+                    return None
+                continue
+            command = answer.strip().lower()
+            if command == "q":
+                if term.confirm("Return to the main menu?"):
+                    return None
+                continue
+            if command == "h":
+                self._show_help()
+                continue
+            target = parse_coordinate(answer)
+            if target is None:
+                self._say("Invalid coordinate. Use A1..J10, like E5.", "warn")
+                continue
+            result, ship = enemy_board.receive_shot(*target)
+            if result == ALREADY:
+                self._say("You already fired there. Pick another square.", "warn")
+                continue
+            self._report_player_shot(target, result, ship)
+            if enemy_board.fleet_destroyed:
+                self._render_battle(player_board, enemy_board)
+                term.status("Victory! The enemy fleet is destroyed.", "ok")
+                term.press_enter()
+                return "win"
+            self._computer_turn(ai, player_board)
+            if player_board.fleet_destroyed:
+                self._render_battle(player_board, enemy_board)
+                term.status("Defeat. Your fleet rests on the ocean floor.", "error")
+                term.press_enter()
+                return "loss"
+
+    def _report_player_shot(self, target, result, ship):
+        label = format_coordinate(*target)
+        if result == MISS:
+            self._say(f"You fire at {label}: miss.", "dim")
+        elif result == HIT:
+            self._say(f"You fire at {label}: hit!", "ok")
+        else:
+            self._say(f"You sunk the enemy {ship.name}!", "ok")
+
+    def _computer_turn(self, ai, player_board):
+        row, col = ai.next_shot()
+        result, ship = player_board.receive_shot(row, col)
+        ai.register_result((row, col), result, ship)
+        label = format_coordinate(row, col)
+        if result == MISS:
+            self._say(f"Enemy fires at {label}: miss.", "dim")
+        elif result == HIT:
+            self._say(f"Enemy fires at {label}: your {ship.name} is hit!", "error")
+        else:
+            self._say(f"Enemy sunk your {ship.name}!", "error")
+
+    def _say(self, text, kind="info"):
+        self._messages.append((text, kind))
+        del self._messages[:-4]
+
+    # ------------------------------------------------------------ rendering
+
+    def _render_battle(self, player_board, enemy_board):
+        term.clear()
+        print()
+        print(term.center(term.style("NAVAL BATTLE", term.Color.CYAN, term.Color.BOLD)))
+        print()
+        own = [term.style("  YOUR OCEAN", term.Color.CYAN)]
+        own += self._board_lines(player_board, hide_ships=False)
+        target = [term.style("  TARGET OCEAN", term.Color.CYAN)]
+        target += self._board_lines(enemy_board, hide_ships=True)
+        width = max(term.visible_len(line) for line in own + target)
+        for left, right in zip(own, target):
+            gap = " " * (width - term.visible_len(left) + 6)
+            print(term.center(left + gap + right))
+        print()
+        print(term.center("Your fleet:  " + self._fleet_status(player_board)))
+        print(term.center("Enemy fleet: " + self._fleet_status(enemy_board)))
+        print()
+        for text, kind in self._messages:
+            print(term.center(term.style(text, term.STATUS_COLORS.get(kind, ""))))
+        print()
+
+    def _render_single_board(self, board, title):
+        print(term.center(term.style(title, term.Color.CYAN, term.Color.BOLD)))
+        print()
+        for line in self._board_lines(board, hide_ships=False):
+            print(term.center(line))
+
+    def _board_lines(self, board, hide_ships):
+        header = "   " + "".join(f"{c + 1:>2} " for c in range(board.size))
+        lines = [term.style(header, term.Color.GRAY)]
+        for r in range(board.size):
+            row = term.style(f"{chr(ord('A') + r)}  ", term.Color.GRAY)
+            row += "".join(
+                f" {self._cell_char(board, r, c, hide_ships)} "
+                for c in range(board.size)
+            )
+            lines.append(row)
+        return lines
+
+    def _cell_char(self, board, row, col, hide_ships):
+        cell = (row, col)
+        ship = board.ship_at(cell)
+        shot = board.shots.get(cell)
+        if shot == MISS:
+            return term.style(self._glyphs["miss"], term.Color.GRAY)
+        if shot in (HIT, SUNK):
+            color = term.Color.RED if (ship is not None and ship.sunk) else term.Color.YELLOW
+            return term.style(self._glyphs["hit"], color, term.Color.BOLD)
+        if ship is not None and not hide_ships:
+            return term.style(self._glyphs["ship"], term.Color.GREEN)
+        return term.style(self._glyphs["water"], term.Color.GRAY)
+
+    def _fleet_status(self, board):
+        parts = []
+        for ship in board.ships:
+            label = f"{ship.name} ({ship.size})"
+            if ship.sunk:
+                parts.append(term.style(label + " SUNK", term.Color.RED))
+            else:
+                parts.append(label)
+        return "  ".join(parts)
+
+    # ---------------------------------------------------------------- help
+
+    def _show_help(self):
+        term.clear()
+        print()
+        term.print_centered([term.style("NAVAL BATTLE - HELP", term.Color.CYAN,
+                                        term.Color.BOLD), ""])
+        lines = [
+            "Objective: sink the whole enemy fleet before the",
+            "computer sinks yours. You trade one shot per turn.",
+            "",
+            "Fleet: Carrier (5), Battleship (4), Cruiser (3),",
+            "       Submarine (3), Destroyer (2).",
+            "",
+            "Controls:",
+            "  - Coordinates look like E5: letter = row, number = column.",
+            "  - When placing ships, add a direction: 'b3 h' or 'b3 v'.",
+            "  - H shows this help, Q returns to the main menu.",
+            "",
+            "Rules:",
+            "  - Ships cannot overlap or leave the ocean; touching is fine.",
+            "  - Hits, misses and sunk ships are announced by name.",
+            "  - Enemy ships stay hidden until you sink them.",
+        ]
+        for line in lines:
+            print(term.center(line))
+        print()
+        term.press_enter()
@@ -0,0 +1,444 @@+"""Reversi (Othello) for Pablo's Terminal Games.
+
+Classic 8x8 Reversi: outflank your opponent's discs to flip them.
+The pure rules live in Board / GameState / ComputerPlayer (no I/O);
+the Game class is the terminal UI on top. See GAME_SPEC.md.
+"""
+from __future__ import annotations
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+
+NAME = "Reversi"
+KEY = "reversi"
+DESCRIPTION = "Outflank and flip your opponent's discs on an 8x8 board."
+
+EMPTY = 0
+BLACK = 1  # shown as X / ●, moves first
+WHITE = 2  # shown as O / ○
+
+SIZE = 8
+DIRECTIONS = (
+    (-1, -1), (-1, 0), (-1, 1),
+    (0, -1),           (0, 1),
+    (1, -1),  (1, 0),  (1, 1),
+)
+
+LABELS = {EMPTY: ".", BLACK: "X", WHITE: "O"}
+
+
+def opponent(player: int) -> int:
+    """Return the other player's constant."""
+    return WHITE if player == BLACK else BLACK
+
+
+class Board:
+    """Pure 8x8 Reversi board: placement, legal moves and flipping."""
+
+    def __init__(self) -> None:
+        self.grid = [[EMPTY] * SIZE for _ in range(SIZE)]
+        mid = SIZE // 2
+        # Standard opening: white on d4/e5, black on e4/d5 (0-indexed).
+        self.grid[mid - 1][mid - 1] = WHITE
+        self.grid[mid - 1][mid] = BLACK
+        self.grid[mid][mid - 1] = BLACK
+        self.grid[mid][mid] = WHITE
+
+    def copy(self) -> "Board":
+        clone = Board.__new__(Board)
+        clone.grid = [row[:] for row in self.grid]
+        return clone
+
+    @staticmethod
+    def inside(row: int, col: int) -> bool:
+        return 0 <= row < SIZE and 0 <= col < SIZE
+
+    def get(self, row: int, col: int) -> int:
+        return self.grid[row][col]
+
+    def flips_for(self, player: int, row: int, col: int) -> list:
+        """All opponent discs flipped by placing at (row, col).
+
+        Returns an empty list when the move is not legal.
+        """
+        if not self.inside(row, col) or self.grid[row][col] != EMPTY:
+            return []
+        enemy = opponent(player)
+        flipped = []
+        for d_row, d_col in DIRECTIONS:
+            line = []
+            r, c = row + d_row, col + d_col
+            while self.inside(r, c) and self.grid[r][c] == enemy:
+                line.append((r, c))
+                r += d_row
+                c += d_col
+            # The line only counts when it ends on one of our own discs.
+            if line and self.inside(r, c) and self.grid[r][c] == player:
+                flipped.extend(line)
+        return flipped
+
+    def is_legal(self, player: int, row: int, col: int) -> bool:
+        return bool(self.flips_for(player, row, col))
+
+    def legal_moves(self, player: int) -> list:
+        """All legal (row, col) moves for player, in board order."""
+        return [
+            (row, col)
+            for row in range(SIZE)
+            for col in range(SIZE)
+            if self.flips_for(player, row, col)
+        ]
+
+    def has_move(self, player: int) -> bool:
+        return any(
+            self.flips_for(player, row, col)
+            for row in range(SIZE)
+            for col in range(SIZE)
+        )
+
+    def apply(self, player: int, row: int, col: int) -> list:
+        """Place a disc and flip every outflanked line.
+
+        Returns the list of flipped squares. Raises ValueError on an
+        illegal move.
+        """
+        flipped = self.flips_for(player, row, col)
+        if not flipped:
+            raise ValueError(f"Illegal move at row {row}, col {col}")
+        self.grid[row][col] = player
+        for r, c in flipped:
+            self.grid[r][c] = player
+        return flipped
+
+    def count(self) -> dict:
+        """Disc counts per player: {BLACK: n, WHITE: n}."""
+        return {
+            BLACK: sum(cell == BLACK for row in self.grid for cell in row),
+            WHITE: sum(cell == WHITE for row in self.grid for cell in row),
+        }
+
+
+class GameState:
+    """Turn flow for one Reversi game: passes, game over, scoring."""
+
+    def __init__(self) -> None:
+        self.board = Board()
+        self.current = BLACK  # black always opens
+
+    def legal_moves(self) -> list:
+        return self.board.legal_moves(self.current)
+
+    def must_pass(self) -> bool:
+        """True when the current player has no legal move but the game
+        is not over."""
+        return not self.board.has_move(self.current) and not self.game_over()
+
+    def game_over(self) -> bool:
+        """The game ends when neither player can move."""
+        return not self.board.has_move(BLACK) and not self.board.has_move(WHITE)
+
+    def play(self, row: int, col: int) -> list:
+        """Play the current player's move and hand over the turn.
+
+        Returns the flipped squares; raises ValueError on an illegal
+        move. The caller is responsible for pass handling: check
+        must_pass() and call pass_turn() when the next player is stuck.
+        """
+        flipped = self.board.apply(self.current, row, col)
+        self.current = opponent(self.current)
+        return flipped
+
+    def pass_turn(self) -> None:
+        """Skip the current player's turn (automatic pass)."""
+        self.current = opponent(self.current)
+
+    def score(self) -> tuple:
+        """(black discs, white discs)."""
+        counts = self.board.count()
+        return counts[BLACK], counts[WHITE]
+
+    def winner(self):
+        """BLACK, WHITE, or None for a draw. Only valid once game_over()."""
+        black, white = self.score()
+        if black > white:
+            return BLACK
+        if white > black:
+            return WHITE
+        return None
+
+
+# ---------------------------------------------------------------- computer AI
+
+# Positional value of each square. Corners are precious, the squares
+# next to a corner are dangerous while that corner is still empty, and
+# edges are generally good.
+_BASE_WEIGHTS = (
+    (100, -20,  10,   5,   5,  10, -20, 100),
+    (-20, -40,  -5,  -5,  -5,  -5, -40, -20),
+    ( 10,  -5,   5,   1,   1,   5,  -5,  10),
+    (  5,  -5,   1,   1,   1,   1,  -5,   5),
+    (  5,  -5,   1,   1,   1,   1,  -5,   5),
+    ( 10,  -5,   5,   1,   1,   5,  -5,  10),
+    (-20, -40,  -5,  -5,  -5,  -5, -40, -20),
+    (100, -20,  10,   5,   5,  10, -20, 100),
+)
+
+CORNERS = ((0, 0), (0, SIZE - 1), (SIZE - 1, 0), (SIZE - 1, SIZE - 1))
+
+# Squares adjacent to each corner (the two "C" squares and the "X" square).
+_CORNER_NEIGHBORS = {
+    (0, 0): ((0, 1), (1, 0), (1, 1)),
+    (0, SIZE - 1): ((0, SIZE - 2), (1, SIZE - 1), (1, SIZE - 2)),
+    (SIZE - 1, 0): ((SIZE - 2, 0), (SIZE - 1, 1), (SIZE - 2, 1)),
+    (SIZE - 1, SIZE - 1): ((SIZE - 2, SIZE - 1), (SIZE - 1, SIZE - 2),
+                           (SIZE - 2, SIZE - 2)),
+}
+
+
+class ComputerPlayer:
+    """Heuristic AI: positional value plus immediate flip count."""
+
+    def __init__(self, player: int) -> None:
+        self.player = player
+
+    def _weight(self, board: Board, row: int, col: int) -> int:
+        weight = _BASE_WEIGHTS[row][col]
+        # Squares next to an empty corner are a liability; once the
+        # corner is taken they lose the penalty.
+        for corner, neighbors in _CORNER_NEIGHBORS.items():
+            if (row, col) in neighbors and board.get(*corner) != EMPTY:
+                weight = max(weight, 2)
+        return weight
+
+    def evaluate(self, board: Board, row: int, col: int) -> float:
+        """Score a legal move: position value plus discs flipped."""
+        return self._weight(board, row, col) + len(board.flips_for(self.player, row, col))
+
+    def choose_move(self, board: Board):
+        """Best legal (row, col) move, or None when there is none."""
+        moves = board.legal_moves(self.player)
+        if not moves:
+            return None
+        return max(moves, key=lambda mv: self.evaluate(board, *mv))
+
+
+# ---------------------------------------------------------------- UI helpers
+
+def parse_coordinate(text: str):
+    """Parse 'd3'-style input into (row, col), or None if malformed."""
+    cleaned = text.strip().lower().replace(" ", "")
+    if len(cleaned) != 2:
+        return None
+    letter, digit = cleaned
+    col = ord(letter) - ord("a")
+    row = ord(digit) - ord("1")
+    if 0 <= row < SIZE and 0 <= col < SIZE and letter.isalpha() and digit.isdigit():
+        return (row, col)
+    return None
+
+
+def coordinate_name(row: int, col: int) -> str:
+    """Human-readable name for a square, e.g. (2, 3) -> 'd3'."""
+    return f"{chr(ord('a') + col)}{row + 1}"
+
+
+class Game:
+    """Interactive Reversi session: mode selection, play loop, replay."""
+
+    def __init__(self) -> None:
+        self.store = get_store()
+
+    def run(self) -> None:
+        term.hide_cursor()
+        try:
+            if not self._choose_mode():
+                return
+            while True:
+                self._play_once()
+                if not term.confirm("Play again?"):
+                    return
+        finally:
+            term.show_cursor()
+
+    # ------------------------------------------------------------ setup
+
+    def _choose_mode(self) -> bool:
+        """Pick PvC or PvP. False means back to the menu."""
+        choice = choose(
+            [term.style(NAME, term.Color.CYAN, term.Color.BOLD), "", DESCRIPTION],
+            ["Player vs Computer", "Player vs Player"],
+            hotkeys=["1", "2"],
+            hint="Enter select - H help - Q/ESC back to menu",
+            extra_keys="h",
+        )
+        if choice == "h":
+            self._show_help()
+            return self._choose_mode()
+        if choice is None:
+            return False
+        self.vs_computer = choice == 0
+        return True
+
+    # ------------------------------------------------------------ one game
+
+    def _play_once(self) -> None:
+        state = GameState()
+        computer = ComputerPlayer(WHITE) if self.vs_computer else None
+        while not state.game_over():
+            self._draw(state)
+            if state.must_pass():
+                name = self._player_name(state.current)
+                term.status(f"{name} has no legal move and must pass.", "warn")
+                term.press_enter()
+                state.pass_turn()
+                continue
+            if computer is not None and state.current == computer.player:
+                move = computer.choose_move(state.board)
+                state.play(*move)
+                term.status(
+                    f"Computer plays {coordinate_name(*move)}.", "dim")
+                term.press_enter()
+                continue
+            if not self._human_turn(state):
+                return  # player quit to the menu
+        self._finish(state)
+
+    def _human_turn(self, state: GameState) -> bool:
+        """Prompt the human until a valid move is played.
+
+        Returns False when the player chooses to leave the game.
+        """
+        while True:
+            prompt = (f"{self._player_name(state.current)}'s move "
+                      "(e.g. d3, h help, q quit): ")
+            answer = read_line(prompt)
+            if answer is None:
+                return False
+            text = answer.strip().lower()
+            if text == "q":
+                if term.confirm("Return to the main menu?"):
+                    return False
+                self._draw(state)
+                continue
+            if text == "h":
+                self._show_help()
+                self._draw(state)
+                continue
+            move = parse_coordinate(text)
+            if move is None:
+                term.status("Invalid input. Use coordinates like d3.", "error")
+                continue
+            if not state.board.is_legal(state.current, *move):
+                term.status("Invalid move. You must outflank at least "
+                            "one opponent disc.", "error")
+                continue
+            state.play(*move)
+            return True
+
+    def _finish(self, state: GameState) -> None:
+        self._draw(state, show_moves=False)
+        black, white = state.score()
+        winner = state.winner()
+        print(term.center(f"Final score - X: {black}   O: {white}"))
+        print()
+        if self.vs_computer:
+            if winner == BLACK:
+                term.status("You win! The board is yours.", "ok")
+                self.store.record_result(KEY, "win")
+            elif winner == WHITE:
+                term.status("The computer wins this one.", "error")
+                self.store.record_result(KEY, "loss")
+            else:
+                term.status("A perfect tie. Well played.", "warn")
+                self.store.record_result(KEY, "draw")
+        else:
+            if winner is None:
+                term.status("A perfect tie. Well played.", "warn")
+                self.store.record_result(KEY, "draw")
+            else:
+                term.status(f"{self._player_name(winner)} wins!", "ok")
+                self.store.record_result(KEY, "win")
+        print()
+
+    # ------------------------------------------------------------ rendering
+
+    def _player_name(self, player: int) -> str:
+        symbol = self._disc(player)
+        if self.vs_computer:
+            side = "You" if player == BLACK else "Computer"
+            return f"{side} ({symbol})"
+        return f"Player {symbol}"
+
+    @staticmethod
+    def _disc(player: int) -> str:
+        if term.UNICODE:
+            return {BLACK: "●", WHITE: "○"}[player]
+        return LABELS[player]
+
+    def _draw(self, state: GameState, show_moves: bool = True) -> None:
+        term.clear()
+        print()
+        print(term.center(term.style(NAME, term.Color.CYAN, term.Color.BOLD)))
+        black, white = state.score()
+        print(term.center(
+            f"{self._disc(BLACK)} {black:2d}   -   {white:2d} {self._disc(WHITE)}"))
+        print()
+
+        hint = set(state.legal_moves()) if show_moves else set()
+        dot = "·" if term.UNICODE else "*"
+        header = "    " + "  ".join(chr(ord("a") + c) for c in range(SIZE))
+        lines = [term.style(header, term.Color.GRAY)]
+        for row in range(SIZE):
+            cells = []
+            for col in range(SIZE):
+                value = state.board.get(row, col)
+                if value == EMPTY:
+                    mark = term.style(dot, term.Color.GREEN) if (row, col) in hint \
+                        else term.style(".", term.Color.GRAY)
+                else:
+                    color = term.Color.WHITE if value == BLACK else term.Color.CYAN
+                    mark = term.style(self._disc(value), color, term.Color.BOLD)
+                cells.append(mark)
+            number = term.style(f"{row + 1:2d} ", term.Color.GRAY)
+            lines.append(f"  {number} " + "  ".join(cells))
+        width = max(term.visible_len(line) for line in lines)
+        pad = max(0, (term.size()[0] - width) // 2)
+        for line in lines:
+            print(" " * pad + line)
+        print()
+        if show_moves and hint:
+            shown = ", ".join(coordinate_name(r, c) for r, c in sorted(hint))
+            print(term.center(term.style(f"Legal moves: {shown}", term.Color.GRAY)))
+            print()
+
+    def _show_help(self) -> None:
+        term.clear()
+        print()
+        term.print_centered([
+            term.style(f"{NAME} - HELP", term.Color.CYAN, term.Color.BOLD), ""])
+        lines = [
+            "OBJECTIVE",
+            "  Finish the game with more discs of your color on",
+            "  the 8x8 board than your opponent.",
+            "",
+            "CONTROLS",
+            "  Type a coordinate such as d3 and press Enter.",
+            "  h  show this help        q  return to the main menu",
+            "",
+            "RULES",
+            "  - Black (X) moves first; players alternate turns.",
+            "  - A move is legal only when it traps at least one",
+            "    opponent disc between the new disc and one of",
+            "    yours, in any of the 8 directions.",
+            "  - Every trapped line is flipped to your color.",
+            "  - Legal moves are marked with dots on the board.",
+            "  - If you have no legal move, your turn is passed.",
+            "  - The game ends when neither player can move;",
+            "    the higher disc count wins.",
+        ]
+        for line in lines:
+            print(term.center(line))
+        print()
+        term.press_enter()
@@ -0,0 +1,314 @@+"""Tic-Tac-Toe - classic 3x3 game for Pablo's Terminal Games.
+
+Pure game logic lives in ``Board``, ``parse_coordinate`` and
+``choose_computer_move`` (no printing, no input) so unit tests can
+drive them headless. ``Game`` is the interactive UI layer on top.
+"""
+from __future__ import annotations
+
+import random
+
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+
+NAME = "Tic-Tac-Toe"
+KEY = "tic_tac_toe"
+DESCRIPTION = "The classic 3x3 game of Xs and Os."
+
+EMPTY = " "
+MARK_X = "X"
+MARK_O = "O"
+
+COLUMNS = "ABC"  # column labels, index 0 -> "A"
+SIZE = 3
+
+# All eight winning lines as (row, col) triples: 3 rows, 3 columns,
+# 2 diagonals.
+WIN_LINES = (
+    ((0, 0), (0, 1), (0, 2)),
+    ((1, 0), (1, 1), (1, 2)),
+    ((2, 0), (2, 1), (2, 2)),
+    ((0, 0), (1, 0), (2, 0)),
+    ((0, 1), (1, 1), (2, 1)),
+    ((0, 2), (1, 2), (2, 2)),
+    ((0, 0), (1, 1), (2, 2)),
+    ((0, 2), (1, 1), (2, 0)),
+)
+
+
+def parse_coordinate(text):
+    """Parse 'b2'-style input (case-insensitive) into (row, col).
+
+    Returns None when the text is not a valid on-board coordinate.
+    """
+    if not text:
+        return None
+    text = text.strip().lower()
+    if len(text) != 2:
+        return None
+    col_char, row_char = text[0], text[1]
+    if col_char not in "abc" or row_char not in "123":
+        return None
+    return int(row_char) - 1, ord(col_char) - ord("a")
+
+
+def format_coordinate(row, col):
+    """Inverse of parse_coordinate: (1, 1) -> 'b2'."""
+    return f"{COLUMNS[col].lower()}{row + 1}"
+
+
+class Board:
+    """Pure 3x3 board state. No printing, no input."""
+
+    def __init__(self):
+        self.cells = [[EMPTY] * SIZE for _ in range(SIZE)]
+
+    def get(self, row, col):
+        return self.cells[row][col]
+
+    def is_empty(self, row, col):
+        return self.cells[row][col] == EMPTY
+
+    def place(self, mark, row, col):
+        """Place mark at (row, col). Returns False if already occupied."""
+        if mark not in (MARK_X, MARK_O) or not self.is_empty(row, col):
+            return False
+        self.cells[row][col] = mark
+        return True
+
+    def empty_cells(self):
+        """List of (row, col) pairs that are still free."""
+        return [(r, c) for r in range(SIZE) for c in range(SIZE)
+                if self.cells[r][c] == EMPTY]
+
+    def is_full(self):
+        return not self.empty_cells()
+
+    def winning_line(self):
+        """Return the winning (line, mark) or None.
+
+        ``line`` is a tuple of three (row, col) pairs. The game is a
+        draw when the board is full and this returns None.
+        """
+        for line in WIN_LINES:
+            marks = [self.cells[r][c] for r, c in line]
+            if marks[0] != EMPTY and marks.count(marks[0]) == SIZE:
+                return line, marks[0]
+        return None
+
+    def winner(self):
+        """Return MARK_X or MARK_O if someone won, else None."""
+        result = self.winning_line()
+        return result[1] if result else None
+
+    def is_game_over(self):
+        return self.winner() is not None or self.is_full()
+
+
+def _find_line_move(board, mark):
+    """Find a cell that completes three-in-a-row for mark, or None."""
+    for line in WIN_LINES:
+        marks = [board.get(r, c) for r, c in line]
+        if marks.count(mark) == 2 and marks.count(EMPTY) == 1:
+            return line[marks.index(EMPTY)]
+    return None
+
+
+def choose_computer_move(board, mark, rng=None):
+    """Pick a reasonable move for the computer.
+
+    Priority: win now, block the opponent's win, take the center,
+    take a corner, otherwise a random free cell. Returns (row, col)
+    or None when the board is full.
+    """
+    rng = rng or random
+    free = board.empty_cells()
+    if not free:
+        return None
+    opponent = MARK_O if mark == MARK_X else MARK_X
+
+    move = _find_line_move(board, mark)
+    if move is not None:
+        return move
+    move = _find_line_move(board, opponent)
+    if move is not None:
+        return move
+    if board.is_empty(1, 1):
+        return (1, 1)
+    corners = [(r, c) for r, c in free if r in (0, 2) and c in (0, 2)]
+    if corners:
+        return rng.choice(corners)
+    return rng.choice(free)
+
+
+class Game:
+    """Interactive UI layer: menus, rendering, prompts, statistics."""
+
+    def run(self):
+        while True:
+            mode = self._choose_mode()
+            if mode is None:
+                return
+            self._play_match(mode)
+            if not term.confirm("Play again?"):
+                return
+
+    # ------------------------------------------------------------ menus
+
+    def _choose_mode(self):
+        title = term.style(NAME, term.Color.CYAN, term.Color.BOLD)
+        choice = choose(
+            [title, "", DESCRIPTION],
+            ["Player vs Computer", "Player vs Player"],
+            hotkeys=["1", "2"],
+            extra_keys="h",
+            hint="H help - Q/ESC return to menu",
+        )
+        if choice == "h":
+            self._show_help()
+            return self._choose_mode()
+        return choice  # 0 (vs computer), 1 (vs player) or None (quit)
+
+    def _show_help(self):
+        term.clear()
+        print()
+        lines = [
+            term.style(f"HOW TO PLAY - {NAME}", term.Color.CYAN, term.Color.BOLD),
+            "",
+            term.style("Objective", term.Color.CYAN),
+            "Be the first to place three of your marks (X or O) in a",
+            "row - horizontally, vertically or diagonally. Fill the",
+            "board with no winner and the game is a draw.",
+            "",
+            term.style("Controls", term.Color.CYAN),
+            "Type a coordinate such as B2 and press Enter to play.",
+            "Columns are the letters A-C, rows are the numbers 1-3.",
+            "  H  show this help screen",
+            "  Q  return to the main menu",
+            "",
+            term.style("Rules", term.Color.CYAN),
+            "X always moves first. Marks cannot be moved or removed,",
+            "and you cannot play on an occupied square.",
+        ]
+        for line in term.box(lines, pad=2):
+            print(term.center(line))
+        print()
+        term.press_enter()
+
+    # ------------------------------------------------------------ match
+
+    def _play_match(self, mode):
+        board = Board()
+        current = MARK_X
+        while True:
+            if mode == 0 and current == MARK_O:
+                self._render(board, "The computer is thinking...")
+                move = choose_computer_move(board, MARK_O)
+                if move is None:
+                    break  # full board, draw
+                board.place(MARK_O, *move)
+            else:
+                label = self._player_label(mode, current)
+                action = self._human_turn(board, label)
+                if action == "quit":
+                    if term.confirm("Return to the main menu?"):
+                        return
+                    continue
+                if action == "help":
+                    self._show_help()
+                    continue
+                board.place(current, *action)
+            if board.is_game_over():
+                break
+            current = MARK_O if current == MARK_X else MARK_X
+
+        self._render(board, "")
+        self._announce_result(board, mode)
+
+    def _player_label(self, mode, mark):
+        if mode == 0:
+            return f"Your move ({mark})"
+        return f"Player {mark}, your move"
+
+    def _human_turn(self, board, label):
+        """Ask for a move. Returns (row, col), 'help' or 'quit'."""
+        while True:
+            self._render(board, "")
+            print(term.center(label))
+            answer = read_line(term.center("Move (e.g. b2, h help, q quit): "))
+            if answer is None:
+                return "quit"
+            answer = answer.strip().lower()
+            if answer == "q":
+                return "quit"
+            if answer == "h":
+                return "help"
+            coord = parse_coordinate(answer)
+            if coord is None:
+                self._render(board, "")
+                term.status("Invalid move. Use a coordinate like b2.", "warn")
+                term.press_enter()
+                continue
+            if not board.is_empty(*coord):
+                self._render(board, "")
+                term.status("That square is already occupied.", "warn")
+                term.press_enter()
+                continue
+            return coord
+
+    # ------------------------------------------------------------ render
+
+    def _render(self, board, message):
+        term.clear()
+        print()
+        term.print_centered([term.style(NAME, term.Color.CYAN, term.Color.BOLD), ""])
+        for line in self._grid_lines(board):
+            print(term.center(line))
+        if message:
+            print()
+            print(term.center(term.style(message, term.Color.GRAY)))
+
+    def _grid_lines(self, board):
+        # Cells are 3 characters wide so rows look like " X │   │ O "
+        # and separators like "───┼───┼───" (ASCII fallbacks apply).
+        v = term.BOX["v"]
+        x = term.BOX["x"]
+        h = term.BOX["h"]
+        lines = ["     " + "   ".join(COLUMNS)]
+        for row in range(SIZE):
+            cells = [self._cell_text(board, row, col) for col in range(SIZE)]
+            lines.append(f" {row + 1}  " + v.join(cells))
+            if row < SIZE - 1:
+                lines.append("    " + x.join([h * 3] * SIZE))
+        return lines
+
+    def _cell_text(self, board, row, col):
+        mark = board.get(row, col)
+        if mark == MARK_X:
+            return " " + term.style(MARK_X, term.Color.CYAN, term.Color.BOLD) + " "
+        if mark == MARK_O:
+            return " " + term.style(MARK_O, term.Color.YELLOW, term.Color.BOLD) + " "
+        return " " * 3
+
+    # ------------------------------------------------------------ result
+
+    def _announce_result(self, board, mode):
+        winner = board.winner()
+        store = get_store()
+        print()
+        if winner is None:
+            store.record_result(KEY, "draw")
+            term.print_centered([term.style("It's a draw!", term.Color.YELLOW)])
+        elif mode == 0:
+            if winner == MARK_X:
+                store.record_result(KEY, "win")
+                term.print_centered([term.style("You win! Congratulations!", term.Color.GREEN)])
+            else:
+                store.record_result(KEY, "loss")
+                term.print_centered([term.style("The computer wins. Try again!", term.Color.RED)])
+        else:
+            store.record_result(KEY, "win")
+            term.print_centered([term.style(f"Player {winner} wins!", term.Color.GREEN)])
+        print()
@@ -0,0 +1,223 @@+#!/usr/bin/env python3
+"""PABLO'S TERMINAL GAMES - main entry point.
+
+Run with:  python main.py
+"""
+from __future__ import annotations
+
+import importlib
+import sys
+
+from core import APP_NAME, AUTHOR, EMAIL, YEAR, __version__
+from core import terminal as term
+from core.input import read_line
+from core.menu import choose
+from core.storage import get_store
+from core.utils import format_time
+
+GAME_REGISTRY = [
+    "games.tic_tac_toe",
+    "games.connect_four",
+    "games.checkers",
+    "games.reversi",
+    "games.naval_battle",
+    "games.gomoku",
+    "games.mancala",
+    "games.minesweeper",
+    "games.game_2048",
+    "games.fifteen",
+]
+
+HOTKEYS = [str(i) for i in range(1, 10)] + ["0"]  # "0" selects game 10
+
+
+def banner_lines() -> list[str]:
+    title = term.style(APP_NAME, term.Color.CYAN, term.Color.BOLD)
+    version = term.style(f"VERSION {__version__}", term.Color.GRAY)
+    inner = max(term.visible_len(title), term.visible_len(version)) + 10
+    b = term.BOX_DOUBLE
+    lines = [b["tl"] + b["h"] * inner + b["tr"]]
+    for text in ("", title, "", version, ""):
+        pad = (inner - term.visible_len(text)) // 2
+        lines.append(b["v"] + " " * pad + text + " " * (inner - pad - term.visible_len(text)) + b["v"])
+    lines.append(b["bl"] + b["h"] * inner + b["br"])
+    return lines
+
+
+def ensure_size() -> bool:
+    """Warn when the terminal is too small. False means the user quit."""
+    while term.too_small():
+        term.clear()
+        cols, rows = term.size()
+        print()
+        term.print_centered([
+            term.style("Your terminal is too small.", term.Color.YELLOW),
+            "",
+            f"Current size: {cols}x{rows}.",
+            f"Please resize it to at least {term.MIN_COLS}x{term.MIN_ROWS}.",
+            "",
+            term.style("Press Enter to check again, or Q to quit.", term.Color.GRAY),
+        ])
+        answer = read_line()
+        if answer is None:
+            return False
+        if answer.strip().lower() == "q":
+            return False
+    return True
+
+
+def launch_game(index: int) -> None:
+    module_name = GAME_REGISTRY[index]
+    display_name = module_name
+    try:
+        module = importlib.import_module(module_name)
+        display_name = module.NAME
+        module.Game().run()
+    except KeyboardInterrupt:
+        pass  # Ctrl-C inside a game: just return to the menu
+    except Exception as exc:  # never dump a traceback on the player
+        term.clear()
+        print()
+        term.status(f"Something went wrong in {display_name}: {exc}", "error")
+        term.status("The game room is still running. Returning to the menu.", "dim")
+        term.press_enter()
+    finally:
+        term.cleanup()
+
+
+def show_help() -> None:
+    term.clear()
+    print()
+    term.print_centered([term.style("HELP", term.Color.CYAN, term.Color.BOLD), ""])
+    lines = [
+        "Choose a game with the arrow keys (or W/S) and Enter,",
+        "or press its number. Press H for help, S for statistics,",
+        "A for about, and Q or ESC to quit.",
+        "",
+        "Inside every game:",
+        "  H      show the game's rules and controls",
+        "  Q/ESC  return to this menu",
+        "",
+        "Board games accept coordinates such as B2 or 1-7,",
+        "depending on the game. Real-time games use the arrow",
+        "keys and WASD. Every game explains its own controls",
+        "on its help screen.",
+    ]
+    for line in lines:
+        print(term.center(line))
+    print()
+    term.print_centered([term.style("The games:", term.Color.CYAN)])
+    print()
+    for module_name in GAME_REGISTRY:
+        try:
+            module = importlib.import_module(module_name)
+            display_name = module.NAME
+            desc = module.DESCRIPTION
+        except Exception:
+            display_name, desc = module_name, ""
+        print(term.center(f"{display_name} - {desc}"))
+    print()
+    term.press_enter()
+
+
+def show_stats() -> None:
+    store = get_store()
+    term.clear()
+    print()
+    term.print_centered([term.style("STATISTICS", term.Color.CYAN, term.Color.BOLD), ""])
+    header = f"{'GAME':<16}{'PLAYED':>8}{'WON':>6}{'LOST':>6}{'DRAWN':>7}"
+    width = term.visible_len(header)
+    pad = max(0, (term.size()[0] - width) // 2)
+    print(" " * pad + term.style(header, term.Color.GRAY))
+    for module_name in GAME_REGISTRY:
+        module = importlib.import_module(module_name)
+        stats = store.game_stats(module.KEY)
+        row = (f"{module.NAME:<16}{stats.get('played', 0):>8}{stats.get('won', 0):>6}"
+               f"{stats.get('lost', 0):>6}{stats.get('drawn', 0):>7}")
+        print(" " * pad + row)
+    print()
+    records = _record_lines(store)
+    if records:
+        term.print_centered([term.style("RECORDS", term.Color.CYAN)])
+        print()
+        for line in records:
+            print(term.center(line))
+    print()
+    term.press_enter()
+
+
+def _record_lines(store) -> list[str]:
+    lines = []
+    high = store.get_record("2048_high_score")
+    if high:
+        lines.append(f"2048 high score: {high}")
+    tile = store.get_record("2048_best_tile")
+    if tile:
+        lines.append(f"2048 best tile: {tile}")
+    for diff in ("easy", "medium", "hard"):
+        best = store.get_record(f"minesweeper_best_time_{diff}")
+        if best is not None:
+            lines.append(f"Minesweeper best time ({diff}): {format_time(best)}")
+    moves = store.get_record("fifteen_best_moves")
+    if moves is not None:
+        lines.append(f"Fifteen Puzzle best: {moves} moves")
+    best_time = store.get_record("fifteen_best_time")
+    if best_time is not None:
+        lines.append(f"Fifteen Puzzle best time: {format_time(best_time)}")
+    return lines
+
+
+def show_about() -> None:
+    term.clear()
+    print()
+    lines = [
+        "",
+        term.style(APP_NAME, term.Color.CYAN, term.Color.BOLD),
+        "",
+        f"Version {__version__}",
+        "",
+        f"Created by {AUTHOR}",
+        YEAR,
+        EMAIL,
+        "",
+        "A collection of small board, strategy and puzzle games",
+        "designed to be played entirely inside the terminal.",
+        "",
+        term.style("Made for people who still enjoy the command line.", term.Color.GRAY),
+        "",
+    ]
+    for line in term.box(lines, pad=3, double=False):
+        print(term.center(line))
+    print()
+    term.press_enter()
+
+
+def main() -> int:
+    if not ensure_size():
+        return 0
+    options = [importlib.import_module(m).NAME for m in GAME_REGISTRY]
+    hint = "Arrows/W-S move - Enter select - H help - S stats - A about - Q quit"
+    while True:
+        choice = choose(banner_lines(), options, hotkeys=HOTKEYS,
+                        extra_keys="hsa", hint=hint)
+        if choice is None:
+            break
+        if choice == "h":
+            show_help()
+        elif choice == "s":
+            show_stats()
+        elif choice == "a":
+            show_about()
+        elif isinstance(choice, int):
+            launch_game(choice)
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except KeyboardInterrupt:
+        pass
+    finally:
+        term.cleanup()
+        print()
@@ -0,0 +1,4 @@+@echo off
+rem PABLO'S TERMINAL GAMES - Windows launcher
+cd /d "%~dp0"
+python main.py %*
@@ -0,0 +1,4 @@+#!/bin/sh
+# PABLO'S TERMINAL GAMES - Linux launcher
+cd "$(dirname "$0")"
+exec python3 main.py "$@"
@@ -0,0 +1,262 @@+"""Headless tests for games.game_2048 (pure Board logic only)."""
+from __future__ import annotations
+
+import random
+import unittest
+
+from games.game_2048 import Board
+
+
+def make_board(rows, seed=1234, spawn=False):
+    """Build a Board with a fixed grid and a seeded RNG.
+
+    With spawn=False the random tile spawn is disabled so moves can
+    be asserted against an exact grid.
+    """
+    board = Board(grid=rows, rng=random.Random(seed))
+    if not spawn:
+        board.spawn_tile = lambda: None
+    return board
+
+
+def tile_count(board):
+    return sum(1 for row in board.grid for value in row if value)
+
+
+class InitialBoardTest(unittest.TestCase):
+    def test_new_board_starts_with_two_tiles(self):
+        board = Board(rng=random.Random(7))
+        self.assertEqual(tile_count(board), 2)
+        values = [v for row in board.grid for v in row if v]
+        self.assertTrue(set(values) <= {2, 4})
+        self.assertEqual(board.score, 0)
+
+    def test_spawn_values_are_two_or_four(self):
+        rng = random.Random(42)
+        values = []
+        for _ in range(50):
+            board = Board(rng=rng)  # spawns two tiles per board
+            values.extend(v for row in board.grid for v in row if v)
+        self.assertTrue(set(values) <= {2, 4})
+        self.assertIn(2, values)
+        self.assertIn(4, values)  # the 10% case shows up over 100 spawns
+
+
+class MovementTest(unittest.TestCase):
+    def test_slide_left(self):
+        board = make_board([
+            [0, 2, 0, 4],
+            [2, 0, 2, 0],
+            [0, 0, 0, 0],
+            [8, 8, 4, 4],
+        ])
+        self.assertTrue(board.move("left"))
+        self.assertEqual(board.grid, [
+            [2, 4, 0, 0],
+            [4, 0, 0, 0],
+            [0, 0, 0, 0],
+            [16, 8, 0, 0],
+        ])
+        self.assertEqual(board.score, 4 + 16 + 8)
+
+    def test_slide_right(self):
+        board = make_board([
+            [0, 2, 0, 4],
+            [2, 0, 2, 0],
+            [0, 0, 0, 0],
+            [8, 8, 4, 4],
+        ])
+        self.assertTrue(board.move("right"))
+        self.assertEqual(board.grid, [
+            [0, 0, 2, 4],
+            [0, 0, 0, 4],
+            [0, 0, 0, 0],
+            [0, 0, 16, 8],
+        ])
+        self.assertEqual(board.score, 4 + 16 + 8)
+
+    def test_slide_up(self):
+        board = make_board([
+            [0, 0, 0, 8],
+            [2, 0, 0, 8],
+            [0, 0, 4, 4],
+            [2, 4, 4, 0],
+        ])
+        self.assertTrue(board.move("up"))
+        self.assertEqual(board.grid, [
+            [4, 4, 8, 16],
+            [0, 0, 0, 4],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+        ])
+        self.assertEqual(board.score, 4 + 8 + 16)  # column 1 slides, no merge
+
+    def test_slide_down(self):
+        board = make_board([
+            [0, 0, 0, 8],
+            [2, 0, 0, 8],
+            [0, 0, 4, 4],
+            [2, 4, 4, 0],
+        ])
+        self.assertTrue(board.move("down"))
+        self.assertEqual(board.grid, [
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 16],
+            [4, 4, 8, 4],
+        ])
+        self.assertEqual(board.score, 4 + 8 + 16)  # column 1 slides, no merge
+
+    def test_unknown_direction_raises(self):
+        board = make_board([[0] * 4 for _ in range(4)])
+        with self.assertRaises(ValueError):
+            board.move("diagonal")
+
+
+class MergeTest(unittest.TestCase):
+    def test_merge_line_pairs(self):
+        self.assertEqual(Board.merge_line([2, 2, 4, 4]), ([4, 8, 0, 0], 12))
+        self.assertEqual(Board.merge_line([4, 4, 8, 8]), ([8, 16, 0, 0], 24))
+        self.assertEqual(Board.merge_line([0, 0, 0, 0]), ([0, 0, 0, 0], 0))
+
+    def test_no_double_merge_in_one_move(self):
+        # A tile created by a merge must not merge again in the same move.
+        self.assertEqual(Board.merge_line([2, 2, 2, 2]), ([4, 4, 0, 0], 8))
+        self.assertEqual(Board.merge_line([2, 2, 2, 0]), ([4, 2, 0, 0], 4))
+        self.assertEqual(Board.merge_line([4, 4, 4, 4]), ([8, 8, 0, 0], 16))
+
+    def test_merge_left_scores_once(self):
+        board = make_board([
+            [2, 2, 2, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+        ])
+        self.assertTrue(board.move("left"))
+        self.assertEqual(board.grid[0], [4, 2, 0, 0])
+        self.assertEqual(board.score, 4)  # not 8: the new 4 cannot re-merge
+
+    def test_merge_direction_changes_pairing(self):
+        board = make_board([
+            [2, 2, 2, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+        ])
+        board.move("right")
+        self.assertEqual(board.grid[0], [0, 0, 2, 4])
+        self.assertEqual(board.score, 4)
+
+    def test_score_accumulates_across_moves(self):
+        board = make_board([
+            [2, 2, 0, 0],
+            [2, 2, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+        ])
+        board.move("left")   # two rows of 2+2 -> 4 + 4 = 8
+        board.move("up")     # the two new 4s merge -> +8
+        self.assertEqual(board.score, 8 + 8)
+
+
+class SpawnTest(unittest.TestCase):
+    def test_valid_move_spawns_exactly_one_tile(self):
+        board = make_board([
+            [0, 2, 4, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+        ], spawn=True)
+        self.assertEqual(tile_count(board), 2)
+        self.assertTrue(board.move("left"))  # no merge, just a slide
+        self.assertEqual(tile_count(board), 3)
+        values = [v for row in board.grid for v in row if v]
+        self.assertTrue(set(values) <= {2, 4})
+
+    def test_invalid_move_spawns_nothing(self):
+        board = make_board([
+            [2, 0, 0, 0],
+            [4, 0, 0, 0],
+            [8, 0, 0, 0],
+            [16, 0, 0, 0],
+        ], spawn=True)
+        before = [row[:] for row in board.grid]
+        self.assertFalse(board.move("left"))  # already packed left
+        self.assertEqual(board.grid, before)
+        self.assertEqual(board.score, 0)
+        self.assertEqual(tile_count(board), 4)
+
+    def test_spawn_on_full_board_returns_none(self):
+        board = make_board([
+            [2, 4, 2, 4],
+            [4, 2, 4, 2],
+            [2, 4, 2, 4],
+            [4, 2, 4, 2],
+        ], spawn=True)
+        self.assertIsNone(board.spawn_tile())
+
+
+class GameOverTest(unittest.TestCase):
+    def test_full_board_without_merges_is_game_over(self):
+        board = make_board([
+            [2, 4, 2, 4],
+            [4, 2, 4, 2],
+            [2, 4, 2, 4],
+            [4, 2, 4, 2],
+        ])
+        self.assertTrue(board.is_game_over())
+        self.assertFalse(board.has_moves())
+
+    def test_empty_cell_means_not_game_over(self):
+        board = make_board([
+            [2, 4, 2, 4],
+            [4, 2, 4, 2],
+            [2, 4, 0, 4],
+            [4, 2, 4, 2],
+        ])
+        self.assertFalse(board.is_game_over())
+
+    def test_available_merge_means_not_game_over(self):
+        board = make_board([
+            [2, 4, 2, 4],
+            [4, 2, 4, 2],
+            [2, 4, 2, 4],
+            [4, 2, 2, 2],
+        ])
+        self.assertFalse(board.is_game_over())
+
+    def test_vertical_merge_means_not_game_over(self):
+        board = make_board([
+            [2, 4, 2, 4],
+            [2, 2, 4, 2],
+            [4, 4, 2, 4],
+            [8, 2, 4, 2],
+        ])
+        self.assertFalse(board.is_game_over())
+
+
+class HighestTileTest(unittest.TestCase):
+    def test_highest_tile(self):
+        board = make_board([
+            [2, 0, 64, 0],
+            [0, 128, 0, 0],
+            [0, 0, 0, 0],
+            [4, 0, 0, 32],
+        ])
+        self.assertEqual(board.highest_tile(), 128)
+        self.assertFalse(board.has_won())
+
+    def test_merging_to_target_wins(self):
+        board = make_board([
+            [1024, 1024, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+            [0, 0, 0, 0],
+        ])
+        board.move("left")
+        self.assertEqual(board.highest_tile(), 2048)
+        self.assertTrue(board.has_won())
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,258 @@+"""Headless unit tests for games.checkers pure logic."""
+import random
+import unittest
+
+from games.checkers import (
+    BLACK, WHITE, CheckersState, Move, choose_computer_chain,
+    coords_to_square, opponent, parse_move, square_to_coords,
+)
+
+
+def make_state(pieces, turn=WHITE):
+    """Build a state from {'b6': 'w', ...} with no standard setup."""
+    state = CheckersState(setup=False)
+    for square, piece in pieces.items():
+        row, col = square_to_coords(square)
+        state.set_piece(row, col, piece)
+    state.turn = turn
+    return state
+
+
+def move(text):
+    src, dst = parse_move(text)
+    return Move(src, dst)
+
+
+def notations(moves):
+    return {m.notation() for m in moves}
+
+
+class CoordinateTests(unittest.TestCase):
+    def test_square_to_coords(self):
+        self.assertEqual(square_to_coords("a1"), (0, 0))
+        self.assertEqual(square_to_coords("h8"), (7, 7))
+        self.assertEqual(square_to_coords("B6"), (5, 1))
+
+    def test_square_to_coords_invalid(self):
+        for bad in ("", "i1", "a9", "a", "a10", None):
+            self.assertIsNone(square_to_coords(bad))
+
+    def test_coords_to_square_roundtrip(self):
+        self.assertEqual(coords_to_square(5, 1), "b6")
+        self.assertEqual(square_to_coords(coords_to_square(2, 3)), (2, 3))
+
+    def test_parse_move_formats(self):
+        self.assertEqual(parse_move("b6-a5"), ((5, 1), (4, 0)))
+        self.assertEqual(parse_move("b6 a5"), ((5, 1), (4, 0)))
+        self.assertEqual(parse_move("b6a5"), ((5, 1), (4, 0)))
+        self.assertEqual(parse_move("B6-A5"), ((5, 1), (4, 0)))
+
+    def test_parse_move_invalid(self):
+        for bad in ("", "b6", "b6-i5", "b9-a5", "hello", None):
+            self.assertIsNone(parse_move(bad))
+
+
+class InitialPositionTests(unittest.TestCase):
+    def test_setup(self):
+        state = CheckersState()
+        self.assertEqual(len(state.pieces(WHITE)), 12)
+        self.assertEqual(len(state.pieces(BLACK)), 12)
+        self.assertEqual(state.turn, WHITE)
+        self.assertIsNone(state.must_continue)
+
+    def test_opening_moves(self):
+        # Standard English draughts opening: 7 legal moves.
+        state = CheckersState()
+        self.assertEqual(len(state.legal_moves()), 7)
+        self.assertTrue(all(not m.is_capture for m in state.legal_moves()))
+
+    def test_apply_simple_move_switches_turn(self):
+        state = CheckersState()
+        state.apply(move("b6-a5"))
+        self.assertEqual(state.piece_at(4, 0), WHITE)
+        self.assertEqual(state.turn, BLACK)
+
+
+class MoveGenerationTests(unittest.TestCase):
+    def test_man_moves_forward_only(self):
+        state = make_state({"d4": "w"})
+        self.assertEqual(notations(state.legal_moves()),
+                         {"d4-c3", "d4-e3"})
+        black = make_state({"d4": "b"}, turn=BLACK)
+        self.assertEqual(notations(black.legal_moves()),
+                         {"d4-c5", "d4-e5"})
+
+    def test_king_moves_both_directions(self):
+        state = make_state({"d4": "W"})
+        self.assertEqual(notations(state.legal_moves()),
+                         {"d4-c3", "d4-e3", "d4-c5", "d4-e5"})
+
+    def test_blocked_squares_are_not_generated(self):
+        # d4 is boxed in by its own pieces: it has no move at all.
+        state = make_state({"d4": "w", "c3": "w", "e3": "w"})
+        d4 = square_to_coords("d4")
+        self.assertFalse(any(m.src == d4 for m in state.legal_moves()))
+
+    def test_board_edges(self):
+        state = make_state({"a1": "W"})
+        self.assertEqual(notations(state.legal_moves()), {"a1-b2"})
+
+
+class ForcedCaptureTests(unittest.TestCase):
+    def test_capture_is_mandatory(self):
+        state = make_state({"d4": "w", "e3": "b", "a1": "w"})
+        legal = state.legal_moves()
+        self.assertTrue(legal)
+        self.assertTrue(all(m.is_capture for m in legal))
+
+    def test_all_available_captures_offered(self):
+        state = make_state({"d4": "w", "c3": "b", "e3": "b"})
+        self.assertEqual(notations(state.legal_moves()),
+                         {"d4xb2", "d4xf2"})
+
+    def test_king_can_capture_backwards(self):
+        state = make_state({"d4": "W", "e5": "b"})
+        self.assertEqual(notations(state.legal_moves()), {"d4xf6"})
+
+    def test_cannot_jump_own_piece(self):
+        # e3 is white: no jump over it, but c3 is a normal step.
+        state = make_state({"d4": "w", "e3": "w"})
+        d4 = square_to_coords("d4")
+        from_d4 = {m.notation() for m in state.legal_moves() if m.src == d4}
+        self.assertEqual(from_d4, {"d4-c3"})
+
+
+class CaptureExecutionTests(unittest.TestCase):
+    def test_jump_removes_captured_piece(self):
+        state = make_state({"d4": "w", "e3": "b"})
+        state.apply(move("d4-f2"))
+        self.assertEqual(state.piece_at(1, 5), WHITE)   # landed on f2
+        self.assertEqual(state.piece_at(2, 4), " ")     # e3 removed
+        self.assertEqual(state.piece_at(3, 3), " ")     # d4 empty
+        self.assertEqual(len(state.pieces(BLACK)), 0)
+
+    def test_illegal_move_raises(self):
+        state = make_state({"d4": "w", "e3": "b"})
+        with self.assertRaises(ValueError):
+            state.apply(move("d4-c3"))  # step while a capture exists
+
+    def test_wrong_color_raises(self):
+        state = make_state({"d4": "b"})  # black piece, white to move
+        with self.assertRaises(ValueError):
+            state.apply(move("d4-c5"))
+
+
+class PromotionTests(unittest.TestCase):
+    def test_white_promotes_on_last_row(self):
+        state = make_state({"b2": "w"})
+        state.apply(move("b2-a1"))
+        self.assertEqual(state.piece_at(0, 0), "W")
+        self.assertEqual(state.turn, BLACK)
+
+    def test_black_promotes_on_last_row(self):
+        state = make_state({"g7": "b"}, turn=BLACK)
+        state.apply(move("g7-h8"))
+        self.assertEqual(state.piece_at(7, 7), "B")
+
+    def test_promotion_by_jump_ends_chain(self):
+        # A man crowned during a jump stops; the new king may not
+        # keep jumping (here over c1) in the same turn.
+        state = make_state({"c3": "w", "b2": "b", "c1": "b"})
+        state.apply(move("c3-a1"))
+        self.assertEqual(state.piece_at(0, 0), "W")
+        self.assertIsNone(state.must_continue)
+        self.assertEqual(state.turn, BLACK)
+
+
+class MultipleCaptureTests(unittest.TestCase):
+    """Double jump: white d6 x c5 -> b4, then b4 x c3 -> d2."""
+
+    def setUp(self):
+        self.state = make_state({"d6": "w", "c5": "b", "c3": "b"})
+
+    def test_first_jump_keeps_turn_and_forces_same_piece(self):
+        self.state.apply(move("d6-b4"))
+        self.assertEqual(self.state.turn, WHITE)
+        self.assertEqual(self.state.must_continue, square_to_coords("b4"))
+        self.assertEqual(notations(self.state.legal_moves()), {"b4xd2"})
+
+    def test_chain_completes_and_switches_turn(self):
+        self.state.apply(move("d6-b4"))
+        self.state.apply(move("b4-d2"))
+        self.assertIsNone(self.state.must_continue)
+        self.assertEqual(self.state.turn, BLACK)
+        self.assertEqual(len(self.state.pieces(BLACK)), 0)
+        self.assertEqual(self.state.piece_at(1, 3), WHITE)  # on d2
+
+    def test_other_piece_cannot_move_mid_chain(self):
+        state = make_state({"d6": "w", "c5": "b", "c3": "b", "h8": "w"})
+        state.apply(move("d6-b4"))
+        with self.assertRaises(ValueError):
+            state.apply(move("h8-g7"))
+
+    def test_capture_chains_reports_full_sequence(self):
+        chains = self.state.capture_chains(*square_to_coords("d6"))
+        self.assertEqual(len(chains), 1)
+        self.assertEqual([m.notation() for m in chains[0]],
+                         ["d6xb4", "b4xd2"])
+
+
+class WinDetectionTests(unittest.TestCase):
+    def test_win_by_capturing_last_piece(self):
+        state = make_state({"d4": "w", "e3": "b"})
+        state.apply(move("d4-f2"))
+        self.assertEqual(state.winner(), WHITE)
+
+    def test_win_by_blocking_all_moves(self):
+        # Black a7 is boxed in: b8 occupied and its jump landing
+        # square c5 occupied, so it has no legal move.
+        state = make_state(
+            {"a7": "b", "b6": "w", "b8": "w", "c5": "w"}, turn=BLACK)
+        self.assertEqual(state.legal_moves(), [])
+        self.assertEqual(state.winner(), WHITE)
+
+    def test_no_winner_mid_game(self):
+        self.assertIsNone(CheckersState().winner())
+
+    def test_no_winner_mid_chain(self):
+        state = make_state({"d6": "w", "c5": "b", "c3": "b"})
+        state.apply(move("d6-b4"))
+        self.assertIsNone(state.winner())
+
+
+class ComputerTests(unittest.TestCase):
+    def test_prefers_most_captures(self):
+        # A two-jump chain from d6 beats the one-jump chain from h8.
+        state = make_state(
+            {"d6": "w", "c5": "b", "c3": "b", "h8": "w", "g7": "b"})
+        chain = choose_computer_chain(state, WHITE, rng=random.Random(1))
+        self.assertEqual([m.notation() for m in chain],
+                         ["d6xb4", "b4xd2"])
+
+    def test_avoids_handing_a_capture(self):
+        # d4-e3 lets black f2 jump it; d4-c3 is safe.
+        state = make_state({"d4": "w", "f2": "b", "h8": "b"})
+        rng = random.Random(2)
+        for _ in range(10):
+            chain = choose_computer_chain(state, WHITE, rng=rng)
+            self.assertEqual(chain[0].notation(), "d4-c3")
+
+    def test_promotes_when_possible(self):
+        state = make_state({"b2": "w", "h8": "b"})
+        chain = choose_computer_chain(state, WHITE, rng=random.Random(3))
+        self.assertEqual(chain[0].notation(), "b2-a1")
+        state.apply(chain[0])
+        self.assertEqual(state.piece_at(0, 0), "W")
+
+    def test_returns_empty_when_no_moves(self):
+        state = make_state(
+            {"a7": "b", "b6": "w", "b8": "w", "c5": "w"}, turn=BLACK)
+        self.assertEqual(choose_computer_chain(state, BLACK), [])
+
+    def test_opponent_helper(self):
+        self.assertEqual(opponent(WHITE), BLACK)
+        self.assertEqual(opponent(BLACK), WHITE)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,175 @@+"""Headless tests for games.connect_four pure logic (Board, ComputerPlayer)."""
+from __future__ import annotations
+
+import unittest
+
+from games.connect_four import (
+    Board,
+    COLUMN_COUNT,
+    ComputerPlayer,
+    EMPTY,
+    PLAYER_ONE,
+    PLAYER_TWO,
+    ROW_COUNT,
+    other_player,
+)
+
+
+def fill_column(board: Board, column: int, pieces: list[int]) -> None:
+    """Drop pieces bottom-up in one column (list order = drop order)."""
+    for piece in pieces:
+        board.drop(column, piece)
+
+
+class BoardBasicsTest(unittest.TestCase):
+    def test_new_board_is_empty(self):
+        board = Board()
+        self.assertEqual(board.moves, 0)
+        self.assertFalse(board.is_full())
+        self.assertEqual(board.winner(), EMPTY)
+        self.assertFalse(board.is_draw())
+        self.assertEqual(board.valid_columns(), list(range(COLUMN_COUNT)))
+
+    def test_piece_falls_to_lowest_free_cell(self):
+        board = Board()
+        row = board.drop(3, PLAYER_ONE)
+        self.assertEqual(row, ROW_COUNT - 1)
+        self.assertEqual(board.grid[ROW_COUNT - 1][3], PLAYER_ONE)
+        row = board.drop(3, PLAYER_TWO)
+        self.assertEqual(row, ROW_COUNT - 2)
+        self.assertEqual(board.grid[ROW_COUNT - 2][3], PLAYER_TWO)
+
+    def test_drop_rejects_out_of_range_column(self):
+        board = Board()
+        self.assertIsNone(board.drop(-1, PLAYER_ONE))
+        self.assertIsNone(board.drop(COLUMN_COUNT, PLAYER_ONE))
+        self.assertEqual(board.moves, 0)
+
+    def test_full_column_rejects_drop(self):
+        board = Board()
+        for _ in range(ROW_COUNT):
+            self.assertIsNotNone(board.drop(0, PLAYER_ONE))
+        self.assertIsNone(board.drop(0, PLAYER_ONE))
+        self.assertFalse(board.is_valid_column(0))
+        self.assertNotIn(0, board.valid_columns())
+
+    def test_other_player(self):
+        self.assertEqual(other_player(PLAYER_ONE), PLAYER_TWO)
+        self.assertEqual(other_player(PLAYER_TWO), PLAYER_ONE)
+
+
+class WinDetectionTest(unittest.TestCase):
+    def test_horizontal_win(self):
+        board = Board()
+        for col in range(4):
+            board.drop(col, PLAYER_ONE)
+        self.assertTrue(board.has_won(PLAYER_ONE))
+        self.assertEqual(board.winner(), PLAYER_ONE)
+
+    def test_no_horizontal_win_with_only_three(self):
+        board = Board()
+        for col in range(3):
+            board.drop(col, PLAYER_ONE)
+        self.assertFalse(board.has_won(PLAYER_ONE))
+
+    def test_vertical_win(self):
+        board = Board()
+        for _ in range(4):
+            board.drop(2, PLAYER_TWO)
+        self.assertTrue(board.has_won(PLAYER_TWO))
+        self.assertEqual(board.winner(), PLAYER_TWO)
+
+    def test_ascending_diagonal_win(self):
+        # Bottom-left to top-right: columns 0..3 with rising heights.
+        board = Board()
+        fill_column(board, 0, [PLAYER_ONE])
+        fill_column(board, 1, [PLAYER_TWO, PLAYER_ONE])
+        fill_column(board, 2, [PLAYER_TWO, PLAYER_TWO, PLAYER_ONE])
+        fill_column(board, 3, [PLAYER_TWO, PLAYER_TWO, PLAYER_TWO, PLAYER_ONE])
+        self.assertTrue(board.has_won(PLAYER_ONE))
+        self.assertEqual(board.winner(), PLAYER_ONE)
+
+    def test_descending_diagonal_win(self):
+        # Top-left to bottom-right: columns 3..0 with falling heights.
+        board = Board()
+        fill_column(board, 3, [PLAYER_ONE])
+        fill_column(board, 2, [PLAYER_TWO, PLAYER_ONE])
+        fill_column(board, 1, [PLAYER_TWO, PLAYER_TWO, PLAYER_ONE])
+        fill_column(board, 0, [PLAYER_TWO, PLAYER_TWO, PLAYER_TWO, PLAYER_ONE])
+        self.assertTrue(board.has_won(PLAYER_ONE))
+        self.assertEqual(board.winner(), PLAYER_ONE)
+
+    def test_diagonal_run_of_three_is_not_a_win(self):
+        board = Board()
+        fill_column(board, 0, [PLAYER_ONE])
+        fill_column(board, 1, [PLAYER_TWO, PLAYER_ONE])
+        fill_column(board, 2, [PLAYER_TWO, PLAYER_TWO, PLAYER_ONE])
+        self.assertFalse(board.has_won(PLAYER_ONE))
+
+    def test_full_board_draw(self):
+        # Fill the board in a pattern with no four in a row:
+        # alternating columns get alternating pieces, offset so that
+        # horizontal and diagonal runs never reach four.
+        board = Board()
+        start_one = [PLAYER_ONE, PLAYER_TWO, PLAYER_ONE,
+                     PLAYER_TWO, PLAYER_ONE, PLAYER_TWO]
+        start_two = [PLAYER_TWO, PLAYER_ONE, PLAYER_TWO,
+                     PLAYER_ONE, PLAYER_TWO, PLAYER_ONE]
+        for col in range(COLUMN_COUNT):
+            pattern = start_one if col in (0, 3, 6) else start_two
+            fill_column(board, col, pattern)
+        self.assertTrue(board.is_full())
+        self.assertEqual(board.winner(), EMPTY)
+        self.assertTrue(board.is_draw())
+
+    def test_full_board_with_winner_is_not_a_draw(self):
+        board = Board()
+        for _ in range(4):
+            board.drop(0, PLAYER_ONE)
+        self.assertFalse(board.is_draw())
+
+
+class ComputerPlayerTest(unittest.TestCase):
+    def setUp(self):
+        self.ai = ComputerPlayer(PLAYER_TWO)
+
+    def test_takes_winning_move(self):
+        board = Board()
+        for _ in range(3):
+            board.drop(1, PLAYER_TWO)
+        self.assertEqual(self.ai.choose_column(board), 1)
+
+    def test_blocks_opponent_win(self):
+        board = Board()
+        for _ in range(3):
+            board.drop(4, PLAYER_ONE)
+        self.assertEqual(self.ai.choose_column(board), 4)
+
+    def test_winning_beats_blocking(self):
+        board = Board()
+        for _ in range(3):
+            board.drop(1, PLAYER_TWO)  # AI can win here
+            board.drop(5, PLAYER_ONE)  # opponent also threatens
+        self.assertEqual(self.ai.choose_column(board), 1)
+
+    def test_prefers_center_on_empty_board(self):
+        board = Board()
+        self.assertEqual(self.ai.choose_column(board), 3)
+
+    def test_prefers_next_center_when_center_full(self):
+        board = Board()
+        # Alternating pieces: a full column with no winner.
+        for i in range(ROW_COUNT):
+            board.drop(3, PLAYER_ONE if i % 2 == 0 else PLAYER_TWO)
+        self.assertEqual(self.ai.choose_column(board), 2)
+
+    def test_never_picks_full_column(self):
+        board = Board()
+        for _ in range(ROW_COUNT):
+            board.drop(3, PLAYER_ONE)
+        for _ in range(30):
+            self.assertNotEqual(self.ai.choose_column(board), 3)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,172 @@+"""Headless unit tests for games.fifteen (pure Board logic only)."""
+from __future__ import annotations
+
+import random
+import unittest
+
+from games.fifteen import Board, DIRECTIONS, OPPOSITE
+
+
+def solved():
+    return Board.solved_tiles()
+
+
+class BoardInitTests(unittest.TestCase):
+    def test_default_board_is_solved(self):
+        board = Board()
+        self.assertTrue(board.is_solved())
+        self.assertEqual(board.tiles, solved())
+
+    def test_explicit_tiles_accepted(self):
+        tiles = solved()
+        tiles[0], tiles[1] = tiles[1], tiles[0]
+        board = Board(tiles)
+        self.assertEqual(board.tiles, tiles)
+        self.assertFalse(board.is_solved())
+
+    def test_invalid_tiles_rejected(self):
+        with self.assertRaises(ValueError):
+            Board([1, 2, 3])
+        with self.assertRaises(ValueError):
+            Board([0] * 16)
+
+    def test_move_count_starts_at_zero(self):
+        self.assertEqual(Board().move_count, 0)
+
+
+class MovementTests(unittest.TestCase):
+    def test_move_slides_tile_into_gap(self):
+        # Solved board: blank at bottom-right (row 3, col 3).
+        board = Board()
+        # "DOWN" = tile above the gap (tile 12) slides down into it.
+        self.assertTrue(board.move("DOWN"))
+        self.assertEqual(board.blank_position(), (2, 3))
+        self.assertEqual(board.tile_at(3, 3), 12)
+        self.assertEqual(board.tile_at(2, 3), 0)
+        self.assertEqual(board.move_count, 1)
+
+    def test_move_right_slides_left_neighbor(self):
+        board = Board()
+        # "RIGHT" = tile left of the gap (tile 15) slides right.
+        self.assertTrue(board.move("RIGHT"))
+        self.assertEqual(board.blank_position(), (3, 2))
+        self.assertEqual(board.tile_at(3, 3), 15)
+
+    def test_move_up_slides_tile_below_gap(self):
+        board = Board()
+        board.move("DOWN")  # gap now at (2, 3)
+        self.assertTrue(board.move("UP"))  # tile 12 slides back up
+        self.assertTrue(board.is_solved())
+
+    def test_impossible_moves_rejected(self):
+        board = Board()
+        # Blank in the bottom-right corner: no tile below or right of it.
+        before = list(board.tiles)
+        self.assertFalse(board.move("UP"))
+        self.assertFalse(board.move("LEFT"))
+        self.assertEqual(board.tiles, before)
+        self.assertEqual(board.move_count, 0)
+
+    def test_unknown_direction_rejected(self):
+        board = Board()
+        self.assertFalse(board.move("SIDEWAYS"))
+        self.assertFalse(board.can_move("SIDEWAYS"))
+
+    def test_can_move_matches_geometry(self):
+        board = Board()
+        self.assertEqual(sorted(board.legal_moves()), ["DOWN", "RIGHT"])
+        board.move("DOWN")
+        self.assertEqual(sorted(board.legal_moves()), ["DOWN", "RIGHT", "UP"])
+
+    def test_moves_preserve_tile_set(self):
+        board = Board()
+        rng = random.Random(42)
+        for _ in range(50):
+            board.move(rng.choice(board.legal_moves()))
+        self.assertEqual(sorted(board.tiles), list(range(16)))
+
+    def test_opposites_cover_all_directions(self):
+        self.assertEqual(sorted(OPPOSITE), sorted(DIRECTIONS))
+        for direction, opposite in OPPOSITE.items():
+            self.assertEqual(OPPOSITE[opposite], direction)
+
+
+class SolvedDetectionTests(unittest.TestCase):
+    def test_solved_state(self):
+        self.assertTrue(Board().is_solved())
+
+    def test_one_move_away_is_not_solved(self):
+        board = Board()
+        board.move("DOWN")
+        self.assertFalse(board.is_solved())
+
+    def test_swapped_tiles_not_solved(self):
+        tiles = solved()
+        tiles[0], tiles[1] = tiles[1], tiles[0]
+        self.assertFalse(Board(tiles).is_solved())
+
+    def test_blank_must_be_last(self):
+        tiles = [0] + list(range(1, 16))  # ordered but blank first
+        self.assertFalse(Board(tiles).is_solved())
+
+
+class SolvabilityTests(unittest.TestCase):
+    def test_solved_board_is_solvable(self):
+        self.assertTrue(Board.is_solvable(solved()))
+
+    def test_known_solvable_board(self):
+        # One legal move (tile 12 down) from the goal state.
+        tiles = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 13, 14, 15, 12]
+        self.assertTrue(Board.is_solvable(tiles))
+
+    def test_known_unsolvable_board(self):
+        # Classic impossible position: only tiles 14 and 15 swapped.
+        tiles = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 14, 0]
+        self.assertFalse(Board.is_solvable(tiles))
+
+    def test_single_swap_of_first_two_tiles_unsolvable(self):
+        tiles = solved()
+        tiles[0], tiles[1] = tiles[1], tiles[0]
+        self.assertFalse(Board.is_solvable(tiles))
+
+    def test_every_legal_move_keeps_solvability(self):
+        # Legal moves from a solvable board must stay solvable.
+        board = Board()
+        rng = random.Random(7)
+        for _ in range(100):
+            self.assertTrue(Board.is_solvable(board.tiles))
+            board.move(rng.choice(board.legal_moves()))
+
+    def test_inversion_count(self):
+        self.assertEqual(Board.count_inversions(solved()), 0)
+        self.assertEqual(Board.count_inversions([2, 1] + list(range(3, 16)) + [0]), 1)
+
+
+class ShuffleTests(unittest.TestCase):
+    def test_generated_boards_are_always_solvable(self):
+        for seed in range(20):
+            board = Board.shuffled(rng=random.Random(seed))
+            self.assertTrue(
+                Board.is_solvable(board.tiles),
+                f"seed {seed} produced an unsolvable board",
+            )
+
+    def test_generated_boards_are_not_already_solved(self):
+        for seed in range(10):
+            self.assertFalse(Board.shuffled(rng=random.Random(seed)).is_solved())
+
+    def test_generated_boards_contain_all_tiles(self):
+        board = Board.shuffled(rng=random.Random(1))
+        self.assertEqual(sorted(board.tiles), list(range(16)))
+
+    def test_shuffle_resets_move_count(self):
+        board = Board.shuffled(rng=random.Random(1))
+        self.assertEqual(board.move_count, 0)
+
+    def test_shuffle_accepts_short_scramble(self):
+        board = Board.shuffled(rng=random.Random(3), scramble_moves=10)
+        self.assertTrue(Board.is_solvable(board.tiles))
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,154 @@+#!/usr/bin/env python3
+"""Headless unit tests for games/gomoku.py (pure logic only)."""
+from __future__ import annotations
+
+import unittest
+
+from games.gomoku import GomokuAI, GomokuBoard, EMPTY, X, O
+
+
+def play(board: GomokuBoard, moves: list[tuple[int, int]]) -> None:
+    """Play a list of alternating moves, asserting each one is legal."""
+    for row, col in moves:
+        assert board.place(row, col), f"move {(row, col)} should be legal"
+
+
+class WinDetectionTests(unittest.TestCase):
+    """Five (or more) consecutive stones win, in every direction."""
+
+    def test_five_horizontal_wins(self):
+        board = GomokuBoard()
+        play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2),
+                     (7, 6), (0, 3), (7, 7)])
+        self.assertEqual(board.winner, X)
+        self.assertTrue(board.is_over())
+
+    def test_five_vertical_wins(self):
+        board = GomokuBoard()
+        play(board, [(3, 4), (0, 0), (4, 4), (0, 1), (5, 4), (0, 2),
+                     (6, 4), (0, 3), (7, 4)])
+        self.assertEqual(board.winner, X)
+
+    def test_five_diagonal_down_right_wins(self):
+        board = GomokuBoard()
+        play(board, [(2, 2), (0, 0), (3, 3), (0, 1), (4, 4), (0, 2),
+                     (5, 5), (0, 3), (6, 6)])
+        self.assertEqual(board.winner, X)
+
+    def test_five_diagonal_up_right_wins(self):
+        board = GomokuBoard()
+        play(board, [(6, 2), (0, 0), (5, 3), (0, 1), (4, 4), (0, 2),
+                     (3, 5), (0, 3), (2, 6)])
+        self.assertEqual(board.winner, X)
+
+    def test_overline_six_in_a_row_wins(self):
+        # X leaves a gap at (7, 7); filling it last creates six in a row.
+        board = GomokuBoard()
+        play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2),
+                     (7, 6), (0, 3), (7, 8), (1, 0), (7, 7)])
+        self.assertEqual(board.winner, X)
+        # The middle stone of the six is also part of the winning line.
+        self.assertTrue(board.wins_through(7, 5))
+
+    def test_four_in_a_row_is_not_a_win(self):
+        board = GomokuBoard()
+        play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2), (7, 6)])
+        self.assertEqual(board.winner, EMPTY)
+        self.assertFalse(board.is_over())
+
+    def test_o_can_win(self):
+        board = GomokuBoard()
+        play(board, [(0, 0), (7, 3), (0, 1), (7, 4), (0, 2), (7, 5),
+                     (0, 3), (7, 6), (14, 14), (7, 7)])
+        self.assertEqual(board.winner, O)
+
+
+class BoardMechanicsTests(unittest.TestCase):
+    def test_players_alternate_starting_with_x(self):
+        board = GomokuBoard()
+        self.assertEqual(board.current_player, X)
+        board.place(7, 7)
+        self.assertEqual(board.current_player, O)
+        board.place(0, 0)
+        self.assertEqual(board.current_player, X)
+
+    def test_occupied_square_is_rejected(self):
+        board = GomokuBoard()
+        self.assertTrue(board.place(7, 7))
+        self.assertFalse(board.place(7, 7))
+        self.assertEqual(board.grid[7][7], X)
+        self.assertEqual(board.current_player, O)  # failed move changes nothing
+
+    def test_out_of_bounds_move_is_rejected(self):
+        board = GomokuBoard()
+        self.assertFalse(board.place(-1, 0))
+        self.assertFalse(board.place(15, 0))
+        self.assertFalse(board.place(0, 15))
+
+    def test_no_moves_after_win(self):
+        board = GomokuBoard()
+        play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2),
+                     (7, 6), (0, 3), (7, 7)])
+        self.assertFalse(board.place(10, 10))
+
+    def test_parse_coordinate_valid(self):
+        self.assertEqual(GomokuBoard.parse_coordinate("h8"), (7, 7))
+        self.assertEqual(GomokuBoard.parse_coordinate("A1"), (0, 0))
+        self.assertEqual(GomokuBoard.parse_coordinate("o15"), (14, 14))
+        self.assertEqual(GomokuBoard.parse_coordinate("  c3 "), (2, 2))
+
+    def test_parse_coordinate_invalid(self):
+        for bad in ("", "h", "8", "8h", "hh", "p1", "h0", "h16", "h-1", "a 1 2"):
+            self.assertIsNone(GomokuBoard.parse_coordinate(bad), bad)
+
+    def test_format_coordinate_roundtrip(self):
+        self.assertEqual(GomokuBoard.format_coordinate(7, 7), "H8")
+        self.assertEqual(GomokuBoard.parse_coordinate(
+            GomokuBoard.format_coordinate(10, 4)), (10, 4))
+
+
+class AITests(unittest.TestCase):
+    def test_ai_completes_its_own_five(self):
+        board = GomokuBoard()
+        play(board, [(0, 0), (3, 3), (0, 1), (3, 4), (1, 0), (3, 5),
+                     (1, 1), (3, 6)])
+        move = GomokuAI(O).choose_move(board)
+        self.assertIn(move, [(3, 2), (3, 7)])
+
+    def test_ai_blocks_immediate_win(self):
+        board = GomokuBoard()
+        play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (1, 0),
+                     (7, 6), (1, 1)])
+        move = GomokuAI(O).choose_move(board)
+        self.assertIn(move, [(7, 2), (7, 7)])
+
+    def test_ai_prefers_winning_over_blocking(self):
+        # Both sides have four in a row; the AI must take its own win.
+        board = GomokuBoard()
+        play(board, [(0, 0), (3, 3), (0, 1), (3, 4), (0, 2), (3, 5),
+                     (0, 3), (3, 6)])
+        move = GomokuAI(O).choose_move(board)
+        self.assertIn(move, [(3, 2), (3, 7)])
+
+    def test_ai_opens_in_the_center(self):
+        board = GomokuBoard()
+        self.assertEqual(GomokuAI(O).choose_move(board), (7, 7))
+
+    def test_ai_plays_near_existing_stones(self):
+        board = GomokuBoard()
+        play(board, [(7, 7), (14, 14)])
+        move = GomokuAI(O).choose_move(board)
+        self.assertIsNotNone(move)
+        row, col = move
+        self.assertLessEqual(max(abs(row - 7), abs(col - 7)), GomokuAI.CANDIDATE_RANGE)
+        self.assertTrue(board.is_empty(row, col))
+
+    def test_ai_returns_none_when_game_over(self):
+        board = GomokuBoard()
+        play(board, [(7, 3), (0, 0), (7, 4), (0, 1), (7, 5), (0, 2),
+                     (7, 6), (0, 3), (7, 7)])
+        self.assertIsNone(GomokuAI(O).choose_move(board))
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,107 @@+"""Tests for core.input: read_line edge cases and read_key normalization.
+
+read_key has two platform branches chosen at import time (msvcrt on
+Windows, termios/select on Unix). Each branch is tested by patching
+its own low-level dependency; only the branch active on the current
+platform runs.
+"""
+from __future__ import annotations
+
+import io
+import unittest
+from contextlib import redirect_stdout
+from unittest import mock
+
+from core import input as core_input
+from core.input import Key, read_key, read_line
+
+
+class ReadLineTest(unittest.TestCase):
+    def test_returns_typed_line_and_forwards_prompt(self):
+        with mock.patch("builtins.input", return_value="b2") as m:
+            self.assertEqual(read_line("Move: "), "b2")
+            m.assert_called_once_with("Move: ")
+
+    def test_eof_returns_none(self):
+        with mock.patch("builtins.input", side_effect=EOFError):
+            with redirect_stdout(io.StringIO()):
+                self.assertIsNone(read_line())
+
+    def test_ctrl_c_returns_none(self):
+        with mock.patch("builtins.input", side_effect=KeyboardInterrupt):
+            with redirect_stdout(io.StringIO()):
+                self.assertIsNone(read_line())
+
+
+if hasattr(core_input, "msvcrt"):  # Windows branch
+
+    class ReadKeyWindowsTest(unittest.TestCase):
+        def _feed(self, *chars):
+            return mock.patch.object(core_input.msvcrt, "getwch",
+                                     side_effect=chars)
+
+        def test_plain_char(self):
+            with self._feed("a"):
+                self.assertEqual(read_key(), "a")
+
+        def test_arrows_are_normalized(self):
+            for code, expected in (("H", Key.UP), ("P", Key.DOWN),
+                                   ("K", Key.LEFT), ("M", Key.RIGHT)):
+                with self.subTest(code=code), self._feed("\xe0", code):
+                    self.assertEqual(read_key(), expected)
+
+        def test_enter_escape_backspace(self):
+            for ch, expected in (("\r", Key.ENTER), ("\x1b", Key.ESCAPE),
+                                 ("\x08", Key.BACKSPACE)):
+                with self.subTest(ch=repr(ch)), self._feed(ch):
+                    self.assertEqual(read_key(), expected)
+
+        def test_unknown_special_key_falls_back_to_empty(self):
+            with self._feed("\x00", "S"):  # e.g. F-keys: no mapping
+                self.assertEqual(read_key(), "")
+
+else:  # Unix branch
+
+    class ReadKeyUnixTest(unittest.TestCase):
+        def _run(self, reads, sequence_ready=False):
+            fake_stdin = mock.Mock()
+            fake_stdin.fileno.return_value = 0
+            fake_stdin.read.side_effect = reads
+            with mock.patch.object(core_input.sys, "stdin", fake_stdin), \
+                 mock.patch.object(core_input.termios, "tcgetattr",
+                                   return_value="saved"), \
+                 mock.patch.object(core_input.termios, "tcsetattr"), \
+                 mock.patch.object(core_input.tty, "setraw"), \
+                 mock.patch.object(core_input.select, "select",
+                                   return_value=([fake_stdin] if sequence_ready
+                                                 else [], [], [])):
+                return read_key()
+
+        def test_plain_char(self):
+            self.assertEqual(self._run(["a"]), "a")
+
+        def test_arrows_are_normalized(self):
+            for seq, expected in (("[A", Key.UP), ("[B", Key.DOWN),
+                                  ("[C", Key.RIGHT), ("[D", Key.LEFT)):
+                with self.subTest(seq=seq):
+                    self.assertEqual(self._run(["\x1b", seq],
+                                               sequence_ready=True), expected)
+
+        def test_lone_escape(self):
+            # No bytes available right after ESC: it was a real ESC press.
+            self.assertEqual(self._run(["\x1b"], sequence_ready=False),
+                             Key.ESCAPE)
+
+        def test_unknown_sequence_falls_back_to_escape(self):
+            self.assertEqual(self._run(["\x1b", "[Z"], sequence_ready=True),
+                             Key.ESCAPE)
+
+        def test_enter_variants_and_backspace(self):
+            for ch, expected in (("\r", Key.ENTER), ("\n", Key.ENTER),
+                                 ("\x7f", Key.BACKSPACE)):
+                with self.subTest(ch=repr(ch)):
+                    self.assertEqual(self._run([ch]), expected)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,212 @@+"""Headless unit tests for games.mancala (Kalah rules).
+
+These tests drive only the pure logic classes (KalahBoard,
+ComputerPlayer); nothing here touches the terminal.
+"""
+import random
+import unittest
+
+from games.mancala import (
+    DRAW,
+    HUMAN,
+    INITIAL_STONES,
+    OPPONENT,
+    PITS_PER_SIDE,
+    SLOT_COUNT,
+    ComputerPlayer,
+    KalahBoard,
+)
+
+TOTAL_STONES = 2 * PITS_PER_SIDE * INITIAL_STONES  # 48
+
+
+def board_with(slots, current=HUMAN):
+    return KalahBoard(slots=slots, current=current)
+
+
+class InitialBoardTests(unittest.TestCase):
+    def test_fresh_board_layout(self):
+        board = KalahBoard()
+        self.assertEqual(board.pits(HUMAN), [INITIAL_STONES] * PITS_PER_SIDE)
+        self.assertEqual(board.pits(OPPONENT), [INITIAL_STONES] * PITS_PER_SIDE)
+        self.assertEqual(board.store(HUMAN), 0)
+        self.assertEqual(board.store(OPPONENT), 0)
+        self.assertEqual(board.current, HUMAN)
+        self.assertFalse(board.game_over)
+        self.assertIsNone(board.winner)
+        self.assertEqual(sum(board.slots), TOTAL_STONES)
+
+    def test_legal_moves_are_the_non_empty_own_pits(self):
+        board = board_with([0, 3, 0, 1, 0, 2, 0, 4, 0, 5, 0, 6, 0, 0])
+        self.assertEqual(board.legal_moves(HUMAN), [2, 4, 6])
+        self.assertEqual(board.legal_moves(OPPONENT), [1, 3, 5])
+
+    def test_illegal_moves_raise(self):
+        board = board_with([0, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0])
+        with self.assertRaises(ValueError):
+            board.apply_move(1)   # empty pit
+        with self.assertRaises(ValueError):
+            board.apply_move(0)   # out of range
+        with self.assertRaises(ValueError):
+            board.apply_move(7)   # out of range
+
+    def test_move_on_finished_game_raises(self):
+        board = board_with([0, 0, 0, 0, 0, 1, 24, 0, 0, 0, 0, 0, 0, 24])
+        board.apply_move(6)  # ends the game (human side empty)
+        self.assertTrue(board.game_over)
+        with self.assertRaises(ValueError):
+            board.apply_move(1)
+
+
+class SowingTests(unittest.TestCase):
+    def test_basic_sowing_distribution(self):
+        board = KalahBoard()
+        result = board.apply_move(2)  # 4 stones from pit 2 into pits 3-6
+        self.assertEqual(board.slots[1], 0)                # source emptied
+        self.assertEqual(board.slots[2:6], [5, 5, 5, 5])   # one stone each
+        self.assertEqual(board.store(HUMAN), 0)            # none reached it
+        self.assertFalse(result.extra_turn)
+        self.assertEqual(board.current, OPPONENT)          # turn passed
+        self.assertEqual(sum(board.slots), TOTAL_STONES)
+
+    def test_sowing_reaches_own_store(self):
+        board = KalahBoard()
+        result = board.apply_move(3)  # 4 stones: pits 4, 5, 6 and store
+        self.assertEqual(board.store(HUMAN), 1)
+        self.assertTrue(result.extra_turn)
+
+    def test_sowing_skips_opponent_store_human(self):
+        # 10 stones in pit 6: wraps past the opponent's side and store.
+        # Slot 2 starts non-empty so the wrapped last stone cannot capture.
+        board = board_with([0, 0, 1, 0, 0, 10, 0, 4, 4, 4, 4, 4, 4, 0])
+        board.apply_move(6)
+        self.assertEqual(board.store(OPPONENT), 0)  # never sown into
+        self.assertEqual(board.store(HUMAN), 1)
+        self.assertEqual(board.slots[7:13], [5, 5, 5, 5, 5, 5])
+        self.assertEqual(board.slots[0:3], [1, 1, 2])  # wrapped around
+        self.assertEqual(sum(board.slots), 35)
+
+    def test_sowing_skips_opponent_store_opponent(self):
+        # Mirror image for the opponent sowing past the human store.
+        board = board_with([4, 4, 4, 4, 4, 4, 0, 0, 0, 1, 0, 0, 10, 0],
+                           current=OPPONENT)
+        board.apply_move(6)
+        self.assertEqual(board.store(HUMAN), 0)
+        self.assertEqual(board.store(OPPONENT), 1)
+        self.assertEqual(board.slots[0:6], [5, 5, 5, 5, 5, 5])
+        self.assertEqual(board.slots[7:10], [1, 1, 2])
+        self.assertEqual(sum(board.slots), 35)
+
+
+class ExtraTurnTests(unittest.TestCase):
+    def test_last_stone_in_own_store_grants_extra_turn(self):
+        board = KalahBoard()
+        result = board.apply_move(3)  # last of 4 stones lands in the store
+        self.assertTrue(result.extra_turn)
+        self.assertEqual(board.current, HUMAN)  # same player moves again
+
+    def test_no_extra_turn_when_last_stone_lands_in_a_pit(self):
+        board = KalahBoard()
+        result = board.apply_move(1)  # lands in pit 5
+        self.assertFalse(result.extra_turn)
+        self.assertEqual(board.current, OPPONENT)
+
+
+class CaptureTests(unittest.TestCase):
+    def test_capture_into_empty_own_pit(self):
+        # Pit 2 has 1 stone; pit 3 is empty; the opposite pit holds 5.
+        board = board_with([1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 5, 0, 0, 0])
+        result = board.apply_move(2)  # last stone lands in empty pit 3
+        self.assertEqual(result.captured, 6)       # 5 opposite + own stone
+        self.assertEqual(board.store(HUMAN), 6)
+        self.assertEqual(board.slots[2], 0)        # own pit emptied
+        self.assertEqual(board.slots[10], 0)       # opposite pit emptied
+        self.assertFalse(result.extra_turn)
+        self.assertEqual(board.current, OPPONENT)
+        self.assertFalse(board.game_over)
+
+    def test_no_capture_when_opposite_pit_is_empty(self):
+        board = board_with([1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0])
+        result = board.apply_move(2)
+        self.assertEqual(result.captured, 0)
+        self.assertEqual(board.slots[2], 1)  # the stone simply stays
+        self.assertEqual(board.store(HUMAN), 0)
+
+    def test_no_capture_on_opponent_side(self):
+        # A stone landing in an empty opponent pit captures nothing.
+        # Slot 0 keeps the human side non-empty so the game continues.
+        board = board_with([1, 0, 0, 0, 0, 7, 0, 0, 4, 4, 4, 4, 4, 0])
+        result = board.apply_move(6)  # last stone lands in an opponent pit
+        self.assertEqual(result.captured, 0)
+        self.assertFalse(result.game_over)
+        self.assertEqual(board.store(HUMAN), 1)  # only the sown stone
+        self.assertEqual(board.slots[7], 1)
+        self.assertEqual(board.slots[8:13], [5, 5, 5, 5, 5])
+
+
+class EndGameTests(unittest.TestCase):
+    def test_end_game_collection_and_scoring(self):
+        # Human plays the last stone of their side into the store.
+        board = board_with([0, 0, 0, 0, 0, 1, 5, 1, 2, 3, 4, 5, 6, 10])
+        result = board.apply_move(6)
+        self.assertTrue(result.game_over)
+        self.assertTrue(board.game_over)
+        # Remaining opponent stones move to the opponent's store.
+        self.assertEqual(board.pits(OPPONENT), [0] * PITS_PER_SIDE)
+        self.assertEqual(board.store(OPPONENT), 10 + 1 + 2 + 3 + 4 + 5 + 6)
+        self.assertEqual(board.store(HUMAN), 6)
+        self.assertEqual(board.winner, OPPONENT)
+        self.assertEqual(sum(board.slots), 37)
+
+    def test_game_can_end_by_capture_emptying_a_side(self):
+        # The captured pit was the opponent's last stone: game over, and
+        # the human's leftover stone is collected into the human store.
+        board = board_with([1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0])
+        result = board.apply_move(2)
+        self.assertEqual(result.captured, 6)
+        self.assertTrue(result.game_over)
+        self.assertEqual(board.store(HUMAN), 7)  # 6 captured + 1 leftover
+        self.assertEqual(board.store(OPPONENT), 0)
+        self.assertEqual(board.winner, HUMAN)
+
+    def test_equal_stores_are_a_draw(self):
+        board = board_with([0, 0, 0, 0, 0, 1, 23, 0, 0, 0, 0, 0, 2, 22])
+        board.apply_move(6)
+        self.assertTrue(board.game_over)
+        self.assertEqual(board.store(HUMAN), 24)
+        self.assertEqual(board.store(OPPONENT), 24)
+        self.assertEqual(board.winner, DRAW)
+
+    def test_game_continues_while_both_sides_have_stones(self):
+        board = KalahBoard()
+        board.apply_move(3)  # extra turn, nothing ends
+        self.assertFalse(board.game_over)
+        self.assertIsNone(board.winner)
+
+
+class ComputerPlayerTests(unittest.TestCase):
+    def test_prefers_extra_turn(self):
+        board = KalahBoard()
+        ai = ComputerPlayer(random.Random(1))
+        # Pit 3 is the only opening move landing in the store.
+        self.assertEqual(ai.choose_move(board), 3)
+
+    def test_prefers_capture_over_plain_store_gain(self):
+        # Pit 3 captures 5 stones; pit 6 only sows one into the store.
+        board = board_with([0, 0, 5, 0, 0, 0, 0, 0, 0, 1, 0, 0, 3, 0])
+        ai = ComputerPlayer(random.Random(2))
+        self.assertEqual(ai.choose_move(board), 3)
+
+    def test_returns_a_legal_move(self):
+        board = KalahBoard()
+        ai = ComputerPlayer(random.Random(3))
+        for pit in (3, 1, 6, 4, 2, 5):  # play a few full turns
+            if board.game_over:
+                break
+            move = ai.choose_move(board) if board.current == OPPONENT else pit
+            self.assertIn(move, board.legal_moves())
+            board.apply_move(move)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,41 @@+"""Tests for core.menu.choose extra-keys vs WASD navigation."""
+from __future__ import annotations
+
+import io
+import unittest
+from contextlib import redirect_stdout
+from unittest import mock
+
+from core import menu
+from core.input import Key
+
+
+def run_choose(keys, **kwargs):
+    """Drive choose() with a scripted key sequence, swallowing output."""
+    with mock.patch.object(menu, "read_key", side_effect=keys):
+        with redirect_stdout(io.StringIO()):
+            return menu.choose("Title", ["one", "two", "three"], **kwargs)
+
+
+class ExtraKeysTest(unittest.TestCase):
+    def test_extra_key_s_wins_over_move_down(self):
+        # Regression test: "s" was consumed as move-down before the
+        # extra_keys check, making the main menu's "S stats" unreachable.
+        self.assertEqual(run_choose(["s"], extra_keys="hsa"), "s")
+
+    def test_extra_key_returned_lowercased(self):
+        self.assertEqual(run_choose(["H"], extra_keys="hsa"), "h")
+
+    def test_s_still_moves_down_when_not_an_extra_key(self):
+        self.assertEqual(run_choose(["s", Key.ENTER]), 1)
+
+    def test_w_still_moves_up_when_not_an_extra_key(self):
+        # From index 0, up wraps to the last option.
+        self.assertEqual(run_choose(["w", Key.ENTER]), 2)
+
+    def test_arrow_keys_unaffected(self):
+        self.assertEqual(run_choose([Key.DOWN, Key.ENTER], extra_keys="hsa"), 1)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,199 @@+#!/usr/bin/env python3
+"""Headless tests for games.minesweeper (Board logic only)."""
+from __future__ import annotations
+
+import random
+import unittest
+
+from games.minesweeper import Board, DIFFICULTIES
+
+
+class TestDifficultyTable(unittest.TestCase):
+    def test_presets_match_the_spec(self):
+        easy = DIFFICULTIES["easy"]
+        self.assertEqual((easy.rows, easy.cols, easy.mines), (9, 9, 10))
+        medium = DIFFICULTIES["medium"]
+        self.assertEqual((medium.rows, medium.cols, medium.mines), (16, 16, 40))
+        hard = DIFFICULTIES["hard"]
+        self.assertEqual((hard.rows, hard.cols, hard.mines), (18, 22, 85))
+
+    def test_record_keys(self):
+        self.assertEqual(DIFFICULTIES["easy"].record_key,
+                         "minesweeper_best_time_easy")
+        self.assertEqual(DIFFICULTIES["medium"].record_key,
+                         "minesweeper_best_time_medium")
+        self.assertEqual(DIFFICULTIES["hard"].record_key,
+                         "minesweeper_best_time_hard")
+
+
+class TestMinePlacement(unittest.TestCase):
+    def test_mine_count_after_generation(self):
+        for diff in DIFFICULTIES.values():
+            board = Board(diff.rows, diff.cols, diff.mines)
+            board.place_mines(0, 0)
+            self.assertEqual(len(board.mines), diff.mines)
+            self.assertTrue(board.mines_placed)
+            for r, c in board.mines:
+                self.assertTrue(0 <= r < diff.rows)
+                self.assertTrue(0 <= c < diff.cols)
+
+    def test_generation_is_reproducible_with_injected_rng(self):
+        a = Board(9, 9, 10, rng=random.Random(42))
+        b = Board(9, 9, 10, rng=random.Random(42))
+        self.assertEqual(a.place_mines(4, 4), b.place_mines(4, 4))
+
+    def test_first_move_safety(self):
+        """The first revealed cell and its neighbors are never mines."""
+        for trial in range(40):
+            board = Board(9, 9, 10)
+            safe_r, safe_c = trial % 9, (trial * 3) % 9
+            board.place_mines(safe_r, safe_c)
+            self.assertNotIn((safe_r, safe_c), board.mines)
+            for nb in board.neighbors(safe_r, safe_c):
+                self.assertNotIn(nb, board.mines)
+
+    def test_first_move_safety_via_reveal(self):
+        for _ in range(20):
+            board = Board(16, 16, 40)
+            board.reveal(7, 8)
+            self.assertTrue(board.mines_placed)
+            self.assertNotIn((7, 8), board.mines)
+            for nb in board.neighbors(7, 8):
+                self.assertNotIn(nb, board.mines)
+
+    def test_first_move_safety_in_corner(self):
+        for _ in range(10):
+            board = Board(9, 9, 10)
+            board.reveal(0, 0)
+            protected = {(0, 0)} | set(board.neighbors(0, 0))
+            self.assertTrue(board.mines.isdisjoint(protected))
+
+    def test_place_mines_twice_is_rejected(self):
+        board = Board(9, 9, 10)
+        board.place_mines(4, 4)
+        with self.assertRaises(RuntimeError):
+            board.place_mines(0, 0)
+
+    def test_too_many_mines_is_rejected(self):
+        board = Board(3, 3, 1)
+        with self.assertRaises(ValueError):
+            board.place_mines(1, 1)  # every cell is protected
+
+    def test_fixed_mines_outside_board_are_rejected(self):
+        with self.assertRaises(ValueError):
+            Board(5, 5, mines={(5, 0)})
+
+
+class TestNeighborCounts(unittest.TestCase):
+    def setUp(self):
+        self.mines = {(0, 0), (0, 4), (2, 2)}
+        self.board = Board(5, 5, mines=self.mines)
+
+    def test_fixed_mines_are_used_verbatim(self):
+        self.assertTrue(self.board.mines_placed)
+        self.assertEqual(self.board.mines, self.mines)
+        self.assertEqual(self.board.mine_count, 3)
+
+    def test_counts_on_known_layout(self):
+        self.assertEqual(self.board.neighbor_mine_count(1, 1), 2)   # (0,0),(2,2)
+        self.assertEqual(self.board.neighbor_mine_count(1, 3), 2)   # (0,4),(2,2)
+        self.assertEqual(self.board.neighbor_mine_count(0, 1), 1)   # (0,0)
+        self.assertEqual(self.board.neighbor_mine_count(3, 1), 1)   # (2,2)
+        self.assertEqual(self.board.neighbor_mine_count(4, 4), 0)
+        self.assertEqual(self.board.neighbor_mine_count(4, 0), 0)
+
+
+class TestFloodReveal(unittest.TestCase):
+    def setUp(self):
+        # One full row of mines at the top; everything below row 1 is empty.
+        self.mines = {(0, c) for c in range(9)}
+        self.board = Board(9, 9, mines=self.mines)
+
+    def test_flood_opens_whole_empty_region(self):
+        newly = self.board.reveal(8, 4)
+        expected = {(r, c) for r in range(1, 9) for c in range(9)}
+        self.assertEqual(newly, expected)
+        self.assertEqual(self.board.revealed, expected)
+
+    def test_flood_stops_at_numbered_border_and_mines(self):
+        self.board.reveal(8, 4)
+        for c in range(9):
+            self.assertNotIn((0, c), self.board.revealed)  # mines stay hidden
+        self.assertEqual(self.board.neighbor_mine_count(1, 0), 2)
+        self.assertEqual(self.board.neighbor_mine_count(1, 4), 3)
+        self.assertEqual(self.board.neighbor_mine_count(1, 8), 2)
+
+    def test_flood_reveal_wins_when_only_mines_remain(self):
+        self.board.reveal(8, 4)
+        self.assertTrue(self.board.is_won)
+        self.assertFalse(self.board.lost)
+
+
+class TestFlags(unittest.TestCase):
+    def setUp(self):
+        self.board = Board(9, 9, mines={(0, 0)})
+
+    def test_toggle_flag_and_counter(self):
+        self.assertEqual(self.board.flags_remaining, 1)
+        self.assertTrue(self.board.toggle_flag(5, 5))
+        self.assertEqual(self.board.flags_remaining, 0)
+        self.assertFalse(self.board.toggle_flag(5, 5))
+        self.assertEqual(self.board.flags_remaining, 1)
+
+    def test_flagged_cell_cannot_be_revealed(self):
+        self.board.toggle_flag(5, 5)
+        self.assertEqual(self.board.reveal(5, 5), set())
+        self.assertNotIn((5, 5), self.board.revealed)
+
+    def test_flag_on_revealed_cell_is_refused(self):
+        self.board.reveal(5, 5)
+        self.assertIsNone(self.board.toggle_flag(5, 5))
+        self.assertNotIn((5, 5), self.board.flagged)
+
+    def test_flag_outside_board_is_refused(self):
+        self.assertIsNone(self.board.toggle_flag(-1, 0))
+        self.assertIsNone(self.board.toggle_flag(9, 9))
+
+
+class TestWinAndLoss(unittest.TestCase):
+    def test_revealing_a_mine_loses_and_exposes_all_mines(self):
+        mines = {(0, 0), (0, 1), (4, 4)}
+        board = Board(5, 5, mines=mines)
+        newly = board.reveal(0, 0)
+        self.assertTrue(board.lost)
+        self.assertEqual(board.exploded_at, (0, 0))
+        self.assertTrue(mines.issubset(board.revealed))
+        self.assertEqual(newly, mines)
+        self.assertFalse(board.is_won)
+
+    def test_no_actions_after_loss(self):
+        board = Board(5, 5, mines={(0, 0)})
+        board.reveal(0, 0)
+        self.assertEqual(board.reveal(4, 4), set())
+        self.assertIsNone(board.toggle_flag(4, 4))
+
+    def test_no_actions_after_win(self):
+        board = Board(4, 4, mines={(0, 0)})
+        board.reveal(3, 3)  # floods everything but the mine
+        self.assertTrue(board.is_won)
+        self.assertEqual(board.reveal(0, 0), set())
+        self.assertIsNone(board.toggle_flag(0, 0))
+
+    def test_win_requires_all_safe_cells(self):
+        board = Board(4, 4, mines={(0, 0), (0, 1)})
+        self.assertFalse(board.is_won)
+        for r in range(4):
+            for c in range(4):
+                if (r, c) not in board.mines:
+                    board.reveal(r, c)
+        self.assertTrue(board.is_won)
+
+    def test_reveal_outside_board_is_ignored(self):
+        board = Board(5, 5, mines={(0, 0)})
+        self.assertEqual(board.reveal(-1, 0), set())
+        self.assertEqual(board.reveal(5, 0), set())
+        self.assertFalse(board.lost)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,202 @@+"""Headless tests for games/naval_battle.py pure logic classes."""
+from __future__ import annotations
+
+import random
+import unittest
+
+from games.naval_battle import (
+    ALREADY,
+    BOARD_SIZE,
+    FLEET,
+    HIT,
+    MISS,
+    SUNK,
+    Board,
+    ComputerAI,
+    parse_coordinate,
+    format_coordinate,
+    random_fleet,
+)
+
+
+class ParseCoordinateTests(unittest.TestCase):
+    def test_valid_letter_first(self):
+        self.assertEqual(parse_coordinate("A1"), (0, 0))
+        self.assertEqual(parse_coordinate("j10"), (9, 9))
+        self.assertEqual(parse_coordinate("E5"), (4, 4))
+
+    def test_valid_number_first_and_spaces(self):
+        self.assertEqual(parse_coordinate("5E"), (4, 4))
+        self.assertEqual(parse_coordinate(" b 3 "), (1, 2))
+        self.assertEqual(parse_coordinate("C 7"), (2, 6))
+
+    def test_invalid(self):
+        for bad in ("", None, "K1", "A0", "A11", "1A1", "hello", "AA", "11"):
+            self.assertIsNone(parse_coordinate(bad), bad)
+
+    def test_format_roundtrip(self):
+        self.assertEqual(format_coordinate(0, 0), "A1")
+        self.assertEqual(format_coordinate(9, 9), "J10")
+        self.assertEqual(parse_coordinate(format_coordinate(3, 6)), (3, 6))
+
+
+class ShipCellsTests(unittest.TestCase):
+    def test_horizontal_and_vertical(self):
+        self.assertEqual(Board.ship_cells((0, 0), 3, "h"),
+                         [(0, 0), (0, 1), (0, 2)])
+        self.assertEqual(Board.ship_cells((2, 1), 3, "v"),
+                         [(2, 1), (3, 1), (4, 1)])
+
+    def test_out_of_bounds_returns_none(self):
+        self.assertIsNone(Board.ship_cells((0, 8), 3, "h"))
+        self.assertIsNone(Board.ship_cells((8, 0), 3, "v"))
+        self.assertIsNone(Board.ship_cells((9, 9), 2, "h"))
+
+    def test_bad_direction_returns_none(self):
+        self.assertIsNone(Board.ship_cells((0, 0), 3, "x"))
+        self.assertIsNone(Board.ship_cells((0, 0), 3, ""))
+
+
+class PlacementTests(unittest.TestCase):
+    def setUp(self):
+        self.board = Board()
+
+    def test_legal_placement(self):
+        cells = Board.ship_cells((0, 0), 5, "h")
+        self.assertTrue(self.board.can_place(cells))
+        self.assertTrue(self.board.place_ship("Carrier", 5, cells))
+        self.assertEqual(len(self.board.ships), 1)
+
+    def test_reject_out_of_bounds(self):
+        self.assertFalse(self.board.place_ship("Carrier", 5,
+                                               [(0, 6), (0, 7), (0, 8), (0, 9), (0, 10)]))
+        self.assertFalse(self.board.place_ship("Destroyer", 2, [(-1, 0), (0, 0)]))
+        self.assertEqual(len(self.board.ships), 0)
+
+    def test_reject_wrong_length(self):
+        self.assertFalse(self.board.place_ship("Destroyer", 2, [(0, 0)]))
+        self.assertFalse(self.board.place_ship("Destroyer", 2,
+                                               [(0, 0), (0, 1), (0, 2)]))
+
+    def test_collision_detection(self):
+        self.assertTrue(self.board.place_ship("Submarine", 3,
+                                              [(4, 4), (4, 5), (4, 6)]))
+        # Overlap on every shared cell must fail.
+        self.assertFalse(self.board.place_ship("Cruiser", 3,
+                                               [(3, 5), (4, 5), (5, 5)]))
+        self.assertFalse(self.board.place_ship("Destroyer", 2, [(4, 4), (4, 5)]))
+        self.assertEqual(len(self.board.ships), 1)
+
+    def test_adjacency_is_allowed(self):
+        self.assertTrue(self.board.place_ship("Submarine", 3,
+                                              [(4, 4), (4, 5), (4, 6)]))
+        self.assertTrue(self.board.place_ship("Cruiser", 3,
+                                              [(5, 4), (5, 5), (5, 6)]))
+        self.assertTrue(self.board.place_ship("Destroyer", 2, [(4, 7), (5, 7)]))
+        self.assertEqual(len(self.board.ships), 3)
+
+
+class CombatTests(unittest.TestCase):
+    def setUp(self):
+        self.board = Board()
+        self.board.place_ship("Destroyer", 2, [(0, 0), (0, 1)])
+        self.board.place_ship("Submarine", 3, [(5, 5), (5, 6), (5, 7)])
+
+    def test_miss_on_water(self):
+        result, ship = self.board.receive_shot(3, 3)
+        self.assertEqual(result, MISS)
+        self.assertIsNone(ship)
+        self.assertEqual(self.board.shots[(3, 3)], MISS)
+
+    def test_hit_on_ship(self):
+        result, ship = self.board.receive_shot(0, 0)
+        self.assertEqual(result, HIT)
+        self.assertEqual(ship.name, "Destroyer")
+        self.assertFalse(ship.sunk)
+
+    def test_repeat_shot_reports_already(self):
+        self.board.receive_shot(0, 0)
+        result, ship = self.board.receive_shot(0, 0)
+        self.assertEqual(result, ALREADY)
+        self.assertIsNone(ship)
+
+    def test_sunk_detection(self):
+        self.assertEqual(self.board.receive_shot(0, 0)[0], HIT)
+        result, ship = self.board.receive_shot(0, 1)
+        self.assertEqual(result, SUNK)
+        self.assertTrue(ship.sunk)
+        self.assertEqual(self.board.shots[(0, 1)], SUNK)
+
+    def test_fleet_destroyed_end_condition(self):
+        self.assertFalse(self.board.fleet_destroyed)
+        for cell in ((0, 0), (0, 1), (5, 5), (5, 6)):
+            self.board.receive_shot(*cell)
+        self.assertFalse(self.board.fleet_destroyed)
+        self.assertEqual(self.board.receive_shot(5, 7)[0], SUNK)
+        self.assertTrue(self.board.fleet_destroyed)
+
+    def test_empty_board_fleet_not_destroyed(self):
+        self.assertFalse(Board().fleet_destroyed)
+
+    def test_afloat_ships(self):
+        self.board.receive_shot(0, 0)
+        self.board.receive_shot(0, 1)
+        self.assertEqual([s.name for s in self.board.afloat_ships()], ["Submarine"])
+
+
+class RandomFleetTests(unittest.TestCase):
+    def test_random_fleet_always_legal(self):
+        for seed in range(300):
+            board = random_fleet(random.Random(seed))
+            ships = board.ships
+            self.assertEqual([s.name for s in ships], [name for name, _ in FLEET])
+            self.assertEqual([s.size for s in ships], [size for _, size in FLEET])
+            all_cells = [cell for ship in ships for cell in ship.cells]
+            # No overlap: every occupied cell is unique.
+            self.assertEqual(len(all_cells), len(set(all_cells)))
+            self.assertEqual(len(all_cells), sum(size for _, size in FLEET))
+            # Every cell is inside the ocean.
+            for r, c in all_cells:
+                self.assertTrue(0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE)
+            # Every ship is a straight contiguous line.
+            for ship in ships:
+                rows = {r for r, _ in ship.cells}
+                cols = {c for _, c in ship.cells}
+                self.assertTrue(len(rows) == 1 or len(cols) == 1)
+
+
+class ComputerAITests(unittest.TestCase):
+    def test_never_repeats_and_covers_board(self):
+        ai = ComputerAI(random.Random(7))
+        shots = [ai.next_shot() for _ in range(BOARD_SIZE * BOARD_SIZE)]
+        self.assertEqual(len(set(shots)), BOARD_SIZE * BOARD_SIZE)
+        for r, c in shots:
+            self.assertTrue(0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE)
+
+    def test_pursues_adjacent_cells_after_hit(self):
+        ai = ComputerAI(random.Random(1))
+        first = ai.next_shot()
+        ai.register_result(first, HIT)
+        neighbors = {
+            (first[0] + dr, first[1] + dc)
+            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1))
+            if 0 <= first[0] + dr < BOARD_SIZE and 0 <= first[1] + dc < BOARD_SIZE
+        }
+        # While hunting, shots must stay adjacent to the hit until the
+        # ship is reported sunk.
+        for _ in range(len(neighbors)):
+            shot = ai.next_shot()
+            self.assertIn(shot, neighbors)
+            neighbors.discard(shot)
+            ai.register_result(shot, MISS)
+
+    def test_sunk_clears_hunt_mode(self):
+        ai = ComputerAI(random.Random(2))
+        ai.register_result((5, 5), SUNK, ship=None)
+        shot = ai.next_shot()
+        # No pending hunt candidates: the shot is unrestricted hunting.
+        self.assertNotEqual(shot, None)
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,34 @@+"""Contract tests: every module in main.GAME_REGISTRY follows GAME_SPEC.md."""
+from __future__ import annotations
+
+import importlib
+import unittest
+
+import main
+
+
+class GameRegistryContractTest(unittest.TestCase):
+    def test_registered_modules_expose_the_spec_api(self):
+        self.assertTrue(main.GAME_REGISTRY, "registry must not be empty")
+        for module_name in main.GAME_REGISTRY:
+            module = importlib.import_module(module_name)
+            with self.subTest(module=module_name):
+                self.assertIsInstance(getattr(module, "NAME", None), str)
+                self.assertTrue(module.NAME.strip())
+                self.assertIsInstance(getattr(module, "KEY", None), str)
+                self.assertTrue(module.KEY.strip())
+                self.assertIsInstance(getattr(module, "DESCRIPTION", None), str)
+                self.assertTrue(module.DESCRIPTION.strip())
+                game = getattr(module, "Game", None)
+                self.assertIsNotNone(game, "module must define class Game")
+                self.assertTrue(callable(getattr(game, "run", None)),
+                                "Game must define a callable run()")
+
+    def test_keys_are_unique(self):
+        keys = [importlib.import_module(m).KEY for m in main.GAME_REGISTRY]
+        self.assertEqual(len(keys), len(set(keys)),
+                         f"duplicated KEYs: {sorted(keys)}")
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,271 @@+"""Headless unit tests for games.reversi (pure logic only)."""
+from __future__ import annotations
+
+import unittest
+
+from games.reversi import (
+    BLACK,
+    EMPTY,
+    SIZE,
+    WHITE,
+    Board,
+    ComputerPlayer,
+    GameState,
+    coordinate_name,
+    opponent,
+    parse_coordinate,
+)
+
+
+def empty_board() -> Board:
+    """A board with every square cleared, for custom setups."""
+    board = Board()
+    board.grid = [[EMPTY] * SIZE for _ in range(SIZE)]
+    return board
+
+
+class BoardSetupTests(unittest.TestCase):
+    def test_standard_starting_position(self):
+        board = Board()
+        self.assertEqual(board.get(3, 3), WHITE)
+        self.assertEqual(board.get(3, 4), BLACK)
+        self.assertEqual(board.get(4, 3), BLACK)
+        self.assertEqual(board.get(4, 4), WHITE)
+
+    def test_initial_score_is_two_each(self):
+        counts = Board().count()
+        self.assertEqual(counts[BLACK], 2)
+        self.assertEqual(counts[WHITE], 2)
+
+    def test_opponent(self):
+        self.assertEqual(opponent(BLACK), WHITE)
+        self.assertEqual(opponent(WHITE), BLACK)
+
+
+class LegalMoveTests(unittest.TestCase):
+    def test_first_player_has_exactly_four_moves(self):
+        moves = Board().legal_moves(BLACK)
+        self.assertEqual(len(moves), 4)
+        self.assertEqual(
+            set(moves), {(2, 3), (3, 2), (4, 5), (5, 4)})
+
+    def test_second_player_has_exactly_four_moves(self):
+        self.assertEqual(len(Board().legal_moves(WHITE)), 4)
+
+    def test_occupied_square_is_not_legal(self):
+        board = Board()
+        self.assertFalse(board.is_legal(BLACK, 3, 3))
+
+    def test_square_without_flank_is_not_legal(self):
+        board = Board()
+        self.assertFalse(board.is_legal(BLACK, 0, 0))
+        self.assertEqual(board.flips_for(BLACK, 0, 0), [])
+
+    def test_move_must_end_on_own_disc(self):
+        # A lone enemy disc with no own disc beyond it cannot be flanked.
+        board = empty_board()
+        board.grid[0][0] = WHITE
+        board.grid[0][1] = EMPTY
+        self.assertFalse(board.is_legal(BLACK, 0, 1))
+
+    def test_has_move_matches_legal_moves(self):
+        board = Board()
+        self.assertTrue(board.has_move(BLACK))
+        cleared = empty_board()
+        cleared.grid[4][4] = WHITE  # no black disc anywhere
+        self.assertFalse(cleared.has_move(BLACK))
+
+
+class FlipTests(unittest.TestCase):
+    def test_opening_move_flips_one_disc(self):
+        board = Board()
+        flipped = board.apply(BLACK, 2, 3)
+        self.assertEqual(flipped, [(3, 3)])
+        self.assertEqual(board.get(2, 3), BLACK)
+        self.assertEqual(board.get(3, 3), BLACK)
+        counts = board.count()
+        self.assertEqual(counts[BLACK], 4)
+        self.assertEqual(counts[WHITE], 1)
+
+    def test_illegal_move_raises_and_changes_nothing(self):
+        board = Board()
+        before = [row[:] for row in board.grid]
+        with self.assertRaises(ValueError):
+            board.apply(BLACK, 0, 0)
+        self.assertEqual(board.grid, before)
+
+    def test_multi_direction_flip_all_eight_directions(self):
+        board = empty_board()
+        # Place white discs around (3, 3), each line anchored by black.
+        anchors = [(1, 1), (1, 3), (1, 5), (3, 1), (3, 5),
+                   (5, 1), (5, 3), (5, 5)]
+        targets = [(2, 2), (2, 3), (2, 4), (3, 2), (3, 4),
+                   (4, 2), (4, 3), (4, 4)]
+        for row, col in anchors:
+            board.grid[row][col] = BLACK
+        for row, col in targets:
+            board.grid[row][col] = WHITE
+        flipped = board.flips_for(BLACK, 3, 3)
+        self.assertEqual(set(flipped), set(targets))
+        board.apply(BLACK, 3, 3)
+        for row, col in targets:
+            self.assertEqual(board.get(row, col), BLACK)
+        # Anchors were already black and are untouched.
+        for row, col in anchors:
+            self.assertEqual(board.get(row, col), BLACK)
+
+    def test_long_line_flip(self):
+        board = empty_board()
+        for col in (1, 2, 3, 4, 5):
+            board.grid[0][col] = WHITE
+        board.grid[0][6] = BLACK
+        flipped = board.apply(BLACK, 0, 0)
+        self.assertEqual(set(flipped), {(0, c) for c in range(1, 6)})
+        self.assertEqual(board.count()[BLACK], 7)
+
+    def test_copy_is_independent(self):
+        board = Board()
+        clone = board.copy()
+        clone.apply(BLACK, 2, 3)
+        self.assertEqual(board.get(2, 3), EMPTY)
+        self.assertEqual(board.count()[BLACK], 2)
+
+
+class PassLogicTests(unittest.TestCase):
+    def _pass_setup(self) -> GameState:
+        # Black to move, but only white has a legal move.
+        state = GameState()
+        state.board.grid = [[EMPTY] * SIZE for _ in range(SIZE)]
+        state.board.grid[0][0] = WHITE
+        state.board.grid[0][1] = BLACK
+        state.board.grid[1][0] = WHITE
+        state.board.grid[1][1] = BLACK
+        state.current = BLACK
+        return state
+
+    def test_must_pass_when_no_legal_move(self):
+        state = self._pass_setup()
+        self.assertEqual(state.legal_moves(), [])
+        self.assertTrue(state.must_pass())
+        self.assertFalse(state.game_over())
+
+    def test_pass_turn_hands_over_and_play_continues(self):
+        state = self._pass_setup()
+        state.pass_turn()
+        self.assertEqual(state.current, WHITE)
+        self.assertFalse(state.must_pass())
+        # White can now flank either black disc, plus a diagonal.
+        self.assertEqual(set(state.legal_moves()), {(0, 2), (1, 2), (2, 2)})
+        state.play(0, 2)
+        self.assertEqual(state.board.get(0, 1), WHITE)
+
+    def test_normal_position_does_not_require_pass(self):
+        state = GameState()
+        self.assertFalse(state.must_pass())
+
+
+class GameOverTests(unittest.TestCase):
+    def test_fresh_game_is_not_over(self):
+        self.assertFalse(GameState().game_over())
+
+    def test_game_over_when_neither_player_can_move(self):
+        state = GameState()
+        # Only white discs and no possible flank for either side.
+        for row in range(SIZE):
+            for col in range(SIZE):
+                state.board.grid[row][col] = WHITE
+        state.board.grid[7][7] = EMPTY
+        state.board.grid[7][6] = BLACK
+        state.board.grid[7][5] = EMPTY
+        self.assertFalse(state.board.has_move(BLACK))
+        self.assertFalse(state.board.has_move(WHITE))
+        self.assertTrue(state.game_over())
+
+    def test_end_of_game_scoring_black_wins(self):
+        state = GameState()
+        for row in range(SIZE):
+            for col in range(SIZE):
+                state.board.grid[row][col] = BLACK
+        state.board.grid[0][0] = WHITE
+        state.board.grid[0][1] = WHITE
+        state.board.grid[0][2] = EMPTY
+        state.board.grid[0][3] = EMPTY
+        self.assertTrue(state.game_over())
+        self.assertEqual(state.score(), (60, 2))
+        self.assertEqual(state.winner(), BLACK)
+
+    def test_end_of_game_draw(self):
+        state = GameState()
+        for row in range(SIZE // 2):
+            for col in range(SIZE):
+                state.board.grid[row][col] = BLACK
+        for row in range(SIZE // 2, SIZE):
+            for col in range(SIZE):
+                state.board.grid[row][col] = WHITE
+        self.assertTrue(state.game_over())
+        self.assertEqual(state.score(), (32, 32))
+        self.assertIsNone(state.winner())
+
+    def test_turn_alternates_after_play(self):
+        state = GameState()
+        state.play(2, 3)
+        self.assertEqual(state.current, WHITE)
+        state.play(2, 2)
+        self.assertEqual(state.current, BLACK)
+
+
+class ComputerPlayerTests(unittest.TestCase):
+    def test_choose_move_returns_a_legal_move(self):
+        board = Board()
+        computer = ComputerPlayer(BLACK)
+        move = computer.choose_move(board)
+        self.assertIn(move, board.legal_moves(BLACK))
+
+    def test_choose_move_none_when_no_moves(self):
+        board = empty_board()
+        board.grid[0][0] = WHITE  # black has no disc to anchor a flank
+        self.assertIsNone(ComputerPlayer(BLACK).choose_move(board))
+
+    def test_ai_prefers_corner(self):
+        board = empty_board()
+        board.grid[0][1] = WHITE
+        board.grid[0][2] = BLACK
+        board.grid[3][3] = WHITE
+        board.grid[3][4] = BLACK
+        move = ComputerPlayer(BLACK).choose_move(board)
+        self.assertEqual(move, (0, 0))
+
+    def test_ai_avoids_square_next_to_empty_corner(self):
+        board = empty_board()
+        board.grid[0][0] = EMPTY  # corner still empty
+        # Two legal moves: (1, 1) next to the corner, (5, 5) far away.
+        board.grid[1][2] = WHITE
+        board.grid[1][3] = BLACK
+        board.grid[5][6] = WHITE
+        board.grid[5][7] = BLACK
+        computer = ComputerPlayer(BLACK)
+        move = computer.choose_move(board)
+        self.assertNotEqual(move, (1, 1))
+        self.assertEqual(move, (5, 5))
+
+
+class CoordinateTests(unittest.TestCase):
+    def test_parse_valid_coordinate(self):
+        self.assertEqual(parse_coordinate("d3"), (2, 3))
+        self.assertEqual(parse_coordinate("D3"), (2, 3))
+        self.assertEqual(parse_coordinate(" a1 "), (0, 0))
+        self.assertEqual(parse_coordinate("h8"), (7, 7))
+
+    def test_parse_rejects_garbage(self):
+        for bad in ("", "d", "d33", "3d", "dd", "i1", "a9", "a0", "q"):
+            self.assertIsNone(parse_coordinate(bad), bad)
+
+    def test_coordinate_name_round_trip(self):
+        for row in range(SIZE):
+            for col in range(SIZE):
+                self.assertEqual(
+                    parse_coordinate(coordinate_name(row, col)), (row, col))
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,157 @@+"""Tests for core.storage.Store (JSON statistics persistence)."""
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from core.storage import Store
+
+
+class StoreTestCase(unittest.TestCase):
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(self._tmp.cleanup)
+        self.path = Path(self._tmp.name) / "stats.json"
+
+    def write(self, data) -> None:
+        if isinstance(data, str):
+            self.path.write_text(data, encoding="utf-8")
+        else:
+            self.path.write_text(json.dumps(data), encoding="utf-8")
+
+
+class LoadTest(StoreTestCase):
+    def test_missing_file_starts_empty(self):
+        store = Store(self.path)
+        self.assertEqual(store.data, {"games": {}, "records": {}})
+
+    def test_valid_json_is_loaded(self):
+        self.write({"games": {"checkers": {"played": 3, "won": 2,
+                                           "lost": 1, "drawn": 0}},
+                    "records": {"2048_high_score": 1024}})
+        store = Store(self.path)
+        self.assertEqual(store.game_stats("checkers"),
+                         {"played": 3, "won": 2, "lost": 1, "drawn": 0})
+        self.assertEqual(store.get_record("2048_high_score"), 1024)
+
+    def test_corrupted_json_starts_empty(self):
+        self.write("{not valid json")
+        store = Store(self.path)
+        self.assertEqual(store.data, {"games": {}, "records": {}})
+
+    def test_non_dict_json_starts_empty(self):
+        self.write(["games", "records"])
+        store = Store(self.path)
+        self.assertEqual(store.data, {"games": {}, "records": {}})
+
+    def test_missing_fields_default_to_zero(self):
+        self.write({"games": {"checkers": {"played": 5}}})
+        store = Store(self.path)
+        self.assertEqual(store.game_stats("checkers"),
+                         {"played": 5, "won": 0, "lost": 0, "drawn": 0})
+
+    def test_unexpected_fields_are_ignored(self):
+        self.write({"games": {"checkers": {"played": 5, "cheated": 99}},
+                    "extra_top_level": True})
+        store = Store(self.path)
+        self.assertNotIn("cheated", store.game_stats("checkers"))
+        self.assertNotIn("extra_top_level", store.data)
+
+    def test_non_int_fields_are_ignored(self):
+        self.write({"games": {"checkers": {"played": "five", "won": 2}}})
+        store = Store(self.path)
+        self.assertEqual(store.game_stats("checkers"),
+                         {"played": 0, "won": 2, "lost": 0, "drawn": 0})
+
+    def test_bool_fields_are_ignored(self):
+        # bool is a subclass of int in Python, but JSON true/false is
+        # not a statistic.
+        self.write({"games": {"checkers": {"played": True, "won": 2}}})
+        store = Store(self.path)
+        self.assertEqual(store.game_stats("checkers"),
+                         {"played": 0, "won": 2, "lost": 0, "drawn": 0})
+
+    def test_non_dict_game_entry_is_ignored(self):
+        self.write({"games": {"checkers": "oops"}})
+        store = Store(self.path)
+        self.assertNotIn("checkers", store.data["games"])
+
+    def test_unknown_game_keys_are_kept(self):
+        # The store is game-agnostic: keys come from the game modules.
+        self.write({"games": {"some_future_game": {"played": 1}}})
+        store = Store(self.path)
+        self.assertEqual(store.game_stats("some_future_game")["played"], 1)
+
+    def test_non_dict_records_are_ignored(self):
+        self.write({"records": "oops"})
+        store = Store(self.path)
+        self.assertEqual(store.data["records"], {})
+
+
+class UpdateTest(StoreTestCase):
+    def test_record_result_counts_each_outcome(self):
+        store = Store(self.path)
+        store.record_result("gomoku", "win")
+        store.record_result("gomoku", "loss")
+        store.record_result("gomoku", "draw")
+        self.assertEqual(store.game_stats("gomoku"),
+                         {"played": 3, "won": 1, "lost": 1, "drawn": 1})
+
+    def test_record_result_rejects_unknown_outcome(self):
+        store = Store(self.path)
+        store.record_result("gomoku", "cheated")
+        self.assertEqual(store.game_stats("gomoku"), {})
+
+    def test_record_result_preserves_existing_data(self):
+        self.write({"games": {"gomoku": {"played": 7, "won": 4,
+                                         "lost": 2, "drawn": 1}}})
+        store = Store(self.path)
+        store.record_result("gomoku", "win")
+        self.assertEqual(store.game_stats("gomoku"),
+                         {"played": 8, "won": 5, "lost": 2, "drawn": 1})
+
+    def test_improve_record_higher_is_better(self):
+        store = Store(self.path)
+        self.assertTrue(store.improve_record("best", 100))
+        self.assertFalse(store.improve_record("best", 50))
+        self.assertTrue(store.improve_record("best", 150))
+        self.assertEqual(store.get_record("best"), 150)
+
+    def test_improve_record_lower_is_better(self):
+        store = Store(self.path)
+        self.assertTrue(store.improve_record("time", 30, lower_is_better=True))
+        self.assertFalse(store.improve_record("time", 45, lower_is_better=True))
+        self.assertTrue(store.improve_record("time", 20, lower_is_better=True))
+        self.assertEqual(store.get_record("time"), 20)
+
+
+class PersistenceTest(StoreTestCase):
+    def test_save_persists_and_reloads(self):
+        store = Store(self.path)
+        store.record_result("mancala", "win")
+        store.set_record("fifteen_best_moves", 42)
+        reloaded = Store(self.path)
+        self.assertEqual(reloaded.game_stats("mancala"),
+                         {"played": 1, "won": 1, "lost": 0, "drawn": 0})
+        self.assertEqual(reloaded.get_record("fifteen_best_moves"), 42)
+
+    def test_saved_file_is_valid_json(self):
+        store = Store(self.path)
+        store.record_result("fifteen", "win")
+        data = json.loads(self.path.read_text(encoding="utf-8"))
+        self.assertEqual(data["games"]["fifteen"]["won"], 1)
+
+    def test_save_leaves_no_temp_file_behind(self):
+        store = Store(self.path)
+        store.save()
+        self.assertFalse((self.path.parent / "stats.tmp").exists())
+
+    def test_save_never_raises_on_io_error(self):
+        store = Store(self.path.parent)  # a directory: writing fails
+        store.record_result("gomoku", "win")  # must not raise
+
+
+if __name__ == "__main__":
+    unittest.main()
@@ -0,0 +1,164 @@+"""Headless unit tests for games.tic_tac_toe pure logic.
+
+These tests never touch the real terminal: they drive Board,
+parse_coordinate and choose_computer_move directly.
+"""
+from __future__ import annotations
+
+import unittest
+
+from games.tic_tac_toe import (
+    Board,
+    EMPTY,
+    MARK_O,
+    MARK_X,
+    SIZE,
+    choose_computer_move,
+    parse_coordinate,
+)
+
+
+class TestParseCoordinate(unittest.TestCase):
+    def test_valid_coordinates(self):
+        self.assertEqual(parse_coordinate("a1"), (0, 0))
+        self.assertEqual(parse_coordinate("b2"), (1, 1))
+        self.assertEqual(parse_coordinate("c3"), (2, 2))
+
+    def test_case_insensitive_and_whitespace(self):
+        self.assertEqual(parse_coordinate("B2"), (1, 1))
+        self.assertEqual(parse_coordinate("  C1 "), (0, 2))
+
+    def test_invalid_coordinates(self):
+        for bad in ("", "a", "a1b", "d1", "a4", "1a", "b0", "xy", "!!"):
+            self.assertIsNone(parse_coordinate(bad), bad)
+
+
+class TestBoardBasics(unittest.TestCase):
+    def test_place_and_get(self):
+        board = Board()
+        self.assertTrue(board.place(MARK_X, 1, 1))
+        self.assertEqual(board.get(1, 1), MARK_X)
+        self.assertFalse(board.is_empty(1, 1))
+
+    def test_place_on_occupied_square_fails(self):
+        board = Board()
+        board.place(MARK_X, 0, 0)
+        self.assertFalse(board.place(MARK_O, 0, 0))
+        self.assertEqual(board.get(0, 0), MARK_X)
+
+    def test_full_board_detection(self):
+        board = Board()
+        self.assertFalse(board.is_full())
+        for r in range(SIZE):
+            for c in range(SIZE):
+                board.place(MARK_X, r, c)
+        self.assertTrue(board.is_full())
+        self.assertEqual(board.empty_cells(), [])
+
+
+def _fill(board, moves):
+    """Play (mark, row, col) triples, asserting each move is legal."""
+    for mark, row, col in moves:
+        assert board.place(mark, row, col), (mark, row, col)
+
+
+class TestWinDetection(unittest.TestCase):
+    def test_horizontal_win(self):
+        for row in range(SIZE):
+            board = Board()
+            _fill(board, [(MARK_X, row, 0), (MARK_X, row, 1), (MARK_X, row, 2)])
+            self.assertEqual(board.winner(), MARK_X)
+            self.assertTrue(board.is_game_over())
+            line, mark = board.winning_line()
+            self.assertEqual(mark, MARK_X)
+            self.assertEqual(set(line), {(row, 0), (row, 1), (row, 2)})
+
+    def test_vertical_win(self):
+        for col in range(SIZE):
+            board = Board()
+            _fill(board, [(MARK_O, 0, col), (MARK_O, 1, col), (MARK_O, 2, col)])
+            self.assertEqual(board.winner(), MARK_O)
+            self.assertTrue(board.is_game_over())
+
+    def test_diagonal_win(self):
+        board = Board()
+        _fill(board, [(MARK_X, 0, 0), (MARK_X, 1, 1), (MARK_X, 2, 2)])
+        self.assertEqual(board.winner(), MARK_X)
+
+    def test_anti_diagonal_win(self):
+        board = Board()
+        _fill(board, [(MARK_O, 0, 2), (MARK_O, 1, 1), (MARK_O, 2, 0)])
+        self.assertEqual(board.winner(), MARK_O)
+
+    def test_draw_detection(self):
+        # X O X
+        # X X O
+        # O X O  -> full board, no winner
+        board = Board()
+        _fill(board, [
+            (MARK_X, 0, 0), (MARK_O, 0, 1), (MARK_X, 0, 2),
+            (MARK_X, 1, 0), (MARK_X, 1, 1), (MARK_O, 1, 2),
+            (MARK_O, 2, 0), (MARK_X, 2, 1), (MARK_O, 2, 2),
+        ])
+        self.assertTrue(board.is_full())
+        self.assertIsNone(board.winner())
+        self.assertTrue(board.is_game_over())
+
+    def test_no_false_positive(self):
+        board = Board()
+        _fill(board, [(MARK_X, 0, 0), (MARK_O, 0, 1)])
+        self.assertIsNone(board.winner())
+        self.assertFalse(board.is_game_over())
+
+
+class TestComputerMove(unittest.TestCase):
+    def test_takes_winning_move(self):
+        # O O .
+        # X X .
+        # . . .   -> O completes row 0 at (0, 2)
+        board = Board()
+        _fill(board, [
+            (MARK_O, 0, 0), (MARK_O, 0, 1),
+            (MARK_X, 1, 0), (MARK_X, 1, 1),
+        ])
+        self.assertEqual(choose_computer_move(board, MARK_O), (0, 2))
+
+    def test_blocks_opponent_win(self):
+        # X X .
+        # . O .
+        # . . .   -> O must block at (0, 2)
+        board = Board()
+        _fill(board, [
+            (MARK_X, 0, 0), (MARK_X, 0, 1),
+            (MARK_O, 1, 1),
+        ])
+        self.assertEqual(choose_computer_move(board, MARK_O), (0, 2))
+
+    def test_prefers_center(self):
+        board = Board()
+        self.assertEqual(choose_computer_move(board, MARK_X), (1, 1))
+
+    def test_prefers_corner_when_center_taken(self):
+        board = Board()
+        board.place(MARK_X, 1, 1)
+        row, col = choose_computer_move(board, MARK_O)
+        self.assertIn(row, (0, 2))
+        self.assertIn(col, (0, 2))
+
+    def test_move_is_always_legal(self):
+        board = Board()
+        _fill(board, [(MARK_X, 0, 0), (MARK_O, 1, 1), (MARK_X, 2, 2)])
+        for _ in range(20):
+            row, col = choose_computer_move(board, MARK_O)
+            self.assertEqual(board.get(row, col), EMPTY)
+
+    def test_full_board_returns_none(self):
+        board = Board()
+        for r in range(SIZE):
+            for c in range(SIZE):
+                board.place(MARK_X, r, c)
+        self.assertIsNone(choose_computer_move(board, MARK_O))
+
+
+if __name__ == "__main__":
+    unittest.main()