Bot programming tutorialsMighty Professional
Build a Legend Bot ยท Machine learning

Self-Play Reinforcement Learning

A clone plays as well as its teacher and no better, because copying never rewards winning. Self-play does: the network plays itself thousands of times, and the moves that led to wins become more likely. This page builds the method, from rewards and advantages to PPO's clipped update and the opponents to train against, and trains the light-cycle project with it: past its teacher within a few hundred games, level with the alpha-beta bot at best.

Time~70 minLevelIntermediatePrereqsBehavior Cloning (the clone self-play starts from) and Measuring Bot Strength (intervals and promotion gates). Reinforcement Learning covers the basics on a small grid.StackC++20 & Rust ยท Python & PyTorch ยท browser demos
โ—‚ Build a Legend BotPhase 5 ยท Machine learningNext ยท Building the Trainer โ–ธ
What you'll be able to do by the end

Work out a move's return and advantage by hand, and its credit with GAE. Explain what a policy-gradient update does, why a critic makes it less noisy, and what PPO's clip stops. Choose the learner's opponents and catch drift before it wastes a day. Add reward shaping that leaves the goal alone, and spot a learner gaming it. Run the project's self-play trainer and read how many games it took to pass the teacher, where the gains flattened, and why it never passed the alpha-beta bot.

01Learn from results

The clone from Behavior Cloning plays its teacher, the Voronoi bot, level: 196 wins in 400 games, an interval of .441 to .539.[1] Training it longer won't help, because its loss only scores how closely it copies. To pass the teacher, the network needs a signal that says which games it won.

That signal is a reward, a number the learner receives after it acts: in the project, +1 for a win and โˆ’1 for a loss, once, after the game's last move. Sutton and Barto state the learner's goal as maximizing "the total amount of reward it receives."[2]

One complete game is an episode. A decision's return is the reward that follows it, discounted: every step of delay multiplies a reward by the discount factor ฮณ (gamma), a number just below 1.

Worked example

The project uses ฮณ = 0.995 per decision. Your bot makes 150 decisions and wins. The last decision's return is the full +1. The 100th decision is 50 steps from the end: 0.99550 = 0.778. The first is 149 steps away: 0.995149 = 0.474. A loss gives the same numbers with a minus sign.

Gt = rt+1 + ฮณ ร— rt+2 + ฮณ2 ร— rt+3 + โ€ฆ

In words: the next reward, plus ฮณ times the one after, plus ฮณยฒ times the one after that, to the end of the game. With one reward at the end, the return is the result times ฮณ raised to the number of decisions still to come.

The discount decides how far back a result's credit reaches. Set it too low and the early moves learn almost nothing: putibuzu, 20th in Back Track King (Summer Challenge 2026), wrote that his bot learned the early game badly because "gamma was too small, so the terminal-only reward didn't propagate back to the beginning."[3]

The hard part is that one result is shared by about 335 moves, counting both players'. The next two sections divide it among them.

02Make winning moves more likely

The network turns a position into a probability for each legal move (Neural Networks for Game Bots). After a game you know which moves it played and how the game ended, which suggests the simplest rule: make the moves of won games more likely and the moves of lost games less likely.

The network's rule for choosing moves is its policy, and training it this way is a policy gradient method; Ronald Williams named the family REINFORCE in 1992.[4] For each move played, backpropagation finds the direction in weight space that makes the move more likely, and the update steps along it, scaled by the outcome. Because every move shares one result, the updates are noisy: a won game's weak moves get rewarded too.

The fix is to compare the result with what was expected. The network's value head predicts the result from a position, from โˆ’1 for a sure loss to +1 for a sure win; used to judge moves, it is called the critic.[2] A move's advantage is how much better it turned out than the critic expected. Schulman et al. define it as the move's value minus the position's, which "measures whether or not the action is better or worse than the policy's default behavior."[5]

Worked example

The critic rated the position +0.2 before your move, and the game was won, +1: the advantage is 1 โˆ’ 0.2 = +0.8, a strong push up. Had it rated the position +0.9, winning was expected, and the advantage is only +0.1. Lose from +0.9 and the advantage is โˆ’1 โˆ’ 0.9 = โˆ’1.9: something after that move went badly wrong.

step = ฮฑ ร— A ร— โˆ‡ log ฯ€(move | position)

In words: find the direction that makes the played move more likely, and step along it by the learning rate times the advantage. A positive advantage pushes the move up, a negative one pushes it down, and an advantage near zero leaves it almost alone.

Subtracting the critic's estimate leaves the average update unchanged, because any baseline that doesn't depend on the move averages out to zero,[2] while it can make each update far less noisy (Reinforcement Learning shows why). When trainer code talks about advantages, it means this number.

03Give each move its share of the result

An advantage needs to know what happened after the move. You can wait for the end of the game and compare the result with the critic's estimate, or look just one move ahead. Each choice has a cost.

Waiting for the result is right on average but noisy, because everything that happened later lands on this move. Looking one move ahead uses the TD error: the reward right after the move, plus ฮณ times the critic's estimate of the next position, minus its estimate before the move, which is calm but only as right as the critic. (GAE), from Schulman et al., blends the two with a second number, ฮป (lambda), between 0 and 1.[5]

Worked example

Your bot makes three moves. The critic's values before them were 0.2, 0.6 and 0.1, and the game was lost, โˆ’1. Take ฮณ = 1 to keep the arithmetic short.

TD errors: 0.6 โˆ’ 0.2 = +0.4 (the first move improved the position), 0.1 โˆ’ 0.6 = โˆ’0.5 (the second was a blunder), and โˆ’1 โˆ’ 0.1 = โˆ’1.1 (the last move, then the loss).

ฮป = 0: the advantages are the TD errors, +0.4, โˆ’0.5 and โˆ’1.1. ฮป = 1: each move gets the result minus its value, โˆ’1.2, โˆ’1.6 and โˆ’1.1, so the good first move takes the blame for the loss. ฮป = 0.5, working back from the end: โˆ’1.1, then โˆ’0.5 + 0.5 ร— (โˆ’1.1) = โˆ’1.05, then +0.4 + 0.5 ร— (โˆ’1.05) = โˆ’0.125. Had the game been won, ฮป = 0.5 would give +0.375, โˆ’0.05 and +0.9, while ฮป = 1 would credit the blunder with +0.4.

ฮดt = rt+1 + ฮณ ร— V(next) โˆ’ V(now)
At = ฮดt + ฮณ ร— ฮป ร— At+1

In words: a move's credit is its own TD error plus ฮณฮป times the next move's credit, computed backwards from the end of the game. At ฮป = 0 only the move's own surprise counts; at ฮป = 1 every later surprise counts in full, and together they add up to the result minus the critic's estimate.

Schulman et al. describe the trade: ฮป = 1 is right on average whatever the critic's accuracy, "but it has high variance due to the sum of terms", while ฮป = 0 "induces bias" unless the critic is exact, "but it typically has much lower variance."[5] Values in between worked best in their experiments. The project uses ฮป = 0.95 with ฮณ = 0.995; reCurse, who won CodinGame's Fall Challenge 2023 with a network trained by PPO, used ฮณ = 0.99 and ฮป = 0.95.[6]

Credit for a move

Drag ฮป and compare the bars. The first panel is one made-up 12-turn game: the critic's value before each turn, a strong move on turn 4, a blunder on turn 8 and two possible endings. The second shows each move's credit if the game is won (teal) or lost (pink), at the project's ฮณ = 0.995. At ฮป = 0.95 the blunder gets +0.28 or โˆ’1.31; at ฮป = 0, โˆ’0.45 either way, its own TD error; at ฮป = 1, +0.43 or โˆ’1.53, because the whole ending lands on it. The gap between a move's two bars is the noise a lucky or unlucky ending adds. Step computes the credits backwards from turn 12, as the trainer does.

04Reuse each batch, but not too far

Playing games costs far more than learning from them: in the project's single-process run, 32 games took 39.2 seconds to play and 1.8 seconds to learn from.[1] So you want to use each batch more than once, but after a few updates the network isn't the one that played those games any more.

, Proximal Policy Optimization, from Schulman et al. in 2017, makes several passes over each batch, called epochs, while keeping each update close to (proximal to) the policy that played the games.[7] For every recorded move it computes a ratio: the move's probability under the network now, divided by its probability when the game was played. The ratio starts at 1. Once it has moved more than ฮต (epsilon) from 1 in the direction the advantage asks for, PPO stops rewarding the update for moving it further.

Worked example

A move had probability 0.30 when it was played; after some updates it has 0.40, a ratio of 1.33. With ฮต = 0.2 the clipped ratio is 1.2.

With advantage +1, PPO scores the move min(1.33 ร— 1, 1.2 ร— 1) = 1.2. The clipped term is the smaller one and doesn't grow with the probability, so this move stops pushing. With advantage โˆ’1 the score is min(โˆ’1.33, โˆ’1.2) = โˆ’1.33: the unclipped term is the smaller one, and the move keeps being pushed back down. The clip stops pushes that have gone far enough; it never stops a correction.

score = min(ratio ร— A, clip(ratio, 1 โˆ’ ฮต, 1 + ฮต) ร— A)

In words: score each move twice, with its ratio as it is and with the ratio held between 1 โˆ’ ฮต and 1 + ฮต, and keep the lower score; training raises the average. In the paper's words, "we only ignore the change in probability ratio when it would make the objective improve, and we include it when it makes the objective worse."[7]

Why PPO clips

Press Play. Each panel follows one move through repeated gradient steps on the same batch: across is the ratio, the solid line is PPO's score and the dashed line the unclipped ratio ร— advantage. In the shaded region the score is flat and the move stops pushing. With ฮต = 0.2 and an old probability of 0.30, the good move rises to 0.364 by step 4 and stops; the bad one falls to 0.236 by step 5. The hollow dots ignore the clip: by step 16 the good move reaches 0.604 and the bad one 0.149. Raise ฮต to allow bigger steps. Only this move's own push is modeled; in a real batch the other moves keep changing the network.

The paper suggested ฮต = 0.2, the best of three values on its robot-control tasks; its Atari runs used 0.1, shrunk to zero during training.[7] The project uses 0.2, three epochs and minibatches of 2,048 decisions, and adds the critic's squared error at half weight and an entropy bonus of 0.005, which Mnih et al. found "improved exploration by discouraging premature convergence to suboptimal deterministic policies."[8] The author's lab also ends a batch's epochs once the KL divergence between old and new move probabilities passes 0.05;[9] the project doesn't, and its updates moved the policy by a KL divergence of about 0.001 to 0.002.[1]

Published CodinGame bots use similar settings. JOiy, 13th in Back Track King (Legend), lists "512 envs, horizon 128, LR 7.5e-5, entropy 0.001, PPO epochs 3, 4 minibatches, clip 0.2".[10] Pduhard, 3rd: "512 games run at once in C++, both seats are played by the current network".[11]

A common question when you first read the code: what does optimizer.step() do? One PyTorch update is three calls: optimizer.zero_grad() clears the last update's slopes, loss.backward() computes the slope of the loss for every weight, and optimizer.step() moves every weight a step against its slope. Section 11 runs all three. The arrays they work on are tensors, arrays with any number of axes.

05Choose who the learner plays

In the learner's opponent is itself, so the opposition gets stronger exactly as fast as the learner does. That's how TD-Gammon learned backgammon: a network that, in Gerald Tesauro's words, "trains itself to be an evaluation function for the game of backgammon by playing against itself and learning from the outcome."[12] But the latest version of yourself is one opponent, and a narrow one.

Strategies can beat each other in a circle, as rock, paper and scissors do, and a learner that only trains to beat its last version can follow the circle forever. DeepMind's AlphaStar paper names the trap: self-play "may chase cycles (for example, where A defeats B, and B defeats C, but A loses to C) indefinitely without making progress." It also gives the classic fix. Fictitious self-play trains against "a uniform mixture of all previous policies", and in a two-player game where one side's gain is the other's loss, that mixture converges to a Nash equilibrium, a mix nothing beats on average.[13]

Beating yourself is not enough

