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 —
socketsocketandthreadingthreading. - Comfort with lists, loops, and basic threading.
Getting Started
Create the project
- Create a folder named
tic-tac-toe-nettic-tac-toe-net. - Inside it, create
multiplayer_tic_tac_toe.pymultiplayer_tic_tac_toe.py.
Write the code
multiplayer_tic_tac_toe.py
Source"""
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
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
# 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)# 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
board = [" " for _ in range(9)]
current_player = "X"
lock = threading.Lock()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
player = current_player
with lock:
current_player = "O" if current_player == "X" else "X"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
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"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
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())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:
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 turnturn = "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 turnA 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:
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)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
| Problem | Cause | Fix |
|---|---|---|
| Both players move freely | Turn never checked in the loop | Enforce player == turnplayer == turn under the lock |
| Opponent’s screen never updates | Board sent only to the mover | Broadcast to all connected clients |
| Board occasionally corrupts | Unlocked access from two threads | Guard every read/write with locklock |
Address already in useAddress already in use | Port not released | setsockopt(SO_REUSEADDR)setsockopt(SO_REUSEADDR) before bind |
| Server hangs after a win | Socket not closed / loop not broken | breakbreak and conn.close()conn.close() on game over |
| Move index off by one | Used 1-9 instead of 0-8 | Keep cells 0-8, or subtract 1 on input |
Variations to Try
- Proper turn engine — enforce alternation and reject out-of-turn moves.
- Spectators — extra connections that receive the board read-only.
- Rematch — reset the board and swap who goes first.
- Tkinter client — a clickable 3×3 grid instead of typing indices.
- Lobby / multiple games — pair players into separate boards.
- AI opponent — single-player vs. a minimax bot.
N×NN×Nboard — 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:
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 coffeeWas this page helpful?
Let us know how we did
