DEV Community

Cover image for I Built an Online Multiplayer Ludo Game
Kehinde Giwa
Kehinde Giwa

Posted on

I Built an Online Multiplayer Ludo Game

So, this year I made a few resolutions — and sure, no one keeps to resolutions, but I decided to at least keep these two: first was no more tutorials, and the other was to build projects instead.
I was looking to improve my backend skills and practice advanced backend concepts I hadn't touched before, so I decided to get my hands dirty and make an online multiplayer game.
I initially decided on chess, but after a few minutes of planning out the move logic and rules, I quickly realized I'd spend a lot more time trying to get the piece movements to work and lose sight of my actual goal — or even more likely, just give up. The alternative was a chess validation library, which didn't sound as fun as building from scratch.

Hard enough that it gave me just enough headache to build but not so much that i'd give up.

So I decided on Ludo. Let me go over my process.

The Frontend

Building the board

So the first thing I worked on was the ludo board — not the landing page or the auth or player selection, the ludo board. I wanted to get the game working first then just plug and play later. I used Tailwind to first implement the outer parts.

Ludo board

Tracking tiles.

Then I thought about the tiles which the pieces were going to move on. Sure, I could do some CSS magic to make the grid, but how was I going to be able to track where the piece was? So, I did a thing : I made a function that took a position and returned a style for it. The function was long and there was probably a better way to write it that I didn’t know, but it worked. Each tile now represented a certain position on the board. I kept this function in a utils folder and called it getCellPosition.

Pieces, dice, and a happy npm find

After I was done with the tiles, I designed the pieces and set them to their starting positions with another function . I also needed a dice component. After a quick search on npm I saw a 3D dice box package 3d-dice, which completely fit my use case, but it turned out it didn’t have type definitions. I initially planned on writing my own .d.ts file, but I realized that the fact that I thought to do that meant someone else probably did too, so I went through the forks of the project and sure enough, I saw a fully typed version.

GAME LOGIC
Implementing the gameplay

This was arguably the hardest part of the entire project: implementing the gameplay. I wouldn’t want to bore you with the intricate details, but I’ll give a high-level overview.

useReducer as the game engine

I decided to use a useReducer to handle the game state, since I knew a game would have lots of state interacting with each other. I had an initial game state object which contained an array of player objects, each with an array of piece objects.
I had 3 reducer actions: ROLL_DICE, SELECT_NUMBER, and MOVE_PIECE. The way I visualized it was: when you play a game of ludo, you roll the dice, select a number in your head, and move a piece — so I built my reducer actions around that.

Flags → State Machine

Before I get into a summary of what each action did, I’d like to mention an early early decision I made. I decided to manage the piece state using flags — something I had read about earlier, so I just went with them. I had a flag for when a piece was in its home, one for when it’s on the board, one for when it’s in the home stretch, and one for when it’s finished.

This caused a big problem later on, because I now had to reset all the other flags when changing one. I fixed that with a state machine — each piece can only have one state at a time

State machine

The reducer actions

So back to the reducer actions: the ROLL_DICE action, which probably sounds misleading now that I think about it, was basically what happened after you clicked roll dice. It didn’t trigger the 3D dice box — that was done by the onClick handler. It just set the roll result to the result of the 3D dice rolling. It also determined whether the current player was able to move based on the result of the dice roll, or whether the player’s turn was to be skipped (Ludo game rules).

Then the main headache was the MOVE_PIECE action. This action handled all piece movement — from getting out of the starting position to capturing pieces and entering the homestretch, even finishing. That was all in the reducer and the game was working fine. Then I proceeded to work on the UI — the home page, the player selection screen, up to the game itself. Then I moved over to the backend.

THE BACKEND

Express, Redis & WebSockets

Working on the backend, I set up my Express app using a new folder structure I saw a friend of mine use. Normally I organize my files into the MVC architecture (model, view, controller), but he organized his into features, where each feature had a controller, service, and router. Then outside the features there was a core folder containing things like errors (custom errors), middlewares, and utils (functions that aren’t scoped to any one feature or service).

Folder structure

Authentication first

I started out by implementing authentication as I normally do — JWT cookies and all. Then I started on the online game.

Reusing the reducer over WebSockets

Remember the big reducer function from earlier? Since a reducer is really just a pure TypeScript function that takes input and returns output, I could reuse the exact same function here using WebSockets.

If you don't know how WebSockets work, check it out here. The analogy I use: email vs. a phone call. An email is sent and you wait for a reply, while a phone call lets both parties communicate in real time. Sockets have events — you emit events and act on them.

web sockets
I had the event handlers on the server, and each handler received an event from the client, processed it, and emitted its own event back.

event handlers

The 7 socket events
Event What it does
create-room Server initializes a new game state, adds the first player, generates a room code and stores it in Redis prefixed with "game:" e.g. "game:001".
join-room Receives the room code, checks if the game exists on Redis and is still available to join, then adds the new player.
start-game Confirms that all the players are ready and sets the game state to in-progress.
ready-player Sets the player’s state to ready.
game-action Takes a reducer action emitted from the client, runs it through the reducer function, and updates the new game state to Redis.
rejoin-game Allows a disconnected player to reconnect to an ongoing game.
disconnect Handles cleanup when a player drops.
Deployment & closing thoughts

So after this I was mostly done with the server and went back to the client. I first implemented authentication with my auth context and provider, then I created a socket provider and a game state provider. The socket provider simply connected to the socket on the backend and broadcast to all the components, while the game state provider held the game state and listened for any incoming socket events from the server and handled them. Then all that was left was to finish up the frontend and the app was ready. I created a container with Docker and deployed on Vercel, Render, and Upstash — and that was the end of my project.
So this was a fun project to build and I learned a lot from it, not just because the game works, but because of what breaking it taught me. The flags-to-state-machine refactor was probably the most valuable thing I took away: sometimes the first design decision that seems reasonable becomes the thing that quietly makes everything harder. I’m currently working on my next project which is a search engine (not a web crawler lol), with Java Spring Boot, so see you then!
If you have any questions or feedback, drop them in the comments — I'd love to hear how you'd have approached it differently.

Source code: Github Live demo: https://ludoly.vercel.app

Top comments (0)