Python for Humans · Draft v0.3 · Oct 28, 2026 · Project · Separate move helpers, one-line checks, and all-line winner detection before reconnecting the game.
Tic-Tac-Toe: Three in a Row
Course project · After Giving Code a Name
- You should already know
- Giving Code a Name, including parameters, return values, and local names; the Your Move project.
- You'll build
- A complete two-player tic-tac-toe game.
- You'll be able to
- Separate display, move validation, and winner detection into functions.
- You need
- This page. Each example runs in its own code box; nothing to install.
Our board remembers every move. But the players still have to spot a winner, and the program repeats the same display instructions.
Let's give those jobs names and finish the game. You'll build three helpers: one to show the board, one to attempt a move, and one to check a win.
Work in three stages: organize moves, check winning lines on fixed boards, then connect the rules to the game. Each stage has a check and a place to stop. Keep the full game for the end; the smaller examples let us work out one piece at a time.
Start from the Working Board
Run this complete starter and take a few turns. It is the end of Your Move, including its quit command. You don't need values from that page.
board = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] player = 'X' moves = 0 while moves < 9: print(f"{board[0]} | {board[1]} | {board[2]}") print("--+---+--") print(f"{board[3]} | {board[4]} | {board[5]}") print("--+---+--") print(f"{board[6]} | {board[7]} | {board[8]}") square = input(f"{player}, choose a square (1-9, or quit): ") if square == 'quit': break if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: position = int(square) - 1 if board[position] in ['X', 'O']: print("That square is taken. Try another one.") else: board[position] = player moves = moves + 1 if player == 'X': player = 'O' else: player = 'X' else: print("Please choose a number from 1 to 9.") print(f"{board[0]} | {board[1]} | {board[2]}") print("--+---+--") print(f"{board[3]} | {board[4]} | {board[5]}") print("--+---+--") print(f"{board[6]} | {board[7]} | {board[8]}") print("Thanks for playing!")
Give the Display a Name
Move the five board-printing lines into def show_board(board):. The parameter receives the list to display. Replace each old display with show_board(board).
Keep the list creation and game loop outside the definition. Run the game again. Its appearance and behavior should stay the same; now the display has one place to change.
Check before continuing: Change a separator inside show_board(). Both the display before a turn and the display at the end should change together. If only one changes, look for a copy you haven't replaced with a call.
A Function Can Answer a Question
A function can return True or False, just as it can return a number. We can use that answer in an if:
def is_mark(value): return value in ['X', 'O'] square = 'X' if is_mark(square): print('That square is taken.') else: print('That square is available.')
Predict what happens with 'X', then change square to '5' and run again. is_mark() gives the caller a yes-or-no answer. The caller decides which message to print.
Our move helper will return a similar answer: did we place a mark? The main loop can use that answer to decide whether to count a move and switch players.
Give a Helper More Than One Input
So far, our chapter examples passed one value to a function. A move needs both a board and a position. Separate the parameters with a comma, and supply arguments in the same order:
def place_x(board, position): board[position] = 'X' squares = ['1', '2', '3'] place_x(squares, 1) print(squares)
In place_x(squares, 1), the first argument becomes the local parameter board, and the second becomes position. Before running, predict the three entries that will be printed.
The parameter board is local, but it refers to the same list as squares. Replacing an item changes that list, so the caller sees X afterward. We didn't assign a new list to the parameter.
By contrast, board = ['X'] inside the function would give the local name a different list. It wouldn't replace the caller's squares. Try that change and compare, then restore the item assignment.
Try the Move Helper on Its Own
Now write make_move(board, square, player). Its three arguments are the board list, the player's answer as a string, and their mark, in that order.
Use the move checks from the starter. A valid, empty square should receive the mark and return True. An occupied square or an answer outside 1–9 should leave the board alone and return False. You can keep the starter's helpful messages.
Work on this small board before putting the helper in the game:
# Define make_move() above the board, then add calls below it. board = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
Add a call with board, '5', and 'X'. Print its returned answer, then print the board. You should get True and an X in the center. Next, in the same run:
- Try
'5'for O. The answer should beFalse, and the center should stay X. - Try
'hello'for O. It should returnFalsewithout crashing or changing the board. - Try
'1'for O. It should returnTrueand put O in the first square.
Hint: Keep rejected moves away from the assignment
Check the string before converting it to an index. Once you have a position, check whether its value is X or O. Only assign board[position] = player on the successful path. Each path needs a returned answer; printing a message doesn't supply one.
Compare a move helper after you've tried the calls
def make_move(board, square, player): if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: position = int(square) - 1 if board[position] in ['X', 'O']: print('That square is taken. Try another one.') return False board[position] = player return True print('Please choose a number from 1 to 9.') return False board = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] print(make_move(board, '5', 'X')) print(make_move(board, '5', 'O')) print(make_move(board, 'hello', 'O')) print(make_move(board, '1', 'O')) print(board)
You should see True, False, False, and True, with helpful messages for the rejected answers. The final board has O at position 0 and X at position 4. Neither rejected answer reached the assignment, so neither changed the board.
When those calls work, put your helper beside show_board() in the working game. Replace the old move checks with if make_move(board, square, player):. Keep counting and switching inside that successful branch. Leave the quit check before it.
Check the game: Enter 5, 5, 1, then quit. X should stay in the center, O should get the first square, and only two moves should have been accepted. The rejected move must not switch players.
This is the end of the first stage. You have organized the game without adding any winning rules. You can stop here.
Check Just One Winning Line
Set the game aside for a moment. On a fixed board, can we tell whether X fills the top row?
board = ['X', 'X', 'X', '4', 'O', '6', '7', '8', '9'] line = [0, 1, 2] matches = 0 for position in line: if board[position] == 'X': matches = matches + 1 print(matches == 3)
The list line contains the three positions to inspect. For each position, we look up the mark and count a match. The comparison after the loop gives True only when all three positions contain X.
Predict the answer, then run it. Change the center entry of the top row, board[1], to O. You should now get False. Restore it, then try line = [0, 3, 6]: does X fill that column?
Notice that we're counting matches in one specified line, not anywhere on the board. Three scattered Xs aren't necessarily a win.
Remember the Winning Lines
There are eight ways to win: three rows, three columns, and two diagonals. Each can be described by three list positions.
A list can hold other lists. Let's inspect two winning rows before we use all eight:
lines = [[0, 1, 2], [3, 4, 5]] for line in lines: print("Checking a line:") for position in line: print(position)
The outer loop chooses one three-position list. The inner loop visits its positions. On the first trip, line is [0, 1, 2], exactly like our one-line check. On the second, it becomes [3, 4, 5].
Start again with the original fixed board in the one-line checking box above. Replace line = [0, 1, 2] with lines = [[0, 1, 2], [3, 4, 5]]. Add for line in lines: around the counting and display instructions. Keep matches = 0 inside this outer loop, before the inner one. Each row needs its own count.
Check before adding a function: With the original fixed board, you should see True, then False. Now try ['X', 'X', '3', '4', '5', 'X', '7', '8', '9']. Both rows should give False. If their marks add together, check where the count resets.
Compare the two-row check after you've tried
board = ['X', 'X', 'X', '4', 'O', '6', '7', '8', '9'] lines = [[0, 1, 2], [3, 4, 5]] for line in lines: matches = 0 for position in line: if board[position] == 'X': matches = matches + 1 print(matches == 3)
The count resets for each line, but keeps increasing while visiting that line's three positions. Moving matches = 0 before both loops would combine the rows. Moving it inside the inner loop would forget each position's earlier matches.
Turn the Check into a Function
Now give that work a name: check_winner(board, player). Use player in place of the fixed 'X', so the same function can check either mark.
Inside the function, use all eight winning lines:
lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ]
Instead of printing a result for every line, return True as soon as a line has three matches. If none wins, return False after the outer loop. A losing top row doesn't tell us whether a later column or diagonal wins.
Try your function in a separate box. Add calls and display their answers; you don't need input or a game loop yet.
# Define check_winner() above this board, then add calls below it. board = ['X', '2', '3', '4', 'X', '6', '7', '8', 'X']
- The supplied diagonal should give
Truefor X andFalsefor O. ['1', 'O', '3', '4', 'O', '6', '7', 'O', '9']should giveTruefor O.['X', 'X', '3', '4', '5', 'X', '7', '8', '9']should giveFalsefor X.- The original empty numbered board should give
Falsefor either player.
Hint: My diagonal never wins
Look for a return False inside the loop over lines. That would end the call after checking just the first line. Only a winning line lets us answer early; a losing line means we need to keep looking.
When these checks work, you've finished the second stage. Explain why the two return statements need different indentation before continuing.
Connect the Rules
After an accepted move, check for a win first. If there isn't one, check whether nine moves have been accepted. A winning ninth move is still a win, so the order matters.
Keep the checks you already made for counting and rejected moves. Add a call to check_winner(board, player) after increasing moves, before switching players. If it returns True, show the board, announce the winner, and leave the loop. Otherwise, if moves is 9, show the board and announce a draw. Only switch players when the game can continue.
Try the sequences below with your connected game. Open the complete version if you need to compare a part that behaves differently.
Compare the complete game after you've tried connecting yours
def show_board(board): print(f"{board[0]} | {board[1]} | {board[2]}") print("--+---+--") print(f"{board[3]} | {board[4]} | {board[5]}") print("--+---+--") print(f"{board[6]} | {board[7]} | {board[8]}") def make_move(board, square, player): if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: position = int(square) - 1 if board[position] in ['X', 'O']: print("That square is taken. Try another one.") return False board[position] = player return True print("Please choose a number from 1 to 9.") return False def check_winner(board, player): lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] for line in lines: matches = 0 for position in line: if board[position] == player: matches = matches + 1 if matches == 3: return True return False board = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] player = 'X' moves = 0 while moves < 9: show_board(board) square = input(f"{player}, choose a square (1-9, or quit): ") if square == 'quit': break if make_move(board, square, player): moves = moves + 1 if check_winner(board, player): show_board(board) print(f"{player} wins!") break elif moves == 9: show_board(board) print("It's a draw!") else: if player == 'X': player = 'O' else: player = 'X' print("Thanks for playing!")
make_move() answers whether the turn was accepted. check_winner() answers whether that accepted move ended the game. The loop keeps those jobs in order: place, count, check, then switch only if play continues.
Try these sequences on separate runs:
- X wins:
1,4,2,5,3. The game should stop immediately after X completes the top row. - O wins:
1,4,2,5,9,6. - Draw:
1,2,3,5,4,6,8,7,9. - Rejected moves:
hello,0,5,5,1, thenquit. The occupied square stays X and the next accepted move belongs to O.
Do you see a final board for each win and draw? Can a player overwrite a mark? Check before adding anything else.
Make It Yours
Choose one bonus:
- Change the board's separators inside
show_board(). Every display should change together. - Add a replay question. Keep the function definitions outside the replay loop; create a new board and reset
movesfor each game.
Click Download .py below your working game to keep your version. Next, in Meet Your Opponent, we'll replace one player's question with a random choice from the empty squares.
Explain why check_winner() returns an answer while show_board() prints something. What does the caller do with each?