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.
Try It Yourself
13 minPlay five games against the random AI. Try to win every time.
Play 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.
Add 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.
Mini-Challenge · 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.
Homework
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.