Build lobby based online multiplayer browser games with React and NodeJS

I recently had to build few educational online (browser) games for a client, the projects were really cool to tackle, but I noticed that there is not many resources available online to "learn" how to make such projects.

After making several games and having those delivered and played by players, I feel quite confident about the approach I took to build these.

So today I wanted to share with you the stack and the architecture I used for such projects, of course you're not forced to follow the exact same approach for your projects, every project is different. Also, if you have feedback or want to share your own implementation, feel free to do so !

You can find a simple example game here and its repository here.

This article assumes you have basic understanding of TypeScript / React / Websockets / OOP, if not, no worries, you can still read and follow along, it's a nice way to start to learn by making games ! :)

Context

In my case, these games required to handle multiple players in a same lobby, realtime communication and synced state.

Seeing such needs, I eliminated my main language from the stack (PHP, I know, I know... don't fuss me, I also know that I could've used tools like Swoole / Octane / etc... but I wanted to switch to another language that's mature enough with asynchronous paradigm).

Having used NodeJS already a bit to build scrappers and bots, I knew it could handle async well and JavaScript / TypeScript are easy to tackle (I've hesitated with Go a lot, but I'll explain this more later in the article) and they do a fantastic job.

Client side, I'm a huge fan of React, there wasn't much discussion about it. I checked Phaser, but our use case didn't require to have a real game engine within the browser. (games were mainly board games, so having a game engine embedded was a bit of an overkill)

We saw projects requirements and my preferences & knowledges, let's now see the stack.

Stack

Yarn Workspaces

Projects make use of Yarn workspaces to manage client and server sides but also to share some code between both.
I could've used Lerna, but I felt giving Yarn workspaces a chance first (from what I've understood Lerna used to mirror Yarn workspaces in the end) and I must say that it works pretty nicely.
I only needed to adjust a bit Next / Nest configuration to play seamlessly with shared code.

Next.js / React.js

Client application is built on top of Next.js (React), having a robust frontend framework makes it much easier to build complex application, in our case, a game.
Data binding directly on top of websocket connection allow players to have a perfectly synced game with others.

I also used Tailwind and Mantine to build the UI. (these are great tools, don't hesitate to check them)

NestJS / NodeJS

Server side is built using NestJS (NodeJS) to manage game instances and handle interactions with clients.

Earlier, I told you I was hesitating with Go, let me explain to you why.
I'm a huge fan of Go, it's a nice language and definitely has great perks, but for my use case NodeJS was presenting more advantages;

  • Performance wise, games don't exceed few thousands players simultaneously at most, these are "board games" (no need for a real game loop), so no need to continuously share instance state with low ping. (if you're really interested about performances, I invite you to check ThePrimeagen great videos about performance comparisons here, here and here)
  • Using the same language (TypeScript) server and client sides makes that you can share code between both (thanks to Yarn workspaces / monorepo setup), this fact can make you gain a lot of time.
  • Finally, having already played around a bit with Unity and other game engines, I really like "traditional" object-oriented paradigm (using it almost every time for my projects, being bots, scrappers, games, web app, ...) and I felt that I could "mimic" traditional game architecture for my use case. Go is also object-oriented in some sense (please don't start a riot if you don't agree, that's my opinion and this guy too apparently)

These points made me choose NodeJS over Go, of course I don't exclude working with Go on future projects !

Socket.IO

Games need to be realtime time, first thing that should come to your mind when you think about web and realtime would probably be WebSockets. It's a basic protocol built on top of TCP, this allows you to communicate in both direction (server to client & client to server).

This is more than enough for our use case, and NodeJS / JavaScript handle those really well. Like other tech choices, I could've used alternatives such as server-sent events or long polling but WebSockets are mature, work well and are easy to handle !

On top of WebSockets, I used Socket.IO which makes a great abstraction to handle clients, it also provides nice features such as; reconnection, rooms behaviour, fallback to long polling (yeah there still people with old browsers) and others.
This comes with a price of course, poorer performances (again!), but don't worry, Socket.IO can still handle a lot of clients, and that's enough for us. ;)
(In case you really worried about performances, then you can totally reproduce the following logic with ws or even scale your Socket.IO instances with a Redis adaptater.)

