← All articles

Web Development/4 min read

Building a real-time "guess 80% of the average" game

How I built a real-time multiplayer number game in Next.js and Socket.io, inspired by the Balance Scale game from Alice in Borderland, with rooms, eliminations, spectators and a Dokploy deployment.

Mohamad.B Alhakim

If you've watched Alice in Borderland, you'll remember the Balance Scale: players secretly pick a number, the target is 80% of the average, and whoever gets closest wins the round. Everyone else loses a point, and the game gets harsher as players drop out.

It's a brilliant little game because it's pure game theory. If everyone picks randomly, the average is around 50 and the target around 40. But everyone knows that, so they aim lower, which pulls the average down, and so on. With perfectly rational players it collapses all the way to zero. Real players aren't perfectly rational, which is what makes it fun.

I wanted to play it with friends, so I built it: a real-time multiplayer version with a Next.js frontend and a Node.js + Socket.io server.

How a game works

  1. Players enter a name and join a room (up to 5 players).
  2. Everyone marks themselves ready, and a countdown starts.
  3. Each round, every player picks a number from 0 to 100 on a grid.
  4. When all living players have chosen, the server computes the target: the average × 0.8.
  5. The player closest to the target wins the round. Everyone else loses a point.
  6. At −3 points you're eliminated, and when only two players are left, the round's loser is out straight away.
  7. The last player standing wins.

The game moves through three stages as players are eliminated (5 to 3 players, then 4 to 3, then the final two), with a stage-transition screen between them so everyone feels the pressure rising.

The server owns the game

The most important design rule: the server is the source of truth. Clients only send intentions (join-room, toggle-ready, select-number, leave-room) and render whatever state the server sends back. Nobody can cheat by editing the game state in their browser, and every player always sees the same thing.

The server code is split into three layers:

  • controllers for socket events: players, rooms and the game itself,
  • services for the logic: room structure, countdowns, starting and resetting games,
  • a small socket-server.js that wires everything together.

Rooms live in an in-memory Map. For a party game where a round lasts seconds, that's exactly enough: no database, no persistence, nothing to clean up.

Resolving a round

When the last living player submits, the server resolves the round in a few small, testable steps:

js
const calculateTarget = (submissions) => { const average = submissions.reduce((sum, n) => sum + n, 0) / submissions.length; return average * 0.8; };

Finding the winner has one subtle detail: ties. If two players are equally close, the one who submitted first wins. The server records a timestamp for every submission, so it never has to pick a winner arbitrarily.

After that, the losers lose a point, eliminated players are switched to spectator mode (they keep watching the remaining rounds), the stage is updated if needed, and the results are broadcast to the room: target, round winner, losers and everyone's points. Three seconds later, the next round starts.

Socket.io rooms do the heavy lifting

Socket.io's rooms map perfectly onto game rooms. io.to(roomName).emit(...) sends to everyone in a game; io.to(player.id).emit(...) sends to one player. That made it easy to send different messages to different people at the same moment: the winner gets a victory event, the others get game-over, and eliminated players get spectate-mode.

On the frontend, a single useMultiplayer hook owns the socket connection and turns all those events into React state: available rooms, players, countdown, stage, results. The UI components (lobby, number grid, player cards, results, victory and game-over screens) just render that state.

Deployment

Both parts run in Docker on my own server with Dokploy: the Socket.io server on its own API subdomain, and the Next.js frontend pointing at it through an environment variable. The server image uses dumb-init so the Node process shuts down cleanly when the container stops, which matters for a long-running WebSocket server.

Looking back

A few things I'd change in the next version:

  • Hide the picks until the reveal. The server broadcasts number-selected with the number as soon as someone chooses, so later players can see earlier picks. In a game about guessing what others will do, that's a big leak. It should only announce that someone has chosen.
  • Validate on the server. The client grid only offers 0–100, but the server should reject anything outside that range too.
  • Add a round timer. Right now a round waits until everyone has chosen, so one player who walks away stalls the game.
  • Bring in the special rules. The show adds new rules at each stage. The stages are already there, so they're a natural place to add them.

Even so, it was a great way to learn real-time design: keep the logic on the server, keep the client dumb, and let Socket.io rooms handle who hears what.

Blog / Keep reading

Related articles.

Building a real-time "guess 80% of the average" game - Mohamad Bashar Alhakim