ScreenshotNeo

BlogHow-to

How to Create a Tic-Tac-Toe Game in Python

Build a playable Python tic-tac-toe game with a nine-cell board, input validation, win checks, draws, testing, and optional GUI ideas.

By the ScreenshotNeo team1 October 20261 min read

Direct answer: represent the 3×3 board with a nine-item list, alternate X and O in a loop, reject invalid or occupied positions, and check the eight winning lines after every legal move. The complete two-player command-line game below uses only Python’s standard library.

What you will build

This local two-player game uses positions 1–9:

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

A flat list stores cells at indexes 0–8, so position 1 maps to index 0. A win is any of eight rows, columns, or diagonals; a draw means all cells are filled with no winner.

Complete runnable Python game

Save as tic_tac_toe.py and run python tic_tac_toe.py (or python3).

WINNING_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))

def show_board(board):
    rows = [" | ".join(board[start:start+3]) for start in (0,3,6)]
    print("\n" + "\n--+---+--\n".join(rows) + "\n")

def get_move(board, player):
    while True:
        try:
            position = int(input(f"Player {player}, choose a position (1-9): "))
        except ValueError:
            print("Enter a whole number from 1 to 9."); continue
        except EOFError:
            print("\nInput ended. Goodbye!"); raise SystemExit
        if not 1 <= position <= 9:
            print("Choose 1 through 9."); continue
        index = position - 1
        if board[index] != " ":
            print("That position is already occupied."); continue
        return index

def has_won(board, player):
    return any(all(board[index] == player for index in line) for line in WINNING_LINES)

def is_draw(board):
    return " " not in board

def play_game():
    board, player = [" "] * 9, "X"
    print("Tic-Tac-Toe\nPositions:")
    show_board([str(number) for number in range(1,10)])
    while True:
        show_board(board)
        board[get_move(board, player)] = player
        if has_won(board, player):
            show_board(board); print(f"Player {player} wins!"); return
        if is_draw(board):
            show_board(board); print("It is a draw."); return
        player = "O" if player == "X" else "X"

if __name__ == "__main__":
    play_game()

How it works

  1. [" "] * 9 creates nine empty cells.
  2. show_board prints three slices.
  3. get_move converts input() text to an integer and retries invalid, out-of-range, or occupied choices without consuming a turn.
  4. The mark is placed, then all eight lines are checked immediately.
  5. If there is no winner, a full board is a draw; otherwise the turn switches.

Implementation choices and extensions

List or dictionary

The list makes indexing and line checks compact. A dictionary keyed 1–9 can read more like the printed board. Do not mix one-based input with zero-based indexes: subtract one exactly once.

Reusable game logic

has_won and is_draw depend only on state, so a GUI can reuse them while replacing terminal input and output.

Optional computer opponent

import random
legal_moves = [i for i, cell in enumerate(board) if cell == " "]
if legal_moves:
    board[random.choice(legal_moves)] = "O"

Check that the legal-moves list is non-empty before random.choice, which raises IndexError for an empty sequence. Minimax is a separate strategy project.

GUI path

Tkinter can provide nine buttons, a turn label, and reset behavior. Button callbacks replace input(); keep the board and rule functions unchanged.

Testing checklist

  • Test all three rows, columns, and both diagonals.
  • Verify an early win ends immediately.
  • Fill the board without a line and verify a draw.
  • Try letters, decimals, negative numbers, 0, 10, and occupied cells.
  • Confirm invalid input does not change the board or turn.
  • Confirm redirected input handles EOFError.

Troubleshooting

Symptom Cause Fix
ValueError Non-numeric input reached int(). Keep the try/except ValueError retry.
Position 1 changes cell 2 Index bases were mixed. Use index = position - 1.
Full winning board reports draw Draw checked first. Check has_won before is_draw.
Extra turn after bad input Turn switched before validation. Switch only after a legal move and result checks.
NameError: WINNING_LINES Constant was misspelled or scoped locally. Define it before has_won at module level.
Redirected input stops abruptly EOF raises EOFError. Handle it as shown or provide nine input lines.

Performance, reliability, and cost

Each move checks a fixed nine-cell board and eight lines, so time and memory stay constant. Validate at the input boundary, keep one winning-line definition, and evaluate the result after every legal move. The game uses only built-in Python features, with no network calls or service cost.

Or skip the browser setup

To capture a hosted game board, demo page, or README image without configuring a headless browser, use ScreenshotNeo. See the API documentation.

# cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp

# Python
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

# Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie banners, newsletter popups, and chat widgets are removed before capture.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the verdict and billing result.
  • An MCP server lets Claude, Cursor, and other AI agents call take_screenshot, get_page_info, and capture_pdf.
  • 1,000 screenshots per month are free with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account.

FAQ

Can I use a nested 3×3 list?

Yes. It mirrors rows directly; document whichever indexing convention you choose.

Why check wins before draws?

The final move can fill the board and complete a line, so the winner must be reported first.

Which Python version works?

Any current stable Python 3 release supports this standard-library code.

How do I replay?

Wrap play_game() in an outer loop and break when players decline another round.