The Problem: A Lesson in Client-Server Architecture
During my computer networking class, I had an assignment to create a multiplayer Pong game using networking, threading, and a client-server architecture. At first glance, my implementation seemed to work. Both players could connect, see each others paddles move, and play the game. However, in my naivete, I let each client handle their own movement, leading to large desyncs, ultimately making the game unplayable.
Now that I’m a bit older and wiser (at least I’d like to think so…), I’d like to take another stab at this problem, but first, let’s review what made the old architecture bad.
The Original Architecture
The initial code followed what I now recognize as a “peer-to-peer through server relay” anti-pattern. Here’s what was wrong:
Bug #1: Race Condition in Player Cleanup
The most immediately crash-inducing bug was in the server’s player removal logic:
# This appeared in TWO places in the code!
game.players.remove(game.players[player_index])
When a player disconnected, the cleanup code ran twice - once inside the main loop and once after it ended. This caused IndexError crashes when the second removal tried to access a player that was already removed.
Impact: Server crashes whenever players disconnect ungracefully.
Bug #2: Each Client Runs Its Own Game Logic
This was the fundamental architectural flaw. Both clients were running independent ball physics:
# Client code - each client doing this independently!
ball.updatePos()
if ball.rect.x > screenWidth:
lScore += 1 # Both clients might detect the same goal!
Impact: Games would quickly desynchronize as each client calculated different ball positions and scores.
Bug #3: Uninitialized Game State
The Player class declared a paddle attribute but never initialized it:
class Player:
paddle: Paddle # Never actually created!
def __init__(self, id):
self.id = id # paddle is still None!
Impact: Serialization errors when trying to send player data between clients.
Bug #4: Missing Ball State Synchronization
The network protocol only exchanged paddle positions and scores - no ball data whatsoever. Each client simulated the ball independently, basically guaranteeing desynchronization.
The Solution: Proper Client-Server Architecture
I completely redesigned the system using authoritative server architecture.
Server: The Single Source of Truth
The new server maintains complete game state and runs all physics:
class GameState:
def __init__(self):
self.left_paddle_y = (HEIGHT / 2) - 25
self.right_paddle_y = (HEIGHT / 2) - 25
self.ball_x = WIDTH / 2
self.ball_y = HEIGHT / 2
self.ball_vel_x = -BALL_SPEED
self.ball_vel_y = 0
self.left_score = 0
self.right_score = 0
self.game_over = False
The server runs a dedicated physics thread for each game:
def game_loop(self):
"""Main game physics loop - runs on server"""
while self.running and len(self.players) > 0:
current_time = time.time()
if current_time - last_time >= TICK_INTERVAL:
self.update_physics(dt)
last_time = current_time
Client: Input & Rendering
Clients now only send input commands and render the server’s authoritative state:
class PlayerInput:
def __init__(self, player_id=0, action=""):
self.player_id = player_id
self.action = action # "up", "down", or ""
# Client sends only input
player_input = PlayerInput(player_id, current_input)
client.sendall(pickle.dumps(player_input))
# Client receives complete game state
game_state = pickle.loads(client.recv(PACKET_SIZE))
Network Protocol: Clean and Simple
- Client → Server: Input commands only (
PlayerInput) - Server → Client: Complete game state (
GameState) - Update Rate: 60fps for smooth gameplay (ESSENTIAL for robust Pong gameplay)
Other Improvements
Thread Safety
I implemented proper thread synchronization in the client to safely share game state between the network thread and rendering thread:
game_state_lock = threading.Lock()
def network_thread():
while network_running:
# ... network code ...
with game_state_lock:
current_state = pickle.loads(data)
# Main loop safely accesses shared state
with game_state_lock:
state = current_state
Robust Error Handling
Added comprehensive error handling for network timeouts, disconnections, and malformed data:
try:
conn.settimeout(0.016) # ~60fps timeout
data = conn.recv(PACKET_SIZE)
if data:
player_input = pickle.loads(data)
game.update_player_input(player_id, player_input.action)
except socket.timeout:
pass # No input received, continue
except Exception as e:
logging.error(f"Connection error: {e}")
break
Game State Management
Proper lifecycle management for games and players:
def remove_player(self, player_id):
self.players = [p for p in self.players if p.id != player_id]
if len(self.players) == 0:
self.running = False # Stop game physics when empty
Results and Lessons Learned
The redesigned system eliminated all synchronization issues and created a genuinely multiplayer experience. Key improvements:
✅ Perfect Synchronization: Impossible for clients to have different game states
✅ No Race Conditions: Clean player management and threading
✅ Robust Networking: Handles disconnections and errors gracefully
What I Learned
- Authority Matters: In multiplayer games, one entity must be the definitive source of truth
- Separate Concerns: Network code, game logic, and rendering should be cleanly separated
- Test Edge Cases: Always test disconnections, timeouts, and error conditions
- Threading is Tricky: Proper synchronization is critical for multi-threaded applications
- Architecture First: Getting the fundamental design right saves countless debugging hours
The Bigger Picture
Redoing this project taught me about how important architectural planning is to a multiplayer game. The original code - while it appeared to work - had a bad architecture, which nerfed it from the start. By applying proper client-server paradigms used in professional game development, I took the broken code from a college project that only partially worked, and transformed it into a pretty solid game.
The experience reinforced why understanding networking fundamentals is so important for any developer working on distributed systems, whether games or web applications. The principles of authoritative servers, input validation, and state synchronization apply far beyond game development.
Next Steps
With a solid foundation in place, I’m considering adding:
- Spectator mode
- Game replay system
- WebSocket support for browser-based clients
The clean architecture makes these features straightforward to implement - exactly what good design should enable.
The Code
Source is available here. As always, you are free to do whatever you want with the code.
Have you worked on multiplayer networking projects? I’d love to hear about your experiences and lessons learned in the comments below!