Docker

Last, but not least. For deployment, I used docker containers (using Alpine NodeJS images for client and server that's enough).

This makes it easy to manage your projects and deploy them, you can also couple it with Kubernetes for bigger projects depending on your requirements and constraints.

Let's build !

Let's start by creating our monorepo / setup, init a new yarn project and specify you'll use workspaces;

{
  "name": "@mazz/memory-cards",
  "private": true,
  "version": "1.0.0",
  "author": "François Steinel",
  "license": "MIT",
  "workspaces": {
    "packages": [
      "workspaces/*"
    ]
  },
  "scripts": {
    "start:dev:client": "yarn workspace @memory-cards/client start:dev",
    "start:dev:server": "yarn workspace @memory-cards/server start:dev",
    "start:prod:client": "yarn workspace @memory-cards/client start:prod",
    "start:prod:server": "yarn workspace @memory-cards/server start:prod"
  }
}

(you can find this file here)

Declaring such package.json will make Yarn understand you're working with multiple "subprojects" (workspaces), by convention I name it "workspaces" to be explicit (in other projects you can find it named "apps" / "packages" / etc...)

I also added custom scripts, these make life easier (not needing to cd into workspaces manually and running commands), you can add as many as you want of course.
In this example I added scripts to start dev and prod servers for both client and server projects.

Then, you can add your "subprojects", within the workspaces folder. We'll have 3 subprojects;

  • client
  • server
  • shared

client: Contain Next.js application
server: Contain NestJS application
shared: Contain code that will be used by both client and server

You can initiate these projects as you would do normally, just make sure that naming convention is correct ("@name/client" / "@name/server" / "@name/shared") and you declared shared workspace as a dependency of client and server (this way its code can be used by both other projects)

In case of doubts, you can find package.json declarations here;

Those are not set in marbles, you can edit those and adjust accordingly to your needs of course.

Managing websockets

To be able to work with websockets in NestJS, you'll first need to install few dependencies, run this command within the server workspace:

yarn add @nestjs/platform-socket.io @nestjs/websockets

Great ! You can now play with websockets !

Run this other command:

nest g gateway game/game

This will create for you a game.gateway.ts / game.module.ts / game.gateway.spec.ts files within a module called game (this article is not about learning how NestJS works, if you have trouble working with it, make sure to follow documentation about the framework first)

To resume very briefly and grossly, a gateway is like a controller you would find in typical MVC applications.
You can declare new message subscribers within your gateway, these will be called when client sends you according message.
(like in an MVC app, avoid having too much business logic in your subscriber directly)

I like to declare a simple "ping" subscriber when I start a project, just to make sure everything works:

@WebSocketGateway()
export class GameGateway
{
  @SubscribeMessage(ClientEvents.Ping)
  onPing(client: Socket): void
  {
    client.emit(ServerEvents.Pong, {
      message: 'pong',
    });
  }
}

You maybe asking:

What's "ClientEvents" and "ServerEvents" ? Shouldn't we just pass plain string to recognize events going through Socket.IO ?

Yeah you're right, I'm using two string enums to declare events. Here's how they look:

export enum ClientEvents
{
  Ping = 'client.ping',
}

export enum ServerEvents
{
  Pong = 'server.pong',
}

I prefer declaring string enums over using interfaces and provides those to Socket.IO server instantiation (same for client instantiation), interfaces are handy, but in my case I like working with enums that I can easily reuse wherever I want in codebase and also attach those to payloads declaration (we'll see later).
Again, if you feel like using interfaces and passing those as generics to your instantiations, feel free to do it !

So, to come back to our first example, what does it do ?

  • A client emit an event through its socket to the server, this event is "client.ping"
  • The server receives the event emitted by the client; "client.ping", check whether there's a subscriber, that's the case, it passes the client (Socket) to the method
  • The server directly emit an event to the exact same client, this event is "server.pong"
  • The client receives the event emitted by the server; "server.pong"

If you can wrap your mind around this concept, that's great because it's the most important to grab !

Also note that the previous example could've been written like so;

@WebSocketGateway()
export class GameGateway
{
  @SubscribeMessage(ClientEvents.Ping)
  onPing(client: Socket): WsResponse<{ message: string }>
  {
    // This is the NestJS way of returning data to the exact same client, notice the return type as well
    return {
      event: ServerEvents.Pong,
      data: {
        message: 'pong',
      },
    };
  }
}

One or another, that's the same behaviour (if you want to return data to the exact same client), choose the one you like most (depending on the context ofc !).

What was the payload stuff you were talking about ?

Depending on events, you might need to pass "data" with an event and it's always better when this data is typed. :)
Here's come into play type declaration for payloads;

export type ServerPayloads = {
  [ServerEvents.Pong]: {
    message: string;
  };

  [ServerEvents.GameMessage]: {
    message: string;
    color?: 'green' | 'red' | 'blue' | 'orange';
  };
};

As you can see, we used the enum we declared before to play as key for this type, this allows us to match each event with its payload typing !

Security & Validation

Check, another example of what we can do now:

@WebSocketGateway()
export class GameGateway
{
  @SubscribeMessage(ClientEvents.LobbyCreate)
  onLobbyCreate(client: AuthenticatedSocket, data: LobbyCreateDto): WsResponse<ServerPayloads[ServerEvents.GameMessage]>
  {
    const lobby = this.lobbyManager.createLobby(data.mode, data.delayBetweenRounds);
    lobby.addClient(client);

    return {
      event: ServerEvents.GameMessage,
      data: {
        color: 'green',
        message: 'Lobby created',
      },
    };
  }
}

We have full code autocompletion based on the return type ! That will definitely make your life easier, trust me.

Hey ! In your example you started implementing the game ? What's "LobbyCreateDto" ? And what's an "AuthenticatedSocket" ? Those were not there in previous examples !

I know, I know, we'll see what those are, no worries, we're making baby steps to see different concepts, it's important to grasp those first, when you'll see the overall piece then hopefully it'll click and all make sense ! :)

So what's an "AuthenticatedSocket" ?

Well, most probably you'll have to authenticate your users in your app / games, this step can be done when a client wants to connect to your server, on your gateway, implement the "OnGatewayConnection" interface:

@WebSocketGateway()
export class GameGateway implements OnGatewayConnection
{
  async handleConnection(client: Socket, ...args: any[]): Promise<void>
  {
    // from here you can verify if the user is authenticated correctly,
    // you can perform whatever operation (database call, token check, ...) 
    // you can disconnect client if it didn't match authentication criterias
    // you can also perform other operations, such as initializing socket attached data 
    // or whatever you would like upon connection
  }
}

Now we can authenticate our users, we know each client passed this step, to be explicit within codebase, I treat them as "authenticated" and they now have a new type:

export type AuthenticatedSocket = Socket & {
  data: {
    lobby: null | Lobby;
  };

  emit: <T>(ev: ServerEvents, data: T) => boolean;
};

As you can see, within data key (which is a handy key provided by Socket.IO on Socket object, you can put anything you want in it), I declare a lobby subkey, this will be used later to attach a lobby to the client.
I also override emit method to explicitly have ServerEvents enum as type of the emitted event.

What about validation ?

To be able to validate properly incoming data, add two packages;

yarn add class-validator class-transformer

NestJS is a great tool, it allows you to validate automatically incoming data passed with an event, to do that you'll need to create your own DTO (Data Transfer Object), it MUST be a class, TypeScript once compiled, does not store metadata about generics or interfaces for your DTOs, so it might fail your validation, do not take chance, use classes.

Here's the validation class for lobby creation:

export class LobbyCreateDto
{
  // I ensure this is a string, but you have many validation rules available and you can create your own validators
  @IsString()
  mode: 'solo' | 'duo';

  @IsInt()
  @Min(1)
  @Max(5)
  delayBetweenRounds: number;
}

I also annotate my gateway with my validation pipe:

@UsePipes(new WsValidationPipe())
@WebSocketGateway()
export class GameGateway implements OnGatewayConnection
{
}

Great ! Incoming data is now validated ! Always make sure to validate user inputs !

Ok, I understand, so we do the basics first, and then we move to the actual implementation ?

You totally right ! It's important to understand the basics of the tools you're using, once that's done then you're good to go and implement your own business logic.

A bit of theory

You can find different type of games;

  • Solo game
  • Multiplayer game
  • Online multiplayer game
  • Massively Multiplayer Online game (MMO)

A solo game don't involve a server to interact with, you play on your client and everything is embedded within it, the game interactions are predefined. (e.g.: The Witcher)

A multiplayer game can be played by multiple players at the same time, still, no servers involved, it's 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 multiple players at the same time, since it's online it involves server(s), clients communicate via persistent connection protocols (TCP / UDP / WS / ...) to server(s) and vice-versa to play. (e.g.: Fall Guys)
This is our use case.

Finally, MMOs, these follows the same principle as above but needs to scale to (potentially) millions of players, they also use servers splitting based on population, shards and layers world zones, theses involves much more complexes concepts. (e.g.: World of Warcraft)
But in our case, that's not needed ! :)

I strongly recommend you to check GDC (Game Developers Conference) videos if you're interested in such topics. (some videos explain much more in depth these concepts, it's fascinating !)

Ok, so what's the deal 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 so players can join and play the game !

Once we'll implement lobbies then you've accomplished a huge piece, from that you'll be able to implement your game logic and invite users to play ! :)

Lobbies management, part 1

Like I said in the beginning of the article, I implemented lobbies and game in a way that makes sense for me, using knowledge from previous experiences I had with Unity and how I imagine lobbies / online games are managed.
You're more than welcome to make feedback and share your own implementation if it's different.

For my use case, we can have lots of players playing all at the same time, but not necessary in the same "lobby", this means we need to separate each "instance" on its own.

This way, if player A, B and C are in lobby 1 playing, their actions won't have consequences on the game of players D, E and F which are in lobby 2.

If we want lobbies, then it would be nice to have a LobbyManager that'll manage these lobbies.

Let's start by declaring that!

export class LobbyManager
{
  public server: Server;

  private readonly lobbies: Map<Lobby['id'], Lobby> = new Map<Lobby['id'], Lobby>();

  public initializeSocket(client: AuthenticatedSocket): void
  {
  }

  public terminateSocket(client: AuthenticatedSocket): void
  {
  }

  public createLobby(mode: LobbyMode, delayBetweenRounds: number): Lobby
  {
  }

  public joinLobby(lobbyId: string, client: AuthenticatedSocket): void
  {
  }

  // Periodically clean up lobbies
  @Cron('*/5 * * * *')
  private lobbiesCleaner(): void
  {
  }
}

Many things going around, let's see one by one what they do.

  • I declare a public property server, this will hold the WebSocket server from Socket.IO (and it's being assigned from the afterInit hook when initializing the server), wsserver is needed to do operations from lobbies to emit to clients for example.

  • I declare another property lobbies, this is a map that holds all ongoing lobbies, mapped by their id.

  • I declare two methods initializeSocket and terminateSocket, I like to attach such methods to managers, that way I can "prepare" the client socket (I call it when the client successfully connected and authenticated) but also execute some code, same behaviour when a client disconnect for whatever reason (I call terminate this time).

  • I declare method createLobby, I think you guessed what this method does.

  • I declare method joinLobby, I think you guessed what this method does too.

  • I declare method lobbiesCleaner, this method is responsible to clean / wipe lobbies periodically after a certain time to avoid memory leak (NodeJS and JavaScript are great tools but don't forget it's a long-running process, if you keep storing references to object and data, at some point you'll run out of memory and your server will crash).
    (You can notice I annotated it with @Cron(), NestJS being that cool, it will execute this method for us every 5th minutes !)

I won't necessarily show every implementation to not pollute visually, if you want to check actual code, you can find full working repository here.

Let's see how we would create a lobby:

  1. A client connects to the server
  2. Player wants to start a new lobby, clicks a button to create a lobby
  3. Client sends "client.lobby.create" event instruction to the server
  4. Server receives the instruction, it can perform whatever check and validation to create this lobby (maybe we want that only administrators create lobbies ? we want to check if the client is not in another lobby ? etc...)
  5. Server creates the lobby and sends successful event

Wait, wait, wait, you always speak about servers and how to handle incoming events, but I still don't know how to manage client side, how do I connect to the server ?!

Yeah, you're right, let's switch a bit to frontend to see how to play with WebSockets and React, that way you'll have full overview of communication !

Client side events management

In this chapter we'll see how I implemented event management client side using React, if you don't use this framework, you can still follow, but you'll probably need to adjust to your own case.

To interact with WebSocket API, on React I wrote a wrapper class around socket client, with that I wrote a custom context provider that I'll wrap my application with.

Why ?

This approach allow me multiple things;

  • Access socket client from wherever I want in the application
  • Control when I want the socket to connect
  • Attach custom behaviours to the socket client (what happen if I get kick by the server ? what happen if client gets exceptions ? ...)
  • Attach listeners (to listen on server events)
  • Declare my own emit method (with forced ClientEvents enum and possibility to type data send)

You can find all of that here.

Then, from your components you can do stuff like this:

export default function Game() {
  const {sm} = useSocketManager();

  const onPing = () => {
    sm.emit({
      event: ClientEvents.Ping,
    });
  };

  return (
    <div>
      <button onClick={onPing}>ping</button>
    </div>
  )
}

If you try this code, open your console, go to Network tab and check the websocket connection, you'll see the client sending pings and the server (if you have the subscriber shown before implemented) responding pongs !

Yeah that's cool, but how do I pass data with the event ?

You only have to set it as the "data" key in the event.emit() object:

const onPing = () => {
  sm.emit({
    event: ClientEvents.Ping,
    data: {
      hour: (new Date()).getHours(),
    },
  });
};

This will pass the hour 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 so:

export default function GameManager() {
  const {sm} = useSocketManager();

  useEffect(() => {
    const onPong: Listener<ServerPayloads[ServerEvents.Pong]> = async (data) => {
      showNotification({
        message: data.message,
      });
    };

    sm.registerListener(ServerEvents.Pong, onPong);

    return () => {
      sm.removeListener(ServerEvents.Pong, onPong);
    };
  }, []);

  return (
    <div>...</div>
  );
}

This example will show a notification (pong) every time the server will send a pong event to the client !

Pay attention to not register multiple time the same listener or the same event, you would duplicate behaviour. (and maybe introducing side effects)
Also don't forget to remove listeners once the component is unmounted (this could produce side effects or memory leaks).

Finally, if you have "global" events, then you can listen on those on Higher Order Components, with a state management library you'll be able to share updates to other components easily.

In the example, I do have global events that I listen on the GameManager.tsx component.
For the state management library, I used Recoil.

After all this, I think we covered client side, I strongly encourage you to check links and check the game example to see how exactly it's implemented.

Let's jump back to lobbies management !

Lobbies management, part 2

Let's remind us the path to create a lobby:

  1. A client connects to the server
  2. Player wants to start a new lobby, clicks a button to create a lobby
  3. Client sends "client.lobby.create" event instruction to the server
  4. Server receives the instruction, it can perform whatever check and validation to create this lobby (maybe we want that only administrators create lobbies ? we want to check if the client is not in another lobby ? etc...)
  5. Server creates the lobby and sends successful event

Well, I guess we can start implement that !

Starting from front:

const onCreateLobby = (mode: 'solo' | 'duo') => {
  sm.emit({
    event: ClientEvents.LobbyCreate,
    // In the example project, you can play duo games or solo games
    data: {
      mode: mode,
      delayBetweenRounds: delayBetweenRounds,
    },
  });
};

Server gateway, with a bit more of code, including the subscriber for lobby creation:

@UsePipes(new WsValidationPipe())
@WebSocketGateway()
export class GameGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 
{
  constructor(
    private readonly lobbyManager: LobbyManager,
  )
  {
  }

  afterInit(server: Server): any
  {
    // Pass server instance to managers
    this.lobbyManager.server = server;

    this.logger.log('Game server initialized !');
  }

  async handleConnection(client: Socket, ...args: any[]): Promise<void>
  {
    // Call initializers to set up socket
    this.lobbyManager.initializeSocket(client as AuthenticatedSocket);
  }

  async handleDisconnect(client: AuthenticatedSocket): Promise<void>
  {
    // Handle termination of socket
    this.lobbyManager.terminateSocket(client);
  }
  
  @SubscribeMessage(ClientEvents.LobbyCreate)
  onLobbyCreate(client: AuthenticatedSocket, data: LobbyCreateDto): WsResponse<ServerPayloads[ServerEvents.GameMessage]>
  {
    const lobby = this.lobbyManager.createLobby(data.mode, data.delayBetweenRounds);
    lobby.addClient(client);

    return {
      event: ServerEvents.GameMessage,
      data: {
        color: 'green',
        message: 'Lobby created',
      },
    };
  }
}

Our LobbyManager:

export class LobbyManager
{
  public server: Server;

  private readonly lobbies: Map<Lobby['id'], Lobby> = new Map<Lobby['id'], Lobby>();

  public initializeSocket(client: AuthenticatedSocket): void
  {
    client.data.lobby = null;
  }

  public terminateSocket(client: AuthenticatedSocket): void
  {
    client.data.lobby?.removeClient(client);
  }

  public createLobby(mode: LobbyMode, delayBetweenRounds: number): Lobby
  {
    let maxClients = 2;

    switch (mode) {
      case 'solo':
        maxClients = 1;
        break;

      case 'duo':
        maxClients = 2;
        break;
    }

    const lobby = new Lobby(this.server, maxClients);

    lobby.instance.delayBetweenRounds = delayBetweenRounds;

    this.lobbies.set(lobby.id, lobby);

    return lobby;
  }
}

And we created a lobby ! :D

Wait, what's a "Lobby" ? Still don't know what's inside...

Well that's quite insightful yeah, a lobby is a class too, it's responsible to group clients together, manage them and also dispatch events to its clients, let's see what's inside !

export class Lobby
{
  public readonly id: string = v4();

  public readonly createdAt: Date = new Date();

  public readonly clients: Map<Socket['id'], AuthenticatedSocket> = new Map<Socket['id'], AuthenticatedSocket>();

  public readonly instance: Instance = new Instance(this);

  constructor(
    private readonly server: Server,
    public readonly maxClients: number,
  )
  {
  }

  public addClient(client: AuthenticatedSocket): void
  {
    this.clients.set(client.id, client);
    client.join(this.id);
    client.data.lobby = this;

    // TODO: other logic

    this.dispatchLobbyState();
  }

  public removeClient(client: AuthenticatedSocket): void
  {
    this.clients.delete(client.id);
    client.leave(this.id);
    client.data.lobby = null;
    
    // TODO: other logic

    this.dispatchLobbyState();
  }

  public dispatchLobbyState(): void
  {
    // TODO: How's a lobby represented to clients ?
  }

  public dispatchToLobby<T>(event: ServerEvents, payload: T): void
  {
    this.server.to(this.id).emit(event, payload);
  }
}

Here we see multiple things, let's review them;

  • I declare an id property, makes sense, we want to identify our lobbies.

  • I declare a createdAt property, this will be used later by the LobbyManager, to clean up lobbies.

  • I declare a clients property, this holds a map of every client associated to this lobby.

  • I declare an instance property, this one is another class, this is actually the game implementation, I differentiate it from the lobby since the lobby is meant to manage clients and state dispatch operations, but the actual game logic is within the Instance class.
    (This approach makes your code more re-usable too, respect SRP principle, easier to get this piece of code out for other projects too)

  • You can notice within the constructor I declare two properties, one for passing the WebSocket server (from the LobbyManager, remember this is needed because the lobby will need to dispatch to Socket.IO rooms, and also a maxClients which is like its name means, maximum clients for this lobby.

  • I declare two methods addClient and removeClient, I think you guess what these do. Don't hesitate to attach custom logic here if you need; if someone joins the lobby, you maybe want to alert other players ? same if someone leaves ?

  • Finally, I declare two last methods; dispatchLobbyState and dispatchToLobby, latest is used to dispatch message to players within the lobby, the previous I use it to automatically retrieve basic information about the lobby to dispatch to the players (such as how many players are connected, instance progression, ...) whatever you feel fit.

You can find exact implementation of this file here.

Also, to be explicit on what's happening here; the client gives the instruction to create a lobby, the server executes, and adds the client directly to that lobby, hence the client is in a lobby, and since on client side you were listening if the client was in a lobby or not, its display updated automatically !

To give you a bit of an exercise, implement yourself the "join a lobby" behaviour, it's a great starting point to play with each domain we saw before. (client, api, server, gateway, lobby)
Of course, if you don't want to, you can still check the example to see how I've done it.

That's a lot to digest so far, feel free to navigate the example project to see how it's done. If you want to take a break, go ahead, I'll wait. :)


Instance implementation

You came back ? Great ! We can continue then, there's not much left to see, I promise !

Instance implementation is the game itself, but we don't care that much about it to be honest, for the example project it's a really simple game to train your memory, nothing fancy, only thing I wanted to speak about is its interactions with clients (players) and lobby.

In example project, players can only do one single action, which is to reveal cards, let's look how it handles it.

Client side, we use the same interface, passing the card index in data to tell to the server which one we revealed.

const onRevealCard = (cardIndex: number) => {
  sm.emit({
    event: ClientEvents.GameRevealCard,
    data: {cardIndex},
  });
};

Then in gateway, we'll listen to this event:

@UsePipes(new WsValidationPipe())
@WebSocketGateway()
export class GameGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 
{
  @SubscribeMessage(ClientEvents.GameRevealCard)
  onRevealCard(client: AuthenticatedSocket, data: RevealCardDto): void
  {
    if (!client.data.lobby) {
      throw new ServerException(SocketExceptions.LobbyError, 'You are not in a lobby');
    }

    client.data.lobby.instance.revealCard(data.cardIndex, client);
  }
}

As you can see, from the subscriber, I check first if the client is in a lobby, if it's not the case then I throw an error. I could've not throw this error and just do:

client.data.lobby?.instance.revealCard(data.cardIndex, client);

But for demo purpose, it's nice to show how you can throw errors. (these are managed and shown to end users thanks to SocketManager.ts, but you can adjust this behaviour to manage them yourself)

Then, from Instance class:

export class Instance
{
  public revealCard(cardIndex: number, client: AuthenticatedSocket): void
  {
    // game logic

    this.lobby.dispatchLobbyState();
  }
}

You can see we pass the cardIndex variable and client object, first is to identify which card needs to be revealed, client is to know who did the action.
After all the game logic is executed, we do a call to our Lobby.dispatchLobbyState() method, this will ensure clients get updated state of the game.

If you're curious about actual game implementation, you can find it here.

And that's pretty much it, you now know how to build a simple lobby based online multiplayer game !
I invite you to check example project to see how it's implemented, tweak it, break it and play with it.

Of course the example project stays really simple, but it's only up to you to make more complex and interesting games and applications, you can play with timers, intervals, interact with a database, there's not much limit ! :)

Lobbies management, part 3

You were talking about lobbies clean up, what does it do exactly ?

NodeJS is a smart tool, it automatically manages memory for you (and for me), but in some case you need to pay attention.

export class LobbyManager
{
  private readonly lobbies: Map<Lobby['id'], Lobby> = new Map<Lobby['id'], Lobby>();
}

Here we declared a map to keep track of lobbies, but this also make that we keep references to all these lobbies objects.

NodeJS will allocate more and more memory every time it needs to, until you don't have enough memory (out of memory). But NodeJS also manage memory for you, it'll "detect" what's not used anymore and "garbage collect" it.

Here's an amazing introduction talk about garbage collection (in Ruby, but concept is about the same in whatever language)

But as you saw, our map is used to reference lobbies, so what would happen if players launched millions of lobbies ? You'll have to periodically clean (delete) lobbies so garbage collector sees you're not referencing those anymore, hence, garbage collect those.

To handle that we'll rely on NestJS task schedulers. I let you install needed packages and configure accordingly.

Then it's really simple;

export class LobbyManager
{
  private readonly lobbies: Map<Lobby['id'], Lobby> = new Map<Lobby['id'], Lobby>();

  // Periodically clean up lobbies
  @Cron('*/5 * * * *')
  private lobbiesCleaner(): void
  {
    for (const [lobbyId, lobby] of this.lobbies) {
      const now = (new Date()).getTime();
      const lobbyCreatedAt = lobby.createdAt.getTime();
      const lobbyLifetime = now - lobbyCreatedAt;

      if (lobbyLifetime > LOBBY_MAX_LIFETIME) {
        lobby.dispatchToLobby<ServerPayloads[ServerEvents.GameMessage]>(ServerEvents.GameMessage, {
          color: 'blue',
          message: 'Game timed out',
        });

        lobby.instance.triggerFinish();

        this.lobbies.delete(lobby.id);
      }
    }
  }
}

We instruct NestJS to execute this method every 5th minutes thanks to the @Cron() annotation, that'll run a check on every lobby to see whether that lobby lifetime is exceeded or not, if it's the case then we delete the lobby from the map, hence not referencing it anymore.

This implementation is really simple but does work well, you just need to find what's a good lobby lifetime value.

Of course, based on your game / application you might need to totally change how you manage lobbies, maybe store them in database and only keep them alive when players are in it. That's up to you, just make sure to monitor your services resources.

We also didn't go into reconnection, the example project stays really simple, but to give you a hint on that, you can attach your user IDs to lobby and check on connection if they're already member of a lobby to attach them back to it.

Conclusion

That was a long article, a lot of stuff to review and digest, but you made it to the end, congratulations ! :)

I didn't go into deployment stuff since it's not the most interesting part but if you're curious, again, you can check the example project, it provides basic docker containers config.

You're free to reuse my implementation, adjust it or inspire yourself from it, it might need tweaking depending on your personal use case though.
Also, keep in mind that this game implementation is not following game loop pattern implementation like you would see in game engine such as Unreal or Unity, here the game reacts to user inputs (or to your own timers that you can implement server side) and don't go on its own. So depending on your needs you might need to implement a game loop. (e.g.: you want to create a world where players can move around and interact)

Having used heavy object-oriented approach with an opinionated framework (NestJS) to handle server side worked quite well, and I was able to scale and manage much bigger projects that way. If you already implemented such apps / games, I'm curious to get your feedback !

A last note; never trust or do validation of user inputs client side. Always take basic inputs from clients and validate them server side, this way players can't cheat (not too easily at least).

Thanks for reading and have a nice day !

June 26, 2022