Press Play. In this made-up game Rush beats Farm by 2 points, Farm beats Defend by 1 and Defend beats Rush by 1, and each round the learner becomes the exact best reply to its training opponent. Purple trains against its latest version only and goes Rush, Defend, Farm, Rush forever; something always beats it by 1 or 2 points a game. Teal trains against the pool of all its versions, whose mix closes in on 25% Rush, 25% Farm and 50% Defend, the mix nothing beats. The second panel plots exploitability, what the best counter-strategy wins per game: for the pool, 0.081 by round 36 and 0.066 by round 60. Rock, paper, scissors behaves the same with even stakes.

Real trainers mix the two. OpenAI Five played "the latest policy against itself for 80% of games" and older versions in the rest, to "avoid strategy collapse".[14] AlphaStar drew past opponents more often the more they beat the learner,[13] and so did Pduhard in Back Track King: "Half the games are against the network itself; the other half against old versions of it, kept in a pool, and the ones that beat us are drawn more often."[11] How big a pool you need depends on the game: in the games Czarnecki et al. studied, small populations kept cycling and larger ones converged.[15]

The project uses the author's lab's default mix.[9] A quarter of each batch's games are against the teacher, a bot that never changes; of the rest, half are the learner against itself and half against a snapshot from its pool. A game against itself gives the learner both seats, so those 37.5% of games supply about 54.5% of its decisions. Every 5 iterations the learner joins the pool, which keeps up to 32 snapshots, and each batch draws 4, favoring the ones that still beat it.

Worked example

A snapshot is drawn with weight (1 โˆ’ rate)ยฒ + 0.02, where rate = (points + 2) / (games + 4) is the learner's score against it, pulled toward one half while there are few games. Beaten 3 times in 10: rate = 5 / 14 = 0.357, weight = 0.643ยฒ + 0.02 = 0.433. Beaten 9 times in 10: rate = 11 / 14 = 0.786, weight = 0.214ยฒ + 0.02 = 0.066. The first snapshot is drawn 6.6 times as often, and the 0.02 keeps every snapshot in rotation.

06Better against itself, worse against everyone else

A pool keeps the learner from going round in circles, but every opponent in it is still the learner's own past, and the learner can find a habit that beats all of them and loses to everything else. Tesauro warned about this in 1995: in a deterministic game a self-play learner "might develop some horrible strategy that nevertheless gives self-consistent results when implemented against itself".[12]

From the author's lab

The lab's first Spring Challenge 2022 run, on October 17, 2026, trained a network cloned from the lab's rule-based bot against itself and its pool. By iteration 300 it scored .50 against that bot; then it drifted. It kept winning most of its games against its own snapshots while nearly every self-play game ran to the 220-turn limit and was decided on wild mana, the tie-break when both bases end with the same health. By iteration 750 it scored .26 against its iteration-300 champion and .12 against the rule-based bot.

The champion gate refused every promotion after iteration 300, so nothing worse was exported, but the run went on to iteration 800 before it was stopped: 500 wasted iterations. The run was rolled back and restarted with three guards: a quarter of the training games against fixed bots, a gate that also checks those bots, and an automatic rollback after three screens in a row below .45 against the champion.[9]

Drift has caught a contest winner. reCurse trained his Fall Challenge 2023 network only against its current self; in the contest's last days he "started seeing weird behaviors and many more losses than I was used to", and an earlier checkpoint "started winning more consistently again." Next time, he wrote, he would look into "nice league systems using AlphaStar as inspiration."[6] Training curves don't show drift either. JOiy: "A run can look healthy on every curve (stable entropy, a well-fit critic, regular promotions) and still be learning nothing that transfers to the ladder."[10]

The guard is opponents that don't learn, used everywhere a number is produced:

  1. Keep fixed bots in the training mix, as the project's quarter of games against its teacher does.
  2. Measure every checkpoint against fixed bots, on seeds no training game uses.
  3. Gate on fixed bots, and roll back a learner that keeps losing screens to its champion.
  4. Check how the games end. In the lab's run, game lengths and tie-break wins showed the drift.

A common question: it shouldn't take long to beat a Silver bot, so what's the win rate against the original? Ask that of every checkpoint. A score against the pool can rise while the bot gets worse; the score against a fixed bot is the one that falls when it does.

When a run stalls, as Spring Challenge 2022's did in Gold, check the lab's five causes there: this drift; an action the cloned teacher never used (Behavior Cloning); greedy play (section 10); heroes deciding independently (Neural Networks for Game Bots); and no opponent in the style of the league's best (Case Study: Five Legends by Search).

07Project step 3: a self-play trainer in one process

Project step 3 ยท Self-play

This step adds two files to train/ and one check. tron.py is the light-cycle engine again in plain Python, with the same rules, seeds, planes, move features and Voronoi bot as the Rust engine. ppo.py is the whole trainer in one Python process: batch, GAE, clipped update, pool and gate. checks/python_engine.py proves the port, as Referees and Simulators teaches: over 30 games it compares what the network sees and the Voronoi bot's move in both engines at every turn, and found 0 mismatches in 4,472 positions.[1]

ppo.py imports its match code from play.py, which loads the Rust library, so build the engine first, as in step 2. From train/:

python ../checks/python_engine.py 30                     # Python engine = Rust engine, turn by turn
python ppo.py --start ../runs/bc/clone.pt --out ../runs/ppo-single --minutes 40 \
    --games 32 --gate-every 5 --screen-seeds 16 --confirm-seeds 64
python evaluate.py ../runs/ppo-single/champion.pt --seeds 200   # the four yardsticks

One iteration of train():

  1. Play a batch. train() draws four pool snapshots, and rollout() gives each game an opponent (kind): the teacher, the learner itself or a snapshot. The learner and the snapshots sample their moves at temperature 1, and every learner decision is logged with its inputs, move, log-probability and the critic's value. This collecting phase is the rollout.
  2. Give each decision its credit: gae(), once per player per game.
  3. Update. ppo_update() makes three passes in minibatches of 2,048, with advantages rescaled to mean 0 and spread 1 in each, so a move counts as good or bad relative to the others.
  4. Snapshot and gate. Every 5 iterations the learner joins the pool. Every few iterations gate() plays it against the champion, both picking their most likely moves: a screen on the same held-out seeds, then, at .55 or better, a confirmation on fresh seeds whose 95% lower bound must be above .5 (Measuring Bot Strength). AlphaGo Zero gated its networks at 55% of 400 games; AlphaZero had no gate.[16][17]
Project step 3 (Python)
"""Self-play PPO in one Python process (page 14).

Each iteration plays a batch of complete games: the learner against itself, against past snapshots of
itself (the pool) and against the Voronoi bot (the teacher). Every learner decision gets an advantage
from GAE, then a few epochs of the clipped PPO update change the weights. Every few iterations a
champion gate plays the learner against the best weights so far on held-out seeds: a screen first,
then a confirmation on fresh seeds.

The engine is plain Python (tron.Games) unless a trainer passes another one with the same calls;
trainer.py passes the batched Rust engine.

    python ppo.py --start ../runs/bc/clone.pt --out ../runs/ppo-single --minutes 30
"""
import argparse
import copy
import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path

import numpy as np
import torch

import tron
from model import choose, entropy, policy, tf32_convolutions
from play import load, match, tensors

TRAINING_SEEDS = 10_000_000   # each training game takes the next seed from here
SCREEN_SEEDS = 3_000_000      # the gate's screen: the same held-out seeds every time
CONFIRM_SEEDS = 3_100_000     # the gate's confirmation: fresh seeds every time
TEACHER = 1                   # the Voronoi bot's number in both engines


@dataclass
class Config:
    games: int = 64             # complete games per iteration
    teacher_share: float = .25  # games against the Voronoi bot
    self_share: float = .5      # of the other games: the learner in both seats (else a pool snapshot)
    pool_slots: int = 4         # snapshots drawn from the pool for one iteration
    pool_size: int = 32
    snapshot_every: int = 5     # iterations between snapshots
    gamma: float = .995         # discount per decision
    lam: float = .95            # GAE: 0 trusts the critic's next value, 1 waits for the result
    clip: float = .2
    epochs: int = 3
    minibatch: int = 2048       # decisions per gradient step
    microbatch: int = 512       # decisions per forward/backward pass inside a step (GPU memory)
    lr: float = 2e-4
    entropy: float = .005       # bonus for keeping some doubt between moves (exploration)
    value_coef: float = .5
    max_grad_norm: float = 1.
    gate_every: int = 10
    screen_seeds: int = 32      # played in both seats
    screen_pass: float = .55
    confirm_seeds: int = 128


class Pool:
    """Past snapshots of the learner. A snapshot the learner still loses to is drawn more often:
    weight (1 - rate)^2 + 0.02, where rate = (points + 2) / (games + 4) is the learner's score against it,
    pulled toward 0.5 while there are few games."""

    def __init__(self, size):
        self.size, self.entries = size, []

    def add(self, model, iteration):
        weights = {name: tensor.detach().cpu().clone() for name, tensor in model.state_dict().items()}
        self.entries.append(dict(weights=weights, iteration=iteration, points=0., games=0))
        if len(self.entries) > self.size:
            del self.entries[1]          # keep the first snapshot; drop the oldest after it

    def rate(self, entry):
        return (entry['points']+2)/(entry['games']+4)

    def sample(self, count, random):
        weights = np.array([(1-self.rate(entry))**2+.02 for entry in self.entries])
        return random.choice(len(self.entries), size=count, p=weights/weights.sum())


def gae(values, reward, gamma, lam):
    """Advantages and value targets for one player's decisions in one game. The only reward is the
    result (+1 win, -1 loss) after the last decision."""
    advantages = np.zeros(len(values), np.float32)
    running = 0.
    for step in reversed(range(len(values))):
        last = step == len(values)-1
        next_value = 0. if last else values[step+1]   # nothing follows the end of the game
        delta = (reward if last else 0.)+gamma*next_value-values[step]   # TD error: better or worse than expected
        running = delta+gamma*lam*running
        advantages[step] = running
    return advantages, advantages+values


@torch.no_grad()
def act(model, planes, features, targets, legal, device, generator):
    """Sampled moves (temperature 1), their log-probabilities, and the critic's values."""
    inputs = tensors(device, planes, features, targets, legal)
    logits, value = model(*inputs)
    move = choose(logits, inputs[3], generator=generator)
    log_probs = torch.log_softmax(logits, 1)
    return move.cpu().numpy(), log_probs.gather(1, move[:, None]).squeeze(1).cpu().numpy(), value.cpu().numpy()


