Real-time multiplayer word games: WebSocket state sync without the footguns
Real-time multiplayer word games: WebSocket state sync without the footguns
You’ve built a word game. It works perfectly in single-player: the dictionary check is fast, the UI is snappy, and the state management is contained within a single browser tab. But the moment you decide to add 2-4 player live matches, the complexity curve doesn't just climb—it goes vertical.
If you’ve ever looked at a live multiplayer Scrabble built on similar patterns or another GWN multiplayer word game, you know the magic of a seamless experience. Achieving that requires moving away from "client-side logic" and embracing a strict, authoritative architecture.
The Golden Rule: The Server is the Only Source of Truth
The biggest mistake developers make when transitioning to multiplayer is letting the client compute the game state. If your client calculates the score or validates the word, a malicious user can simply open the browser console and overwrite their score or bypass your dictionary.
Authoritative Server Pattern:
- Client sends an Intent: "I want to play 'APPLE' at these coordinates."
- Server validates: Is it their turn? Is the word in the dictionary? Does it fit the board?
- Server updates state: The server modifies the master board state.
- Server broadcasts: The server pushes the new state (or a delta) to all connected clients.
Never let the client tell the server what the new score is. The client should only ever send commands and receive events.
Designing Your WebSocket Protocol
Don't just dump JSON blobs over the wire. You need a structured protocol to handle the chaos of network latency. Split your messages into Commands (Client to Server) and Events (Server to Client).
Use sequence numbers for every message. If a player receives event #42 but their last processed event was #40, they know they’ve missed a packet and can request a state sync.
// A robust message handler for 'submit-guess'
type GameCommand = { type: 'submit-guess'; word: string; seq: number };
async function handleMessage(ws: WebSocket, data: GameCommand) {
const room = await getRoom(ws.roomId);
// 1. Anti-cheat: Rate limiting
if (isRateLimited(ws.userId)) return sendError(ws, "Too many guesses");
// 2. Authoritative Validation
if (!room.isPlayerTurn(ws.userId)) return sendError(ws, "Not your turn");
if (!isValidWord(data.word)) return sendError(ws, "Invalid word");
// 3. Update State
const result = await room.applyGuess(data.word);
// 4. Broadcast Event
broadcast(room.id, {
type: 'guess-accepted',
payload: result,
seq: room.nextSeq()
});
}
Handling the "Dropped Player" Problem
In a 4-player game, someone will lose their Wi-Fi connection. If they refresh, they shouldn't lose their spot.
Implement a Resume Token. When a player connects, the server issues a unique, short-lived token. If the connection drops, the client attempts to reconnect with that token. The server then replays the missed events from the message log since the player's last known sequence number. This makes the reconnection feel instantaneous rather than a jarring "reloading game..." screen.
Scaling: The Room-as-a-Unit Pattern
If you try to keep all game state in a single global database, you’ll hit locking contention immediately. Instead, treat each game room as an isolated unit of compute.
- Durable Objects (Cloudflare): Perfect for this. Each room lives in a single, stateful instance that keeps the game board in memory.
- Redis Streams: If you prefer a traditional backend, use a Redis stream per room to act as the message bus and state buffer.
Keep your application tier stateless. The WebSocket server should just be a thin pipe that forwards commands to the specific "Room Actor" (the Durable Object or Redis-backed service) responsible for that game.
Anti-Cheat: Never Trust the Client
Beyond rate-limiting, you must implement server-side dictionary validation. Use a high-performance trie structure or a Bloom filter on the server to validate words in constant time. If a user tries to submit a word that isn't in your dictionary, the server should reject it and potentially flag the account for suspicious activity.
The Things You’ll Regret Not Doing Day 1
If you skip these, you will be rewriting your backend in six months:
- Persistent Replay Log: Store every command sent to a room in a database. When a bug occurs (and it will), you need to be able to "replay" the game to see exactly what sequence of events led to the state corruption.
- Error Surfacing: Don't just silently drop invalid commands. If a guess is rejected, send a specific error code back to the client so the UI can show a "Word not in dictionary" toast. A silent failure is the fastest way to lose players.
- Heartbeats: Implement a ping/pong mechanism. Don't wait for the TCP connection to time out; detect dead connections within 5-10 seconds so you can update the UI to show the player as "Disconnected" rather than "Thinking."
Building multiplayer is hard, but by keeping the server authoritative and the protocol strictly typed, you avoid the "sync hell" that plagues most real-time games. Start small, keep your state isolated, and always assume the client is lying to you.
