Build lobby based online multiplayer browser games with React and NodeJS

We recently undertook the task of developing a few educational online (browser) games for a client. These projects were not only exciting to work on, but I also noticed a scarcity of resources available online for "learning" how to create such projects.

Having completed several games and seeing them delivered and played by users, I now feel confident in the approach I took to build them.

Today, I'd like to share with you the stack and architecture I employed for these projects. While you're not obligated to follow the exact same approach for your projects (since every project is unique), I encourage you to explore and adapt as needed. Additionally, if you have any feedback or wish to share your own implementations, please feel free to do so!

You can explore a simple example game here and its corresponding repository here.

This article assumes a basic understanding of TypeScript, React, Websockets, and Object-Oriented Programming (OOP). However, if you're unfamiliar with these concepts, don't worry! You can still read along and follow the discussion. It's a great opportunity to learn by diving into the world of game development! :)

Context

In my case, these games required handling multiple players in the same lobby, real-time communication, and synced state.

Considering these needs, I decided to remove 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 language that is mature enough with an asynchronous paradigm.

Having already used NodeJS a bit to build scrapers and bots, I knew it could handle asynchronous operations well, and JavaScript / TypeScript are easy to work with. (I considered Go for a while, but I'll explain this further later in the article).

On the client side, I'm a huge fan of React, so there wasn't much debate about it. I briefly looked into Phaser, but our use case didn't require a full-fledged game engine within the browser. (The games were mainly board games, so embedding a game engine seemed a bit excessive).

After considering the project requirements and my preferences and knowledge, let's now dive into the stack.

Stack

Yarn Workspaces

Projects utilize Yarn workspaces for managing both client and server sides, as well as sharing code between them. Although Lerna could have been an option, I opted to try out Yarn workspaces first, and it has worked quite well. I only needed to make slight adjustments to the Next / Nest configuration to ensure seamless integration 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 applications, in our case, a game. Data binding directly on top of a websocket connection allows 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, the games do not exceed a few thousand players simultaneously at most. These are "board games" (no need for a real game loop), so there is no need to continuously share instance state with low ping. If you're really interested in performance, I invite you to check out ThePrimeagen's 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 adapter.)

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: Contains Next.js application
server: Contains NestJS application
shared: Contains code that will be used by both client and server

You can initiate these projects as you would do normally, just make sure that the naming convention is correct ("@name/client" / "@name/server" / "@name/shared") and you declare the 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 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 summarize 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 a client sends you the corresponding 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 may be asking:

What's "ClientEvents" and "ServerEvents"? Shouldn't we just pass plain strings 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 providing those to the 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 the codebase and also attach to payload declarations (we'll see this 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 emits an event through its socket to the server. This event is "client.ping"
  • The server receives the event emitted by the client; "client.ping", checks whether there's a subscriber (there is), and passes the client (Socket) to the method.
  • The server directly emits 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 grasp!

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

@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 taking baby steps to understand different concepts. It's important to grasp those first. When you see the overall picture, 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 the client if it didn't match authentication criteria.
    // 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 has passed this step. To be explicit within the 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 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 delve 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 container configurations.

You're free to reuse my implementation, adjust it, or take inspiration from it. It might need tweaking depending on your personal use case, though. Also, keep in mind that this game implementation does not follow the game loop pattern implementation like you would see in game engines such as Unreal or Unity. Here, the game reacts to user inputs (or your own timers that you can implement server-side) and doesn't go on its own. Depending on your needs, you might need to implement a game loop (e.g., if you want to create a world where players can move around and interact).

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

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

Thanks for reading, and have a nice day!

Lire plus

Let's create together

Navigate

Connect

Office

1%