How I Built a Multiplayer Bingo Game with WebSockets (and Learned a Lot)

Introduction
Hey! I’m Febin, a student (at the time of writing) and a tinkerer who loves building stuff using code, mostly using typescript and sometimes Python too :) . I’m currently working as a Fullstack intern at Zynact. And this is my first time writing a blog. So this blog is about how I learned to build a real time multiplayer game of bingo. It started with this;
Once, I saw two of my friends playing Bingo on paper. They had these paper sheets with bingo boxes drawn all over them. The thing is, you couldn’t really reuse them. You had to draw a fresh grid every time you need to play again. That got me thinking: wouldn’t it be much easier (and more fun) to just play it on your phone?. I knew someone out there had probably built something like this but still, it sounded fun to try making it myself.
At the same time, I was looking for my next side project. And this felt like the perfect excuse to dive into it. So when I got back to my room, I created a github repo and named it bingo. Progress was slow since I was busy with other stuff. I mostly worked on it during weekends.
What is bingo?
Bingo is a classic game where two players compete against each other. Each player starts by drawing a 5x5 grid of boxes (the grid size can vary, but 5x5 is the most common). They then fill each box with random numbers, usually ranging from 1 to 25 (though this can increase depending on the grid size), ensuring there are no repetitions. Once both players have their boards ready, the game begins. Here’s how it works:- The caller: One player calls out a number.
- The action: Both players must find and cross out that number on their grids.
- This continues back and forth, with each player taking turns calling out numbers and crossing them off.
The goal is to get a full row, column, or diagonal crossed off. Whenever a row, column, or diagonal is fully crossed out, one of the letters in BINGO is marked. The total number of letters marked depends on how many complete rows, columns, or diagonals have been crossed off.
The first player to cross out all five letters of BINGO shouts "BINGO" and wins the game!
Demo
Watch the demo
Play the game
https://bingooo.vercel.app
View the source code
GitHub Repository
Project setup
So the idea was clear, a game of bingo where two players can play between each other. Each player has a 5 by 5 gird and can fill it out as they like. One player creates the game (this player is internally called the host) a game code is generated and this code is shared to another player and they can join the game using this code (this player is internally called the guest).
So the next question was how to build it. Since this is a multiplayer game there needs to be a centralized control (or a server) and a frontend for the players to actually play. For the server it didn’t need any complex systems and since I didn’t intend to store any stuff, so there isn’t a need for databases. So it was plain and simple server setup. And I knew I had to use websockets for the communication b/w the players, but the issue was I only had surface level know abouts of websockets (I actually did use websockets in the frontend before in Sunva). So that was the only challenging part of the backend. But once I learned websockets it was easy. Its not a hard concept. So I built a simple Express server and added web socket support to handle real-time communication between players.
If you want to know more about websocket, I would recommend
So I setup a quick backend using this template. I used typescript for both the backend and frontend as it would make the development easy and enjoyable.
Now I had the bare backend setup.
Now for the frontend, I was comfortable with react so I choose react to build the frontend. I used vite as the build tool, tailwindcss for styling (it’s a framework that helps you style using HTML and utility classes instead of writing CSS), shadcn for prebuilt components.
For peeps who think all these tools sounds complex.. read this;
It really isn’t. It makes life a lot easier. Think for this as tools which are there to make stuff easier.
Why tailwindcss?
Well, tailwind makes life a lot easier. Imagine this. You create a div then to style it you have to define a class name and then, over on the css side you need to call it and then style. This seems verbose.
Instead of this
<style>
.some-class-name {
background-color: red;
color: blue;
}
</style>
....
<div class="some-class-name">Hi</div>
You can do the same with tailwindcss
<div class="bg-red-500 text-blue-500">Hi</div>
Simple as that.
Set up tailwindcss with vite - https://tailwindcss.com/docs/guides/vite
Why shadcn?
Again great stuff. This can make your life a lot simple. Shadcn has made prototyping a lot lot simpler. I’ve been using it in almost all of my frontend projects.
Suppose, you wanted something like a dialog box, or a sidebar, or any other components what you’d do is you’d implement it by yourself. But issue is its time consuming and hard. So what tools like shadcn does is, it has prebuilt components which you can install with a single command and thats it. Now you have a browser friendly, battle tested components in your code. (Shadcn needs tailwindcss to work)
Set up shadcn - https://ui.shadcn.com/docs/installation/vite
Building the Game Logic and Web Socket Communication
Now the basic setup is done, we can get to the actual game logic. The game follows a simple sequence of steps. One player(host) creates a game and shares the game code to another player (through external means) this player(guest) can then join the game. Then a player gets randomly chosen and starts first. Then whenever a player makes a move its send to the other player. But issue is we can’t directly send from one player to another player. So we need a middleman(the server), who checks if the move is valid and re-transmits the messages. So one player sends a message to the middleman who then re-transmits the message to the other player, whilst tracking the whole game. Below is a simple sequence diagram of the events would happen in game.
Now this sounds simple on paper but how would we implement this?
These are the doubts I had when I started out.
How would the server know which player to send the message to? I mean whats a client identified by? There could be multiple players playing at the same time. So how?
How can I group two players together in the same game?
What if a player refreshes or disconnects mid-game?
How do I manage the states; like who’s turn it is, what all numbers are crossed etc?
And what would be the edge cases…?
Now before we answer these questions we need to know some basic stuffs about web sockets.
So what is a web socket?
Simply put, a web socket is a way for the browser and the server to talk to each other in real time.
Normally, with HTTP (like how websites usually work), the browser makes a request, and the server sends a response. That’s it. If you want new data, you have to make another request. It’s like knocking on a door every time you want to say something.
But with web sockets, once the connection is made, it stays open. Both the client and server can send messages to each other at any time (no knocking required). It’s a constant open line, kind of like a phone call between your browser and the server.
This is perfect for real-time stuff like multiplayer games, chat apps, live dashboards - basically, anywhere you want instant updates.
A sample implementation
Backend
The backend we’ll be using is in nodejs and node doesn’t have a native implementation of web socket. So we’ll be using a library called ws. It’s the most basic implementation of the websocket protocol.
Q.Why not other advanced libs like socket.IO?
→ For me this, is learning project. I wanted to understand how WebSocket really works — the low-level stuff like:
How messages are sent and received
How to handle connections manually
How to build my own reconnection or room system
Libraries like Socket.IO abstract a lot of that away. While they're powerful, they hide the magic.
Open the terminal and do the below command;
# Initilize a new nodejs project and install the deps
npm init -y
npm install express ws
Open your preferred code editor and open the index.js
file and paste the following command there;
// index.js
// Import the express module to create the server
const express = require("express");
// Create an Express application instance
const app = express();
// Start the server and listen on the specified PORT
app.listen(5000, () => {
// Log the server URL to the console
console.log(`Server started on port 5000`);
console.log(`http://localhost:5000`);
});
So whats the above code does is, it creates an express server (which is an HTTP server) and listens to incoming connections on port 5000.
To run the code, do this in the terminal
node index.js
This will output
Server started on port 5000
http://localhost:5000
Now how do we integrate web socket to this? Its simple.
// index.js
const express = require("express");
// Importing the http module to create a basic HTTP server
const http = require("http");
// Importing the WebSocket (it exports 'Server' but for readability we import it as 'WebSocketServer') module to enable WebSocket communication
const WebSocket = require("ws");
const app = express();
// Create a basic HTTP server using express app as the request handler (this will handle http requests)
const server = http.createServer(app);
// Create a WebSocket server instance and attach it to the HTTP server (this will handle websocket connections)
const wss = new WebSocket.Server({ server });
// Example WebSocket connection handler (optional)
wss.on('connection', (ws) => {
console.log("New WebSocket connection");
ws.send("Welcome to the WebSocket server!");
});
// Start the HTTP + WebSocket server
server.listen(5000, () => {
console.log(`Server started on port 5000`);
console.log(`http://localhost:5000`);
});
So whats this? Well, we made a basic express server that listens to the PORT 5000. That is the express server uses the HTTP protocol and the web socket uses another protocol. So we need to bind them together. To do this we create a sever using const server = http.createServer(app)
. And a websocket handler const wss = new WebSocketServer({ server });
. We pass the server instance to the websocket handler thus binds them together.
The basic part of our backend is done. The backend now can listen to incoming connection, but it reply to it or do anything with it. So we need to build out that part;
The client can connect to this and send events to the backend, which then reacts to it. This is a two way connection. Both the frontend and backend send events and reply in real time. So to handle this in the backend, we need to write the handling part;
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// Listen for frontend connections
wss.on('connection', (ws) => {
console.log('New WebSocket connection');
// When the frontend messages do the following
ws.on('message', (message) => {
console.log(`Received: ${message}`);
// Send back what the frontend just said
ws.send(`You said: ${message}`);
});
// Handle closing
ws.on('close', () => {
console.log('WebSocket closed');
});
// On initial connection
ws.send('Welcome to WebSocket server!');
});
server.listen(5000, () => {
console.log('Server started on port 5000');
console.log('http://localhost:5000');
});
Now we’ve built a basic websocket server. When listens to a client and when a client sends any message, it replies back You said: <the message>
This is only the half part. Now we need a frontend which will connect to the backend and send and receive messages.
Frontend
I built the frontend using reactjs in typescript. For simplicity sake, I’ll be using HTML and JS for the simple implementation.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebSocket Client</title>
</head>
<body>
<h2>WebSocket Client</h2>
<script></script>
</body>
</html>
This is the basic HTML template. For demonstration, the simple web page will have a box to show what the server has responded with, a text box to type messages which will be sent to the sever, and a button to send the message.
<div id="messages"></div>
<input type="text" id="input" placeholder="Type a message..."/>
<button onclick="sendMessage()">Send</button>
And inside the script tag;
// The message box
const msgBox = document.getElementById('messages');
// input text box
const input = document.getElementById('input');
// The websocket instance
const socket = new WebSocket('ws://localhost:5000');
// Executes when the websocket is open
socket.addEventListener('open', () => {
msgBox.innerHTML += "Connected to Websocket" + '<br>';
});
// Executes each time when the sever sends a message
socket.addEventListener('message', (event) => {
msgBox.innerHTML += event.data + '<br>';
});
// Executes when the socket gets closed
socket.addEventListener('close', () => {
msgBox.innerHTML += 'WebSocket connection closed' + '<br>'
});
function sendMessage() {
const message = input.value;
// Check if the socket is ready
if (message && socket.readyState === WebSocket.OPEN) {
// If ready, then send the message
socket.send(message);
msgBox.innerHTML += 'You: ' + message + '<br>';
input.value = '';
}
}
This is the general idea of how websockets work. It allows us for real time bi-directional communication. Now we can build on this idea and creates games, chats, and many other systems with this.
Actual Game Logic
The idea of a bingo game is not so different from the above implementation. In the above html code, you type something and when you press the button it sends to the backend and gets a reply back. Now what if, instead of an input box and a button, when we click on a cell of a 5 by 5 grid it sends that cell number to the backend (which does some stuff) and marks that cell as completed. Now issue is there is an another player, how would we send this data to that player. This is where rooms come into play.
A room is a virtual place for targeted communication. That is, if I want to send a message to a specific user, I’d want them to be in a room with me. Each room should be uniquely identifiable. In this app, its the game code. Which one player would generate and give to the other player who they’d want to play against.
Each room is identified by a room ID (here its the game code) and should have a two players associated with each room (it can start with 1 player and then the guest player would join in later). The player in a room should have the current game state of that player (that is, the position of each numbers, state of “bingoness” etc), the websocket through which the sever can communicate with that player. The game room itself should contain some metadata such as the name of the game room, current state of the game, who’s turn it is etc.
It’d look something like this;
Each game room is an object uniquely identifable by a game room ID. For the fastest access, its best to store this in a hash map. With the game room ID as the label or key and the game room object as the value.
When a new player creates a game it creates an ID and associates it with a new game room. This game room will have the host player as the player that created it. And when the game ID is shared to another player, it checks if guest player is empty, if yes it gets populated with the joining players data. So each updates only changes that specific game room and wont effect other game rooms. This allows for independent game rooms.
Handing reactions and game chat
The reactions and game chat was fairly straight forward.
For reactions; when a player presses a reaction, it sends a new message with the type reaction, the room ID, the type of user, and the ID of reaction the user pressed . This then routed to the other player and it shows a reaction. Now the reverse also works. So a simple reaction system.
For the chat, it’s the same concept, expect the message type is chat. For each message along with the actual message, the room ID, the type of user is sent. The sever then routes it to the correct user and it shows as a message. Again the reverse works
Answering the doubts I had
1. How would the server know which player to send the message to?
Every time a client connects via WebSocket, the server gets a unique socket connection object (eg: in ws
, it's usually ws
or socket
depending on your naming).
So internally, you might store:
const players = {
'player1-id': WebSocketObject1,
'player2-id': WebSocketObject2,
}
This way, you can send a message to a specific client using:
players['player1-id'].send(JSON.stringify({ your: 'message' }));
So, each client is identified by their web socket connection, and you can assign them a unique ID (like a short game code) to track who’s who.
2. How can I group two players together in the same game?
You can implement a game room or session code. Here's a basic idea:
One player creates a game → server creates a room.
Second player joins using that code
Server now associates both their web socket connections to the same room
In code, this can look like:
const games = {
'ABCD': {
players: [socket1, socket2],
state: { ... }
}
}
So the game code or room ID lets you group players together.
3. What if a player refreshes or disconnects mid-game?
This is a tricky part. With WebSockets:
If the tab is closed or refreshed, the socket connection is lost
The server can listen for this via
socket.on("close", ...)
You can:
Show a "Player disconnected" message to the other player
Optionally support reconnects by storing game state and associating it with a player ID/cookie/token
But on free hosting (like Render), automatic reconnects are harder unless you build robust logic and use persistent storage. This is something I would like to revisit and fix. If you are interested collabrations are always welcome @ https://github.com/fbn776/Bingo :)
4. How do I manage the states like who’s turn it is, what numbers are crossed, etc.?
Store the game state on the server. Example:
{
gameCode: 'ABCD',
players: [player1Socket, player2Socket],
currentTurn: 0, // index of player in `players`
numbersCrossed: {
player1: [5, 12],
player2: [3, 7],
},
board: { ... }
}
Every time a player makes a move:
Server checks if it’s their turn
Updates the state
Notifies the other player
Server acts as the single source of truth for the game.
5. What would be the edge cases?
Here are some that usually pop up:
Player disconnects mid-turn
One player refreshes, and a new socket is created
One player takes too long to play (maybe implement a timeout?)
Race conditions - both players clicking at the same time (like saying Bingo at the same time)
Game code collisions (two games get the same code)
Same player opens two tabs and joins as both players
You can handle these by:
Detecting disconnects and showing status
Ensuring only two players per room
Using UUIDs or checking uniqueness for game codes
Optionally locking moves once made until the next turn
Conclusion
Building this project was a fun ride. I started with a basic experience in web sockets, and by the end, I had a working multiplayer game that people could actually play and enjoy. I learned a lot from handling real-time events to managing game state and syncing players' actions smoothly.
That said, it’s not perfect. The free Render tier shuts down the server after inactivity, and it also disconnects long-running web socket connections. So if you're idle for a while or mid-game, you might get kicked out and right now, there's no automatic reconnection logic (yet). Something I plan to fix soon!
Despite those hiccups, it was super satisfying to build something end-to-end and see people react to it in real time. If you’re thinking about building your own game, I say give it a shot. You’ll hit some walls, but you’ll learn a ton along the way.
Thanks for reading! ✌️
Subscribe to my newsletter
Read articles from Febin Nelson P directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Febin Nelson P
Febin Nelson P
I mostly build full-stack stuff (because someone has to), but I love jumping into whatever needs to get done—frontend, backend, or somewhere in between. Sharing my experiments, learnings, and half-baked ideas as I go.