Create online multiplayer games based on a lobby using React and NodeJS.
July 5, 2022
/
25 minutes
We recently had to build some online educational games (browser) for a client. The projects were really exciting to tackle, but I noticed that there are few resources available online to "learn" how to create such projects.
After creating several games and delivering them to users to play, I now feel quite confident in the approach I adopted to build them.
Today, I wanted to share with you the technology stack and architecture I used for these projects. Of course, you are not required to follow exactly the same approach for your projects; each project is different. Additionally, if you have any feedback or want to share your own implementations, feel free to do so!
You can find a simple game example here and its corresponding repository here.
This article assumes a basic understanding of TypeScript, React, Websockets, and Object-Oriented Programming (OOP). If not, no worries! You can still read and follow along. It's a great opportunity to learn by diving into the world of game development! :)
Context
In my case, these games required managing multiple players in the same lobby, real-time communication, and a synchronized state.
Noticing these needs, I eliminated my primary language from the stack (PHP, I know, I know... don’t judge me). I am aware that tools like Swoole / Octane / etc. could have been used, but I wanted to switch to another sufficiently mature language with an asynchronous paradigm.
Having already used NodeJS a bit to create scrapers and bots, I knew it could handle asynchronous operations well, and JavaScript / TypeScript are easy to manipulate. (I considered Go for a while, but I will explain that later in the article).
On the client side, I am a big fan of React, so there wasn’t much debate about that. I briefly looked into Phaser, but our use case didn’t require a complete game engine in the browser. (The games were mainly board games, so integrating a game engine seemed a bit excessive).
After considering the project requirements, my preferences, and knowledge, let’s now dive into the stack.
Stack
Yarn Workspaces
The projects use Yarn workspaces to manage both client and server sides, as well as to share code between them. While Lerna could have been an option, I chose to try Yarn workspaces first, and it worked very well. I only needed to make slight changes to the Next / Nest configuration to ensure smooth integration with the shared code.
Next.js / React.js
The client application is built on Next.js (React), which makes it much easier to build complex applications—in our case, a game. Data binding directly over a websocket connection allows players to have a game perfectly synchronized with one another.
I also used Tailwind and Mantine to build the user interface. They are great tools; feel free to check them out.
NestJS / NodeJS
The server side is built using NestJS (NodeJS) to manage game instances and handle interactions with clients.
Earlier, I mentioned I was hesitating with Go; let me explain why. I am a big fan of Go, it’s a nice language and certainly has great advantages, but for my use case, NodeJS had more benefits;
In terms of performance, the games don’t exceed a few thousand players simultaneously at most. These are "board games" (no need for a real game loop), so there’s no need to constantly share the state of the instance with low latency. If you’re really interested in performance, I invite you to check out the great videos by ThePrimeagen on performance comparisons here, here, and here.)
By using the same language (TypeScript) on both the server and client sides, you can share code between the two (thanks to Yarn workspaces / monorepo configuration), this can save you a lot of time.
Finally, having already played a bit with Unity and other game engines, I really like the "traditional" object-oriented paradigm (I use it almost all the time for my projects, whether they are bots, scrapers, games, web applications, ...) and I felt I could "mimic" the traditional game architecture for my use case. Go is also object-oriented in a sense (please don’t start a riot if you disagree, it’s my opinion and this guy does too apparently)
These points made me choose NodeJS over Go; of course, I’m not ruling out working with Go on future projects!
Socket.IO
Games have to be real-time, the first thing that should come to your mind when thinking about the web and real-time would probably be WebSockets. It's a core protocol built on TCP, allowing two-way communication (server to client and client to server).
That’s more than sufficient for our use case, and NodeJS / JavaScript handle them very well. As with other technology choices, I could have used alternatives such as server-sent events or long polling, but WebSockets are mature, work well, and are easy to manage!
In addition to WebSockets, I used Socket.IO, which provides great abstraction for handling clients, and also offers interesting features like; reconnection, room behavior, fallbacks to long polling (yes, there are still people with old browsers), and more.
This does come at a price, slightly lower performance (still!), but don’t worry, Socket.IO can still handle many clients and that's enough for us. ;)
(If you are really concerned about performance, you can totally reproduce the following logic with ws or even scale your Socket.IO instances with a Redis adapter.)
Docker
Last but not least. For deployment, I used Docker containers (using Alpine NodeJS images for both client and server, which is sufficient).
This makes it easier to manage and deploy your projects; you can also couple them with Kubernetes for larger projects based on your needs and constraints.
Let’s Build!
Let’s start by creating our monorepo / setup, initiate a new yarn project and specify that you will be using workspaces;
(you can find this file here)
Declaring such a package.json will allow Yarn to understand that you are working with several "sub-projects" (workspaces), by convention I name it "workspaces" to be explicit (in other projects, you might find it named "apps" / "packages" / etc...)
I also added custom scripts, which makes life easier (no need to cd into the workspaces manually and run commands), of course, you can add as many as you like.
In this example, I added scripts to start the development and production servers for both client and server projects.
Next, you can add your "sub-projects" in the workspaces folder. We will have 3 sub-projects;
client
server
shared
client: Contains the Next.js application
server: Contains the NestJS application
shared: Contains the code that will be used by both the client and the server.
You can initialize these projects as you normally would, just make sure the naming convention is correct ("@name/client" / "@name/server" / "@name/shared") and declare the shared workspace as a dependency for the client and server (this way, its code can be used by the other two projects).
If in doubt, you can find the package.json declarations here;
https://github.com/Mazzzoni/memory-cards/blob/main/workspaces/client/package.json
https://github.com/Mazzzoni/memory-cards/blob/main/workspaces/server/package.json
https://github.com/Mazzzoni/memory-cards/blob/main/workspaces/shared/package.json
These elements are not set in stone; you can edit and adjust them based on your needs, of course.
Managing Websockets
To be able to work with websockets in NestJS, you will first need to install a few dependencies. Run this command in the server workspace:
Great! You can now play with the websockets!
Run this other command:
This will create for you the files game.gateway.ts / game.module.ts / game.gateway.spec.ts in a module called game (this article is not intended to teach how NestJS works; if you have difficulties with that, be sure to follow the framework documentation first).
To summarize very briefly and roughly, a gateway is like a controller you would find in typical MVC applications.
You can declare new subscribers to messages in your gateway; these will be called when the client sends you the corresponding message.
(like in an MVC app, avoid having too much business logic directly in your subscriber)
I like to declare a simple "ping" subscriber when I start a project, just to make sure everything works:
You may be wondering:
What are "ClientEvents" and "ServerEvents"? Shouldn’t we just use strings to recognize the events passing through Socket.IO?
Yes, you are right. I use two string enumerations to declare the events. Here’s what they look like:
I prefer to declare string enumerations rather than using interfaces and providing them at Socket.IO server instantiation (same for client instantiation). Interfaces are handy, but in my case, I like to work with enumerations that I can easily reuse all over the codebase and also attach to payload type declarations (we will see that later).
Again, if you prefer to use interfaces and pass them as generics to your instantiations, feel free to do so!
So, back to our first example, what does it do?
A client emits an event via its socket to the server. This event is "client.ping"
The server receives the event emitted by the client; "client.ping", checks if there is a subscriber (there is), and passes the client (Socket) to the method.
The server directly emits an event to the same client. This event is "server.pong"
The client receives the event emitted by the server; "server.pong"
If you can grasp this concept, that’s great because it’s the most important thing to understand!
Also note that the previous example could have been written like this;
Either way, it's the same behavior (if you want to send back data to the same client), choose whichever you prefer (depending on the context, of course!).
What was that payload thing you were talking about?
Depending on the events, you might need to pass "data" along with an event, and it’s always better when that data is typed. :) Here’s how type declaration for payloads comes into play;
As you can see, we used the enum we declared earlier as a key for this type, allowing us to match each event with its payload typing!
Security & Validation
Here is another example of what we can do now:
We have a complete code autocompletion based on the return type! This will certainly make your life easier, believe me.
Hey! In your example, did you start implementing the game? What is "LobbyCreateDto"? And what is an "AuthenticatedSocket"? They were not in the previous examples!
I know, I know, we will see what these elements are, don't worry, we are taking small steps to understand different concepts. It's important to grasp them well first. When you see the big picture, hopefully, everything will fit together and make sense! :)
So, what is an "AuthenticatedSocket"?
Well, it is very likely that you will need to authenticate your users in your application/games. This step can be done when a client wants to connect to your server. On your gateway, implement the interface "OnGatewayConnection":
Now we can authenticate our users. We know that each client has gone through this step. To be explicit in the code, I treat them as "authenticated," and they now have a new type.
As you can see, in the key data (which is a convenient key provided by Socket.IO on the Socket object, you can put whatever you want there), I declare a subkey lobby, which will be used later to attach a lobby to the client.
I've also replaced the emit method to explicitly have the enumeration ServerEvents as the type of emitted event.
And the validation?
In order to validate incoming data correctly, add two packages:
NestJS is a great tool that allows you to automatically validate incoming data transmitted with an event, for this, you will need to create your own DTO (Data Transfer Object), this MUST be a class, TypeScript once compiled does not store metadata on generics or interfaces of your DTOs, so this can cause your validation to fail, do not take risks, use classes.
Here is the validation class for creating the lobby :
I also annotate my gateway with my validation channel :
Great ! Incoming data is now validated ! Always ensure to validate user inputs !
Okay, I get it, so we first cover the basics, and then we move on to the actual implementation ?
You are absolutely right ! It is important to understand the basics of the tools you are using, once that's done, you are ready to implement your own business logic.
A Bit of Theory
You can find different types of games ;
Single-player Game
Multiplayer Game
Online Multiplayer Game
Massively Multiplayer Online Game (MMO)
A single-player game does not involve interaction with a server, you play on your client and everything is integrated, game interactions are predefined. (e.g.: The Witcher)
A multiplayer game can be played by multiple players at the same time, still without any server involved, this is local multiplayer: all players play on the same machine, from the same game client (e.g.: Cuphead)
An online multiplayer game can be played by several players simultaneously, since it is online, it involves one or more servers, clients communicate via persistent connection protocols (TCP / UDP / WS / ...) to the server(s) and vice versa to play. (e.g.: Fall Guys) Here is our use case.
Finally, MMOs, which follow the same principle as above but must accommodate (potentially) millions of players, they also use server distribution based on population, shards, and layered world zones, which implies much more complex concepts. (e.g.: World of Warcraft) But in our case, this is not necessary! :)
I highly recommend checking out the videos from the GDC (Game Developers Conference) if you're interested in such topics. (some videos explain these concepts in much more depth, it's fascinating!)
Okay, so what's the problem with our browser game?
Well, we have a server, we have clients, we have a way to communicate between both in real-time, now we need to know how to implement lobbies for players to join and play the game!
Once we implement the lobbies, you will have accomplished a huge part, from there you can implement your game logic and invite users to play ! :)
Lobby Management, Part 1
As I mentioned at the beginning of the article, I've implemented lobbies and the game in a way that makes sense to me, using the knowledge gained from my previous experiences with Unity and how I imagine online lobbies/games are managed. You are more than welcome to comment and share your own implementation if it's different.
For my use case, we can have a lot of players all playing at the same time, but not necessarily in the same "lobby", which means we need to separate each "instance" individually.
This way, if players A, B, and C are in lobby 1 playing, their actions will not have consequences on the game of players D, E, and F who are in lobby 2.
If we want lobbies, then it would be nice to have a LobbyManager that will manage these lobbies.
Let's start by declaring that !
Lots of things are happening, let's see one by one what they do.
I declare a public property server, which will hold the Socket.IO WebSocket server (and it is assigned from the afterInit hook during server initialization), wsserver is necessary to perform operations from lobbies to emit to clients for example.
I declare another property lobbies, this is a map that contains all the ongoing lobbies, mapped by their identifier.
I declare two methods initializeSocket and terminateSocket, I like to attach such methods to handlers, this way I can "prepare" the client socket (I call it when the client has connected and authenticated successfully) but also execute code, the same behavior when a client disconnects for any reason (I call terminate this time).
I declare the method createLobby, I think you guessed what this method does.
I declare the method joinLobby, I think you guessed what this method does as well.
I declare the method lobbiesCleaner, this method is responsible for cleaning / clearing lobbies periodically after a certain time to avoid memory leaks (NodeJS and JavaScript are great tools but remember it's a long-running process, if you keep storing references to objects and data, at some point you will run out of memory and your server will crash). (You may notice that I annotated it with @Cron(), NestJS being so cool, it will execute this method for us every 5 minutes!)
I won't necessarily show every implementation so as not to visually clutter, if you want to check the actual code, you can find the complete repository here.
Let's see how we would create a lobby :
A client connects to the server
The player wants to start a new lobby, clicks a button to create a lobby
The client sends the event instruction "client.lobby.create" to the server
The server receives the instruction, it can perform all checks and validation to create this lobby (maybe we want only administrators to create lobbies ? We want to check if the client is not in another lobby ? etc...)
The server creates the lobby and sends a success event
Wait, wait, wait, you're always talking about servers and how to handle incoming events, but I still don't know how to handle the client side, how do I connect to the server ?!
Yes, you're right, let's move on to the frontend a bit to see how to play with WebSockets and React, that way you'll have a complete overview of the communication !
Client-Side Event Management
In this chapter, we will see how I implemented client-side event management using React, if you are not using this framework, you can still follow along but you will probably need to adapt to your own situation.
To interact with the WebSocket API, on React, I wrote a wrapper class around the socket client, with which I created a custom context provider to wrap my application.
Why?
This approach allows me several things ;
Access the socket client from anywhere in the application
Control when I want the socket to connect
Attach custom behaviors to the socket client (what happens if I get kicked by the server? what happens if the client receives exceptions? ...)
Attach listeners (to listen to events from the server)
Declare my own emit method (with the ClientEvents enumeration enforced and the ability to input outgoing data)
You can find all of this here.
Then, from your components, you can do things like this:
If you try this code, open your console, go to the Network tab, and check the WebSocket connection, you will see the client sending pings and the server (if you have the subscriber shown previously implemented) responding pongs!
Yeah, that's cool, but how do I send data with the event?
Just set it as the "data" key in the event.emit() object:
This will send the time with the event! :)
Okay, and what about incoming events from the server?
For that, you need to declare listeners on the client, you can do it like this :
This example will display a notification (pong) every time the server sends a pong event to the client !
Be careful not to register the same listener or the same event multiple times, you would duplicate the behavior. (and maybe introduce side effects) Also, remember to remove the listeners once the component is unmounted (that could create side effects or memory leaks).
Finally, if you have "global" events, you can listen to them on higher-order components, with a state management library, you will be able to easily share updates to other components.
In the example, I have global events that I listen to on the GameManager.tsx component. For the state management library, I used Recoil.
After all this, I think we've covered the client side, I highly encourage you to check the links and look at the game example to see how it is implemented exactly.
Now, back to lobby management!
Lobby Management, Part 2
Let's recall the steps to create a lobby :
A client connects to the server
The player wants to start a new lobby, clicks a button to create a lobby
The client sends the event instruction "client.lobby.create" to the server
The server receives the instruction, it can perform all checks and validation to create this lobby (maybe we want only administrators to create lobbies ? We want to check if the client is not in another lobby ? etc...)
The server creates the lobby and sends a success event
Well, I guess we can start implementing this !
Starting with the gateway :
Server gateway, with a bit more code, including the subscriber for creating the lobby :
Our LobbyManager :
And we created a lobby! :D
Wait, what's a "Lobby"? I still don't know what's inside...
Well, it's quite informative yes, a lobby is also a class, it is responsible for grouping clients, managing them, and also sending events to its clients, let's see what's inside!
Here, we see several things, let's go through them ;
I declare a property id, it makes sense, we want to identify our lobbies.* I declare a property createdAt, it will be used later by the LobbyManager, to clean up the lobbies.
I declare a property clients, it contains a map of each client associated with this lobby.
I declare a property instance, this is another class, it is actually the game implementation, I differentiate it from the lobby since the lobby is supposed to manage clients and state distribution operations, but the actual game logic resides in the Instance class. (This approach also makes your code more reusable, respecting the SRP principle, it is easier to port this piece of code for other projects too)
You may notice that in the constructor, I declare two properties, one to pass the server WebSocket (from the LobbyManager, remember that this is necessary because the lobby will need to send messages to the Socket.IO rooms, and also a maxClients which is, as its name indicates, the maximum number of clients for this lobby.
I declare two methods addClient and removeClient, I think you guess what they do. Feel free to attach custom logic if you need; if someone joins the lobby, maybe you want to alert the other players? same if someone leaves?
Finally, I declare two last methods; dispatchLobbyState and dispatchToLobby, the latter is used to send messages to players in the lobby, the former I use to automatically retrieve basic information about the lobby to send to players (like the number of connected players, the progress of the instance, ...) anything you want.
You can find the exact implementation of this file here.
Additionally, to be explicit about what happens here; the client gives the instruction to create a lobby, the server executes it and adds the client directly to this lobby, so the client is in a lobby, and as the client side was listening to whether the client was in a lobby or not, its display updated automatically!
To give you a little exercise, implement the "join a lobby" behavior yourself, it's a great starting point to play with every domain we've seen before. (client, api, server, gateway, lobby) Of course, if you don't want to, you can always check the example to see how I did it.
That's a lot to digest so far, feel free to go through the example project to see how it's done. If you want to take a break, go ahead, I'll wait. :)
Instance Implementation
Are you back? Great! We can continue then, there isn't much left to see, I promise!
The instance implementation is the game itself, but we don't care that much to be honest, for the example project it is a really simple game to train memory, nothing fancy, the only thing I wanted to talk about is its interactions with clients (players) and the lobby.
In the example project, players can only take one action, which is to reveal cards, let's see how it handles this.
On the client side, we use the same interface, passing the index of the card as data to tell the server which one we revealed.
Then, in the gateway, we will listen to this event :
As you can see from the subscriber, I first check if the client is in a lobby, if not, I return an error. I could have chosen not to return this error and simply do :
But for demonstration purposes, it is interesting to show how you can generate errors. (These are handled and displayed to end users via SocketManager.ts, but you can adjust this behavior to handle them yourself)
Then, from the Instance class :
You can see that we pass the cardIndex variable and the client object, the first is to identify which card should be revealed, the client needs to know who performed the action. Once all the game logic is executed, we make a call to our Lobby.dispatchLobbyState() method, this will ensure that clients receive an updated state of the game.
If you are curious to know the actual implementation of the game, you can find it here.
And that's about it, you now know how to create a simple online multiplayer game based on the lobby! I invite you to check the project example to see how it is implemented, refine it, break it and play with it.
Of course, the project example remains very simple, but it’s up to you to create more complex and interesting games and applications, you can play with timers, intervals, interact with a database, there’s not much limit! :)
Lobby Management, Part 3
You talked about cleaning up lobbies, what exactly does it do?
NodeJS is a smart tool, it automatically manages memory for you (and for me), but in some cases, you need to be cautious.
Here, we declared a map to keep track of lobbies, but that also means we are keeping references to all these lobby objects.
NodeJS will allocate more and more memory every time it needs it, until you run out of memory (no more memory). But NodeJS also manages memory for you, it will "detect" what is no longer used and will "garbage collect" it.
Here is an amazing introduction to garbage collection (in Ruby, but the concept is pretty much the same in any language)
But as you have seen, our map is used to reference the lobbies, so what would happen if players were to launch millions of lobbies? You will need to periodically clean up (delete) lobbies so that the garbage collector sees that you are no longer referencing them, thus cleaning them up.
To manage this, we will rely on NestJS task schedulers. I will let you install the necessary packages and configure accordingly.
Then, it’s really simple ;
We ask NestJS to execute this method every 5 minutes thanks to the @Cron() annotation, which will perform a check on each lobby to see if the lobby's lifespan has expired or not, if so, we remove the lobby from the map, which makes it no longer referenced.
This implementation is really simple but works well, you just need to find what the right lobby lifespan value is.
Of course, depending on your game/application, you may need to completely change how you manage lobbies, perhaps store them in a database and keep them alive only when players are present. It's up to you to decide, just be sure to monitor your service resources.
We also did not address reconnection, the example project remains very simple, but to give you a hint on that, you can attach your user credentials to the lobby and check upon connection if they are already members of a lobby to attach them back.
Conclusion
This was a long article, a lot to go over and digest, but you’ve made it to the end, congratulations! :)
I did not cover deployment aspects as it is not the most interesting part, but if you are curious, you can check the example project. It provides basic Docker container configurations.
You are free to reuse my implementation, adjust it, or be inspired by it. This may require adjustments based on your personal use case. Also, keep in mind that this game implementation does not fit into the game loop model (https://gameprogrammingpatterns.com/game-loop.html) that you would see in game engines like Unreal or Unity. Here, the game reacts to user inputs (or your own timers you may implement on the server side) and does not continue by itself. Depending on your needs, you might need to implement a game loop (for example, if you want to create a world where players can move and interact).
Having used a heavily object-oriented approach with an opinionated framework like NestJS for server-side management has worked quite well, and I was able to scale and manage much larger projects this way. If you've implemented such applications/games before, I’m curious to hear your feedback !
One last note: never trust or validate user inputs on the client side. Always take base inputs from clients and validate them on the server side. That way, players cannot cheat (at least not too easily).
Thank you for reading, and have a great day!
François Steinel