AlphaZero Framework + Tic-Tac-Toe (Sub-project 1)

date:2026-05-15 status:Approved (brainstorming → implementation) sub project:1 target:Game-agnostic AlphaZero framework, validated end-to-end on Tic-Tac-Toe

⚠ Design correction (post-implementation): This spec describes the original design that included AlphaGo Zero-style arena gating. We removed arena gating during implementation after it caused training stagnation on TTT (best_net froze at iter 5; agent learned a defeatist O policy; lost 100% of O-side games against perfect play). The Trainer now matches the AlphaZero (2017) paper: _promote_candidate_to_best is called unconditionally after each iteration. See concepts.html concept card #3 for the visual story, or Sub-project 2 spec §6 "Lessons from Sub-project 1" for the technical post-mortem. The arena-related sections of this spec are preserved as historical record of the original design; the current code does not implement them.

1. Context

This is the first of a multi-stage project to build an AlphaZero-style chess engine from scratch. The user's primary goal is learning: build the full AlphaZero pipeline (board representation → neural network → MCTS → self-play → training loop), understand each component deeply, and let the achieved ELO emerge from the work — rather than chasing a target ELO via shortcuts.

The project is decomposed into five sub-projects. This document specifies Sub-project 1.

# Scope Target Estimate
1 AlphaZero framework + Tic-Tac-Toe (this spec) Pipeline validated; zero losses vs perfect TTT solver 2–3 weeks
2 Connect 4 plugged into the same framework Beats minimax-depth-8 reliably 1–2 weeks
3 Chess (python-chess wrapper, scaled net, core training) 1500–1800 ELO from scratch 4–8 weeks
4 Productionize: UCI protocol, cloud training, longer runs 1800–2300 ELO open-ended
5 Hybrid (supervised pre-training, distillation) 2500+ ELO open-ended

Sub-project 1 produces the reusable framework. Everything downstream depends on it being correct.


2. Goals

3. Non-goals

These are explicitly out of scope for Sub-project 1; some are picked up in later sub-projects.


4. Architecture

The framework is structured as four orthogonal layers with strict boundaries. The most important design property: MCTS does not import the neural network, and the neural network does not import MCTS. They communicate through a function signature (eval_fn). This makes each layer independently testable and means moving to Connect 4 or chess in later sub-projects requires changing only the Game class — every other layer is reusable.

┌─────────────────────────────────────────────────────────────┐
│  L4. Training orchestration (Trainer)                        │
│      self-play → buffer → train → arena, looping             │
└──┬───────────┬───────────┬───────────┬──────────────────────┘
   │           │           │           │
   ▼           ▼           ▼           ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│SelfPlay │ │ Replay  │ │ Arena   │ │ Eval vs │
│ Worker  │ │ Buffer  │ │         │ │ Solver  │
└────┬────┘ └─────────┘ └────┬────┘ └─────────┘
     │                       │
     ▼                       ▼
┌────────────────────┐ ┌────────────────────────┐
│ L3. MCTS           │ │ L2. Game (rules,       │
│   PUCT, expand,    │ │     encoding,          │
│   backup           │ │     symmetries)        │
└──────────┬─────────┘ └────────────────────────┘
           │ eval_fn(state) → (priors, value)
           ▼
┌──────────────────────────────────────────────────────────────┐
│  L1. AlphaZeroNet — PyTorch ResNet with policy + value heads │
│      Pure forward pass; knows nothing about MCTS or training │
└──────────────────────────────────────────────────────────────┘
Layer Component Imports Does NOT know about
L1 AlphaZeroNet torch, Game.input_shape, Game.action_size MCTS, self-play, training loop
L2 Game (and TicTacToe) nothing NN, MCTS, training
L3 MCTS Game, an eval_fn callable NN class, training, self-play
L4 SelfPlayWorker, ReplayBuffer, Arena, Trainer everything above (top-level)

This table is the design's load-bearing claim. If we ever find ourselves importing MCTS inside the NN or Game inside the trainer's train_step, we've broken an abstraction.


5. Components

5.1 Game interface (+ TicTacToe implementation)

Role: encapsulates everything game-specific so MCTS / NN / training don't know which game they're operating on.

class Game(ABC):
    input_shape: tuple[int, ...]   # e.g. (3, 3, 3) for TTT — (channels, H, W)
    action_size: int                # e.g. 9 for TTT

    def initial_state() -> State
    def current_player(state) -> int                     # +1 or -1
    def legal_actions_mask(state) -> np.ndarray[bool]    # shape (action_size,)
    def apply(state, action: int) -> State
    def terminal_value(state) -> Optional[float]         # None if not terminal; else ±1 / 0 from current player's POV
    def encode(state) -> np.ndarray                      # shape == input_shape
    def canonical_state(state) -> State                  # rewrite state so current player is "+1"
    def symmetries(encoded_state, policy) -> list[tuple] # data augmentation; default returns [(s, π)]

