DEV Community

Shreyash Tripathi
Shreyash Tripathi

Posted on

Building a real-time multiplayer drawing game with Socket.IO: rooms, timers, scoring (and the bugs I'd fix)

SketchRace is a multiplayer drawing and guessing game: one player draws a secret word, everyone else races to guess it in the chat. A teammate and I built it; I worked on the game server, a Node.js + Express app with Socket.IO, and my teammate built the Next.js frontend.

This post walks through how the server works, with the real code, and ends with the bugs I'd fix if I built it again.

The whole game lives in memory

There's no database. One Node process keeps all game state in four plain objects, keyed by room id:

const rooms = {};          // players, owner, round, current drawer, current word
const points = {};         // points[roomId][playerName]
const correctGuessers = {};// who has already guessed this turn
const activeTimers = {};   // the running setInterval handles per room

const wordSelect = 7;  // seconds to pick a word
const wordGuess = 45;  // seconds to guess
const maxRounds = 3;
const hintTime = 20;   // reveal a hint when 20 s are left
Enter fullscreen mode Exit fullscreen mode

For a game where a match lasts a few minutes and nobody needs history, that's enough, and it keeps every read and write instant.

Rooms

Each match has its own URL (/room/[roomId]). Joining puts the socket into a Socket.IO room and creates the room state the first time:

socket.on("join", ({ roomId, name }) => {
  socket.join(roomId);
  if (!rooms[roomId]) {
    rooms[roomId] = {
      users: [],
      ownerId: socket.id,
      gamestarted: false,
      currentDrawerIndex: 0,
      currentRound: 1,
      currentWord: "",
      hintRevealed: false,
    };
  }
  rooms[roomId].users.push({ id: socket.id, name });
  io.to(roomId).emit("user-list", { users: rooms[roomId].users, ownerId: rooms[roomId].ownerId });
});
Enter fullscreen mode Exit fullscreen mode

When the last player disconnects, the room, its guessers and its timers are deleted, so nothing leaks between matches.

Live drawing: send strokes, not pictures

Sending the canvas as an image after every stroke would be slow and heavy. Instead each client sends tiny line segments, and the server relays them to everyone else in the room:

socket.on("drawing", ({ roomId, prevX, prevY, x, y, brushColor, brushSize }) => {
  socket.to(roomId).emit("drawing", { prevX, prevY, x, y, brushColor, brushSize });
});
Enter fullscreen mode Exit fullscreen mode

socket.to(roomId) broadcasts to the room except the sender, who has already drawn the line locally. Each segment is a handful of numbers, so the other canvases keep up in real time.

The server owns the clock

If every browser ran its own timer, clocks would drift and players would see different time left. So the server runs the only clock and broadcasts a tick every second:

activeTimers[roomId].selectionTimer = setInterval(() => {
  timeLeft--;
  io.to(roomId).emit("word-selection-time", timeLeft);
  if (timeLeft === 0) {
    clearInterval(activeTimers[roomId].selectionTimer);
    wordGuessingTime(roomId);
  }
}, 1000);
Enter fullscreen mode Exit fullscreen mode

A turn is: 7 seconds for the drawer to pick a word, then 45 seconds of guessing. After each turn the pen moves to the next player, and after everyone has drawn, the round counter goes up. After 3 rounds the server sends the final scores:

// proceedToNextTurn, simplified
room.currentDrawerIndex = (room.currentDrawerIndex + 1) % room.users.length;
if (room.currentDrawerIndex === 0) {
  room.currentRound++;
  if (room.currentRound > maxRounds) {
    io.to(roomId).emit("final-scores", points[roomId]);
    io.to(roomId).emit("game-ended");
    return;
  }
}
io.to(roomId).emit("clear-canvas");
wordSelectionTime(roomId);
Enter fullscreen mode Exit fullscreen mode

Hints, sent only to guessers

When 20 seconds are left, the server reveals two random letters of the word. It sends the hint per player, skipping the drawer, using each socket's own id as a room:

room.users.forEach((user) => {
  if (user.id !== currentDrawer.id) {
    io.to(user.id).emit("word-hint", { hint: maskedWord });
  }
});
Enter fullscreen mode Exit fullscreen mode

Scoring that rewards both sides

The scoring tries to be fair to guessers and the drawer:

  • Guesser: 10 points + a time bonus of up to 10, reduced by 20% if the hint was already shown.
  • Drawer: +5 for every correct guess, +10 more if everyone guesses it.
  • End of turn: the drawer also gets a bonus proportional to the share of players who got it.

If every guesser gets the word before time runs out, the server stops the timer and moves to the next turn after a 3-second pause.

What I'd fix if I built it again

Reading the code again with fresh eyes, four things stand out.

1. The time bonus is almost always 0. Time left is calculated from the timer's _idleTimeout, which is the interval (1000 ms), not the time remaining. So timeRemaining works out to about 1 second, and the "up to 10" bonus rounds to 0. The fix is to store when the turn ends and compute from that:

room.turnEndsAt = Date.now() + wordGuess * 1000;
// on a correct guess:
const timeRemaining = Math.max(0, (room.turnEndsAt - Date.now()) / 1000);
Enter fullscreen mode Exit fullscreen mode

2. Every player receives the secret word. word-selected broadcasts word-to-guess to the whole room, so anyone with dev tools open can read it. The word should go only to the drawer, with guesses checked on the server.

3. The server trusts the client's correct-guess. A client can claim a correct guess without typing the word. With the word kept on the server (fix 2), the server can compare each chat message itself and award points only when it matches.

4. State in memory means one server. Everything lives in one Node process, so a restart ends every game, and it can't scale across instances. The Socket.IO Redis adapter plus Redis-backed room state would fix both.

None of these broke the game for friends playing together, but they're exactly the things that matter once strangers join.

What I learned

  • Make the server the single source of truth for time, turns and scores; clients just render what they're told.
  • Send the smallest possible update (a line segment, a tick) instead of whole states.
  • Never trust the client with anything that decides who wins.

You can play the game at sketchrace.vercel.app, and read the project case study on my portfolio at shreyashtripathi.in/projects/sketchrace. I'm a Frontend Developer and UI/UX Engineer in Noida. Questions about real-time games with Socket.IO are welcome in the comments.

tags:

cover_image: https://direct_url_to_image.jpg

Use a ratio of 100:42 for best results.

published_at: 2026-09-26 08:38 +0000


Top comments (0)