def rollout(learner, pool, opponents, config, random, first_seed, new_games, device, generator):
    """Plays config.games complete games and returns the learner's decisions with advantages."""
    count = config.games
    games = new_games(range(first_seed, first_seed+count))
    # Who the learner plays in each game: -2 the teacher, -1 itself, k >= 0 the k-th drawn snapshot.
    kind = np.where(random.random(count) < config.teacher_share, -2,
                    np.where(random.random(count) < config.self_share, -1,
                             random.integers(len(opponents), size=count)))
    learner_seat = random.integers(2, size=count)
    log = []                    # per step: the learner decisions made in that step
    while not (done := games.done()).all():
        planes, features, targets, legal, to_move = games.observe()
        moves = np.zeros(count, np.int64)
        has_move = legal.any(1)
        learner_turn = ~done & ((kind == -1) | (to_move == learner_seat))
        # A player with no legal move crashes whatever it plays: no decision is recorded.
        record = learner_turn & has_move
        if record.any():
            move, logp, value = act(learner, planes[record], features[record], targets[record], legal[record], device,
                                    generator)
            moves[record] = move
            log.append(dict(game=np.nonzero(record)[0], seat=to_move[record], planes=planes[record],
                            features=features[record], targets=targets[record], legal=legal[record], move=move,
                            logp=logp, value=value))
        teacher_turn = ~done & ~learner_turn & (kind == -2)
        if teacher_turn.any():
            moves[teacher_turn] = games.bot_moves(TEACHER, wanted=teacher_turn)[teacher_turn]
        for slot, (_, model) in enumerate(opponents):
            turn = ~done & ~learner_turn & (kind == slot) & has_move
            if turn.any():
                moves[turn] = act(model, planes[turn], features[turn], targets[turn], legal[turn], device, generator)[0]
        games.step(moves)
    scores, plies = games.scores(), games.plies()
    games.close()
    for game in np.nonzero(kind >= 0)[0]:
        entry = pool.entries[opponents[kind[game]][0]]
        entry['points'] += float(scores[game, learner_seat[game]])
        entry['games'] += 1
    # One trajectory per (game, seat): that player's decisions in the order they were made.
    batch = {key: np.concatenate([step[key] for step in log]) for key in log[0]}
    trajectory = batch['game']*2+batch['seat']
    order = np.argsort(trajectory, kind='stable')
    batch = {key: value[order] for key, value in batch.items()}
    trajectory = trajectory[order]
    starts = np.flatnonzero(np.r_[True, trajectory[1:] != trajectory[:-1]])
    ends = np.r_[starts[1:], len(trajectory)]
    batch['advantage'] = np.zeros(len(trajectory), np.float32)
    batch['return'] = np.zeros(len(trajectory), np.float32)
    for begin, end in zip(starts, ends):
        won = scores[batch['game'][begin], batch['seat'][begin]] == 1
        batch['advantage'][begin:end], batch['return'][begin:end] = gae(
            batch['value'][begin:end], 1. if won else -1., config.gamma, config.lam)
    teacher_games = kind == -2
    stats = dict(new_games=count, new_decisions=len(trajectory), plies=round(float(plies.mean()), 1),
                 teacher_games=int(teacher_games.sum()),
                 teacher_score=float(scores[teacher_games, learner_seat[teacher_games]].mean())
                 if teacher_games.any() else None)
    del batch['game'], batch['seat']
    return batch, stats


def unpack_planes(packed):
    """Planes packed 8 per byte (np.packbits) back to 0/1 bytes [n, 8, 20, 30], on the GPU."""
    bits = (packed[:, :, None] >> torch.arange(7, -1, -1, device=packed.device)) & 1
    return bits.flatten(1).view(-1, tron.PLANES, tron.HEIGHT, tron.WIDTH)


def ppo_update(model, optimizer, batch, config, device, generator):
    """config.epochs passes over the batch in random minibatches of the clipped PPO loss. Each
    minibatch is one optimizer step; its gradient is summed over micro-batches to save GPU memory."""
    # The whole batch goes to the GPU once, planes packed 8 per byte; minibatches are cut out there.
    batch = dict(batch, planes=np.packbits(batch['planes'].reshape(len(batch['planes']), -1), axis=1))
    tensors = {key: torch.from_numpy(value).to(device) for key, value in batch.items()}
    size = len(tensors['move'])
    totals, updates = np.zeros(5), 0
    for _ in range(config.epochs):
        order = torch.randperm(size, generator=generator).to(device)
        for start in range(0, size, config.minibatch):
            index = order[start:start+config.minibatch]
            # Advantages are normalised over the whole minibatch, as if it were one pass.
            mean, std = tensors['advantage'][index].mean(), tensors['advantage'][index].std()
            optimizer.zero_grad()
            step = np.zeros(5)
            for chunk in index.split(config.microbatch):
                sub = {key: value[chunk] for key, value in tensors.items()}
                with tf32_convolutions():
                    log_probs, value = policy(model, unpack_planes(sub['planes']), sub['features'],
                                              sub['targets'].long(), sub['legal'])
                new_logp = log_probs.gather(1, sub['move'].long()[:, None]).squeeze(1)
                ratio = (new_logp-sub['logp']).exp()        # pi_new(move) / pi_old(move)
                advantage = (sub['advantage']-mean)/(std+1e-8)
                # Clipped surrogate: no reward for pushing the ratio past 1 +/- clip in the advantage's direction.
                policy_loss = -torch.min(ratio*advantage, ratio.clamp(1-config.clip, 1+config.clip)*advantage).mean()
                value_loss = (value-sub['return']).square().mean()
                bonus = entropy(log_probs, sub['legal']).mean()
                loss = policy_loss+config.value_coef*value_loss-config.entropy*bonus
                share = len(chunk)/len(index)               # this chunk's part of the minibatch mean
                with tf32_convolutions():
                    (share*loss).backward()
                with torch.no_grad():
                    approx_kl = (sub['logp']-new_logp).mean()
                    clipped = ((ratio-1).abs() > config.clip).float().mean()
                step += share*np.array([policy_loss.item(), value_loss.item(), bonus.item(), approx_kl.item(),
                                        clipped.item()])
            torch.nn.utils.clip_grad_norm_(model.parameters(), config.max_grad_norm)
            optimizer.step()
            totals += step
            updates += 1
    del tensors
    if device == 'cuda':
        torch.cuda.empty_cache()        # hand the cached memory back to the other jobs on the GPU
    names = ('policy_loss', 'value_loss', 'entropy', 'approx_kl', 'clip_fraction')
    return {name: round(float(total), 5) for name, total in zip(names, totals/updates)}


def gate(learner, champion, confirmations, config, new_games):
    """Screen the learner against the champion on the same held-out seeds every time; if it scores at
    least config.screen_pass, confirm on fresh seeds and promote only if the 95% interval's lower end
    is above 0.5. Both play greedily."""
    learner.eval()
    screen = match(learner, champion, SCREEN_SEEDS, config.screen_seeds, new_games=new_games)
    record = dict(screen=screen, promoted=False)
    if screen['rate'] >= config.screen_pass:
        first = CONFIRM_SEEDS+confirmations*config.confirm_seeds
        confirm = match(learner, champion, first, config.confirm_seeds, new_games=new_games)
        record.update(confirm=confirm, promoted=confirm['low'] > .5)
    learner.train()
    return record


COUNTERS = ('iteration', 'games', 'decisions', 'confirmations', 'champion_iteration', 'champion_games', 'next_seed',
            'seconds')


def train(args, config, new_games, on_iteration=None, checkpoint=None):
    """The training loop shared by both engines. on_iteration(state) runs after every iteration
    (trainer.py uses it for checkpoints, the status file and the strength curve); `checkpoint`
    resumes a run that trainer.py saved."""
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    resumed = checkpoint['state']['iteration'] if checkpoint else 0
    torch.manual_seed(args.seed+resumed)
    generator = torch.Generator(device=device).manual_seed(args.seed+resumed)
    cpu_generator = torch.Generator().manual_seed(args.seed+resumed)
    random = np.random.default_rng([args.seed, resumed])
    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)
    learner = load(args.start, device).train()
    champion = copy.deepcopy(learner).eval()
    optimizer = torch.optim.Adam(learner.parameters(), lr=config.lr)
    pool = Pool(config.pool_size)
    pool.add(learner, 0)
    state = dict(iteration=0, games=0, decisions=0, confirmations=0, champion_iteration=0, champion_games=0,
                 seconds=0., next_seed=TRAINING_SEEDS, out=out, learner=learner, champion=champion,
                 optimizer=optimizer, pool=pool, stop=False)
    if checkpoint:
        learner.load_state_dict(checkpoint['learner'])
        champion.load_state_dict(checkpoint['champion'])
        optimizer.load_state_dict(checkpoint['optimizer'])
        pool.entries = checkpoint['pool']
        state.update({key: checkpoint['state'][key] for key in COUNTERS})
    clock = time.time()-state['seconds']
    while not state['stop'] and time.time()-clock < args.minutes*60:
        state['iteration'] += 1
        iteration = state['iteration']
        # Draw this iteration's pool opponents (each a frozen copy of an old learner).
        opponents = []
        for index in pool.sample(config.pool_slots, random):
            model = copy.deepcopy(learner).eval()
            model.load_state_dict(pool.entries[index]['weights'])
            opponents.append((index, model))
        started = time.time()
        batch, stats = rollout(learner, pool, opponents, config, random, state['next_seed'], new_games, device,
                               generator)
        state['next_seed'] += config.games
        rolled = time.time()
        losses = ppo_update(learner, optimizer, batch, config, device, cpu_generator)
        state['games'] += stats['new_games']
        state['decisions'] += stats['new_decisions']
        row = dict(iteration=iteration, games=state['games'], decisions=state['decisions'], **stats, **losses,
                   rollout_seconds=round(rolled-started, 2), update_seconds=round(time.time()-rolled, 2))
        if iteration % config.snapshot_every == 0:
            pool.add(learner, iteration)
        if iteration % config.gate_every == 0:
            record = gate(learner, champion, state['confirmations'], config, new_games)
            state['confirmations'] += 'confirm' in record
            if record['promoted']:
                champion.load_state_dict(learner.state_dict())
                state['champion_iteration'], state['champion_games'] = iteration, state['games']
            row['gate'] = record
        state['seconds'] = round(time.time()-clock, 1)
        row.update(seconds=state['seconds'], champion_iteration=state['champion_iteration'])
        with open(out/'metrics.jsonl', 'a') as log:
            log.write(json.dumps(row)+'\n')
        print(json.dumps({key: value for key, value in row.items() if key != 'gate'}), flush=True)
        if 'gate' in row:
            print('  gate:', json.dumps(row['gate']), flush=True)
        if on_iteration:
            on_iteration(state)
    save(learner, out/'learner.pt')
    save(champion, out/'champion.pt')
    return state


def save(model, path):
    torch.save({'shape': model.shape, 'weights': {name: tensor.cpu() for name, tensor in model.state_dict().items()}},
               path)


