Learning Goals
3 minBy the end of this lesson you can:
- Represent a 3×3 grid as a list of lists in Python.
- Print the grid cleanly with borders.
- Read a move from the user — row and column — and validate it.
- Alternate turns between X and O.
Warm-Up · The Grid Shape
5 minA 3×3 grid is a list of three lists, each with three items:
board = [ [".", ".", "."], [".", ".", "."], [".", ".", "."], ] print(board[0][0]) # top-left print(board[1][1]) # centre print(board[2][2]) # bottom-right
Two indices: board[row][col]. Row first, column second — the maths convention. Rows count 0, 1, 2 from the top; columns 0, 1, 2 from the left.
A list of lists is the standard way to model a 2-D grid. Tic-tac-toe. Chess. Sudoku. Battleship. Once you can read and write board[r][c], you can model any grid game.
New Concept · Build, Print, Place
14 min1 · Build the board
Don't hand-type three identical lists. Use a comprehension. (We'll see exactly why the obvious-looking [["."] * 3] * 3 is dangerous in a moment.)
def new_board(): return [["." for _ in range(3)] for _ in range(3)]
The shared-reference trap
This common-looking code is broken:
board = [["."] * 3] * 3 # ❌ all three rows are the SAME list board[0][0] = "X" print(board) # → [['X', '.', '.'], ['X', '.', '.'], ['X', '.', '.']] # ↑ ↑ all changed at once!
The outer * 3 doesn't make three separate lists — it makes three references to the same list. Use the comprehension above to build three independent rows.
2 · Print the board
def show(board): print() print(" 0 1 2") # column labels print(" ───┬───┬───") for r in range(3): print(f"{r} {board[r][0]} │ {board[r][1]} │ {board[r][2]}") if r < 2: print(" ───┼───┼───") print()
0 1 2 ───┬───┬─── 0 . │ . │ . ───┼───┼─── 1 . │ X │ . ───┼───┼─── 2 . │ . │ O
Box-drawing characters (│ ─ ┼) come standard with Unicode terminals. If your terminal doesn't render them, swap for plain ASCII (| - +).
3 · Place a piece
def place(board, row, col, piece): if not (0 <= row <= 2 and 0 <= col <= 2): return False # off-board if board[row][col] != ".": return False # square already taken board[row][col] = piece return True # success
Two checks before writing: in-range, and the square is empty. Return True/False so the caller can loop until a valid move comes in.
4 · Switch turns
Two pieces, alternate. Use a variable that flips each turn.
turn = "X" # after a successful move: turn = "O" if turn == "X" else "X"
You'll also see a fancier turn = "XO"[turn == "X"] trick — same effect, harder to read. Stick with the conditional.
Worked Example · The Playable Skeleton
12 minSave as ttt1.py. Plays a game with no win detection — just 9 moves alternating X and O.
# ttt1.py — board + display + move loop (no win detection yet) 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() def place(board, row, col, piece): if not (0 <= row <= 2 and 0 <= col <= 2): return False if board[row][col] != ".": return False board[row][col] = piece return True def ask_move(turn): while True: raw = input(f"{turn}'s move — row col: ").strip() parts = raw.split() if len(parts) != 2: print(" ! Type two numbers, like '1 2'.") continue try: r, c = int(parts[0]), int(parts[1]) except ValueError: print(" ! Numbers only please.") continue return r, c # --- play 9 moves --- board = new_board() turn = "X" for move_no in range(9): show(board) while True: r, c = ask_move(turn) if place(board, r, c, turn): break print(" ! Square taken or off-board. Try again.") turn = "O" if turn == "X" else "X" show(board) print("Board is full. (No win detection yet — that's Part 2.)")
Sample session
0 1 2 ───┬───┬─── 0 . │ . │ . ───┼───┼─── 1 . │ . │ . ───┼───┼─── 2 . │ . │ . X's move — row col: 1 1 0 1 2 ───┬───┬─── 0 . │ . │ . ───┼───┼─── 1 . │ X │ . ───┼───┼─── 2 . │ . │ . O's move — row col: 0 0 ...
Read the diff
Three small reusable functions — new_board, show, place — plus an ask_move input helper. The 9-move loop just calls them in order, switching turn after each successful place. Win detection comes next; the foundation is set.
Try It Yourself
13 minPrint a board with X already in the centre by hand-placing it before the loop starts.
Hint
board = new_board() board[1][1] = "X" show(board)
Write a function empties(board) that returns how many "." squares are left. Test it after a few moves.
Hint
def empties(board): count = 0 for row in board: for cell in row: if cell == ".": count += 1 return count # One-liner equivalent: def empties2(board): return sum(cell == "." for row in board for cell in row)
The one-liner uses two nested generator-expression loops — same shape, less code. sum(boolean) works because True is 1 and False is 0.
Write empty_squares(board) that returns a list of (r, c) tuples for every empty square. Useful for the random AI in Part 3.
Hint
def empty_squares(board): return [(r, c) for r in range(3) for c in range(3) if board[r][c] == "."] print(empty_squares(board)) # → [(0, 0), (0, 1), ...]
A double comprehension. Read it as "for each row, for each column, if empty, give me the pair". Three lines of nested loops collapsed into one.
Mini-Challenge · Show the Move Number
8 minUpgrade ttt1.py so that:
- Each prompt shows the move number (1-9) and whose turn it is.
- The board print includes a header line
Move 5 of 9. - After every move, also print how many empty squares remain.
Use f-strings and the helpers from earlier.
Show one possible solution
# inside the for loop: for move_no in range(1, 10): print(f"\n=== Move {move_no} of 9 — {turn}'s turn ===") show(board) while True: r, c = ask_move(turn) if place(board, r, c, turn): break print(" ! Square taken or off-board.") print(f" {empties(board)} squares left.") turn = "O" if turn == "X" else "X"
Non-negotiables: a move counter, a header line per move, and an "empties" report after each placement. This is groundwork for Part 2's "draw" detection — if the board is full and nobody's won, it's a draw.
Recap
3 minA 3×3 grid is a list of lists. Build with a nested comprehension — never with * 3 (shared references!). Access cells with board[r][c], row first, column second. Print with a small show function that draws separator lines. Take input as two numbers per move; validate range and that the square is empty before placing. Switch turns with a one-line ternary. Four helper functions cover the whole foundation.
Vocabulary Card
- list of lists
- The standard Python representation of a 2-D grid.
- board[r][c]
- Two indices — row first, then column.
- shared-reference trap
[["."] * 3] * 3creates three references to the same row, not three separate rows. Use a comprehension instead.- turn switcher
turn = "O" if turn == "X" else "X"— flip between two values cleanly.
Homework
4 minExtend ttt1.py with two features:
- Player names. Ask for two player names at the start. Replace "X's move" with the actual name in prompts.
- 4×4 mode. Make the board size a constant
N = 3at the top of the file. Replace every hard-coded3withN. SetN = 4to play 4×4 tic-tac-toe (it's harder!).
Sample · key changes
N = 3 # change to 4 for 4×4 def new_board(): return [["." for _ in range(N)] for _ in range(N)] def show(board): print() print(" " + " ".join(str(c) for c in range(N))) for r in range(N): line = " " + " │ ".join(board[r]) print(f"{r} {line}") if r < N - 1: print(" " + "─┼─".join(["─"] * N)) print() def place(board, row, col, piece): if not (0 <= row < N and 0 <= col < N): return False if board[row][col] != ".": return False board[row][col] = piece return True name_x = input("X's name: ") name_o = input("O's name: ") name_of = {"X": name_x, "O": name_o} for move_no in range(N * N): ... print(f"{name_of[turn]} ({turn})'s turn")
Non-negotiables: a single N constant, every grid reference uses it, and prompts use the player names. The display function now scales to any N. Try the 4×4 version — much harder!