I built a multiplayer board game that runs in the browser. Keeping everyone's screen in sync was the hardest part by far.
Here's how I did it in TileLord.
Server owns everything
Never trust the client. The client sends what the player wants to do. The server checks if it's valid, updates the game, and tells everyone what happened.
// Client
socket.emit("placeTile", { x: 3, y: -1, rotation: 2 });
// Server
socket.on("placeTile", (data) => {
const result = game.tryPlaceTile(player, data);
if (result.valid) {
io.to(roomId).emit("tilePlaced", result.state);
} else {
socket.emit("actionRejected", { reason: result.error });
}
});
Even for a chill board game, people will try to mess with the payloads.
One room per game
Each game gets its own Socket.io room. Players join when they enter, and updates only go to that room.
socket.join(`game:${roomId}`);
io.to(`game:${roomId}`).emit("gameStateUpdate", sanitizedState);
Don't send everything though. If your game has hidden info (like a tile deck), strip that out before sending. Each player should only see what they're allowed to see.
Log every action
I store every move as an event instead of just keeping the current state:
actionLog.push({
type: "TILE_PLACED",
playerId,
position: { x, y },
rotation,
timestamp: Date.now(),
});
You get a lot from this:
- Replays - play the log forward
- Reconnects - rebuild what a player missed while offline
- Debugging - replay the exact sequence that caused a bug
The replay feature took about a day to build and players use it all the time. Worth it.
Disconnects are normal
People lose wifi. Their phone locks. Their laptop sleeps. Handle this from day one.
Turn timeouts: if someone doesn't move in time, a bot takes their turn so the game keeps going.
const turnTimer = setTimeout(() => {
botPlayer.takeTurn(game);
io.to(roomId).emit("botMove", game.getState());
}, TURN_TIMEOUT_MS);
Reconnects: when they come back, send them the current state.
socket.on("rejoinGame", ({ roomId, playerId }) => {
const room = rooms.get(roomId);
if (room) {
socket.join(`game:${roomId}`);
socket.emit("fullState", room.game.getStateForPlayer(playerId));
}
});
Spectators are free
Once you have rooms, spectators just join without being able to send game actions. They get the same updates as players.
TL/DR
- Server decides everything. Clients just ask.
- Socket.io rooms, one per game.
- Log every action. Replays and debugging come for free.
- Handle disconnects early. They will happen constantly.
Get the sync layer right first. The rest falls into place after that.
I'm building TileLord at tilelord.com, a free Carcassonne alternative you can play with friends or bots online.
Top comments (0)