def arguments(description=__doc__):
    parser = argparse.ArgumentParser(description=description, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--start', required=True, help='the behavior-cloned network')
    parser.add_argument('--out', required=True)
    parser.add_argument('--minutes', type=float, default=30)
    parser.add_argument('--seed', type=int, default=1)
    for field, value in asdict(Config()).items():
        parser.add_argument('--'+field.replace('_', '-'), type=type(value), default=value)
    return parser


def main():
    args = arguments().parse_args()
    config = Config(**{field: getattr(args, field) for field in asdict(Config())})
    (Path(args.out)).mkdir(parents=True, exist_ok=True)
    (Path(args.out)/'config.json').write_text(json.dumps(dict(args=vars(args), engine='python'), indent=1))
    train(args, config, tron.Games)


if __name__ == '__main__':
    main()
"""Light cycles in plain Python with numpy: the same rules, seeds, Voronoi bot and planes as the Rust
engine (engine/src), so the single-process trainer can play its games without the C interface.
checks/python_engine.py replays games in both and compares every plane and every move."""
from collections import deque

import numpy as np

WIDTH, HEIGHT = 30, 20
CELLS = WIDTH*HEIGHT
UP, RIGHT, DOWN, LEFT = range(4)
UNREACHED = 1 << 20
PLANES = 8
MASK64 = (1 << 64)-1


def neighbor(cell, direction):
    """The cell one step away, or None off the board."""
    x, y = cell % WIDTH, cell//WIDTH
    if direction == UP:
        return cell-WIDTH if y > 0 else None
    if direction == RIGHT:
        return cell+1 if x+1 < WIDTH else None
    if direction == DOWN:
        return cell+WIDTH if y+1 < HEIGHT else None
    return cell-1 if x > 0 else None


# The on-board neighbours of every cell, computed once.
NEIGHBORS = [[next_cell for next_cell in (neighbor(cell, direction) for direction in range(4)) if next_cell is not None]
             for cell in range(CELLS)]


class SplitMix64:
    def __init__(self, seed):
        self.state = seed & MASK64

    def next(self):
        self.state = (self.state+0x9E3779B97F4A7C15) & MASK64
        mixed = self.state
        mixed = ((mixed ^ (mixed >> 30))*0xBF58476D1CE4E5B9) & MASK64
        mixed = ((mixed ^ (mixed >> 27))*0x94D049BB133111EB) & MASK64
        return mixed ^ (mixed >> 31)

    def below(self, bound):
        return self.next() % bound


class Game:
    def __init__(self, seed):
        random = SplitMix64(seed)
        first = random.below(CELLS)
        second = random.below(CELLS)
        while second == first:
            second = random.below(CELLS)
        self.cells = [0]*CELLS            # 0 free, p + 1 for player p's trail
        self.cells[first], self.cells[second] = 1, 2
        self.heads = [first, second]
        self.headings = [RIGHT if head % WIDTH < WIDTH//2 else LEFT for head in self.heads]
        self.to_move, self.plies, self.winner = 0, 0, None

    def legal(self, player):
        return [direction for direction in (UP, RIGHT, DOWN, LEFT)
                if (target := neighbor(self.heads[player], direction)) is not None and self.cells[target] == 0]

    def step(self, direction):
        player = self.to_move
        target = neighbor(self.heads[player], direction)
        if target is None or self.cells[target]:
            self.winner = 1-player             # a crash: the other player wins
        else:
            self.cells[target] = player+1
            self.heads[player], self.headings[player] = target, direction
        self.plies += 1
        self.to_move = 1-player


def distances(cells, start):
    """Breadth-first distances from `start` over free cells."""
    dist = [UNREACHED]*CELLS
    dist[start] = 0
    queue = deque([start])
    while queue:
        cell = queue.popleft()
        for next_cell in NEIGHBORS[cell]:
            if cells[next_cell] == 0 and dist[next_cell] == UNREACHED:
                dist[next_cell] = dist[cell]+1
                queue.append(next_cell)
    return dist


def territory(cells, heads):
    """Free cells each head reaches strictly first, and whether no free cell is reachable by both."""
    first, second = np.array(distances(cells, heads[0])), np.array(distances(cells, heads[1]))
    free = np.array(cells) == 0
    counts = [int((free & (first < second)).sum()), int((free & (second < first)).sum())]
    shared = (free & (first < UNREACHED) & (second < UNREACHED)).any()
    return counts, not shared


def flood_count(cells, head):
    return sum(distance < UNREACHED for distance in distances(cells, head))-1


def voronoi_bot(game):
    """The page-4 bot: best (Voronoi margin * 1000 + room) after the move; straight, right, left, back."""
    me, heading = game.to_move, game.headings[game.to_move]
    best, best_score = heading, None
    for direction in (heading, (heading+1) % 4, (heading+3) % 4, (heading+2) % 4):
        target = neighbor(game.heads[me], direction)
        if target is None or game.cells[target]:
            continue
        cells = game.cells.copy()
        cells[target] = me+1
        heads = game.heads.copy()
        heads[me] = target
        counts, _ = territory(cells, heads)
        score = (counts[me]-counts[1-me])*1000+flood_count(cells, target)
        if best_score is None or score > best_score:
            best, best_score = direction, score
    return best


def move_features(game):
    """Room and Voronoi margin after each move, divided by 600 (zeros for illegal moves)."""
    me = game.to_move
    out = np.zeros((4, 2), np.float32)
    for direction in range(4):
        target = neighbor(game.heads[me], direction)
        if target is None or game.cells[target]:
            continue
        cells = game.cells.copy()
        cells[target] = me+1
        heads = game.heads.copy()
        heads[me] = target
        counts, _ = territory(cells, heads)
        out[direction] = np.float32(flood_count(cells, target))/np.float32(CELLS), \
            np.float32(counts[me]-counts[1-me])/np.float32(CELLS)
    return out


def observe(game):
    """What the player to move sees: planes [8, 20, 30] (0/1), room and margin after each move, the
    cell each move enters (-1 off the board) and which moves are legal. Same as engine/src/features.rs."""
    me = game.to_move
    cells = np.array(game.cells)
    mine = np.array(distances(game.cells, game.heads[me]))
    theirs = np.array(distances(game.cells, game.heads[1-me]))
    free = cells == 0
    planes = np.zeros((PLANES, CELLS), np.uint8)
    planes[0] = 1
    planes[1] = cells == me+1
    planes[2] = ~free & (cells != me+1)
    planes[3, game.heads[me]] = 1
    planes[4, game.heads[1-me]] = 1
    planes[5] = free & (mine < theirs)
    planes[6] = free & (theirs < mine)
    planes[7] = free & (mine < UNREACHED)
    targets = [neighbor(game.heads[me], direction) for direction in range(4)]
    legal = [target is not None and game.cells[target] == 0 for target in targets]
    return (planes.reshape(PLANES, HEIGHT, WIDTH), move_features(game),
            np.array([-1 if target is None else target for target in targets]), np.array(legal))


class Games:
    """Many Python games behind the same calls as the Rust batch (tron_env.Batch), so the trainer can
    run on either engine."""

    def __init__(self, seeds):
        self.games = [Game(seed) for seed in seeds]
        self.count = len(self.games)

    def observe(self):
        planes = np.zeros((self.count, PLANES, HEIGHT, WIDTH), np.uint8)
        features = np.zeros((self.count, 4, 2), np.float32)
        targets, legal = np.full((self.count, 4), -1), np.zeros((self.count, 4), bool)
        for i, game in enumerate(self.games):
            if game.winner is None:
                planes[i], features[i], targets[i], legal[i] = observe(game)
        return planes, features, targets, legal, np.array([game.to_move for game in self.games])

    def step(self, moves):
        for game, move in zip(self.games, moves):
            if game.winner is None:
                game.step(int(move))

    def done(self):
        return np.array([game.winner is not None for game in self.games])

    def scores(self):
        out = np.zeros((self.count, 2), np.int32)
        for i, game in enumerate(self.games):
            if game.winner is not None:
                out[i, game.winner] = 1
        return out

    def plies(self):
        return np.array([game.plies for game in self.games])

    def bot_moves(self, bot, wanted):
        """Only the Voronoi bot (the teacher) lives in the Python engine."""
        return np.array([voronoi_bot(game) if wants and game.winner is None else -1
                         for game, wants in zip(self.games, wanted)])

    def close(self):
        pass
"""The pure-Python engine (train/tron.py) against the Rust engine (through the C ABI): same start cells,
same planes and move features (bit for bit), same legal moves and the same Voronoi-bot move at every
ply of games that mix the Voronoi bot with random legal moves.

    python checks/python_engine.py [games]
"""
import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'train'))
import tron                                    # noqa: E402
from tron_env import VORONOI, Batch            # noqa: E402

games = int(sys.argv[1]) if len(sys.argv) > 1 else 50
random = np.random.default_rng(0)
checked, mismatches = 0, 0
for k in range(games):
    seed = 8_000_000+k
    python_game, rust = tron.Game(seed), Batch(1, threads=1)
    rust.reset(0, seed)
    while python_game.winner is None:
        planes, features, targets, legal, _ = rust.observe()
        p_planes, p_features, p_targets, p_legal = tron.observe(python_game)
        teacher = tron.voronoi_bot(python_game)
        same = (np.array_equal(planes[0], p_planes) and np.array_equal(features[0], p_features)
                and np.array_equal(targets[0], p_targets) and np.array_equal(legal[0], p_legal)
                and teacher == rust.bot_moves(VORONOI)[0])
        checked += 1
        mismatches += not same
        options = python_game.legal(python_game.to_move)
        # Mostly the Voronoi bot, sometimes a random legal move, so positions vary.
        move = int(random.choice(options)) if options and random.random() < .2 else teacher
        python_game.step(move)
        rust.step(np.array([move]))
    assert rust.done()[0] and rust.scores()[0, python_game.winner] == 1
print(f'{games} games, {checked} positions, {mismatches} mismatches')
sys.exit(1 if mismatches else 0)

The single-process run, on one shared desktop with the network on the GPU:[1]

Single-process runResult
Length43 iterations, 1,376 games, 322,390 learner decisions, 40.6 minutes
Speed39.2 s of play and 1.8 s of update per 32-game batch: 2,810 games an hour, gate matches not counted
Promotionsiteration 10 (320 games; confirmation 77 of 128) and 20 (640 games; 76 of 128)
Failed confirmationsiteration 30 (73 of 128) and 40 (67 of 128)

The champion from iteration 20, after 640 self-play games, on the evaluation seeds (200 seeds, both seats):

Champion vsGames wonShare, 95% interval
Voronoi (its teacher)249 of 400.623 [.574, .669]
the clone it started from241 of 400.603 [.554, .649]
flood fill393 of 400.983 [.964, .991]
alpha-beta157 of 400.393 [.346, .441]

Check: the trainer passes its teacher. After 640 self-play games the champion beats the Voronoi bot 249 games to 151 and the clone it started from 241 to 159, both intervals above .5.

What's intentionally missing

Weight averaging (Building the Trainer), an early stop on KL (section 04), a rollback on drift and fixed bots in the gate (section 06), reward shaping (section 09), and games of three or four cycles: the project trains one against one, while Tron Battle seats 2 to 4 cycles per battle after its first league.[18] Building the Trainer adds checkpoints, resuming, a clean stop and a status file.

Building the Trainer runs the same train() on the Rust engine with 512 games per iteration: 79,360 games in 100 minutes. Every 12 iterations it measured the learner, playing its most likely move, against the teacher, the clone and the alpha-beta bot on the evaluation seeds. That run gives the project's strength curve:[1]