Key decisions:

TTT-specific encoding: state is a 3×3 numpy array with +1 / −1 / 0. Encoding into a (3, 3, 3) tensor uses 3 planes: my-pieces, opponent-pieces, all-ones (the constant plane helps small CNNs reason about board edges — a standard trick).

5.2 AlphaZeroNet

Role: PyTorch module. Encoded state batch → (policy logits, value).

class AlphaZeroNet(nn.Module):
    def __init__(self, input_shape, action_size, n_blocks=4, n_channels=32): ...
    def forward(x) -> (policy_logits[B, action_size], value[B])

Structure: input conv → N residual blocks → two heads (policy + value).

A residual block is:

out = relu(x + bn2(conv2(relu(bn1(conv1(x))))))

Loss is the AlphaZero loss:

policy_loss = −sum(π · log_softmax(policy_logits))  # soft target — not one-hot
value_loss  = mse(value, z)
loss        = policy_loss + value_loss              # L2 regularization via AdamW(weight_decay)

Key decisions:

5.3 MCTS

Role: from a given state, run N simulations, return an improved policy (visit distribution).

class MCTS:
    def __init__(self, game, eval_fn, c_puct=1.5,
                 dirichlet_alpha=1.0, dirichlet_weight=0.25): ...
    def search(self, root_state, num_simulations: int,
               add_root_noise: bool) -> np.ndarray
        # returns shape (action_size,), summing to 1.0

eval_fn is (encoded_state) -> (priors, value). This is the boundary — MCTS does not know it is calling a neural network. In tests we mock it with hand-coded priors and verify MCTS behavior independently.

Tree node:

@dataclass
class Node:
    prior: float                      # P(s, a) of the action that led HERE
    visit_count: int                  # N
    value_sum: float                  # W (cumulative)
    children: dict[int, Node]         # action_index → child
    is_expanded: bool

    @property
    def Q(self): return self.value_sum / max(1, self.visit_count)

Key decisions:

U(s, a) = c_puct · P(s, a) · sqrt(sum_b N(s, b)) / (1 + N(s, a)) score = Q(s, a) + U(s, a)

priors[root] = 0.75 · priors[root] + 0.25 · Dirichlet(α)

α scales inversely with average legal moves: 1.0 for TTT, 0.3 for chess in the paper.

5.4 SelfPlayWorker

Role: play one full game using MCTS, emit training tuples.

def run_one_game(game, eval_fn, num_simulations,
                 temperature_threshold=6) -> list[(s, π, z)]:
    state = game.initial_state()
    mcts = MCTS(game, eval_fn, ...)
    history = []                       # list of (encoded_s, π, current_player)
    move = 0
    while game.terminal_value(state) is None:
        canon = game.canonical_state(state)
        π = mcts.search(canon, num_simulations, add_root_noise=True)
        action = (sample_from(π) if move < temperature_threshold
                  else π.argmax())
        history.append((game.encode(canon), π, game.current_player(state)))
        state = game.apply(state, action)
        move += 1

    # Game terminated — assign z per ply from THAT PLY'S MOVER's POV
    z_final = game.terminal_value(state)
    examples = []
    # for each (s, π, player_at_ply) in history:
    #     z_at_ply = z_final * sign_for(player_at_ply, final_player)
    return augment_with_symmetries(examples)

Key decisions:

5.5 ReplayBuffer

Role: fixed-size FIFO of training tuples.

class ReplayBuffer:
    def __init__(capacity: int)
    def add(examples: list[tuple]) -> None
    def sample(batch_size: int) -> (state_batch, policy_batch, value_batch)
    def __len__()

Backed by collections.deque(maxlen=capacity). No prioritization — uniform random sampling is what the AlphaZero paper specifies and what we'll use. Prioritized experience replay has been tried by replications with mixed results; not worth the complexity in v1.

5.6 Trainer (orchestration)

Role: the outer loop. Owns the network, the replay buffer, the schedule.

