Train a Connect Four agent by self-play against a league of its past selves, rate every snapshot with Elo, and serve the champion as an app you can play against.

Self-play league for Connect Four

Code available on GitHub.

In a two-player game there is no fixed environment to beat: the opponent is the environment, and the best opponent for a learner is usually another copy of itself. Self-play is how AlphaGo, OpenAI Five, and AlphaStar were trained. It comes with a well-known failure: a learner that only plays its current self can go in circles, learning to beat today’s habits while forgetting how to beat yesterday’s. AlphaStar’s answer was a league: keep every past snapshot as an opponent, train against the ones the learner finds hardest, and rate everyone against everyone so progress is measured instead of assumed.

This tutorial builds a small league for Connect Four. The game and the training both run as batched tensors, so a generation plays tens of thousands of games on one GPU in a few seconds per iteration. Flyte runs everything around it:

  • a warm GPU learner that keeps its CUDA context between generations;
  • a fan-out of head-to-head matches on a pool of CPU workers after every generation;
  • a joint Elo fit over every result, and a live report with the ladder and the score matrix;
  • the champion, published as an artifact and served as a FastAPI app you can play in the browser.

How a generation works

            flowchart LR
    D["league<br/>CPU driver"] --> P["pick_opponents<br/>bots + strongest snapshots"]
    P --> T["train_generation<br/>warm GPU, PPO self-play"]
    T -- "snapshot (File)" --> M1["play_match<br/>vs random"]
    T --> M2["play_match<br/>vs tactical"]
    T --> M3["play_match<br/>vs gen-1 ... gen-n"]
    M1 & M2 & M3 --> E["fit Elo<br/>update report"]
    E --> D

    classDef gpu fill:#3b82f633,stroke:#3b82f6,stroke-width:1.5px
    classDef cpu fill:#94a3b826,stroke:#94a3b8,stroke-width:1px
    class T gpu
    class D,P,M1,M2,M3,E cpu
        
  1. The driver picks the learner’s opponents for this generation: two scripted bots, and the three highest-rated snapshots so far.
  2. train_generation plays 2,048 games per iteration against those opponents and itself, and runs a PPO update on the learner’s moves. After 25 iterations it saves a snapshot.
  3. The new snapshot plays every member of the league, 400 games per pairing, all at once on the CPU pool.
  4. The driver refits Elo over every match so far and redraws the report.
Need Construct
Keep the GPU learner warm between generations flyte.ReusePolicy(replicas=1) on the training environment
Rate a snapshot against the whole league at once asyncio.gather over play_match calls on a reusable CPU pool
Don’t redo work after a driver crash cache="auto" on training and matches, and retries=2 on the driver
One view per generation flyte.group
Watch the ladder while it trains report=True on the driver and on every training generation
Keep and serve the champion flyte.artifacts, and a FastAPIAppEnvironment that loads the latest league output

Getting started

You need a Union cluster with an NVIDIA L4, or any CUDA GPU (change train_env in league.py).

git clone https://github.com/unionai/unionai-examples
cd unionai-examples/v2/tutorials/self_play_league
python league.py            # 20 generations, about 25 minutes
python league.py --smoke    # 2 short generations
python app.py               # serve the champion of the latest league run

The game as tensors

Every board is stored from the point of view of the player to move, so one network plays both sides. A move places a stone, checks for four in a row with a 2D convolution, and flips the board for the other player. Thousands of games step together on the GPU.

