Beginnerpython~90 min

Tic-Tac-Toe

Build a two-player Tic-Tac-Toe board you play in the terminal. Print a 3×3 grid, take turns placing X and O, detect rows / columns / diagonals, and call the game when someone wins or it's a draw.

You're going to build two-player Tic-Tac-Toe in the terminal — the classic game where players take turns marking a 3×3 grid with X and O, and the first to claim three in a row (horizontally, vertically, or diagonally) wins. By the end you'll have a working game you can play with a friend, and you'll have written board display logic, win detection, and a turn-based game loop — all patterns that show up in bigger projects.

We're not racing. Each step is one idea. If a hint helps, take it — there's no penalty.

Before you start

Modern macOS ships Python 3 — try python3 --version. If it's older than 3.10, install via Homebrew: brew install python@3.12.

The walkthrough

Make the project file

In your terminal, inside the tic-tac-toe folder, create an empty file called tictactoe.py. Open it in your editor.

You can do this any way you like — touch tictactoe.py, code tictactoe.py, "File → New" in your editor, whatever's natural for you.

After this step your file should look like… (tictactoe.py)

Represent the 3×3 board as a list

Create a board representation. A board has 9 cells (3 rows × 3 columns), and each cell can be empty, contain an X, or contain an O.

The simplest way to represent this is a list of 9 strings, starting all empty: [" ", " ", " ", " ", " ", " ", " ", " ", " "]. Cells will be indexed 0–8, left to right, top to bottom. (Later we'll let players use 1–9 for friendliness.)

In tictactoe.py, create that list. Store it in a variable called board. Then print it to confirm.

After this step you should see…
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']

Print the board as a readable grid

Right now board prints as a jumbled list. We need a function that displays it like an actual Tic-Tac-Toe board with rows, columns, and separators.

Write a function called display_board(board) that prints something like:

 1 | 2 | 3
-----------
 4 | 5 | 6
-----------
 7 | 8 | 9

(The numbers above are placeholders — we'll show X and O soon. For now, cells 1–9 are just spaces because the board is empty.)

After this step you should see…
 1 | 2 | 3
-----------
4 | 5 | 6
-----------
7 | 8 | 9

Write a function to place X or O on the board

When a player makes a move, we need to update the board. Write a function called place_mark(board, position, mark) that:

  • Takes the board, a position (0–8), and a mark (either "X" or "O")
  • Sets board[position] = mark
  • Returns the modified board

Then test it: call the function to place an X at position 4 (the center), display the board, and verify the center cell now shows X.

After this step you should see…
 1 | 2 | 3
-----------
4 | X | 6
-----------
7 | 8 | 9

Read player input — position 1–9

Players should say "I want cell 5" (1–9), not "I want cell 4" (0–8). Write a function called get_move(player) that:

  • Takes the player ("X" or "O")
  • Asks the player which cell they want (using input())
  • Converts the input from a string to an int
  • Subtracts 1 (so the player's 1–9 input becomes the board's 0–8 index)
  • Returns that 0-indexed position

Test it: call get_move("X"), type 5, and print the returned position. You should see 4.

After this step you should see…
X's turn. Pick a cell (1-9): 5
4

Reject out-of-range and occupied cells

Right now if a player types 15 or an already-taken cell, the game breaks. Write a function called is_valid_move(board, position) that:

  • Returns True if the position is in range (0–8) AND the cell is empty
  • Returns False otherwise

Then wrap get_move() in a loop that keeps asking until the player picks a valid cell:

while True:
    position = get_move(player)
    if is_valid_move(board, position):
        break
    print("That cell is taken or out of range. Try again.")

Test it by trying to pick an invalid cell, then a valid one.

After this step you should see…
X's turn. Pick a cell (1-9): 10
That cell is taken or out of range. Try again.
X's turn. Pick a cell (1-9): 5
4

Track whose turn it is

We need to alternate between X and O. Write a function called switch_player(current_player) that:

  • Takes the current player ("X" or "O")
  • Returns the other player

Also set up a variable current_player = "X" at the top of your game (X always goes first). Then, after each move, call current_player = switch_player(current_player) to swap turns.

Test it by playing two moves and confirming both X and O appear on the board.

After this step you should see…
X's turn. Pick a cell (1-9): 5
O's turn. Pick a cell (1-9): 3
1 | 2 | O
-----------
4 | X | 6
-----------
7 | 8 | 9

Detect three in a row

A player wins if they claim three in a row horizontally. Write a function called check_win_rows(board, mark) that:

  • Takes the board and a mark ("X" or "O")
  • Checks each of the three rows (cells 0–2, 3–5, 6–8)
  • Returns True if any row has all three cells equal to the mark
  • Returns False otherwise

Test it by filling a row with X and confirming it returns True.

After this step you should see…
True

Extend win detection to columns and diagonals

Extend the win-detection logic. Write a function called check_win(board, mark) that checks all winning configurations:

  • Three in a row (horizontally) — cells 0–2, 3–5, 6–8 (reuse check_win_rows())
  • Three in a column (vertically) — cells 0–3–6, 1–4–7, 2–5–8
  • Both diagonals — 0–4–8 and 2–4–6

Return True if any configuration matches the mark.

Test it by filling different winning patterns and confirming check_win() returns True for each.

After this step you should see…
True
True
True

Detect a full board with no winner

If the board fills up and no one has won, the game is a draw. Write a function called is_board_full(board) that:

  • Returns True if all 9 cells are filled (no spaces left)
  • Returns False if any cell is still empty

Test it by filling the board and confirming it returns True.

After this step you should see…
True

What does `all(c != ' ' for c in board)` return?

Build the main game loop

Now tie it all together. Write the main game loop that:

  1. Displays the board
  2. Gets the current player's move (with validation)
  3. Places the mark
  4. Checks if that player won — if so, display the board, print a congratulations message, and break
  5. Checks if the board is full — if so, print a draw message and break
  6. Switch to the next player
  7. Repeat

Run the complete game and play through a few moves until someone wins or it's a draw.

After this step you should see…
 1 | 2 | 3
-----------
4 | 5 | 6
-----------
7 | 8 | 9
X's turn. Pick a cell (1-9): 5
1 | 2 | 3
-----------
4 | X | 6
-----------
7 | 8 | 9
O's turn. Pick a cell (1-9): 1
O | 2 | 3
-----------
4 | X | 6
-----------
7 | 8 | 9
X's turn. Pick a cell (1-9): 4
O | 2 | 3
-----------
X | X | 6
-----------
7 | 8 | 9
O's turn. Pick a cell (1-9): 7
O | 2 | 3
-----------
X | X | 6
-----------
O | 8 | 9
X wins!

Clear the screen and add a replay loop

Two small polish moves:

  1. At the top of the game loop, clear the terminal screen so the board doesn't scroll off: import os and call os.system("clear") on macOS/Linux or os.system("cls") on Windows. A cross-platform approach is to check sys.platform.
  2. After the game ends (win or draw), ask the player if they want to play again. If yes, reset the board and current_player to initial state and loop. If no, print "Thanks for playing!" and exit.

Run the game, play to completion, then choose to play again.

After this step you should see…
Play again? (yes/no): yes

You did it

Run python3 tictactoe.py and play through to victory (or a draw). Then play again. That game didn't exist a few hours ago — now you've got something you can challenge a friend with.

You wrote:

  • a board representation and display function
  • input parsing and validation
  • turn-based game state management
  • win-detection logic for rows, columns, and diagonals
  • a full game loop with termination conditions
  • cross-platform screen clearing
  • a replay mechanic

Every one of those is a pattern you'll use again — in card games, word games, even 3-D game engines.

Stretch goals