Python Central HubSign in

Python Projects / Intermediate

Multiplayer Tic-Tac-Toe (Socket Programming)

Build a networked two-player Tic-Tac-Toe in Python with sockets and threads — shared board, win detection, turn locking — then fix the turn-enforcement and broadcast gaps and add reconnection and a GUI.

Abstract

Tic-Tac-Toe is simple enough that the game never gets in the way of the real lesson: how do two programs on different machines share one consistent state? In this project you build a socket server that hosts a game board, assigns players X and O, validates moves, detects a winner, and uses a lock to keep the shared board from being corrupted by two threads at once. Along the way you'll see exactly why naive multiplayer code lets both players move at once — and how to enforce turns and broadcast the board to everyone.

You will leave understanding:

  • How a server holds authoritative game state two clients connect to.
  • Why shared mutable state across threads needs a threading.Lock.
  • The win/draw detection algorithm over a flat 9-cell board.
  • The difference between having a lock and actually enforcing turns.

Prerequisites

  • Python 3.6 or above.
  • A text editor or IDE.
  • Standard library only — socket and threading.
  • Comfort with lists, loops, and basic threading.

Getting Started

Create the project

  1. Create a folder named tic-tac-toe-net.
  2. Inside it, create multiplayer_tic_tac_toe.py.

Write the code

multiplayer_tic_tac_toe.pySource
multiplayer_tic_tac_toe.py
"""
Multiplayer Tic-Tac-Toe

A Python-based multiplayer Tic-Tac-Toe game using socket programming. Features include:
- Two players can play over a network.
- Real-time updates of the game board.
"""

import socket
import threading

# Constants
HOST = "127.0.0.1"
PORT = 65432

# Game board
board = [" " for _ in range(9)]
current_player = "X"
lock = threading.Lock()

def print_board():
    """Print the game board."""
    print("\n")
    for i in range(3):
        print(" | ".join(board[i * 3:(i + 1) * 3]))
        if i < 2:
            print("-" * 5)

def check_winner():
    """Check if there is a winner."""
    winning_combinations = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8],  # Rows
        [0, 3, 6], [1, 4, 7], [2, 5, 8],  # Columns
        [0, 4, 8], [2, 4, 6]              # Diagonals
    ]
    for combo in winning_combinations:
        if board[combo[0]] == board[combo[1]] == board[combo[2]] != " ":
            return board[combo[0]]
    if " " not in board:
        return "Draw"
    return None

def handle_client(conn, addr):
    """Handle communication with a client."""
    global current_player
    conn.sendall("Welcome to Tic-Tac-Toe!\n".encode())
    conn.sendall("You are player {}\n".format(current_player).encode())

    player = current_player
    with lock:
        current_player = "O" if current_player == "X" else "X"

    while True:
        conn.sendall("\n".join(board).encode())
        conn.sendall("\nEnter your move (0-8): ".encode())
        move = conn.recv(1024).decode().strip()

        if not move.isdigit() or int(move) not in range(9):
            conn.sendall("Invalid move. Try again.\n".encode())
            continue

        move = int(move)
        with lock:
            if board[move] == " ":
                board[move] = player
                winner = check_winner()
                if winner:
                    conn.sendall("\nGame Over! Winner: {}\n".format(winner).encode())
                    break
            else:
                conn.sendall("Spot already taken. Try again.\n".encode())

    conn.close()

def main():
    """Main server function."""
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((HOST, PORT))
    server.listen(2)
    print("Server started. Waiting for players...")

    while True:
        conn, addr = server.accept()
        print(f"Player connected from {addr}")
        threading.Thread(target=handle_client, args=(conn, addr)).start()

if __name__ == "__main__":
    main()

Run it

cmd
# Start the server
C:\Users\Your Name\tic-tac-toe-net> python multiplayer_tic_tac_toe.py
Server started. Waiting for players...
 
# Connect two clients (e.g. with `telnet 127.0.0.1 65432` or a small client script)

How it fits together

Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.

diagram Diagram mermaid

Step-by-Step Explanation

1. Shared state + a lock

Python
board = [" " for _ in range(9)]
current_player = "X"
lock = threading.Lock()

The board is a flat list of 9 cells (index 0-8). Because two client threads can touch board and current_player at the same time, every change is guarded by lock to prevent a race condition — two writes interleaving and leaving the board inconsistent.

2. Assigning players

Python
player = current_player
with lock:
    current_player = "O" if current_player == "X" else "X"

The first client becomes X, the second O. with lock: ensures the swap is atomic — without it, two simultaneous connections could both grab "X".

