Skip to content

Multiplayer Tic-Tac-Toe (Socket Programming)

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.Lockthreading.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 — socketsocket and threadingthreading.
  • Comfort with lists, loops, and basic threading.

Getting Started

Create the project

  1. Create a folder named tic-tac-toe-nettic-tac-toe-net.
  2. Inside it, create multiplayer_tic_tac_toe.pymultiplayer_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()
 
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

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

Step-by-Step Explanation

1. Shared state + a lock

multiplayer_tic_tac_toe.py
board = [" " for _ in range(9)]
current_player = "X"
lock = threading.Lock()
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 boardboard and current_playercurrent_player at the same time, every change is guarded by locklock to prevent a race condition — two writes interleaving and leaving the board inconsistent.

2. Assigning players

multiplayer_tic_tac_toe.py
player = current_player
with lock:
    current_player = "O" if current_player == "X" else "X"
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:with lock: ensures the swap is atomic — without it, two simultaneous connections could both grab “X”.

3. Win detection

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

4. Validating a move

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

Look closely — the server swaps current_playercurrent_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
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

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)
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()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 == turnplayer == 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 locklock
Address already in useAddress already in usePort not releasedsetsockopt(SO_REUSEADDR)setsockopt(SO_REUSEADDR) before bind
Server hangs after a winSocket not closed / loop not brokenbreakbreak and conn.close()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×NN×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.

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.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did