class Trainer:
    def run(self):
        for iteration in range(config.num_iterations):
            # 1. Self-play with current best NN
            for _ in range(config.games_per_iteration):
                examples = run_one_game(
                    game,
                    eval_fn=self.best_net,
                    num_simulations=config.num_simulations,
                )
                self.replay_buffer.add(examples)

            # 2. Train: K minibatches sampled from buffer
            if len(self.replay_buffer) >= config.min_buffer_size:
                for _ in range(config.training_steps_per_iteration):
                    batch = self.replay_buffer.sample(config.batch_size)
                    self._train_step(batch)

            # 3. Arena every N iterations
            if iteration % config.arena_interval == 0:
                win_rate = arena.play_match(
                    candidate=self.candidate_net,
                    opponent=self.best_net,
                    num_games=config.arena_games,
                )
                if win_rate >= config.arena_threshold:
                    self.best_net = copy.deepcopy(self.candidate_net)
                else:
                    self.candidate_net.load_state_dict(self.best_net.state_dict())

            # 4. Eval vs perfect solver every M iterations
            if iteration % config.eval_interval == 0:
                self._eval_vs_solver()

            # 5. Checkpoint
            torch.save({...}, f"checkpoints/iter_{iteration}.pt")

Key decisions:

5.7 Arena

def play_match(game, agent_a, agent_b, num_games=40) -> float:
    """Returns agent_a's win rate. Alternates colors to split the white-mover advantage."""

Agents are callables (state) -> action_int. The MCTS-with-current-NN agent is built by wrapping (network) -> agent: a function that for any given state runs MCTS with eval_fn = network, τ=0, no root noise, and returns argmax. Same MCTS class, different settings.

5.8 Perfect TTT solver (eval ground truth)

def solve_tictactoe(state, player) -> int:   # returns +1 / 0 / −1
    """Standard alpha-beta minimax. TTT has ~5478 states; runs in <1ms cached."""

Used in evaluation:

def eval_vs_solver(agent, num_games=200) -> dict:
    """Play agent (alternating colors) vs perfect-play opponent.
    Return: {wins, draws, losses}. Pass criterion: zero losses."""

TTT is a draw with perfect play. A correctly trained agent should always draw or win — never lose. Any loss = a bug somewhere in the pipeline.


6. Data flow

Three nested loops: the iteration (Trainer.run), one self-play game, one MCTS simulation.

6.1 One training iteration

ITERATION N
  best_net (frozen for this iteration)
        │
        ▼
  SelfPlay × 100 games  ──→  ReplayBuffer  ──→  train_step × 500  ──→  candidate_net
   (uses best_net)          (FIFO 50K)        (AdamW, lr=1e-3)
                                                                            │
                                                                            ▼
                            ┌── Arena (every K iter) ──────────────────────┐
                            │  candidate vs best, 40 games                  │
                            │  win_rate ≥ 0.55 → best_net := candidate     │
                            │  else            → candidate := best_net      │
                            └───────────────────────────────────────────────┘
                                                                            │
                            Periodic: eval_vs_solver, checkpoint            │
                                                                            ▼
                                                                  (next iteration)

Concrete TTT scale: 1 iteration ≈ 100 games × ~7 plies × 8 symmetries = ~5,600 training tuples. 500 minibatches × batch_size 64 = 32K samples seen per iteration. ~30–50 iterations to convergence. Estimated wall-time on M4 Pro: 15–45 minutes total for the full Sub-project 1 training run.

6.2 One self-play game

state₀ = game.initial_state()
loop while game.terminal_value(state) is None:
    canon  = game.canonical_state(state)
    π      = MCTS.search(canon, num_simulations=50, add_root_noise=True)
    action = sample(π)  if move < temperature_threshold
             argmax(π)  otherwise
    record (game.encode(canon), π, current_player(state))
    state  = game.apply(state, action)
end loop

z_final = game.terminal_value(state)
for each recorded ply, assign z from THAT PLY'S mover's POV
augment with symmetries  (TTT: 1 game → up to 8× training tuples)

6.3 One MCTS simulation

1. SELECT
   path = [root]; node = root
   while node.is_expanded and not terminal:
       a = argmax_a [ Q(s, a)  +  c_puct · P(s, a) · √ΣN / (1 + N(s, a)) ]
       node = node.children[a];  path.append(node)

2. EXPAND
   if terminal:
       leaf_value = game.terminal_value(node.state)
   else:
       priors, leaf_value = eval_fn(node.state)
       priors = mask_illegal(priors, legal_mask); renormalize
       for a in legal_actions:
           node.children[a] = Node(prior=priors[a], ...)
       node.is_expanded = True

3. BACKUP
   value = leaf_value
   for node in reversed(path):
       node.visit_count += 1
       node.value_sum   += value
       value = −value      # zero-sum sign flip

After 50 such simulations, π = visit_counts / sum at the root.

Dirichlet noise is applied to the root's children's priors exactly once, before the 50 simulations start, and only during self-play.

6.4 One training step

batch = replay_buffer.sample(64)
states, target_πs, target_zs = batch.tensors

policy_logits, values = candidate_net(states)
policy_loss = −(target_πs * log_softmax(policy_logits, dim=-1)).sum(dim=-1).mean()
value_loss  = F.mse_loss(values.squeeze(-1), target_zs)
loss = policy_loss + value_loss   # L2 reg via AdamW(weight_decay=1e-4)

optimizer.zero_grad()
loss.backward()
optimizer.step()

Roughly 10 lines of PyTorch. The complexity in this project lives in MCTS and self-play, not in training itself.


7. Critical invariants

Six rules that, if broken, cause silent training failure (training looks like it's running but the agent never improves, or improves and then collapses). These each become a named regression test.

# Invariant Failure mode if broken
1 Self-play uses best_net; training updates candidate_net. Self-play data is generated by a regressed network → buffer poisoned → next training step trains on bad data.
2 Dirichlet noise: root only, self-play only. Adding it during arena breaks the head-to-head signal. Adding it deep in the tree corrupts search.
3 Backup flips sign per ply (zero-sum). Q values don't represent current-player POV → PUCT picks the wrong moves → MCTS plays badly.
4 canonical_state applied consistently at every encode site. NN sees inconsistent inputs across positions → never converges.
5 Z is assigned per-ply from that ply's mover's POV, not the final-mover's POV. Value targets are systematically wrong; value head never learns.
6 Replay buffer is NOT reset between iterations. Training signal becomes too narrow per iteration; loss oscillates.

Some of these are reinforced by the type system / shape of the API (#3 is enforced by MCTS internals; #6 is enforced by the trainer never calling replay_buffer.clear()). Others require explicit tests (#1, #2, #4, #5).


8. Testing strategy

8.1 Philosophy

8.2 Test pyramid (by component)

Game / TicTacToe — pure-function tests; coverage target near 100%:

AlphaZeroNet — shape and convergence sanity:

MCTS — deterministic with mocks; coverage target near 100%:

SelfPlayWorker — focus on z-assignment:

ReplayBuffer — boring but covered:

Arena:

Trainer — light unit testing, heavy integration:

8.3 The 6-invariants map

Invariant Test
Self-play uses best_net test_selfplay_calls_best_net_not_candidate (install counting wrapper, assert best_net.forward called and candidate_net.forward not called)
Dirichlet noise scope test_dirichlet_noise_changes_root_priors_only + test_arena_disables_dirichlet_noise
Backup sign flip test_backup_flips_sign_per_ply
Canonical state enforced structurally by Game API; cross-check via test_encode_swaps_planes_when_player_changes
Z per-ply mover's POV The three test_z_assignment_* tests
Buffer not reset test_replay_buffer_not_reset_between_iterations

8.4 Kill criterion (the success bar)

@pytest.mark.slow
def test_e2e_tictactoe_reaches_zero_losses_vs_perfect_solver():
    """Train an AlphaZero agent on TTT. Play 200 games vs perfect minimax solver.
    Assert: 0 losses. (TTT is a draw with perfect play; agent must match.)"""

    trainer = Trainer(game=TicTacToe(), config=TTT_TRAINING_CONFIG)
    trainer.run()   # ~15 min on M4 Pro

    agent = make_mcts_agent(trainer.best_net, num_simulations=50, temperature=0.0)
    results = play_match(TicTacToe(), agent, PerfectTTTSolver(), num_games=200)
    assert results.losses == 0, f"Agent lost {results.losses} games to perfect play"

This test passing is the definition of Sub-project 1 success. Sub-project 2 (Connect 4) can begin only after this passes consistently.

8.5 Tooling


9. Hyperparameter defaults (Tic-Tac-Toe)

Frozen dataclass; values are starting points from the AlphaZero paper adapted to TTT scale.

@dataclass(frozen=True)
class TrainingConfig:
    # Network
    n_blocks: int = 4
    n_channels: int = 32

    # MCTS
    num_simulations: int = 50
    c_puct: float = 1.5
    dirichlet_alpha: float = 1.0
    dirichlet_weight: float = 0.25

    # Self-play
    games_per_iteration: int = 100
    temperature_threshold: int = 6

    # Training
    training_steps_per_iteration: int = 500
    batch_size: int = 64
    learning_rate: float = 1e-3
    weight_decay: float = 1e-4

    # Replay buffer
    replay_buffer_capacity: int = 50_000
    min_buffer_size: int = 5_000

    # Arena
    arena_interval: int = 5
    arena_games: int = 40
    arena_threshold: float = 0.55

    # Eval vs solver
    eval_interval: int = 5
    eval_games: int = 200

    # Schedule
    num_iterations: int = 50

    # Misc
    seed: int = 42
    device: str = "auto"
    checkpoint_dir: str = "checkpoints"
    log_dir: str = "runs"

The handful that actually matter:

Knob Why
num_simulations = 50 Most impactful. Too few (5–10) → MCTS adds little over raw NN. Too many → self-play crawls. 50 is enough on TTT's tiny tree.
c_puct = 1.5 Lower bound of the typical range; appropriate for small branching factor. Chess uses 2.5–4.
temperature_threshold = 6 For TTT this is nearly the whole game. Schedule mostly matters for longer games; kept for forward compatibility.
arena_threshold = 0.55 AlphaGo Zero value. AlphaZero (2017) dropped arena; we keep it for safety during development.
min_buffer_size = 5,000 Prevents training on a tiny correlated buffer in iteration 1. Buffer fills after ~100 games × ~50 (with symmetries) in iter 1.
weight_decay = 1e-4 AlphaZero paper value. Higher would underfit; lower would risk overfitting.

Loaded from configs/tictactoe.toml so the exact config is saved alongside checkpoints without code changes.


10. Project layout

alphazero/                            # repo root (~/git/alphazero)
├── pyproject.toml                    # PEP 621 metadata + dependencies
├── .gitignore                        # ✓ exists
├── concepts.html                     # ✓ exists — visual primer
├── tools/
│   └── render_spec.py                # md → styled html (this spec, future specs)
├── configs/
│   └── tictactoe.toml                # TrainingConfig for TTT
├── docs/superpowers/specs/
│   ├── 2026-05-15-alphazero-framework-tictactoe-design.md   ← this doc
│   └── 2026-05-15-alphazero-framework-tictactoe-design.html ← rendered
├── src/alphazero/                    # source package (src-layout)
│   ├── __init__.py
│   ├── config.py                     # TrainingConfig + load_config()
│   ├── games/
│   │   ├── __init__.py
│   │   ├── base.py                   # Game ABC
│   │   └── tictactoe.py
│   ├── solvers/
│   │   ├── __init__.py
│   │   └── tictactoe_solver.py       # perfect minimax (eval ground truth)
│   ├── network.py                    # AlphaZeroNet + ResidualBlock
│   ├── mcts.py                       # MCTS + Node
│   ├── selfplay.py                   # run_one_game + z-assignment
│   ├── replay_buffer.py
│   ├── arena.py                      # play_match
│   ├── trainer.py                    # Trainer.run — outer loop
│   ├── eval_vs_solver.py             # eval harness for TTT
│   └── cli.py                        # python -m alphazero ...
├── tests/
│   ├── __init__.py
│   ├── conftest.py                   # shared fixtures
│   ├── test_game_tictactoe.py
│   ├── test_network.py
│   ├── test_mcts.py
│   ├── test_selfplay.py
│   ├── test_replay_buffer.py
│   ├── test_arena.py
│   ├── test_trainer.py
│   ├── test_invariants.py            # the 5 named invariant tests
│   └── test_e2e_tictactoe.py         # kill criterion (@pytest.mark.slow)
├── checkpoints/                      # .gitignored
└── runs/                             # .gitignored

Notes:


11. Dependencies & tooling

[project]
name = "alphazero"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "torch>=2.2",         # PyTorch — MPS backend works on M4 Pro
    "numpy>=1.26",
    "tomli>=2.0",         # config loading (3.11+ has tomllib; tomli for compat)
    "tqdm>=4.66",         # progress bars during self-play
    "tensorboard>=2.16",  # local logging — no account / cloud needed
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-xdist>=3.5",
    "ruff>=0.4",          # lint + format
    "markdown>=3.10",     # for tools/render_spec.py
]

Choices:


12. CLI surface

python -m alphazero train --config configs/tictactoe.toml
python -m alphazero eval  --checkpoint checkpoints/iter_30.pt --opponent perfect_solver
python -m alphazero play  --checkpoint checkpoints/iter_30.pt --as x    # nice-to-have

The interactive play subcommand is a nice-to-have for sanity checks — useful for verifying the trained agent "feels right" (blocks obvious wins, takes obvious wins) beyond the numeric eval.


13. Success criterion (recap)

Sub-project 1 is done when:

pytest -m slow tests/test_e2e_tictactoe.py passes consistently: a trained agent loses zero games out of 200 against a perfect TTT minimax solver, alternating colors.

Only after this passes does Sub-project 2 (Connect 4) begin.


14. Out of scope (saved for future sub-projects)


15. References