Project: an unbeatable game opponent
Game state and rules, minimax with a cache, and an exhaustive test that proves it never loses.
Create a free account Try part of it here
Why it matters
Game-playing programs are where search algorithms became famous, and the idea underneath them, "look ahead at every possible future and pick the best one, assuming the other side does the same", shows up far beyond games: in planning, scheduling and security analysis. Tic-tac-toe is small enough to search completely, so you can build an opponent that never loses and then prove it, which is a rare and satisfying thing in programming. Along the way you'll practice recursion, caching and testing on something you can play.
What you'll be able to do
- Model a game as an immutable state plus functions for its rules
- Search a game tree with minimax, and speed it up with a cache
- Prefer quick wins and slow losses by scoring with depth
- Prove a program never loses with an exhaustive test
What it covers
- A game as data and rules
- Looking ahead: the game tree
- Minimax
- Win fast, lose slowly
- State you can derive
- Worked example: how much alpha-beta saves
- Ridge Runner: write the runner's brain
A game as data and rules
Separate what the game looks like right now (the state) from what's allowed (the rules). Here the state is the board: a tuple of nine cells, numbered 0 to 8 from the top left, each "X", "O" or " ". A tuple can't be changed, so playing a move returns a new board instead of altering the old one. That makes every function easy to test, and, as you'll see, easy to cache.
LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)] EMPTY = (" ",) * 9 def moves(board): return [i for i, cell in enumerate(board) if cell == " "] def play(board, i, player): return board[:i] + (player,) + board[i + 1:] def winner(board): for a, b, c in LINES: if board[a] != " " and board[a] == board[b] == board[c]: return board[a] return None def show(board): rows = [" | ".join(board[r:r + 3]) for r in (0, 3, 6)] print("\n---------\n".join(rows)) board = EMPTY for i, player in [(4, "X"), (0, "O"), (2, "X"), (6, "O"), (3, "X"), (5, "O"), (1, "X"), (7, "O")]: board = play(board, i, player) show(board) print("winner:", winner(board), "| free cells:", moves(board))
Whose turn is it? You never need to store it: X always starts, so if the counts of X and O are equal, it's X's turn. Deriving facts from the state, instead of keeping extra variables in sync, removes a whole class of bugs.
LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)] def winner(board): for a, b, c in LINES: if board[a] != " " and board[a] == board[b] == board[c]: return board[a] return None print(winner(("O", "X", "X", " ", "O", "X", " ", " ", "O")), winner((" ",) * 9))
Show the answer
O None
Cells 0, 4 and 8 are all "O", a diagonal, so it prints O. An empty board has no winner, so None.
Looking ahead: the game tree
From any position you can list every move, then every reply to each move, and so on until each game ends. That branching picture is the game tree. A function that walks it is naturally recursive: the result for a position is built from the results for the positions after each move.
How big is tic-tac-toe's tree? Count every complete game:
import time from functools import cache LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)] def winner(board): for a, b, c in LINES: if board[a] != " " and board[a] == board[b] == board[c]: return board[a] return None def turn(board): return "X" if board.count("X") == board.count("O") else "O" @cache def games(board): """How many different complete games can follow from this board?""" if winner(board) or " " not in board: return 1 p = turn(board) return sum(games(board[:i] + (p,) + board[i + 1:]) for i, c in enumerate(board) if c == " ") seen = set() def positions(board): if board in seen: return seen.add(board) if not winner(board): p = turn(board) for i, c in enumerate(board): if c == " ": positions(board[:i] + (p,) + board[i + 1:]) start = time.perf_counter() print(f"{games((' ',) * 9):,} complete games") positions((" ",) * 9) print(f"{len(seen):,} different positions, counted in {time.perf_counter() - start:.2f}s")
There are over a quarter of a million different games but only a few thousand different positions, because many move orders reach the same board. That gap is the key to making search fast: if you remember the answer for each position, you never work it out twice. Both counts above do that. positions keeps a set of the boards it has already seen, and games is wrapped in @cache from functools, which stores the result for each argument it's called with and returns it straight away the next time. A cache only works when the arguments are hashable, which is another reason the board is a tuple.
Minimax
Give every finished game a score from X's point of view: +1 if X won, -1 if O won, 0 for a draw. Now work backward. On X's turn, X picks the move with the highest score; on O's turn, O picks the lowest. Each player assumes the other plays perfectly, so the score of a position is the result of perfect play from there. That rule is minimax.
Try it first on a game small enough to follow by hand. In Trail markers, a pile holds some markers, and the two players take turns removing 1, 2 or 3 of them. Whoever takes the last marker wins.
from functools import cache @cache def best(markers): """(score, move) for the player about to move: +1 means they can force a win.""" if markers == 0: return (-1, None) # the other player just took the last marker options = [] for take in (1, 2, 3): if take <= markers: their_score, _ = best(markers - take) options.append((-their_score, take)) # good for them is bad for me return max(options) for n in range(1, 13): score, take = best(n) print(f"{n:2} markers: {'win by taking ' + str(take) if score == 1 else 'lose against perfect play'}")
This version uses a common shortcut called negamax: instead of one player maximizing and the other minimizing, each player maximizes their own score, and a score passes to the other player with its sign flipped. The pattern in the output is the game's secret: a pile that's a multiple of 4 is lost for the player to move, and from any other pile you win by leaving a multiple of 4. The search found that without being told.
Now play it. You move first from 15 markers, and the program plays perfectly, so you win only by leaving it a multiple of 4 every time:
from functools import cache @cache def best(markers): if markers == 0: return (-1, None) return max((-best(markers - take)[0], take) for take in (1, 2, 3) if take <= markers) markers = 15 while markers > 0: answer = input(f"{markers} markers left. Take 1, 2 or 3: ") if answer not in ("1", "2", "3") or int(answer) > markers: print("Take 1, 2 or 3, and no more than are left.") continue markers -= int(answer) if markers == 0: print("You took the last marker. You win!") break score, take = best(markers) markers -= take print(f"I take {take}." + (" I can't lose from here." if score == 1 else "")) if markers == 0: print("I took the last marker. I win!")
Scores flow up the tree: each player takes the best option for themselves.
Win fast, lose slowly
Plain minimax treats every win alike, so an opponent that could win right now might wander and win three moves later, which looks foolish. Scoring with depth fixes it: a win is worth more the sooner it comes, and a loss costs less the later it comes. With nine cells, 10 - depth for an X win and depth - 10 for an O win works, where depth counts the moves already on the board.
Breaking ties matters too. When several moves score the same, pick deterministically, such as the lowest cell number, so the program behaves the same in every run and its tests are repeatable.
Proving it never loses
How do you test "this opponent never loses"? For tic-tac-toe you can check every game: let the program play one side, and at each of the other side's turns try every possible reply. If no branch ends in a loss, the claim is proved, not just likely. That's an exhaustive test, possible whenever the space of inputs is small enough to enumerate; for larger problems, tests sample it instead.
Beyond tic-tac-toe
Chess has more possible positions than there are atoms in the observable universe, so a chess program can't search to the end. It searches a few moves ahead, scores the positions it reaches with an evaluation function (material, mobility, king safety), and uses alpha-beta pruning to skip branches that can't change the decision. Programs for Go went further with Monte Carlo tree search and neural networks that learn the evaluation. The skeleton you're building is the same in all of them.
State you can derive
Write turn(board), which returns "X" or "O" (X always starts), and full(board), which is True when no cell is " ". Don't store whose turn it is anywhere: work it out from the board.
def turn(board): pass def full(board): pass print(turn((" ",) * 9), turn(("X",) + (" ",) * 8), full(("X", "O") * 4 + ("X",)))
Worked example: how much alpha-beta saves
Alpha-beta pruning returns the same answer as minimax but skips branches that can't matter. Counting the positions each one visits after X takes the center shows the saving, with no caching in either so the comparison is fair.
LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)] def winner(b): for a, c, d in LINES: if b[a] != " " and b[a] == b[c] == b[d]: return b[a] return None def score_of(b): w = winner(b) return 1 if w == "X" else -1 if w == "O" else 0 if " " not in b else None visits = {"minimax": 0, "alphabeta": 0} def minimax(b, p): visits["minimax"] += 1 s = score_of(b) if s is not None: return s nxt = "O" if p == "X" else "X" scores = [minimax(b[:i] + (p,) + b[i + 1:], nxt) for i, c in enumerate(b) if c == " "] return max(scores) if p == "X" else min(scores) def alphabeta(b, p, alpha=-2, beta=2): visits["alphabeta"] += 1 s = score_of(b) if s is not None: return s nxt = "O" if p == "X" else "X" best = -2 if p == "X" else 2 for i, c in enumerate(b): if c != " ": continue v = alphabeta(b[:i] + (p,) + b[i + 1:], nxt, alpha, beta) if p == "X": best, alpha = max(best, v), max(alpha, v) else: best, beta = min(best, v), min(beta, v) if alpha >= beta: break # the other player would never allow this line, so stop looking return best center = (" ",) * 4 + ("X",) + (" ",) * 4 # X took the center; O to move print("minimax:", minimax(center, "O"), "after", f"{visits['minimax']:,}", "positions") print("alpha-beta:", alphabeta(center, "O"), "after", f"{visits['alphabeta']:,}", "positions")
Both say the position is worth 0: with perfect play from here, the game is a draw. Alpha-beta keeps two bounds while it searches: alpha, the best score X is already sure of, and beta, the best O is sure of. As soon as a branch proves worse for the player choosing it than an option they already have, the rest of that branch can't change the decision, so the search stops there. It visits a small fraction of the positions plain minimax does, and with good move ordering the saving grows with the size of the game. Caching, as in the lesson, attacks the same waste from the other side.
Ridge Runner: write the runner's brain
Ridge Runner is an endless runner, played by code. A runner races along a ridge that gets faster as it goes, and the course throws three kinds of obstacle at it: rocks and gaps to jump, and low branches to duck under. You don't steer it yourself. You write policy(seen), a function that looks at what's ahead and returns "run", "jump" or "duck", and the simulator scores it on 20 courses.
A jump lasts 6 steps and a duck 5, so timing matters: jump too early over a gap and you land in it. Start by reading the simulator, then measure, change one thing, and measure again. Unlike tic-tac-toe, this world is too big to search exhaustively, so your evidence is the score on many courses, including ones you didn't tune on.
It must
policy(seen)uses only the observation (distance,kind,width,speed) and returns one of the three actions- It averages at least 950 out of 1000 on courses 1 to 20 (
score(policy)) - It also averages at least 950 on courses 21 to 40, which you don't tune on (
score(policy, range(21, 41))) - Write down, in a few comments or a printed note, what made the biggest difference and why
- Show the scores of at least two earlier versions of your policy, so the improvement is visible
Go further
- Tune your policy's thresholds automatically with a search over seeds 1 to 20, then report the held-out score
- Find the course where your policy does worst and explain what goes wrong there
- Draw one run with the turtle canvas: the ridge, the obstacles and the runner's jumps
import random WIDTH = {"rock": 1, "branch": 3, "gap": 4} LENGTH = 1000 JUMP_STEPS, DUCK_STEPS = 6, 5 def make_course(seed): """Obstacles along the ridge: (position, kind), at least 20 apart.""" rng = random.Random(seed) obstacles, x = [], 30 while True: x += rng.randint(20, 42) if x >= LENGTH - 10: return obstacles kind = rng.choice(list(WIDTH)) obstacles.append((x, kind)) x += WIDTH[kind] def run_course(policy, seed): """How far the runner gets on one course: LENGTH if it reaches the end.""" obstacles = make_course(seed) pos, air, duck = 0.0, 0, 0 while pos < LENGTH: speed = 1 + 1.5 * pos / LENGTH # Landing in a gap or on a rock, or standing up under a branch, ends the run. for at, kind in obstacles: if at <= pos <= at + WIDTH[kind] and ((kind != "branch" and air == 0) or (kind == "branch" and duck == 0)): return round(at) ahead = [o for o in obstacles if o[0] > pos] nxt = ahead[0] if ahead else None seen = {"distance": round(nxt[0] - pos, 2) if nxt else None, "kind": nxt[1] if nxt else None, "width": WIDTH[nxt[1]] if nxt else None, "speed": round(speed, 3)} if air == 0 and duck == 0: action = policy(seen) if action == "jump": air = JUMP_STEPS elif action == "duck": duck = DUCK_STEPS new_pos = pos + speed for at, kind in obstacles: if pos < at + WIDTH[kind] and new_pos >= at: safe = air > 0 if kind != "branch" else (duck > 0 and air == 0) if not safe: return round(max(at, pos)) pos = new_pos air, duck = max(0, air - 1), max(0, duck - 1) return LENGTH def score(policy, seeds=range(1, 21)): """The average distance over many courses: 1000 means it finished every one.""" return sum(run_course(policy, s) for s in seeds) / len(seeds) def policy(seen): return "run" # your code: look at seen and decide print("courses 1-20:", score(policy)) print("courses 21-40:", score(policy, range(21, 41)))
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why is the board a tuple rather than a list?