3. Win detection

Python
winning_combinations = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8],   # rows
    [0, 3, 6], [1, 4, 7], [2, 5, 8],   # columns
    [0, 4, 8], [2, 4, 6]               # diagonals
]
for combo in winning_combinations:
    if board[combo[0]] == board[combo[1]] == board[combo[2]] != " ":
        return board[combo[0]]
if " " not in board:
    return "Draw"

All 8 winning lines are hard-coded as index triples. The chained comparison checks "all three equal and not empty" in one line. No empty cells and no winner means a draw.

4. Validating a move

Python
if not move.isdigit() or int(move) not in range(9):
    conn.sendall("Invalid move. Try again.\n".encode())
    continue
...
with lock:
    if board[move] == " ":
        board[move] = player
    else:
        conn.sendall("Spot already taken. Try again.\n".encode())

Input is validated twice: it must be a digit 0-8, and the target cell must be empty. Both checks happen before mutating the board.

The Big Gap: Turns Aren't Actually Enforced

Look closely — the server swaps current_player once at connect, but the move loop never checks whose turn it is. Both players can hammer moves whenever they like. Real turn enforcement:

Python
turn = "X"   # global, lock-protected
 
# inside the move handler:
with lock:
    if player != turn:
        conn.sendall("Not your turn. Wait.\n".encode())
        continue
    if board[move] == " ":
        board[move] = player
        winner = check_winner()
        turn = "O" if turn == "X" else "X"   # hand off the turn

A multiplayer server is the referee — clients propose moves, the server decides if they're legal.

The Other Gap: Broadcast the Board to Both

The current code sends the board only to the player who just moved. The opponent never sees it. Track both connections and push updates to both:

Python
clients = []   # filled as players connect
 
def send_board():
    rendered = render(board).encode()
    for c in list(clients):
        try:
            c.sendall(rendered)
        except OSError:
            clients.remove(c)

Call send_board() after every accepted move so both screens stay in sync.

Common Mistakes

ProblemCauseFix
Both players move freelyTurn never checked in the loopEnforce player == turn under the lock
Opponent's screen never updatesBoard sent only to the moverBroadcast to all connected clients
Board occasionally corruptsUnlocked access from two threadsGuard every read/write with lock
Address already in usePort not releasedsetsockopt(SO_REUSEADDR) before bind
Server hangs after a winSocket not closed / loop not brokenbreak and conn.close() on game over
Move index off by oneUsed 1-9 instead of 0-8Keep cells 0-8, or subtract 1 on input

Variations to Try

  1. Proper turn engine — enforce alternation and reject out-of-turn moves.
  2. Spectators — extra connections that receive the board read-only.
  3. Rematch — reset the board and swap who goes first.
  4. Tkinter client — a clickable 3×3 grid instead of typing indices.
  5. Lobby / multiple games — pair players into separate boards.
  6. AI opponent — single-player vs. a minimax bot.
  7. N×N board — generalize win detection to k-in-a-row.

Real-World Applications

  • Turn-based multiplayer games — chess, checkers, board games.
  • Authoritative servers — the anti-cheat pattern in online games.
  • Collaborative state — shared whiteboards, live documents.
  • State synchronization — keeping multiple clients consistent.

Educational Value

  • Authoritative server design — the server as referee.
  • Thread safety — locks and race conditions on shared state.
  • Protocol design — how clients and server agree on messages.
  • Game logic — win/draw detection over a flat array.

Next Steps

  • Add a real turn engine that rejects out-of-turn moves.
  • Broadcast the board to both players after each move.
  • Build a Tkinter client with a clickable grid.
  • Add rematch and a simple lobby.

Try it here

Here's a local two-player board to feel the game logic — click a cell to place a mark (X and O alternate), get three in a row to win, then click to reset:

sketch Play tic-tac-toe p5.js
Click a cell to place your mark. X and O alternate; three in a row wins. Click after a game to reset.

Checkpoint exercise

This checkpoint practices the server-side rule of a multiplayer game: decide from a board string whether someone has won, it is a draw, or play continues.

exerciseYour turn

Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.

Output
Press Run to see what your code prints.

Conclusion

You built networked Tic-Tac-Toe and learned the defining idea of online multiplayer: the server owns the truth, clients only propose. You also saw why "has a lock" isn't the same as "enforces the rules" — turn logic and broadcasting are what make it a real game. Those patterns scale straight up to chess servers and collaborative editors. Full source on GitHub. Explore more networking and game projects on Python Central Hub.

Was this page helpful?

Let us know how we did