Skip to content

Multiplayer Tic-Tac-Toe (Socket Programming)

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.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Standard library only — socket and threading.
  • Comfort with lists, loops, and basic threading.
  1. Create a folder named tic-tac-toe-net.
  2. Inside it, create multiplayer_tic_tac_toe.py.
multiplayer_tic_tac_toe.py pch.viewSource
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()
command
# 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)

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
multiplayer_tic_tac_toe.py
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.

multiplayer_tic_tac_toe.py
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”.

multiplayer_tic_tac_toe.py
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.

multiplayer_tic_tac_toe.py
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

Section titled “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:

turns.py
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

Section titled “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:

broadcast.py
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.

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
  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.
  • 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.
  • 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.
  • 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.

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.

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.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading