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.
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
- Players enter a name and join a room (up to 5 players).
- Everyone marks themselves ready, and a countdown starts.
- Each round, every player picks a number from 0 to 100 on a grid.
- When all living players have chosen, the server computes the target: the average × 0.8.
- The player closest to the target wins the round. Everyone else loses a point.
- At −3 points you're eliminated, and when only two players are left, the round's loser is out straight away.
- 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.jsthat 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:
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-selectedwith 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.
Web Development/4 min read
How this blog is built, and how I got it back after a reset
Next.js, Strapi and Dokploy, and the lessons from rebuilding it all.
Web Development/4 min read
CourseCraft, a course website from a folder of videos
A zero-dependency Node.js app that turns course folders into a website.
Decentralized Web/5 min read
SilentEcho, a diary the server can't read
An end-to-end encrypted journal with Flutter, Go and a web portal.