Strength against games playedShare of 400 games won by the learner, playing its most likely move, against three fixed opponents after 0 to 73,728 self-play games. Against the Voronoi teacher it rises from .490 to .635 after 6,144 games and stays between .667 and .723 from 12,288 on; against the clone it rises from .500 to .642, then stays between .672 and .700; against the alpha-beta bot it rises from .300 to .505, and none of its intervals lies above .5. Promotions came at 5,120, 10,240 and 30,720 games..2.3.4.6.7.8.5level020k40k60k80kself-play games playedshare of 400 games wonvs teachervs clonevs alpha-betapromotions0 games: .490 [.441, .539] against the Voronoi bot (teacher)6,144 games: .635 [.587, .681] against the Voronoi bot (teacher)12,288 games: .688 [.640, .731] against the Voronoi bot (teacher)18,432 games: .667 [.620, .712] against the Voronoi bot (teacher)24,576 games: .682 [.635, .726] against the Voronoi bot (teacher)30,720 games: .693 [.646, .736] against the Voronoi bot (teacher)36,864 games: .698 [.651, .741] against the Voronoi bot (teacher)43,008 games: .700 [.653, .743] against the Voronoi bot (teacher)49,152 games: .693 [.646, .736] against the Voronoi bot (teacher)55,296 games: .685 [.638, .729] against the Voronoi bot (teacher)61,440 games: .700 [.653, .743] against the Voronoi bot (teacher)67,584 games: .723 [.677, .764] against the Voronoi bot (teacher)73,728 games: .715 [.669, .757] against the Voronoi bot (teacher)0 games: .500 [.451, .549] against the clone6,144 games: .642 [.594, .688] against the clone12,288 games: .672 [.625, .717] against the clone18,432 games: .682 [.635, .726] against the clone24,576 games: .698 [.651, .741] against the clone30,720 games: .685 [.638, .729] against the clone36,864 games: .688 [.640, .731] against the clone43,008 games: .680 [.633, .724] against the clone49,152 games: .690 [.643, .733] against the clone55,296 games: .700 [.653, .743] against the clone61,440 games: .700 [.653, .743] against the clone67,584 games: .698 [.651, .741] against the clone73,728 games: .695 [.648, .738] against the clone0 games: .300 [.257, .347] against the alpha-beta bot6,144 games: .388 [.341, .436] against the alpha-beta bot12,288 games: .432 [.385, .481] against the alpha-beta bot18,432 games: .417 [.370, .466] against the alpha-beta bot24,576 games: .463 [.414, .511] against the alpha-beta bot30,720 games: .460 [.412, .509] against the alpha-beta bot36,864 games: .463 [.414, .511] against the alpha-beta bot43,008 games: .470 [.422, .519] against the alpha-beta bot49,152 games: .477 [.429, .526] against the alpha-beta bot55,296 games: .448 [.400, .496] against the alpha-beta bot61,440 games: .482 [.434, .531] against the alpha-beta bot67,584 games: .490 [.441, .539] against the alpha-beta bot73,728 games: .505 [.456, .554] against the alpha-beta botvs teachervs clonevs alpha-betaStrength against games playedShare of 400 games won by the learner, playing its most likely move, against three fixed opponents after 0 to 73,728 self-play games. Against the Voronoi teacher it rises from .490 to .635 after 6,144 games and stays between .667 and .723 from 12,288 on; against the clone it rises from .500 to .642, then stays between .672 and .700; against the alpha-beta bot it rises from .300 to .505, and none of its intervals lies above .5. Promotions came at 5,120, 10,240 and 30,720 games..2.3.4.6.7.8.5level020k40k60k80kself-play games playedshare of 400 games wonvs teachervs clonevs alpha-betapromotions0 games: .490 [.441, .539] against the Voronoi bot (teacher)6,144 games: .635 [.587, .681] against the Voronoi bot (teacher)12,288 games: .688 [.640, .731] against the Voronoi bot (teacher)18,432 games: .667 [.620, .712] against the Voronoi bot (teacher)24,576 games: .682 [.635, .726] against the Voronoi bot (teacher)30,720 games: .693 [.646, .736] against the Voronoi bot (teacher)36,864 games: .698 [.651, .741] against the Voronoi bot (teacher)43,008 games: .700 [.653, .743] against the Voronoi bot (teacher)49,152 games: .693 [.646, .736] against the Voronoi bot (teacher)55,296 games: .685 [.638, .729] against the Voronoi bot (teacher)61,440 games: .700 [.653, .743] against the Voronoi bot (teacher)67,584 games: .723 [.677, .764] against the Voronoi bot (teacher)73,728 games: .715 [.669, .757] against the Voronoi bot (teacher)0 games: .500 [.451, .549] against the clone6,144 games: .642 [.594, .688] against the clone12,288 games: .672 [.625, .717] against the clone18,432 games: .682 [.635, .726] against the clone24,576 games: .698 [.651, .741] against the clone30,720 games: .685 [.638, .729] against the clone36,864 games: .688 [.640, .731] against the clone43,008 games: .680 [.633, .724] against the clone49,152 games: .690 [.643, .733] against the clone55,296 games: .700 [.653, .743] against the clone61,440 games: .700 [.653, .743] against the clone67,584 games: .698 [.651, .741] against the clone73,728 games: .695 [.648, .738] against the clone0 games: .300 [.257, .347] against the alpha-beta bot6,144 games: .388 [.341, .436] against the alpha-beta bot12,288 games: .432 [.385, .481] against the alpha-beta bot18,432 games: .417 [.370, .466] against the alpha-beta bot24,576 games: .463 [.414, .511] against the alpha-beta bot30,720 games: .460 [.412, .509] against the alpha-beta bot36,864 games: .463 [.414, .511] against the alpha-beta bot43,008 games: .470 [.422, .519] against the alpha-beta bot49,152 games: .477 [.429, .526] against the alpha-beta bot55,296 games: .448 [.400, .496] against the alpha-beta bot61,440 games: .482 [.434, .531] against the alpha-beta bot67,584 games: .490 [.441, .539] against the alpha-beta bot73,728 games: .505 [.456, .554] against the alpha-beta botvs teachervs clonevs alpha-beta
The learner's share of 400 games against each yardstick as self-play games accumulate. Shaded bands are 95% intervals for the teacher and the alpha-beta bot; the clone's overlap the teacher's. Pink ticks mark the three promotions. Hover a point for its numbers, or read them in the table below.
Games playedMinutes since startvs teachervs clonevs alpha-beta
00.0.490 [.441, .539].500 [.451, .549].300 [.257, .347]
6,1445.6.635 [.587, .681].642 [.594, .688].388 [.341, .436]
12,28813.5.688 [.640, .731].672 [.625, .717].432 [.385, .481]
18,43220.1.667 [.620, .712].682 [.635, .726].417 [.370, .466]
24,57626.5.682 [.635, .726].698 [.651, .741].463 [.414, .511]
30,72036.1.693 [.646, .736].685 [.638, .729].460 [.412, .509]
36,86444.0.698 [.651, .741].688 [.640, .731].463 [.414, .511]
43,00853.1.700 [.653, .743].680 [.633, .724].470 [.422, .519]
49,15261.9.693 [.646, .736].690 [.643, .733].477 [.429, .526]
55,29670.1.685 [.638, .729].700 [.653, .743].448 [.400, .496]
61,44077.8.700 [.653, .743].700 [.653, .743].482 [.434, .531]
67,58485.8.723 [.677, .764].698 [.651, .741].490 [.441, .539]
73,72893.2.715 [.669, .757].695 [.648, .738].505 [.456, .554]

Against the alpha-beta bot the learner climbed from the clone's .300 to about .5 and stopped. On 1,000 fresh games, both seats, on seeds no run had touched:[1]

Against alpha-betaGames wonShare, 95% interval
champion (30,720 games), the one that ships473 of 1,000.473 [.442, .504]
last learner (79,360 games)508 of 1,000.508 [.477, .539]
the clone295 of 1,000.295 [.268, .324]
the Voronoi teacher312 of 1,000.312 [.284, .341]

The network never passed the alpha-beta bot: it is level at best, and both intervals contain .5. Self-play took it from the clone's .295 to about .5 against the search and no further. When to Use Machine Learning gave the reason to expect it: light cycles offer three moves a turn and are decided by forcing tactics, where a search you wrote is hard to beat. In the author's lab, too, the Tron Battle bot that reached Legend was an alpha-beta search with no network. Networks Inside Search puts the network inside a search.

08How much self-training

A common worry: people say self-play needs millions of games, your trainer counts iterations, and a restart just threw away 100 of them. How much is that? It depends on what an iteration holds.

In the PPO paper an iteration is one batch: "each of N (parallel) actors collect T timesteps of data", then the network trains on it.[7] The project's iterations are 32 or 512 complete games. The author's lab's Back Track King trainer advanced 512 games by 32 turns each; its games lasted 55 to 60 turns, so 100 iterations were about 27,000 finished games and 21 minutes.[9]

The project's answer to how much self-training it needed:[1]

  1. The clone: 3,000 recorded games and under five minutes of training.
  2. Passing the teacher: a few hundred games; .623 against it after 640.
  3. Flattening: from 12,288 games to 79,360, .667 to .723 against the teacher and .672 to .700 against the clone.
  4. The gate agreed: promotions at 5,120, 10,240 and 30,720 games, and none in the last 48,640.
  5. The alpha-beta bot: not passed; .43 by 12,288 games, .46 by 30,720, then .45 to .51.
Gate at iterationGames playedScreen, of 128Confirmation, of 512Promoted
105,12076 (.594)313 (.611 [.568, .653])yes
2010,24071 (.555)279 (.545 [.502, .588])yes
30 to 5015,360 to 25,60065 to 68not runno
6030,72071 (.555)284 (.555 [.511, .597])yes
70 to 14035,840 to 71,68062 to 68not runno
15076,80071 (.555)274 (.535 [.492, .578])no

From 35,840 games on, the screens sat at .48 to .56: learner and champion were about equally strong, and 128 games can't tell a few points apart. The one confirmation, at 76,800 games, had a lower bound of .492. A gate that has stopped promoting while the fixed yardsticks stay flat is what a plateau looks like.

From the author's lab

The lab's first network to reach Legend in Back Track King, on the permanent ladder after the contest, came from iteration 3,350 of its main run: 917,630 self-play games (82.4 million training samples) in about 11.7 hours on one RTX 3080 Ti, starting from a clone of the author's hand-written contest bot. The run went on to 74,335 iterations and 21.8 million games. Later champions kept beating earlier ones in local games, while on the live ladder no later version scored above the spread of six identical resubmissions of one network.

Game counts guarantee nothing. The lab's Troll Farm network had 778,786 training games behind it, a quarter of them against rule-based bots, when its live games were diagnosed on October 29, 2026, and it still trained one or two cheap trolls early in a game and never another: in its own games it could rarely afford one, so it never learned what one was worth.[9]

