Learning Goals
3 minBy the end of this lesson you can:
- Pick a random empty square with
random.choice. - Wrap the game in a play-again loop with a running scoreboard.
- Add a simple AI that takes a winning move when offered, and blocks the opponent's about-to-win move.
- Save lifetime statistics to a file.
Warm-Up · The Random AI
5 minThe simplest possible AI: pick a random empty square. Embarrassing? Sure. But it works — and it gives you a one-player mode you didn't have yesterday.
import random def random_move(board): empties = [(r, c) for r in range(3) for c in range(3) if board[r][c] == "."] return random.choice(empties)
One line for the empties list (the comprehension from PY-L2-39's homework). One line for the choice. The AI now plays.
An AI doesn't have to be smart. The first version is random. Smart comes later — and it's built on the same shape.
New Concept · Three Polish Moves
14 min1 · The play-again loop
Wrap the entire game logic in play_one_game(). The outer loop runs as many games as the player wants.
scores = {"X": 0, "O": 0, "draw": 0} while True: result = play_one_game() # returns "X", "O" or "draw" scores[result] += 1 print(f"\nScoreboard: X={scores['X']} O={scores['O']} draws={scores['draw']}") if input("Play again? (y/n) ").lower() != "y": break
The dict-based score is a tiny version of PY-L2-22's high-score table — kept in memory only, for now.
2 · Mixed human/AI mode
One player is the human, the other is the AI. Let the user pick which they want to be.
def play_one_game(): board = new_board() you = input("Play as X or O? ").upper() if you not in ("X", "O"): you = "X" turn = "X" while True: show(board) if turn == you: r, c = ask_move(turn) if not place(board, r, c, turn): print(" ! Invalid."); continue else: print(f"AI ({turn}) thinking...") r, c = random_move(board) place(board, r, c, turn) print(f"AI plays {r}, {c}") done, w = game_over(board) if done: show(board) return w if w else "draw" turn = "O" if turn == "X" else "X"
Two branches in the loop — human turn or AI turn — but they end the same way: place a piece, check game over.
3 · Smart AI · take wins, block losses
The random AI is a punching bag. Upgrade it with two simple rules:
- If the AI can win now (one of its pieces is two-of-three on a line and the third is empty), play that square.
- Otherwise, if the opponent can win next turn, play that square to block.
- Otherwise, fall back to a random move.
def find_winning_move(board, piece): """Return (r, c) of a square that would complete a line for piece, or None.""" for line in LINES: cells = [board[r][c] for r, c in line] if cells.count(piece) == 2 and cells.count(".") == 1: # find the empty square in this line for (r, c), v in zip(line, cells): if v == ".": return (r, c) return None def smart_move(board, piece): opponent = "O" if piece == "X" else "X" win = find_winning_move(board, piece) if win: return win block = find_winning_move(board, opponent) if block: return block return random_move(board)
Three lines, three rules. This won't beat a good human, but it stops being a punching bag.
4 · Persistence
Save the running score to a file after every game so it survives between runs. Same pattern as PY-L2-22 with one less field.
import json def load_scores(): try: with open("ttt_scores.json") as f: return json.load(f) except FileNotFoundError: return {"X": 0, "O": 0, "draw": 0} def save_scores(scores): with open("ttt_scores.json", "w") as f: json.dump(scores, f)
json.load / json.dump is a sneak peek at PY-L2-45 — for now, treat it as "save a dict, load a dict".
Worked Example · The Shipped Tic-Tac-Toe
12 minSave as ttt3.py. Bring all the helpers from Parts 1 and 2 into the same file (or import from a ttt_engine module).
# ttt3.py — final tic-tac-toe with AI + scoreboard import random import json # --- core board engine (from Parts 1-2) --- def new_board(): return [["." for _ in range(3)] for _ in range(3)] def show(board): print() print(" 0 1 2") print(" ───┬───┬───") for r in range(3): print(f"{r} {board[r][0]} │ {board[r][1]} │ {board[r][2]}") if r < 2: print(" ───┼───┼───") print() LINES = [ [(0,0),(0,1),(0,2)], [(1,0),(1,1),(1,2)], [(2,0),(2,1),(2,2)], [(0,0),(1,0),(2,0)], [(0,1),(1,1),(2,1)], [(0,2),(1,2),(2,2)], [(0,0),(1,1),(2,2)], [(0,2),(1,1),(2,0)], ] def winner(board): for line in LINES: a = board[line[0][0]][line[0][1]] b = board[line[1][0]][line[1][1]] c = board[line[2][0]][line[2][1]] if a == b == c and a != ".": return a return None def game_over(board): w = winner(board) if w: return (True, w) if all(cell != "." for row in board for cell in row): return (True, None) return (False, None) def place(board, r, c, piece): if not (0 <= r <= 2 and 0 <= c <= 2): return False if board[r][c] != ".": return False board[r][c] = piece return True def ask_move(turn): while True: raw = input(f"{turn}'s move — row col: ").strip().split() if len(raw) != 2: print(" ! Two numbers."); continue try: return int(raw[0]), int(raw[1]) except ValueError: print(" ! Numbers only.") # --- AI --- def empty_squares(board): return [(r, c) for r in range(3) for c in range(3) if board[r][c] == "."] def random_move(board): return random.choice(empty_squares(board)) def find_winning_move(board, piece): for line in LINES: cells = [board[r][c] for r, c in line] if cells.count(piece) == 2 and cells.count(".") == 1: for (r, c), v in zip(line, cells): if v == ".": return (r, c) return None def smart_move(board, piece): opponent = "O" if piece == "X" else "X" w = find_winning_move(board, piece) if w: return w b = find_winning_move(board, opponent) if b: return b return random_move(board) # --- persistence --- def load_scores(): try: with open("ttt_scores.json") as f: return json.load(f) except FileNotFoundError: return {"X": 0, "O": 0, "draw": 0} def save_scores(scores): with open("ttt_scores.json", "w") as f: json.dump(scores, f) # --- play loop --- def play_one_game(human_piece, ai_func): board = new_board() turn = "X" while True: show(board) if turn == human_piece: while True: r, c = ask_move(turn) if place(board, r, c, turn): break print(" ! Invalid.") else: print(f"AI ({turn}) thinking...") r, c = ai_func(board, turn) place(board, r, c, turn) print(f" AI plays {r}, {c}") done, w = game_over(board) if done: show(board) return w if w else "draw" turn = "O" if turn == "X" else "X" scores = load_scores() print("Welcome to Tic-Tac-Toe!") print(f"Lifetime scores → X={scores['X']} O={scores['O']} draws={scores['draw']}") while True: p = input("\nPlay as X (goes first) or O? ").upper() if p not in ("X", "O"): p = "X" diff = input("Difficulty (r)andom / (s)mart? ").lower() ai = random_move if diff == "r" else (lambda b, _: smart_move(b, "O" if p == "X" else "X")) result = play_one_game(p, lambda b, t: ai(b, t)) if result == "draw": print("Draw!") scores["draw"] += 1 else: print(f"🏆 {result} wins!") scores[result] += 1 save_scores(scores) print(f"Lifetime: X={scores['X']} O={scores['O']} draws={scores['draw']}") if input("\nPlay again? (y/n) ").lower() != "y": break print("Bye!")
Read the diff
Everything from Parts 1 and 2, plus four new pieces. random_move and smart_move are the two AI levels. load_scores/save_scores use JSON for persistence. The main loop wires it all together — pick your piece, pick a difficulty, play, save, repeat.
The two-rule AI can't lose to a careless player, but a careful one (the "fork" trick) can still beat it. To make tic-tac-toe truly unbeatable you need the minimax algorithm — that's a Level-3 topic. For now, your AI knows enough to be fun.
Basic
5 minPlay five games against the random AI. Try to win every time.
Challenge 1
4 minPlay the smart AI as X. Try to win. Can you? It should be very hard — but if you double-fork (set up two threats at once) you can.
Challenge 2
4 minAdd a third rule to smart_move: before falling back to random, if the centre is empty, take it.
Hint
def smart_move(board, piece): opponent = "O" if piece == "X" else "X" w = find_winning_move(board, piece) if w: return w b = find_winning_move(board, opponent) if b: return b if board[1][1] == ".": # take the centre return (1, 1) return random_move(board)
The centre is involved in 4 of the 8 winning lines (vs 3 for corners, 2 for edges). Taking it gives you the most opportunities. This single rule makes the AI noticeably stronger.
Challenge 3 · AI vs AI Tournament
8 minTake the human player out of the loop. Run 100 games between random_move and smart_move. Tally wins for each.
Predict before running: how many should the smart AI win? Then test.
Show one possible solution
# tournament.py — 100 games AI vs AI import random # (paste new_board, LINES, winner, game_over, place, empty_squares, # random_move, find_winning_move, smart_move here) def play_one(ai_x, ai_o): board = new_board() turn = "X" while True: if turn == "X": r, c = ai_x(board, "X") else: r, c = ai_o(board, "O") place(board, r, c, turn) done, w = game_over(board) if done: return w if w else "draw" turn = "O" if turn == "X" else "X" results = {"X": 0, "O": 0, "draw": 0} for _ in range(100): # Smart plays X, random plays O results[play_one(smart_move, random_move)] += 1 print("Smart (X) vs Random (O) — 100 games:") print(results)
Non-negotiables: a pure simulation loop (no UI), two AI functions passed as arguments, and a result tally. Smart vs random should be lopsided — smart wins about 70-80% if it goes first. Try swapping who plays X.
Recap
3 minA complete game in three lessons. Part 1: board + display + move loop. Part 2: win detection and draw detection. Part 3: an AI opponent, a play-again loop, persistent scores. The AI is built in layers — random first, then take wins, then block losses. Each rule on its own is tiny; together they make an opponent that's fun to play. Tic-tac-toe is the perfect example of how big projects come together — one small reusable function at a time.
Vocabulary Card
- AI agent
- A function that picks a move given the current board.
- random AI
- The simplest possible agent — pick a random valid move.
- find_winning_move
- The building block of both "take wins" and "block losses". Find a square that completes a line for a given piece.
- tournament
- AI vs AI batch simulation. Useful for comparing agents without a human.
Extra Mission
4 minBuild a four-tier difficulty selector:
- Easy. Random moves only.
- Medium. Take wins; otherwise random.
- Hard. Take wins, block losses; otherwise random.
- Pro. Take wins, block losses, then prefer centre, then corners, then edges.
Add the difficulty to the saved scores — track Easy/Medium/Hard/Pro separately.
Sample · four difficulties
PREFERENCE = [(1, 1)] + [(r, c) for r in (0, 2) for c in (0, 2)] + \ [(0, 1), (1, 0), (1, 2), (2, 1)] # centre, corners, edges def pick_preferred(board): for (r, c) in PREFERENCE: if board[r][c] == ".": return (r, c) return None # board full def ai_easy(board, piece): return random_move(board) def ai_medium(board, piece): return find_winning_move(board, piece) or random_move(board) def ai_hard(board, piece): opponent = "O" if piece == "X" else "X" return (find_winning_move(board, piece) or find_winning_move(board, opponent) or random_move(board)) def ai_pro(board, piece): opponent = "O" if piece == "X" else "X" return (find_winning_move(board, piece) or find_winning_move(board, opponent) or pick_preferred(board))
Non-negotiables: four AI functions, each layering one extra rule on the last. x or y or z falls through to the first non-None value — Python's "or" returns whichever value first becomes truthy, perfect for this ladder.