Tech · · 5 min

Build lobby-based online multiplayer browser games with React and Node.js

Learn to build lobby-based multiplayer browser games with React and Node.js: a simple architecture, a full example, and a ready-to-use repository.

Build lobby-based online multiplayer browser games with React and Node.js

We recently had to build several online (browser-based) educational games for a client. The projects were exciting, but there are few resources for learning how to build this kind of thing. After creating, shipping, and watching users play several of them, I am sharing the tech stack and architecture I used.

You can try a simple example game at https://memory-cards.mazz.lol/ and browse its repository at https://github.com/Mazzzoni/memory-cards. This article assumes a basic understanding of TypeScript, React, WebSockets, and object-oriented programming.

Context

These games required managing multiple players in the same lobby, real-time communication, and synchronized state. I set aside PHP (my primary language) in favor of a language with a mature asynchronous paradigm. Having already used Node.js for scrapers and bots, I knew it handled async well, and TypeScript is straightforward to work with. On the client side, I am a strong advocate of React.

The Stack

  • Yarn Workspaces to manage client and server in a monorepo and share code between them.
  • Next.js / React for the client application. Data binding over a WebSocket connection enables perfectly synchronized gameplay. UI with Tailwind and Mantine.
  • NestJS / Node.js on the server side to manage game instances and client interactions. Node.js advantages over Go here: no need for a low-latency game loop (board games), shared TypeScript code between client and server, and a familiar object-oriented paradigm.
  • Socket.IO on top of WebSockets: reconnection, room management, long-polling fallback.
  • Docker for deployment (Alpine Node.js images).

Let's Build

We start with the monorepo by initializing a Yarn project with workspaces.

{
  "name": "@mazz/memory-cards",
  "private": true,
  "version": "1.0.0",
  "license": "MIT",
  "workspaces": {
    "packages": ["workspaces/*"]
  }
}

We add three sub-projects inside the workspaces folder: client (Next.js), server (NestJS), and shared (code used by both). We declare the shared workspace as a dependency of both client and server.

Handling WebSockets

In NestJS, a gateway works like an MVC controller: you declare message subscribers there, called when the client sends the corresponding message. To type events, I use string enumerations (ClientEvents, ServerEvents) and map them to their payload types.

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

Security and Validation

You will likely need to authenticate your users on connection, via the OnGatewayConnection interface. Once authenticated, the client has a new type.

export type AuthenticatedSocket = Socket & {
  data: {
    lobby: null | Lobby;
  };
  emit: <T>(...args: any[]) => void;
};

To validate incoming data, NestJS supports automatic validation via DTOs. These MUST be classes: TypeScript does not preserve interface metadata at compile time. Always validate user input.

A Bit of Theory

There are several types of games: single-player, local multiplayer, online multiplayer (our case, with a server and persistent protocols), and MMO. For our browser game, we have a server, clients, and a way to communicate in real time. What remains is implementing lobbies.

Lobby Management

To isolate each game session, we introduce a LobbyManager that handles lobbies.

export class LobbyManager {
  public server: Server;
  private readonly lobbies: Map<Lobby['id'], Lobby> = new Map();
  // initializeSocket / terminateSocket / createLobby / joinLobby / lobbiesCleaner
}

The flow to create a lobby: the client connects, clicks to create a lobby, sends the client.lobby.create event, the server validates the request, creates the lobby, and sends back the success event.

On the client side (React), I wrote a wrapper class around the socket client and a context provider to access the socket anywhere, control the connection, and attach listeners. From a component:

export default function Game() {
  const { sm } = useSocketManager();
  const onPing = () => {
    sm.emit({ event: ClientEvents.Ping });
  };
  return <button onClick={onPing}>ping</button>;
}

For incoming events, we declare listeners and remove them when the component unmounts to avoid memory leaks and side effects.

useEffect(() => {
  const onPong = (data) => showNotification({ message: data.message });
  sm.registerListener(ServerEvents.Pong, onPong);
  return () => sm.removeListener(ServerEvents.Pong, onPong);
}, []);

The Lobby is also a class: it groups clients, manages them, and sends events to them.

export class Lobby {
  public readonly id: string = v4();
  public readonly createdAt: Date = new Date();
  public readonly clients: Map<Socket['id'], AuthenticatedSocket> = new Map();
  public readonly instance: Instance = new Instance(this);

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

  public removeClient(client: AuthenticatedSocket): void {
    this.clients.delete(client.id);
    client.leave(this.id);
    client.data.lobby = null;
    this.dispatchLobbyState();
  }
}

The actual game logic lives in a separate Instance class (single responsibility principle), which makes the code more reusable.

Lobby Cleanup

Node.js manages memory, but our Map holds references to lobbies, so we need to clean them up periodically to let the garbage collector do its job. Using NestJS task schedulers:

@Cron('*/5 * * * *')
private lobbiesCleaner(): void {
  for (const [lobbyId, lobby] of this.lobbies) {
    const lifetime = Date.now() - lobby.createdAt.getTime();
    if (lifetime > LOBBY_MAX_LIFETIME) {
      this.lobbies.delete(lobbyId);
    }
  }
}

Conclusion

That was a long article, but you made it to the end. You now know how to build a simple lobby-based online multiplayer game. This implementation does not suit the game-loop model used by engines like Unreal or Unity: here, the game reacts to user input and does not run on its own. An object-oriented approach with NestJS on the server worked very well for scaling.

One final note: never trust client-side user input. Always validate on the server, so players cannot cheat.

The full example repository is available at https://github.com/Mazzzoni/memory-cards. Thanks for reading.

Article written by François Steinel.