The whole batched project run was 79,360 games, 8.6% of that. Game counts don't carry over from one game to another, and neither does the look of a plateau. The lab's gate never stopped promoting (101 promotions, the last at iteration 73,800 of 74,335),[9] because a champion only has to beat its own past versions in local games; its plateau showed only on the live ladder (Case Study: Back Track King follows the lab's run to its end).

A common question near the top of a ladder: the bot is a few places from first, so is there anything to tweak? By then the gains left can be smaller than the noise of one live result. After the lab's network reached the top few places of Back Track King's Legend league (permanent ladder, October 25, 2026), reward variants, a league of frozen opponents, a search, distillation and Q-learning were all tried, and none scored above the noise band of identical resubmissions.[9]

09A more frequent signal, the same goal

A common question once self-play runs: how do you get a learning signal more often than once per game? pays some reward during the game. Paid carelessly, it changes what the learner optimizes.

Ng, Harada and Russell recount Randlรธv and Alstrรธm's simulated bicycle, rewarded for progress toward a goal: "The agent learned to ride in tiny circles near the start state because no penalty was incurred for riding away from the goal." They also proved which bonuses are safe. Give every position a score ฮฆ (phi), its potential, and pay ฮณ ร— ฮฆ(after) โˆ’ ฮฆ(before) for each move. Over a game the payments cancel down to an amount fixed by the starting position, so the best policy can't change; being potential-based is "a necessary and sufficient condition for it to guarantee consistency with the optimal policy".[19]

Worked example

Let ฮฆ be your Voronoi margin divided by 600, the board's size. With ฮณ = 1, a move that lifts the margin from +20 cells to +50 pays 50 / 600 โˆ’ 20 / 600 = +0.05, and a move that drops it back costs the same. Over a game the payments add up to ฮฆ(end) โˆ’ ฮฆ(start); with ฮฆ set to 0 at the end, that is โˆ’ฮฆ(start) however the game went, so a win still beats a loss by exactly 2.

shaped reward = reward + ฮณ ร— ฮฆ(after) โˆ’ ฮฆ(before)

In words: pay the learner for raising the potential and charge it for lowering it, with the potential set to 0 when the game ends. Everything paid along the way comes back out, so only the result decides which way of playing is best.

The reward you wrote vs the game you meant

Drag the farming bonus. You win by walking six moves to their base; each turn before you arrive, their attack wins with probability 0.05, and after 40 turns the game is a draw. The reward as written is +1 for a win plus w per turn spent farming a mana well. For each w the widget solves the game exactly and plots the real win probability of the best plan. Up to w = 0.0417 the plan walks straight there and wins 0.774 of games; past it, the plan farms 32 turns, arrives on turn 40 and wins 0.135. Tick the potential-based form, which takes the bonus back at the end: the plan never changes.

That collapse is : the learner maximizes the reward you wrote, which isn't the game you meant, with solutions "valid in some literal sense" that "don't meet the designer's intent".[20] Krakovna et al. describe a boat-racing agent paid for hitting green blocks that learned to circle and hit the same blocks again and again, because "shaping rewards can change the optimal policy if they are not potential-based".[21]

The top players of Back Track King (Summer Challenge 2026) disagree about shaping. Indrill, 1st with deep Q-learning, added each turn's change in the projected final score gap between the players to the result, to "have a less delayed reward".[22] Pduhard, 3rd, paid each turn's income gap and ยฑ30 for the result, and lists "Two attempts at shaping the reward" among what didn't work.[11] JOiy, 13th, found that per-turn shaping "teaches the network to optimise the proxy", and his best bots trained on a reward paid only at the end.[10] delineate, who won Spring Challenge 2026 (Troll Farm), used heavy shaping to teach his network the basic tasks; once it chose its own targets, "it would find ways to game the reward shaping", so his final stages trained on the endgame score difference with no shaping.[23] Treat shaping as a tool with a known failure mode.

From the author's lab

For Back Track King the lab used Indrill's idea in the safe form: the potential was the projected final margin (the score gap plus the income gap times the turns left), and the end of the game paid the real margin, scaled down, plus ยฑ1 for the result. Its Spring Challenge 2022 reward included a potential term worth 0.01 per point of wild-mana lead, so a typical lead of 1,000 counted as much as 10 hits on a base. That potential also returned to 0 at the end of every game. But wild mana wasn't among the network's inputs, so its critic couldn't predict that part of the reward or cancel it, and the term acted as a real objective. In 125 Gold ranking games, 64 of the live bot's 70 wins came on the wild-mana tie-break, and none of its 55 losses did: it lost those on base health. The fix cut the weight to 0.001.[9]

Why the critic decides whether a potential cancels

Add a potential bonus ฮณ ร— ฮฆ(next) โˆ’ ฮฆ(now) to the reward and GAE's TD error becomes reward + ฮณ ร— (V + ฮฆ)(next) โˆ’ (V + ฮฆ)(now), the TD error of a critic that estimates V + ฮฆ; Schulman et al. note that GAE is itself shaping, with the critic as the potential.[5] With ฮป = 1 the bonuses telescope to โˆ’ฮฆ(now), which doesn't depend on the move. With ฮป below 1 a move's credit also takes in potentials a few moves ahead, and they cancel only if the critic learns to subtract ฮฆ, which it can do only if it can compute ฮฆ from what the network sees.

The project uses no shaping. If you add a potential, build it from inputs the network already reads, such as the room and Voronoi margin it gets for every move.

10Greedy or sampled

During training the learner samples its moves, drawing each at random in proportion to its probability, so it keeps trying moves it rates a little lower. When the bot plays for real you choose: the most likely move every turn, greedy play, or keep sampling.

Between the two sits the temperature ฯ„ (tau) from Neural Networks for Game Bots: divide every logit (the network's raw score for a move) by ฯ„ before the softmax turns the scores into probabilities, so ฯ„ near 0 is greedy play and large ฯ„ is close to random. A greedy policy can loop: where two moves tie, or nearly tie, and each leads back to the other, it goes back and forth forever. Sampling breaks the tie by chance.

Greedy or sampled?

Press Play, then drag ฯ„ to 0. The policy scores each move by how much closer it gets to the goal in a straight line, and an episode is lost after 40 moves. In front of the wall up and down tie, so greedy play (ฯ„ = 0) goes up, down, up until it runs out of moves, and wins none of its 400 seeded episodes. At ฯ„ = 1 it sometimes takes a lower-scoring move around the wall and wins 0.87; at ฯ„ = 3, closer to a random walk, 0.63. The board replays the first six of the 400 episodes behind each point in the second panel.

From the author's lab

In Spring Challenge 2022, with fog of war and heroes that move in 16 directions, the lab's network first played greedily. Against the rule-based bot it was cloned from, on the same maps, champion 1850 scored .50 picking its most likely move and .85 sampling, and iteration 2052 scored .45 greedy against .91, .93 and .91 sampling at temperatures .25, .5 and 1 (160 maps, both seats). Greedy screens had also triggered rollbacks that weren't needed. The lab switched the bot, its gates and its benchmarks to sampling at ฯ„ = .5, then trained at .5 too, so the policy it trained was the policy it played.[9]

The project trains at ฯ„ = 1 and plays greedily, and here greedy play is fine: the champion scores .693 [.646, .736] against its teacher greedy and .655 [.607, .700] sampling at ฯ„ = 1 on the same seeds, a difference inside both intervals.[1] Its policy is sharp: the average entropy of its move probabilities stayed near 0.1, where three equally likely moves would give 1.1. Two contest winners played greedily: reCurse's policy "is played with the action having the highest probability", and Indrill's "selection is a simple max. No randomness".[6][22] Measure both before you submit, and if sampling wins, train at the temperature you will play at.

11GAE and the clipped loss in code

These complete programs compute GAE and PPO's clipped loss on the worked examples of sections 03 and 04 and check every number. The C++ and Rust programs work out by hand the slope of the loss on each move's logit; the PyTorch tab lets autograd do it and takes one optimizer.step(). All three agree on every number they print (the PyTorch tab skips the two single-move checks): the clipped decision gets a slope of exactly 0, and one gradient step with a learning rate of 1 moves the chosen move from 0.25 to 0.315 in the second decision and from 0.50 to 0.421 in the third, whose advantage is โˆ’1.

GAE and PPO's clipped loss
// GAE and PPO's clipped loss, checked against the worked examples on this page.
// It prints each result and exits with 1 if any number differs from the page's.
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdio>
#include <vector>

// Credit for one player's decisions in one game. values[i] is the critic's estimate before
// decision i; the only reward is the result (+1 win, -1 loss), after the last decision.
struct Credit {
    std::vector<double> advantages;     // how much better each move did than the critic expected
    std::vector<double> value_targets;  // what the critic trains toward: advantage + value
};

Credit gae(const std::vector<double>& values, double reward, double discount, double gae_lambda) {
    const int count = static_cast<int>(values.size());
    Credit credit{std::vector<double>(count), std::vector<double>(count)};
    double running = 0.0;  // the advantage of the decision after this one
    for (int step = count - 1; step >= 0; --step) {
        const bool last = step == count - 1;
        const double next_value = last ? 0.0 : values[step + 1];  // nothing follows the end
        const double step_reward = last ? reward : 0.0;            // only the result pays
        // TD error: what happened (reward, then the critic's next estimate) minus what was expected.
        const double td_error = step_reward + discount * next_value - values[step];
        // Later surprises count as well, shrinking by discount * gae_lambda for each step back.
        running = td_error + discount * gae_lambda * running;
        credit.advantages[step] = running;
        credit.value_targets[step] = running + values[step];
    }
    return credit;
}

// One decision's share of PPO's clipped objective.
struct ClippedTerm {
    double ratio;      // the move's probability now / its probability when the game was played
    double objective;  // min(ratio * advantage, clipped ratio * advantage)
    double slope;      // d objective / d ratio: 0 once the clip has taken over
};

ClippedTerm clipped_term(double new_log_prob, double old_log_prob, double advantage, double clip) {
    const double ratio = std::exp(new_log_prob - old_log_prob);
    const double clipped_ratio = std::clamp(ratio, 1.0 - clip, 1.0 + clip);
    // The smaller of the two: no credit for moving the ratio past the clip.
    const double objective = std::min(ratio * advantage, clipped_ratio * advantage);
    // Flat once the ratio has moved past the clip in the direction the advantage asks for.
    const bool flat = (advantage > 0 && ratio > 1.0 + clip) || (advantage < 0 && ratio < 1.0 - clip);
    return {ratio, objective, flat ? 0.0 : advantage};
}

constexpr int kMoves = 3;
using Logits = std::array<double, kMoves>;

struct Decision {
    Logits logits;        // the network's scores for the three moves now
    int chosen;           // the move played when the game was recorded
    double old_log_prob;  // the log probability of that move back then
    double advantage;     // from GAE
};

Logits softmax(const Logits& logits) {
    const double top = *std::max_element(logits.begin(), logits.end());  // keeps exp() in range
    Logits probabilities{};
    double total = 0.0;
    for (int k = 0; k < kMoves; ++k) {
        probabilities[k] = std::exp(logits[k] - top);
        total += probabilities[k];
    }
    for (double& probability : probabilities) probability /= total;
    return probabilities;
}

// The minibatch's policy loss (minus the mean objective, because optimizers minimize) and its
// slope on every logit: the numbers PyTorch's backward() computes for the same batch.
double clipped_loss(const std::vector<Decision>& batch, double clip, std::vector<Logits>& gradients) {
    const double count = static_cast<double>(batch.size());
    double total = 0.0;
    gradients.assign(batch.size(), Logits{});
    for (std::size_t i = 0; i < batch.size(); ++i) {
        const Decision& decision = batch[i];
        const Logits probabilities = softmax(decision.logits);
        const ClippedTerm term = clipped_term(std::log(probabilities[decision.chosen]), decision.old_log_prob,
                                              decision.advantage, clip);
        total += term.objective;
        if (term.slope == 0.0) continue;  // clipped: this decision adds nothing to the gradient
        // d ratio / d logit k = ratio * ((k == chosen) - p_k): raising the chosen move's logit raises its
        // probability, and raising any other logit takes probability away from it.
        for (int k = 0; k < kMoves; ++k) {
            const double is_chosen = k == decision.chosen ? 1.0 : 0.0;
            gradients[i][k] = -term.slope * term.ratio * (is_chosen - probabilities[k]) / count;
        }
    }
    return -total / count;
}

int failures = 0;
// Prints one row of results and counts any that differ from the page's numbers by 1e-6 or more.
void expect(const char* what, const std::vector<double>& actual, const std::vector<double>& expected) {
    std::printf("%-32s", what);
    for (std::size_t i = 0; i < actual.size(); ++i) {
        const bool ok = std::abs(actual[i] - expected[i]) < 1e-6;
        std::printf(" %10.6f%s", actual[i], ok ? "" : " (MISMATCH)");
        if (!ok) ++failures;
    }
    std::printf("\n");
}

int main() {
    // Section 03: three moves, the critic's values before them, then a loss. No discount.
    const std::vector<double> values{0.2, 0.6, 0.1};
    expect("lost, lambda 0", gae(values, -1.0, 1.0, 0.0).advantages, {0.4, -0.5, -1.1});
    expect("lost, lambda 1", gae(values, -1.0, 1.0, 1.0).advantages, {-1.2, -1.6, -1.1});
    expect("lost, lambda 0.5", gae(values, -1.0, 1.0, 0.5).advantages, {-0.125, -1.05, -1.1});
    expect("won, lambda 0.5", gae(values, +1.0, 1.0, 0.5).advantages, {0.375, -0.05, 0.9});
    expect("value targets, lost, 0.5", gae(values, -1.0, 1.0, 0.5).value_targets, {0.075, -0.45, -1.0});

    // The widget's 12-turn game at the project's settings: credit for the blunder on turn 8.
    const std::vector<double> game{0.00, 0.05, 0.02, 0.10, 0.45, 0.50, 0.48, 0.55, 0.10, 0.15, 0.25, 0.30};
    expect("blunder, game won / lost",
           {gae(game, +1.0, 0.995, 0.95).advantages[7], gae(game, -1.0, 0.995, 0.95).advantages[7]},
           {0.284085, -1.312591});

    // Section 04: a move whose probability rose from 0.30 to 0.40 since the game was played.
    const double clip = 0.2;
    const ClippedTerm good = clipped_term(std::log(0.40), std::log(0.30), +1.0, clip);
    const ClippedTerm bad = clipped_term(std::log(0.40), std::log(0.30), -1.0, clip);
    expect("advantage +1: objective, slope", {good.objective, good.slope}, {1.2, 0.0});
    expect("advantage -1: objective, slope", {bad.objective, bad.slope}, {-4.0 / 3.0, -1.0});

    // One minibatch of three decisions, each of which played move 0.
    std::vector<Decision> batch{
        {{std::log(0.40), std::log(0.35), std::log(0.25)}, 0, std::log(0.30), +1.0},  // past the clip
        {{std::log(0.25), std::log(0.50), std::log(0.25)}, 0, std::log(0.30), +1.0},  // inside it
        {{std::log(0.50), std::log(0.30), std::log(0.20)}, 0, std::log(0.40), -1.0},  // rose, but was bad
    };
    std::vector<Logits> gradients;
    expect("minibatch loss", {clipped_loss(batch, clip, gradients)}, {-0.261111111});
    const std::vector<std::vector<double>> gradient_expected{
        {0.0, 0.0, 0.0}, {-0.208333333, 0.138888889, 0.069444444}, {0.208333333, -0.125, -0.083333333}};
    const std::vector<std::vector<double>> after_expected{
        {0.40, 0.35, 0.25}, {0.315381634, 0.445727777, 0.238890590}, {0.421437667, 0.352898187, 0.225664147}};
    for (std::size_t i = 0; i < batch.size(); ++i) {
        expect("gradient", {gradients[i].begin(), gradients[i].end()}, gradient_expected[i]);
        // One plain gradient step with a learning rate of 1: logits -= gradient.
        for (int k = 0; k < kMoves; ++k) batch[i].logits[k] -= gradients[i][k];
        const Logits after = softmax(batch[i].logits);
        expect("probabilities after one step", {after.begin(), after.end()}, after_expected[i]);
    }
    std::printf("%s\n", failures ? "FAILED" : "all checks passed");
    return failures ? 1 : 0;
}
// GAE and PPO's clipped loss, checked against the worked examples on this page.
// It prints each result and exits with 1 if any number differs from the page's.

// Credit for one player's decisions in one game. values[i] is the critic's estimate before
// decision i; the only reward is the result (+1 win, -1 loss), after the last decision.
struct Credit {
    advantages: Vec<f64>,    // how much better each move did than the critic expected
    value_targets: Vec<f64>, // what the critic trains toward: advantage + value
}

fn gae(values: &[f64], reward: f64, discount: f64, gae_lambda: f64) -> Credit {
    let count = values.len();
    let mut credit = Credit { advantages: vec![0.0; count], value_targets: vec![0.0; count] };
    let mut running = 0.0; // the advantage of the decision after this one
    for step in (0..count).rev() {
        let last = step == count - 1;
        let next_value = if last { 0.0 } else { values[step + 1] }; // nothing follows the end
        let step_reward = if last { reward } else { 0.0 }; // only the result pays
        // TD error: what happened (reward, then the critic's next estimate) minus what was expected.
        let td_error = step_reward + discount * next_value - values[step];
        // Later surprises count as well, shrinking by discount * gae_lambda for each step back.
        running = td_error + discount * gae_lambda * running;
        credit.advantages[step] = running;
        credit.value_targets[step] = running + values[step];
    }
    credit
}

// One decision's share of PPO's clipped objective.
struct ClippedTerm {
    ratio: f64,     // the move's probability now / its probability when the game was played
    objective: f64, // min(ratio * advantage, clipped ratio * advantage)
    slope: f64,     // d objective / d ratio: 0 once the clip has taken over
}

fn clipped_term(new_log_prob: f64, old_log_prob: f64, advantage: f64, clip: f64) -> ClippedTerm {
    let ratio = (new_log_prob - old_log_prob).exp();
    let clipped_ratio = ratio.clamp(1.0 - clip, 1.0 + clip);
    // The smaller of the two: no credit for moving the ratio past the clip.
    let objective = (ratio * advantage).min(clipped_ratio * advantage);
    // Flat once the ratio has moved past the clip in the direction the advantage asks for.
    let flat = (advantage > 0.0 && ratio > 1.0 + clip) || (advantage < 0.0 && ratio < 1.0 - clip);
    ClippedTerm { ratio, objective, slope: if flat { 0.0 } else { advantage } }
}

const MOVES: usize = 3;
type Logits = [f64; MOVES];

struct Decision {
    logits: Logits,    // the network's scores for the three moves now
    chosen: usize,     // the move played when the game was recorded
    old_log_prob: f64, // the log probability of that move back then
    advantage: f64,    // from GAE
}

fn softmax(logits: &Logits) -> Logits {
    let top = logits.iter().copied().fold(f64::NEG_INFINITY, f64::max); // keeps exp() in range
    let mut probabilities = [0.0; MOVES];
    let mut total = 0.0;
    for k in 0..MOVES {
        probabilities[k] = (logits[k] - top).exp();
        total += probabilities[k];
    }
    for probability in probabilities.iter_mut() {
        *probability /= total;
    }
    probabilities
}

// The minibatch's policy loss (minus the mean objective, because optimizers minimize) and its
// slope on every logit: the numbers PyTorch's backward() computes for the same batch.
fn clipped_loss(batch: &[Decision], clip: f64, gradients: &mut Vec<Logits>) -> f64 {
    let count = batch.len() as f64;
    let mut total = 0.0;
    gradients.clear();
    gradients.resize(batch.len(), [0.0; MOVES]);
    for (i, decision) in batch.iter().enumerate() {
        let probabilities = softmax(&decision.logits);
        let term = clipped_term(probabilities[decision.chosen].ln(), decision.old_log_prob, decision.advantage, clip);
        total += term.objective;
        if term.slope == 0.0 {
            continue; // clipped: this decision adds nothing to the gradient
        }
        // d ratio / d logit k = ratio * ((k == chosen) - p_k): raising the chosen move's logit raises its
        // probability, and raising any other logit takes probability away from it.
        for k in 0..MOVES {
            let is_chosen = if k == decision.chosen { 1.0 } else { 0.0 };
            gradients[i][k] = -term.slope * term.ratio * (is_chosen - probabilities[k]) / count;
        }
    }
    -total / count
}

// Prints one row of results and counts any that differ from the page's numbers by 1e-6 or more.
fn expect(what: &str, actual: &[f64], expected: &[f64], failures: &mut u32) {
    print!("{:<32}", what);
    for (value, wanted) in actual.iter().zip(expected) {
        let ok = (value - wanted).abs() < 1e-6;
        print!(" {:10.6}{}", value, if ok { "" } else { " (MISMATCH)" });
        if !ok {
            *failures += 1;
        }
    }
    println!();
}

fn main() {
    let mut failures = 0;
    // Section 03: three moves, the critic's values before them, then a loss. No discount.
    let values = [0.2, 0.6, 0.1];
    expect("lost, lambda 0", &gae(&values, -1.0, 1.0, 0.0).advantages, &[0.4, -0.5, -1.1], &mut failures);
    expect("lost, lambda 1", &gae(&values, -1.0, 1.0, 1.0).advantages, &[-1.2, -1.6, -1.1], &mut failures);
    expect("lost, lambda 0.5", &gae(&values, -1.0, 1.0, 0.5).advantages, &[-0.125, -1.05, -1.1], &mut failures);
    expect("won, lambda 0.5", &gae(&values, 1.0, 1.0, 0.5).advantages, &[0.375, -0.05, 0.9], &mut failures);
    expect("value targets, lost, 0.5", &gae(&values, -1.0, 1.0, 0.5).value_targets, &[0.075, -0.45, -1.0],
           &mut failures);

    // The widget's 12-turn game at the project's settings: credit for the blunder on turn 8.
    let game = [0.00, 0.05, 0.02, 0.10, 0.45, 0.50, 0.48, 0.55, 0.10, 0.15, 0.25, 0.30];
    let blunder = [gae(&game, 1.0, 0.995, 0.95).advantages[7], gae(&game, -1.0, 0.995, 0.95).advantages[7]];
    expect("blunder, game won / lost", &blunder, &[0.284085, -1.312591], &mut failures);

    // Section 04: a move whose probability rose from 0.30 to 0.40 since the game was played.
    let clip = 0.2;
    let good = clipped_term(0.40_f64.ln(), 0.30_f64.ln(), 1.0, clip);
    let bad = clipped_term(0.40_f64.ln(), 0.30_f64.ln(), -1.0, clip);
    expect("advantage +1: objective, slope", &[good.objective, good.slope], &[1.2, 0.0], &mut failures);
    expect("advantage -1: objective, slope", &[bad.objective, bad.slope], &[-4.0 / 3.0, -1.0], &mut failures);

    // One minibatch of three decisions, each of which played move 0.
    let decision = |probabilities: [f64; MOVES], old: f64, advantage: f64| Decision {
        logits: probabilities.map(f64::ln),
        chosen: 0,
        old_log_prob: old.ln(),
        advantage,
    };
    let mut batch = vec![
        decision([0.40, 0.35, 0.25], 0.30, 1.0),  // past the clip
        decision([0.25, 0.50, 0.25], 0.30, 1.0),  // inside it
        decision([0.50, 0.30, 0.20], 0.40, -1.0), // rose, but was bad
    ];
    let mut gradients = Vec::new();
    expect("minibatch loss", &[clipped_loss(&batch, clip, &mut gradients)], &[-0.261111111], &mut failures);
    let gradient_expected = [[0.0, 0.0, 0.0], [-0.208333333, 0.138888889, 0.069444444], [0.208333333, -0.125, -0.083333333]];
    let after_expected = [[0.40, 0.35, 0.25], [0.315381634, 0.445727777, 0.238890590], [0.421437667, 0.352898187, 0.225664147]];
    for i in 0..batch.len() {
        expect("gradient", &gradients[i], &gradient_expected[i], &mut failures);
        // One plain gradient step with a learning rate of 1: logits -= gradient.
        for k in 0..MOVES {
            batch[i].logits[k] -= gradients[i][k];
        }
        expect("probabilities after one step", &softmax(&batch[i].logits), &after_expected[i], &mut failures);
    }
    println!("{}", if failures > 0 { "FAILED" } else { "all checks passed" });
    std::process::exit(if failures > 0 { 1 } else { 0 });
}
# The same checks with PyTorch: GAE in plain Python, then one PPO update step in which autograd
# computes the slopes the C++ and Rust programs work out by hand. Exits with 1 on a mismatch.
import sys

import torch

failures = 0


def expect(what, actual, expected):
    """Prints one row of results and counts any that differ from the page's numbers by 1e-6 or more."""
    global failures
    row = []
    for value, wanted in zip(actual, expected):
        ok = abs(value-wanted) < 1e-6
        row.append(f'{value:10.6f}' + ('' if ok else ' (MISMATCH)'))
        failures += 0 if ok else 1
    print(f'{what:<32}', ' '.join(row))


def gae(values, reward, discount, gae_lambda):
    """Advantages and value targets for one player's decisions in one game; the only reward is the
    result (+1 win, -1 loss), after the last decision. Same arithmetic as gae() in ppo.py."""
    advantages = [0.0]*len(values)
    running = 0.0                                    # the advantage of the decision after this one
    for step in reversed(range(len(values))):
        last = step == len(values)-1
        next_value = 0.0 if last else values[step+1]   # nothing follows the end
        step_reward = reward if last else 0.0          # only the result pays
        td_error = step_reward+discount*next_value-values[step]
        running = td_error+discount*gae_lambda*running
        advantages[step] = running
    return advantages, [advantage+value for advantage, value in zip(advantages, values)]


# Section 03: three moves, the critic's values before them, then a loss. No discount.
values = [0.2, 0.6, 0.1]
expect('lost, lambda 0', gae(values, -1.0, 1.0, 0.0)[0], [0.4, -0.5, -1.1])
expect('lost, lambda 1', gae(values, -1.0, 1.0, 1.0)[0], [-1.2, -1.6, -1.1])
expect('lost, lambda 0.5', gae(values, -1.0, 1.0, 0.5)[0], [-0.125, -1.05, -1.1])
expect('won, lambda 0.5', gae(values, 1.0, 1.0, 0.5)[0], [0.375, -0.05, 0.9])
expect('value targets, lost, 0.5', gae(values, -1.0, 1.0, 0.5)[1], [0.075, -0.45, -1.0])

# The widget's 12-turn game at the project's settings: credit for the blunder on turn 8.
game = [0.00, 0.05, 0.02, 0.10, 0.45, 0.50, 0.48, 0.55, 0.10, 0.15, 0.25, 0.30]
expect('blunder, game won / lost', [gae(game, 1.0, 0.995, 0.95)[0][7], gae(game, -1.0, 0.995, 0.95)[0][7]],
       [0.284085, -1.312591])

# Section 04: one minibatch of three decisions, each of which played move 0. float64 so the
# numbers match the C++ and Rust programs to the last digit printed.
clip = 0.2
probabilities = torch.tensor([[0.40, 0.35, 0.25],     # past the clip
                              [0.25, 0.50, 0.25],     # inside it
                              [0.50, 0.30, 0.20]],    # rose, but was bad
                             dtype=torch.float64)
logits = probabilities.log().requires_grad_()       # the weights this step will change
chosen = torch.tensor([0, 0, 0])
old_log_probs = torch.tensor([0.30, 0.30, 0.40], dtype=torch.float64).log()
advantages = torch.tensor([1.0, 1.0, -1.0], dtype=torch.float64)
optimizer = torch.optim.SGD([logits], lr=1.0)        # the plain step of the C++ and Rust programs

new_log_probs = torch.log_softmax(logits, 1).gather(1, chosen[:, None]).squeeze(1)
ratio = (new_log_probs-old_log_probs).exp()
# The same min as ppo.py: the clipped term takes over only where it is the more pessimistic one.
loss = -torch.min(ratio*advantages, ratio.clamp(1-clip, 1+clip)*advantages).mean()
optimizer.zero_grad()                               # gradients add up across backward() calls
loss.backward()                                     # autograd fills logits.grad
expect('minibatch loss', [loss.item()], [-0.261111111])
gradient_expected = [[0.0, 0.0, 0.0], [-0.208333333, 0.138888889, 0.069444444], [0.208333333, -0.125, -0.083333333]]
for row, wanted in zip(logits.grad.tolist(), gradient_expected):
    expect('gradient', row, wanted)
optimizer.step()                                    # one update: logits -= lr * logits.grad
after_expected = [[0.40, 0.35, 0.25], [0.315381634, 0.445727777, 0.238890590], [0.421437667, 0.352898187, 0.225664147]]
for row, wanted in zip(torch.softmax(logits, 1).tolist(), after_expected):
    expect('probabilities after one step', row, wanted)
print('FAILED' if failures else 'all checks passed')
sys.exit(1 if failures else 0)
What's intentionally missing

A network: here the logits themselves are the weights. Advantages rescaled within each minibatch, the critic's loss, the entropy bonus, minibatches drawn at random, several epochs, and Adam in place of plain gradient steps. The project's ppo.py in section 07 has all of them.

12Where self-play goes wrong

A score against your own past isn't progress

A learner can beat every snapshot in its pool while it gets worse against everything else. Keep fixed bots in training, measure every checkpoint against them on seeds training never uses, and gate on them.

A NaN from the mask in the entropy bonus. An illegal move's log-probability is โˆ’โˆž, and 0 ร— โˆ’โˆž is NaN: in the project the first PPO step turned every weight into NaN. Zero the masked log-probabilities first, as entropy() in model.py does (Neural Networks for Game Bots).

Reading the clone's .500 against itself as a measurement. Light cycles have no draws, and two identical deterministic players play the same game from both seats of a seed, so each wins one: the curve's first point against the clone is that mirror.

A learning rate set for a random network. A clone's policy is already sharp: in a short test at 1 ร— 10โˆ’4, the project's updates moved it by only 0.0001 to 0.0004 of KL divergence, so the runs used 2 ร— 10โˆ’4 and an entropy bonus of 0.005.[1]

A shaping term the network can't see, which stays in the advantages and acts as a real reward (section 09), and training at one temperature while playing at another (section 10).

Training one against one for a game of four. A network that has only met one opponent has never practiced a battle of three or four cycles.

13What's next

Building the Trainer runs this loop faster and longer. The games move back to the Rust engine, 512 at a time behind the C interface, the learner trains on the GPU, and the trainer gains checkpoints, a clean stop on Ctrl+C, resuming and a status file. It measures games per hour before and after on the same machine.

The project so far, with this step's files in teal:

project/
โ”œโ”€โ”€ engine/                    Rust: rules, bots, features, network, C interface
โ”‚   โ”œโ”€โ”€ Cargo.toml
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ”œโ”€โ”€ board.rs           grid, distances, room, Voronoi territory
โ”‚       โ”œโ”€โ”€ game.rs            rules, seeded starts, CodinGame frames
โ”‚       โ”œโ”€โ”€ bots.rs            flood-fill and Voronoi bots (the teacher)
โ”‚       โ”œโ”€โ”€ alphabeta.rs       the alpha-beta bot (a yardstick)
โ”‚       โ”œโ”€โ”€ features.rs        what the network sees
โ”‚       โ”œโ”€โ”€ net.rs             the network's forward pass in Rust
โ”‚       โ”œโ”€โ”€ ffi.rs             the C interface Python calls
โ”‚       โ””โ”€โ”€ lib.rs
โ”œโ”€โ”€ train/
โ”‚   โ”œโ”€โ”€ tron_env.py            drives the engine from Python
โ”‚   โ”œโ”€โ”€ model.py               the network in PyTorch
โ”‚   โ”œโ”€โ”€ parity.py              Rust and PyTorch choose the same moves
โ”‚   โ”œโ”€โ”€ record.py              records the teacher's decisions
โ”‚   โ”œโ”€โ”€ bc.py                  behavior cloning
โ”‚   โ”œโ”€โ”€ play.py                matches, both seats, Wilson intervals
โ”‚   โ”œโ”€โ”€ evaluate.py            a network against the yardsticks
โ”‚   โ”œโ”€โ”€ tron.py                light cycles in plain Python
โ”‚   โ””โ”€โ”€ ppo.py                 self-play PPO in one process
โ”œโ”€โ”€ checks/
โ”‚   โ””โ”€โ”€ python_engine.py       the Python engine against the Rust one
โ”œโ”€โ”€ data/
โ”‚   โ”œโ”€โ”€ bc-train.npz           496,402 decisions from 3,000 games
โ”‚   โ””โ”€โ”€ bc-heldout.npz         49,864 decisions from 300 games
โ””โ”€โ”€ runs/
    โ”œโ”€โ”€ bc/
    โ”‚   โ”œโ”€โ”€ clone.pt           the clone's weights
    โ”‚   โ””โ”€โ”€ bc.json            its training log
    โ””โ”€โ”€ ppo-single/
        โ”œโ”€โ”€ config.json        the run's settings
        โ”œโ”€โ”€ metrics.jsonl      one line per iteration, gates included
        โ”œโ”€โ”€ learner.pt         the last weights
        โ””โ”€โ”€ champion.pt        the gate's champion

14Sources

Quotes are from each author's own text. Numbers marked as the project's were measured with the code on this page and the series' earlier pages; numbers marked as the author's lab are first-hand records from one lab and aren't general results. Widget readouts are computed in the page, exactly or from seeded episodes.

  1. Build a Legend Bot running project, steps 2 and 3, run on October 30, 2026 on one machine (Intel Core i9-11900KF; NVIDIA RTX 3080 Ti shared with other training jobs). The clone's matches (runs/logs/eval-clone.log); the single-process run (runs/ppo-single/, runs/logs/eval-single-champion.log); the batched run's strength curve and gates (runs/ppo/curve.jsonl, runs/ppo/metrics.jsonl); the final 1,000-game test (runs/logs/final-alphabeta-test.log); the Python-against-Rust engine check; and the champion sampled at temperature 1 against its teacher. The code is on this page and the series' earlier pages.
  2. Richard S. Sutton and Andrew G. Barto, 2018, second edition. Reinforcement Learning: An Introduction, MIT Press. Section 3.2 (reward), 3.3 (return and discount rate), 13.4 (a baseline that doesn't depend on the action) and 13.5 (the critic).
  3. putibuzu, 2026. Rails and soup, CodinGame forum, Summer Challenge 2026 feedback and strategies, post 4. A discount factor too small for a reward that comes only at the end.
  4. Ronald J. Williams, 1992. Simple Statistical Gradient-Following Algorithms for Connectionist Reinforcement Learning, Machine Learning 8, 229โ€“256 (author's preprint). REINFORCE: weight changes proportional to the reward minus a baseline.
  5. John Schulman, Philipp Moritz, Sergey Levine, Michael I. Jordan, Pieter Abbeel, 2016. High-Dimensional Continuous Control Using Generalized Advantage Estimation, ICLR 2016. The advantage function, the TD residual, GAE and its trade between bias and variance, and section 4 on GAE as reward shaping with the critic as the potential.
  6. reCurse, 2024. reCurse's Codingame Fall Challenge 2023 Postmortem (gist created 2024-01-08, last updated 2026-09-20). PPO with ฮณ = 0.99 and ฮป = 0.95, greedy play, and the late losses from training only against the current network.
  7. John Schulman, Filip Wolski, Prafulla Dhariwal, Alec Radford, Oleg Klimov, 2017. Proximal Policy Optimization Algorithms, arXiv:1707.06347 (preprint). The probability ratio, the clipped objective, several epochs of minibatch updates per batch, an iteration in Algorithm 1, and the clip settings for the robot-control and Atari runs.
  8. Volodymyr Mnih et al., 2016. Asynchronous Methods for Deep Reinforcement Learning, ICML 2016. Adding the policy's entropy to the objective to discourage early convergence on one action.
  9. Author's lab records, 2026 (private repository). The trainer's KL stop, opponent mix and pool weights, framework/ppo_selfplay.py and framework/rl/trainer.py; Spring Challenge 2022's drift, rollback and guards, and its greedy and sampled results, docs/multi-game.md and experiments/runs/spring2022/evaluations.jsonl; the rewards, framework/rl/rewards.py, games/backtrackking/rl.py and games/spring2022/rl.py; the wild-mana diagnosis, experiments/spring2022-gold-20261029.json; Back Track King's main run and its promotions, experiments/runs/nn-main/metrics.jsonl and evaluations.jsonl; its best live rank and later variants, AGENTS.md (October 25 and 26 entries) and experiments/live-submissions/summer-challenge-2026-back-track-king/submitted.jsonl; Troll Farm's run and diagnosis, experiments/runs/trollfarm/metrics.jsonl and docs/postmortem-lessons.md.
  10. JOiy, 2026. JOiy's bot: Summer challenge 2026 (gist, last updated 2026-10-10). His PPO settings, curves that look healthy without the bot improving on the ladder, and a terminal-only reward against per-turn shaping.
  11. Pduhard, 2026. Summer Challenge 2026, Back Track King: 3rd, README. 512 games at once with both seats trained, half the games against a pool that draws the snapshots that beat the network more often, the income-gap reward, and two shaping attempts that didn't work.
  12. Gerald Tesauro, 1995. Temporal Difference Learning and TD-Gammon, Communications of the ACM 38(3), 58โ€“68 (transcription published with the ACM's permission). A network trained by playing itself, and the risk of self-play in deterministic games.
  13. Oriol Vinyals et al., 2019. Grandmaster level in StarCraft II using multi-agent reinforcement learning, Nature 575, 350โ€“354 (authors' manuscript). Self-play that chases cycles, fictitious self-play, and opponents drawn by how often they beat the learner.
  14. Christopher Berner et al. (OpenAI), 2019. Dota 2 with Large Scale Deep Reinforcement Learning, arXiv:1912.06680, section 3.2 and appendix N. 80% of games against the latest policy and 20% against past versions.
  15. Wojciech Marian Czarnecki et al., 2020. Real World Games Look Like Spinning Tops, NeurIPS 2020. How population size decides whether self-play training converges or cycles, in their two-player games.
  16. David Silver et al., 2017. Mastering the game of Go without human knowledge, Nature 550, 354โ€“359 (authors' manuscript). The evaluator that promoted a network only above 55% of 400 games.
  17. David Silver et al., 2018. A general reinforcement learning algorithm that masters chess, shogi, and Go through self-play, Science 362, 1140โ€“1144 (preprint). Self-play games always generated by the latest network, with no gate.
  18. CodinGame. Tron Battle, game statement. Battles of 2 to 4 cycles, with one opponent in the first league.
  19. Andrew Y. Ng, Daishi Harada, Stuart Russell, 1999. Policy invariance under reward transformations: Theory and application to reward shaping, ICML 1999. Potential-based shaping and its guarantee, and Randlรธv and Alstrรธm's bicycle learner.
  20. Dario Amodei, Chris Olah, Jacob Steinhardt, Paul Christiano, John Schulman, Dan Manรฉ, 2016. Concrete Problems in AI Safety, arXiv:1606.06565, section 4 on reward hacking.
  21. Victoria Krakovna et al., 2020. Specification gaming: the flip side of AI ingenuity, DeepMind blog, April 21. Shaping that isn't potential-based, and the boat that circled to hit the same green blocks.
  22. Indrill, 2026. Ranked 1st, CodinGame forum, Summer Challenge 2026 feedback and strategies, post 5. Deep Q-learning, a reward reshaped with a projected final score, and greedy selection.
  23. delineate, 2026. Codingame Spring Challenge 2026 (delineate) (gist). Shaping to teach the first steps, a network that gamed it, and training without it.