Learning Goals
3 minBy the end of this lesson you can:
- List the 8 winning lines of a 3×3 board.
- Write
winner(board)that returns"X","O"orNone. - Detect a draw — full board with no winner.
- End the move loop the instant the game finishes — no extra moves played.
Warm-Up · The Eight Lines
5 minHow do you win at tic-tac-toe? Three of your marks on the same row, column, or diagonal. That's 3 + 3 + 2 = 8 winning lines.
Rows (0,0) (0,1) (0,2)
(1,0) (1,1) (1,2)
(2,0) (2,1) (2,2)
Columns (0,0) (1,0) (2,0)
(0,1) (1,1) (2,1)
(0,2) (1,2) (2,2)
Diagonals (0,0) (1,1) (2,2)
(0,2) (1,1) (2,0)Store these as a list of triples. Then check each one.
Win detection isn't magical. It's "check each winning line; if all three cells match a piece, that piece won". The hard part was already done — listing the eight lines.
New Concept · The Winner Function
14 minListing the lines
LINES = [ # rows [(0, 0), (0, 1), (0, 2)], [(1, 0), (1, 1), (1, 2)], [(2, 0), (2, 1), (2, 2)], # columns [(0, 0), (1, 0), (2, 0)], [(0, 1), (1, 1), (2, 1)], [(0, 2), (1, 2), (2, 2)], # diagonals [(0, 0), (1, 1), (2, 2)], [(0, 2), (1, 1), (2, 0)], ]
Eight lines, three cells each. Hand-typed for clarity. (You could generate them with a loop, but a literal list is easier to read.)
The winner check
def winner(board): for line in LINES: (r1, c1), (r2, c2), (r3, c3) = line a, b, c = board[r1][c1], board[r2][c2], board[r3][c3] if a == b == c and a != ".": return a # "X" or "O" return None
For each line, read the three cells. If all three are the same and not blank, we've found the winner. Otherwise, return None.
The chain comparison a == b == c
That's Python letting you chain. It's the same as a == b and b == c. Reads naturally; works on any two-step comparison.
Draw detection
def is_full(board): for row in board: if "." in row: return False return True
If any row still has a ".", the board isn't full. A draw = full board AND no winner.
Putting it together
def game_over(board): w = winner(board) if w: return (True, w) # someone won if is_full(board): return (True, None) # draw return (False, None) # keep playing
Returns a tuple — (done?, winner_or_None). The caller can unpack and react.
Update the main loop
Yesterday's loop ran exactly 9 moves. Today, we check after every move:
while True: show(board) r, c = ask_move(turn) if not place(board, r, c, turn): print(" ! Invalid move."); continue done, w = game_over(board) if done: show(board) if w: print(f"\n🏆 {w} wins!") else: print("\nIt's a draw.") break turn = "O" if turn == "X" else "X"
Notice break — we exit the loop the instant we detect a win or draw.
Worked Example · The Complete Two-Player Game
12 minSave as ttt2.py:
# ttt2.py — tic-tac-toe with full win + draw detection 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 is_full(board): return all(cell != "." for row in board for cell in row) def game_over(board): w = winner(board) if w: return (True, w) if is_full(board): 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 please."); continue try: return int(raw[0]), int(raw[1]) except ValueError: print(" ! Numbers only.") # --- main --- board = new_board() turn = "X" while True: show(board) while True: r, c = ask_move(turn) if place(board, r, c, turn): break print(" ! Square taken or off-board.") done, w = game_over(board) if done: show(board) if w: print(f"🏆 {w} wins!") else: print("It's a draw.") break turn = "O" if turn == "X" else "X"
Sample win
X's move — row col: 1 1 0 1 2 ───┬───┬─── 0 . │ . │ . ───┼───┼─── 1 . │ X │ . ───┼───┼─── 2 . │ . │ . O's move — row col: 0 0 ... (a few moves later) ... X's move — row col: 2 2 0 1 2 ───┬───┬─── 0 X │ O │ . ───┼───┼─── 1 . │ X │ O ───┼───┼─── 2 . │ . │ X 🏆 X wins!
Read the diff
Three new functions — winner, is_full, game_over — and the main loop now breaks the moment done is True. is_full uses the one-liner trick all(cell != "." for row in board for cell in row) — same shape as PY-L2-39's empties homework.
Try It Yourself
13 minRun the game and deliberately win three different ways — a row, a column, a diagonal. Make sure all three are detected.
Play a game where neither side wins. The program should print It's a draw. after the 9th move.
One way to force a draw
X: 1,1 O: 0,0 X: 0,2 O: 2,0 X: 1,0 O: 1,2 X: 2,2 O: 2,1 X: 0,1 → It's a draw.
Modify winner to return both the piece and the line that won. Use that in show to print the winning cells in upper-case or with brackets.
Hint
def winner_with_line(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, line return None, None def show_with_winners(board, win_line=None): win_cells = set(win_line) if win_line else set() ... cell = board[r][c] if (r, c) in win_cells: cell = f"[{cell}]" ...
Set of winning cells = constant-time lookup. set(win_line) works because every cell is a 2-tuple — sets accept tuples.
Mini-Challenge · The Anti-Cheat Test Suite
8 minBuild tests.py — a small set of unit tests for winner(). Don't use the unittest module (we'll see it in Level 4); just hand-roll like in PY-L2-24's feedback tests.
Tests:
- Empty board →
None - X in row 0 →
"X" - O in column 2 →
"O" - X on main diagonal →
"X" - O on anti-diagonal →
"O" - Mixed board, nobody won →
None
Show one possible solution
# tests.py — unit tests for winner() from ttt2 import winner # if winner is in a module EMPTY = [["." for _ in range(3)] for _ in range(3)] def board_of(rows): return [list(r) for r in rows] tests = [ ("empty", EMPTY, None), ("row 0", board_of(["XXX", "...", "..."]), "X"), ("col 2", board_of(["..O", "..O", "..O"]), "O"), ("diag", board_of(["X..", ".X.", "..X"]), "X"), ("antidiag", board_of(["..O", ".O.", "O.."]), "O"), ("mixed", board_of(["XOX", "OXO", "OXO"]), None), ] passing = 0 for name, board, expected in tests: actual = winner(board) ok = (actual == expected) print(f"{'OK ' if ok else 'FAIL'} {name}: got {actual}, expected {expected}") passing += ok print(f"\n{passing}/{len(tests)} passing")
Non-negotiables: six test cases covering rows, columns, both diagonals, full board with no winner, and empty board. The board_of helper turns a list of strings into a list of lists — much shorter than typing out each row.
Recap
3 minEight winning lines. One function. Check each line; if all three cells are the same non-empty piece, that piece won. Draw = full board with no winner. game_over(board) returns a (done, winner) tuple; the main loop breaks the instant done is True. Tomorrow we add an AI opponent and a play-again loop.
Vocabulary Card
- LINES
- A list of all winning lines — 3 rows, 3 columns, 2 diagonals.
- winner(board)
- Returns the winning piece, or
Noneif nobody has won. - chain comparison
a == b == c— Python lets you chain. Means "all three are equal".- (done, winner) tuple
- Multi-return so the caller can tell "winner X" from "draw" from "keep playing".
Homework
4 minBuild a generate_lines function that builds the 8-line list automatically for any N×N board. Then use it in winner so the game generalises beyond 3×3.
For an N×N board you have N rows, N columns and 2 diagonals — total 2N + 2 lines, each of N cells.
Sample · generate_lines
def generate_lines(n): lines = [] # Rows for r in range(n): lines.append([(r, c) for c in range(n)]) # Columns for c in range(n): lines.append([(r, c) for r in range(n)]) # Main diagonal lines.append([(i, i) for i in range(n)]) # Anti-diagonal lines.append([(i, n - 1 - i) for i in range(n)]) return lines print(generate_lines(3)) # 8 lines print(generate_lines(4)) # 10 lines def winner(board): n = len(board) lines = generate_lines(n) for line in lines: cells = [board[r][c] for r, c in line] first = cells[0] if first != "." and all(cell == first for cell in cells): return first return None
Non-negotiables: generate_lines(n) works for any size, the winner function uses it, and all(...) handles the "every cell matches" check for arbitrary line lengths. Now your tic-tac-toe is actually a tic-tac-toe engine — same code plays 3×3, 4×4 or 10×10.