connect4.py
class Boards:
    """A batch of Connect Four games, stepped together on one device."""

    def __init__(self, n: int, device: str = "cpu"):
        self.n, self.device = n, device
        self.kernels = _K.to(device)
        self.board = torch.zeros(n, ROWS, COLS, device=device)
        self.height = torch.zeros(n, COLS, dtype=torch.long, device=device)
        self.moves = torch.zeros(n, dtype=torch.long, device=device)

    def reset(self, mask: torch.Tensor | None = None) -> None:
        mask = torch.ones(self.n, dtype=torch.bool, device=self.device) if mask is None else mask
        self.board[mask] = 0
        self.height[mask] = 0
        self.moves[mask] = 0

    def legal(self) -> torch.Tensor:
        return self.height < ROWS

    def wins(self, board: torch.Tensor) -> torch.Tensor:
        """True where the +1 player has four in a row."""
        hits = F.conv2d((board == 1).float().unsqueeze(1), self.kernels, padding=3)
        return (hits >= 4).flatten(1).any(1)

    def step(self, action: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        """Play ``action`` for the player to move. Returns (mover won, game over)."""
        idx = torch.arange(self.n, device=self.device)
        row = ROWS - 1 - self.height[idx, action]
        self.board[idx, row, action] = 1
        self.height[idx, action] += 1
        self.moves += 1
        won = self.wins(self.board)
        over = won | (self.moves >= ROWS * COLS)
        self.board = -self.board  # the other player moves next
        return won, over

The league starts with two scripted opponents. The tactical bot wins when it can, blocks when it must, and otherwise prefers the center. It beats a random player almost every time, and it is the bar the learner has to clear.

connect4.py
def random_bot(boards: Boards, gen: torch.Generator | None = None) -> torch.Tensor:
    return torch.multinomial(boards.legal().float(), 1, generator=gen).squeeze(1)

CENTER = torch.tensor([1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0])

def tactical_bot(boards: Boards, gen: torch.Generator | None = None) -> torch.Tensor:
    """Win if it can, block if it must, otherwise play a random center-weighted move."""
    win = winning_moves(boards, boards.board)
    block = winning_moves(boards, -boards.board)
    weights = boards.legal().float() * CENTER.to(boards.device)
    weights = torch.where(block.any(1, keepdim=True), block.float(), weights)
    weights = torch.where(win.any(1, keepdim=True), win.float(), weights)
    return torch.multinomial(weights, 1, generator=gen).squeeze(1)

BOTS = {"random": random_bot, "tactical": tactical_bot}

The network is a small residual convolutional network with a policy head and a value head. Illegal moves are masked out of the policy.

connect4.py
class PolicyValueNet(nn.Module):
    """A small residual conv net: board in, move logits and a value in [-1, 1] out."""

    def __init__(self, channels: int = 64, blocks: int = 4):
        super().__init__()
        self.stem = nn.Sequential(nn.Conv2d(2, channels, 3, padding=1), nn.ReLU())
        self.blocks = nn.ModuleList(
            nn.Sequential(nn.Conv2d(channels, channels, 3, padding=1), nn.ReLU(), nn.Conv2d(channels, channels, 3, padding=1))
            for _ in range(blocks)
        )
        self.policy = nn.Sequential(nn.Conv2d(channels, 2, 1), nn.Flatten(), nn.Linear(2 * ROWS * COLS, COLS))
        self.value = nn.Sequential(nn.Conv2d(channels, 1, 1), nn.Flatten(), nn.Linear(ROWS * COLS, 64), nn.ReLU(),
                                   nn.Linear(64, 1), nn.Tanh())

    def forward(self, board: torch.Tensor, legal: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        x = torch.stack([(board == 1).float(), (board == -1).float()], 1)
        h = self.stem(x)
        for block in self.blocks:
            h = F.relu(h + block(h))
        logits = self.policy(h).masked_fill(~legal, -1e9)
        return logits, self.value(h).squeeze(1)

Self-play training

A training iteration plays one batch of games against the opponents, splitting the games evenly between them. The learner moves first in half of the games. Only the learner’s moves are kept, and each one gets the final outcome of its game as its return.

selfplay.py
@torch.no_grad()
def collect(net: PolicyValueNet, opponents: list, games: int, device: str, gen: torch.Generator) -> dict:
    """Play ``games`` games in parallel, split evenly across ``opponents``, and keep the learner's moves."""
    boards = Boards(games, device)
    group = torch.arange(games, device=device) % len(opponents)  # which opponent each game faces
    learner_turn = torch.arange(games, device=device) % 2 == 0  # the learner moves first in half the games
    buf = {k: [] for k in ("board", "legal", "action", "logp", "value", "game")}
    outcome = torch.full((games,), float("nan"), device=device)
    alive = torch.ones(games, dtype=torch.bool, device=device)
    while alive.any():
        legal = boards.legal()
        logits, value = net(boards.board, legal)
        dist = torch.distributions.Categorical(logits=logits)
        action = torch.multinomial(dist.probs, 1, generator=gen).squeeze(1)
        for i, opponent in enumerate(opponents):
            mask = (group == i) & ~learner_turn & alive
            if mask.any():
                action[mask] = opponent(boards, gen)[mask]
        mine = learner_turn & alive
        for k, v in (("board", boards.board), ("legal", legal), ("action", action), ("logp", dist.log_prob(action)),
                     ("value", value), ("game", torch.arange(games, device=device))):
            buf[k].append(v[mine].clone())
        # Finished games keep playing a legal dummy move; their results are already recorded.
        action = torch.where(alive, action, legal.float().argmax(1))
        won, over = boards.step(action)
        newly = over & alive
        outcome[newly & won & learner_turn] = 1.0
        outcome[newly & won & ~learner_turn] = -1.0
        outcome[newly & ~won] = 0.0
        alive &= ~over
        learner_turn = ~learner_turn
    data = {k: torch.cat(v) for k, v in buf.items()}
    data["return"] = outcome[data.pop("game")]
    per_opponent = [outcome[group == i] for i in range(len(opponents))]
    data["results"] = [{"win": float((o == 1).float().mean()), "draw": float((o == 0).float().mean()),
                        "loss": float((o == -1).float().mean())} for o in per_opponent]
    return data

The update is PPO with the value head as the baseline. Because the return is the game’s outcome, the advantage of a move is how much better the game went than the value head expected from that position.

selfplay.py
def ppo_update(net: PolicyValueNet, opt: torch.optim.Optimizer, data: dict, epochs: int = 3,
               batch: int = 4096, clip: float = 0.2) -> dict:
    """PPO-clip on Monte Carlo returns, with a value baseline and an entropy bonus."""
    n = data["action"].shape[0]
    adv = data["return"] - data["value"]
    adv = (adv - adv.mean()) / (adv.std() + 1e-8)
    stats = {"policy_loss": 0.0, "value_loss": 0.0, "entropy": 0.0, "updates": 0}
    for _ in range(epochs):
        for idx in torch.randperm(n, device=adv.device).split(batch):
            logits, value = net(data["board"][idx], data["legal"][idx])
            dist = torch.distributions.Categorical(logits=logits)
            ratio = (dist.log_prob(data["action"][idx]) - data["logp"][idx]).exp()
            pg = -torch.min(ratio * adv[idx], ratio.clamp(1 - clip, 1 + clip) * adv[idx]).mean()
            vl = F.mse_loss(value, data["return"][idx])
            ent = dist.entropy().mean()
            loss = pg + 0.5 * vl - 0.01 * ent
            opt.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0)
            opt.step()
            stats["policy_loss"] += float(pg.detach())
            stats["value_loss"] += float(vl.detach())
            stats["entropy"] += float(ent.detach())
            stats["updates"] += 1
    return {k: v / max(stats["updates"], 1) for k, v in stats.items() if k != "updates"}

train_generation wraps this in a task. It loads the learner and its optimizer state from the previous generation’s checkpoint, trains, and returns the new checkpoint as a flyte.io.File. Its live report shows the score against each opponent as the iterations go by.

league.py
@train_env.task(report=True)
async def train_generation(
    generation: int, learner: File | None, opponents: list[Player], iterations: int = 25, games: int = 2048,
) -> tuple[File, str]:
    """Train the learner for one generation against the given league opponents and itself, and snapshot it."""
    import torch
    from connect4 import PolicyValueNet, net_bot
    from selfplay import train

    device = "cuda"
    torch.manual_seed(generation)
    net = PolicyValueNet().to(device)
    opt = torch.optim.Adam(net.parameters(), lr=3e-4)
    if learner is not None:
        state = torch.load(await learner.download(), map_location=device, weights_only=True)
        net.load_state_dict(state["model"])
        opt.load_state_dict(state["optimizer"])

    # Games are split evenly across the opponents and the current learner itself.
    names = [p.name for p in opponents] + ["self"]
    players = [await as_bot(p, device) for p in opponents] + [net_bot(net)]

    def live(row: dict) -> None:
        history.append(row)
        flyte.report.replace(reports.training_live(generation, history, names, iterations, games))
        flyte.report.flush()

    history: list[dict] = []
    train(net, opt, players, iterations, games, device, seed=generation, on_iteration=live)

    path = f"{tempfile.mkdtemp()}/gen-{generation}.pt"
    torch.save({"model": net.state_dict(), "optimizer": opt.state_dict(), "generation": generation}, path)
    return await File.from_local(path), json.dumps({"opponents": names, "history": history})

The league

The environments make the split between the two kinds of work explicit. Training is one warm GPU replica that every generation of a run reuses. Matches are a pool of CPU replicas that grows while a new snapshot plays the league, and shrinks after. Both cache their results, so a driver that is retried after a crash replays finished generations from the cache instead of training them again.

league.py
# unionai-reuse provides the bridge that reusable environments need.
image = flyte.Image.from_debian_base(name="connect4-league").with_pip_packages("torch==2.9.1", "numpy<3", "unionai-reuse==0.2.1")
# The game and the trainer are imported inside the tasks, so the code bundle lists them.
CODE = ["connect4.py", "selfplay.py"]

# The learner keeps a warm CUDA context and its optimizer between generations: one reusable
# GPU replica serves every train_generation call of a run.
train_env = flyte.TaskEnvironment(
    name="c4-train",
    image=image,
    resources=flyte.Resources(cpu=4, memory="16Gi", gpu="L4:1"),
    reusable=flyte.ReusePolicy(replicas=1, concurrency=1, idle_ttl=900),
    cache="auto",
    include=CODE,
)

# Matches are CPU work, and a new snapshot plays every member of the league at once. A warm
# pool of CPU replicas takes the fan-out without starting a pod per match.
match_env = flyte.TaskEnvironment(
    name="c4-match",
    image=image,
    resources=flyte.Resources(cpu=2, memory="4Gi"),
    reusable=flyte.ReusePolicy(replicas=(1, 4), concurrency=4, idle_ttl=600, scaledown_ttl=120),
    cache="auto",
    include=CODE,
)

driver_env = flyte.TaskEnvironment(
    name="c4-league-driver",
    image=image,
    resources=flyte.Resources(cpu=2, memory="4Gi"),
    depends_on=[train_env, match_env],
)

A match is a batch of games between two players, each greedy after a random opening, so that two deterministic players don’t replay the same game 400 times:

selfplay.py
@torch.no_grad()
def match(player_a, player_b, games: int, device: str = "cpu", seed: int = 0, opening: int = 4) -> dict:
    """Play ``games`` games between two players, alternating who moves first.

    The first ``opening`` moves of every game are random, so a pair of greedy players does not
    replay the same game every time. Returns A's wins, draws, and losses.
    """
    gen = torch.Generator(device=device).manual_seed(seed)
    boards = Boards(games, device)
    a_turn = torch.arange(games, device=device) % 2 == 0
    alive = torch.ones(games, dtype=torch.bool, device=device)
    result = torch.zeros(games, device=device)
    while alive.any():
        legal = boards.legal()
        if int(boards.moves.max()) < opening:
            action = torch.multinomial(legal.float(), 1, generator=gen).squeeze(1)
        else:
            action = torch.where(a_turn, player_a(boards, gen), player_b(boards, gen))
        action = torch.where(alive & legal.gather(1, action[:, None]).squeeze(1), action, legal.float().argmax(1))
        won, over = boards.step(action)
        newly = over & alive
        result[newly & won] = torch.where(a_turn[newly & won], 1.0, -1.0)
        alive &= ~over
        a_turn = ~a_turn
    return {"win": int((result == 1).sum()), "draw": int((result == 0).sum()), "loss": int((result == -1).sum())}

Elo

Each match gives a score from one side’s point of view. The ratings are fitted jointly over all of them, Bradley-Terry style, with the random bot pinned at 0. A difference of 400 points means the stronger player is expected to score 10 to 1.

elo.py
def fit_elo(results: list[dict], anchor: str = "random", anchor_rating: float = 0.0, iters: int = 2000) -> dict[str, float]:
    """Ratings that best explain every match result at once.

    Each result is ``{"a", "b", "win", "draw", "loss"}`` from A's side, and a draw counts as half
    a win for each player. Every pairing also gets one virtual draw, so a player who wins every
    game gets a large rating instead of an infinite one. Unlike updating Elo one game at a time,
    a joint fit does not depend on the order the matches finished in, so a re-run of the same
    matches gives the same ladder.
    """
    players = sorted({r["a"] for r in results} | {r["b"] for r in results})
    rating = {p: 0.0 for p in players}
    lr = 20.0
    for _ in range(iters):
        grad = {p: 0.0 for p in players}
        for r in results:
            n = r["win"] + r["draw"] + r["loss"] + 1
            expected = 1 / (1 + 10 ** ((rating[r["b"]] - rating[r["a"]]) / 400))
            score = (r["win"] + 0.5 * r["draw"] + 0.5) / n
            grad[r["a"]] += n * (score - expected)
            grad[r["b"]] -= n * (score - expected)
        total = sum(r["win"] + r["draw"] + r["loss"] + 1 for r in results) / max(len(players), 1)
        for p in players:
            rating[p] += lr * grad[p] / max(total, 1)
    shift = anchor_rating - rating.get(anchor, 0.0)
    return {p: round(v + shift, 1) for p, v in rating.items()}

Choosing opponents

The learner always plays itself and the two bots. The rest of its games go to the three highest-rated snapshots. Those are the opponents it has the most to learn from, and they stay in the mix even after newer snapshots arrive.

league.py
def pick_opponents(members: list[Player], results: list[dict], top: int = 3) -> list[Player]:
    """The bots, plus the strongest past snapshots by Elo.

    Training only against recent snapshots lets the learner forget how to beat older styles of
    play. Training against the strongest members keeps the pressure where the learner is
    weakest, a simple form of the prioritized league play behind AlphaStar.
    """
    ratings = elo.fit_elo(results)
    bots = [m for m in members if m.policy is None]
    snapshots = sorted((m for m in members if m.policy is not None), key=lambda m: -ratings.get(m.name, 0))
    return bots + snapshots[:top]

The driver

league.py
@driver_env.task(report=True, produces_artifacts=True, retries=2)
async def league(generations: int = 20, iterations: int = 25, games: int = 2048, match_games: int = 400) -> File:
    """Run the self-play league, keep the Elo ladder up to date, and publish the champion."""
    members = [Player("random"), Player("tactical")]
    results = [json.loads(await play_match(members[1], members[0], match_games))]
    ladder: list[dict] = []  # Elo of every member after each generation
    training: list[dict] = []
    learner: File | None = None

    for g in range(1, generations + 1):
        with flyte.group(f"generation-{g}"):
            learner, log = await train_generation(g, learner, pick_opponents(members, results), iterations, games)
            snapshot = Player(f"gen-{g}", learner)
            # The new snapshot plays every existing member at once, on the CPU pool.
            new = await asyncio.gather(*[play_match(snapshot, m, match_games, seed=g) for m in members])
        members.append(snapshot)
        results += [json.loads(r) for r in new]
        training.append({"generation": g, **json.loads(log)})
        ratings = elo.fit_elo(results)
        ladder.append({"generation": g, "ratings": ratings})
        await flyte.report.replace.aio(reports.league_live(ladder, results, training, generations), do_flush=True)

    ratings = ladder[-1]["ratings"]
    champion = max((m for m in members if m.policy is not None), key=lambda m: ratings[m.name])
    game = json.loads(await sample_game(champion, Player("tactical")))
    await flyte.report.replace.aio(reports.league_final(ladder, results, training, champion.name, game), do_flush=True)

    card = await artifacts.Card.create_from.aio(
        content=reports.card(ladder, results, champion.name, game), format="html", card_type="model")
    vs_tactical = next(r for r in results if r["a"] == champion.name and r["b"] == "tactical")
    metadata = artifacts.Metadata.create_model_metadata(
        name="connect4-champion",
        description=f"Connect Four self-play champion {champion.name}: Elo {ratings[champion.name]:.0f}, "
                    f"{vs_tactical['win'] / match_games:.0%} wins against the tactical bot",
        framework="PyTorch",
        model_type="policy-value network",
        architecture="residual CNN, 4 blocks x 64 channels",
        task="Connect Four",
        serial_format="pt",
        card=card,
        attrs={"generation": champion.name, "elo": f"{ratings[champion.name]:.0f}",
               "win_vs_tactical": f"{vs_tactical['win'] / match_games:.3f}"},
    )
    return artifacts.new(champion.policy, metadata)

Results

A run with the defaults, 20 generations of 25 iterations of 2,048 games, took 23 minutes on one L4 and 231 matches (92,400 rated games) on the CPU pool.

Elo Score against the tactical bot
Random bot 0 (pinned)
Tactical bot 456
First snapshot (generation 1) about 390
Champion (generation 19) 547 75% wins, greedy play

The learner passes the tactical bot within two generations, then keeps improving more slowly. In training, where both sides sample their moves, its score against the tactical bot climbs from 2% to 88%.

How the league picks opponents matters. An earlier version trained against the three most recent snapshots instead of the three strongest. Its 12-generation run stalled at the tactical bot’s level: the champion won 50% of greedy games against it. Training against the strongest snapshots kept the learner improving.

The driver’s report shows the ladder, a sample game against the tactical bot, and the training curves:

The league report: champion Elo, score against the tactical bot, the Elo ladder, a sample game, and training curves

The score matrix shows every pairing. Each row is a player’s score against each column: later generations (lower rows) score higher against earlier ones (left columns), and the bots’ rows darken as the league grows.

The score matrix of the 20-generation league

Because training and matches are cached, running the same league again replays every generation from the cache. The rerun took 39 seconds instead of 23 minutes, and only the driver did any work. A driver that crashes partway through resumes the same way.

A rerun of the league in the UI: every action is a cache hit, and the run takes 39 seconds

Play the champion

app.py serves the champion as a FastAPI app. The champion’s checkpoint is an app parameter whose value is a RunOutput: when the app is deployed, Flyte resolves it to the output of the latest successful league run, so a new league run followed by a redeploy serves the new champion with no code change. The app scales to zero when nobody is playing.

app.py
arena = FastAPIAppEnvironment(
    name="connect4-arena",
    app=app,
    # A CPU-only torch is enough to pick a move, and much smaller than the CUDA build.
    image=flyte.Image.from_debian_base(name="connect4-arena")
    .with_pip_packages("torch==2.9.1", index_url="https://download.pytorch.org/whl/cpu")
    .with_pip_packages("numpy<3", "fastapi", "uvicorn"),
    parameters=[
        # The champion published by the most recent successful league run.
        flyte.app.Parameter(
            name="champion",
            value=flyte.app.RunOutput(type="file", task_name="c4-league-driver.league"),
            mount=CHAMPION_PATH,
            download=True,
        ),
    ],
    # The network is imported when the app starts, so the code bundle lists it.
    include=["connect4.py"],
    resources=flyte.Resources(cpu=1, memory="2Gi"),
    scaling=flyte.app.Scaling(replicas=(0, 1), scaledown_after=600),
    requires_auth=False,
)

The page sends the board after each of your moves, and the app answers with the champion’s move, its move probabilities, and its value estimate for the position:

app.py
@app.post("/move")
async def move(position: Position) -> dict:
    """The champion's move for a position where it is the champion's turn."""
    import torch
    import torch.nn.functional as F

    board = torch.tensor(position.board, dtype=torch.float32).unsqueeze(0)
    legal = board[:, 0, :] == 0
    with torch.no_grad():
        logits, value = state["net"](board, legal)
    probs = F.softmax(logits, 1)[0]
    return {"column": int(probs.argmax()), "probs": [round(float(p), 3) for p in probs], "value": round(float(value), 3)}

Here the champion has just blocked both ends of a three-in-a-row:

Playing the champion in the browser

Going further

  • Add search. The network is a strong prior for a few plies of lookahead. Monte Carlo tree search on top of it, AlphaZero style, beats the raw policy by a wide margin.
  • A bigger league. Add main exploiters, learners whose only job is to beat the current champion, as AlphaStar did. Each one is another train_generation call in the same loop.
  • Other games. Anything that can be written as batched tensor steps, from Othello to simple card games, drops into connect4.py without changing the league.