Multiplayer Tic-Tac-Toe (Socket Programming)
Abstract
Section titled “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
Section titled “Prerequisites”- Python 3.6 or above.
- A text editor or IDE.
- Standard library only —
socketandthreading. - Comfort with lists, loops, and basic threading.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
tic-tac-toe-net. - Inside it, create
multiplayer_tic_tac_toe.py.
Write the code
Section titled “Write the code”multiplayer_tic_tac_toe.py
pch.viewSource"""
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
Section titled “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)How it fits together
Section titled “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.
flowchart TD
RUN(["python multiplayer_tic_tac_toe.py"])
print_board("print_board")
check_winner("check_winner")
handle_client("handle_client")
main("main")
RUN --> main
handle_client --> check_winner
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Shared state + a lock
Section titled “1. Shared state + a 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 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
Section titled “2. Assigning players”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
Section titled “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"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
Section titled “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())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:
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 turnA 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:
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
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| Both players move freely | Turn never checked in the loop | Enforce player == 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 lock |
Address already in use | Port not released | setsockopt(SO_REUSEADDR) before bind |
| Server hangs after a win | Socket not closed / loop not broken | break and 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
Section titled “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×Nboard — generalize win detection to k-in-a-row.
Real-World Applications
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading