Créez des jeux multijoueurs en ligne basés sur un lobby avec React et NodeJS.

Nous avons récemment dû construire quelques jeux éducatifs en ligne (navigateur) pour un client. Les projets étaient vraiment passionnants à aborder, mais j'ai remarqué qu'il y a peu de ressources disponibles en ligne pour "apprendre" à réaliser de tels projets.

Après avoir créé plusieurs jeux et les avoir livrés et joués par les utilisateurs, je me sens maintenant assez confiant dans l'approche que j'ai adoptée pour les construire.

Aujourd'hui, je voulais partager avec vous la pile technologique et l'architecture que j'ai utilisées pour ces projets. Bien sûr, vous n'êtes pas obligé de suivre exactement la même approche pour vos projets ; chaque projet est différent. De plus, si vous avez des commentaires ou si vous souhaitez partager vos propres implémentations, n'hésitez pas à le faire !

Vous pouvez découvrir un exemple de jeu simple ici et son dépôt correspondant ici.

Cet article suppose une compréhension de base de TypeScript, React, des Websockets et de la Programmation Orientée Objet (POO). Si ce n'est pas le cas, pas de soucis ! Vous pouvez toujours lire et suivre la discussion. C'est une excellente occasion d'apprendre en plongeant dans le monde du développement de jeux ! :)

Context

Dans mon cas, ces jeux nécessitaient de gérer plusieurs joueurs dans le même lobby, une communication en temps réel et un état synchronisé.

Constatant ces besoins, j'ai éliminé mon langage principal de la pile (PHP, je sais, je sais... ne me jugez pas). Je suis conscient que des outils comme Swoole / Octane / etc. auraient pu être utilisés, mais je voulais passer à un autre langage suffisamment mature avec un paradigme asynchrone.

Ayant déjà utilisé NodeJS un peu pour créer des scrapers et des bots, je savais qu'il pouvait bien gérer les opérations asynchrones, et JavaScript / TypeScript sont faciles à manipuler. (J'ai envisagé Go pendant un moment, mais j'expliquerai cela plus tard dans l'article).

Du côté client, je suis un grand fan de React, donc il n'y avait pas beaucoup de débat à ce sujet. J'ai brièvement examiné Phaser, mais notre cas d'utilisation ne nécessitait pas un moteur de jeu complet dans le navigateur. (Les jeux étaient principalement des jeux de société, donc intégrer un moteur de jeu semblait un peu excessif).

Après avoir pris en compte les exigences du projet, mes préférences et mes connaissances, plongeons maintenant dans la pile.

Stack

Yarn Workspaces

Les projets utilisent les espaces de travail Yarn pour gérer à la fois le côté client et serveur, ainsi que pour partager du code entre eux. Bien que Lerna aurait pu être une option, j'ai choisi d'essayer d'abord les espaces de travail Yarn, et cela a très bien fonctionné. J'ai seulement eu besoin d'apporter de légères modifications à la configuration Next / Nest pour assurer une intégration harmonieuse avec le code partagé.

Next.js / React.js

L'application client est construite sur Next.js (React), ce qui facilite grandement la création d'applications complexes, dans notre cas, un jeu. La liaison de données directement sur une connexion websocket permet aux joueurs d'avoir un jeu parfaitement synchronisé avec les autres.

J'ai également utilisé Tailwind and Mantine pour construire l'interface utilisateur. Ce sont d'excellents outils, n'hésitez pas à les découvrir.

NestJS / NodeJS

Le côté serveur est construit en utilisant NestJS (NodeJS) pour gérer les instances de jeu et gérer les interactions avec les clients.

Plus tôt, je vous ai dit que j'hésitais avec Go, laissez-moi vous expliquer pourquoi. Je suis un grand fan de Go, c'est un langage agréable et a certainement de grands avantages, mais pour mon cas d'utilisation, NodeJS présentait plus d'avantages ;

  • Côté performance, les jeux ne dépassent pas quelques milliers de joueurs simultanément au maximum. Il s'agit de "jeux de société" (pas besoin de boucle de jeu réelle), donc il n'est pas nécessaire de partager continuellement l'état de l'instance avec une faible latence. Si vous êtes vraiment intéressé par les performances, je vous invite à regarder les excellentes vidéos de ThePrimeagen sur les comparaisons de performances here, here and here)
  • En utilisant le même langage (TypeScript) côté serveur et côté client, vous pouvez partager du code entre les deux (grâce aux workspaces Yarn / configuration monorepo), ce fait peut vous faire gagner beaucoup de temps.
  • Enfin, ayant déjà joué un peu avec Unity et d'autres moteurs de jeux, j'aime beaucoup le paradigme "traditionnel" orienté objet (je l'utilise presque tout le temps pour mes projets, que ce soit des bots, des scrappers, des jeux, des applications web, ...) et j'ai senti que je pouvais "imiter" l'architecture de jeu traditionnelle pour mon cas d'utilisation. Go est également orienté objet dans un certain sens (s'il vous plaît, ne déclenchez pas une émeute si vous n'êtes pas d'accord, c'est mon opinion et cet homme aussi apparemment)

Ces points m'ont fait choisir NodeJS plutôt que Go, bien sûr, je n'exclus pas de travailler avec Go sur de futurs projets !

Socket.IO

Les jeux doivent être en temps réel, la première chose qui devrait vous venir à l'esprit quand vous pensez au web et au temps réel serait probablement les WebSockets. C'est un protocole de base construit sur TCP, cela vous permet de communiquer dans les deux sens (serveur à client et client à serveur).

C'est plus que suffisant pour notre cas d'utilisation, et NodeJS / JavaScript les gère très bien. Comme pour d'autres choix technologiques, j'aurais pu utiliser des alternatives telles que les événements envoyés par le serveur ou le long polling, mais les WebSockets sont matures, fonctionnent bien et sont faciles à gérer !

En plus des WebSockets, j'ai utilisé Socket.IO qui fournit une excellente abstraction pour gérer les clients, il offre également des fonctionnalités intéressantes telles que ; la reconnexion, le comportement des salles, le retour au long polling (oui, il y a encore des gens avec de vieux navigateurs) et d'autres.
Cela a bien sûr un prix, de moins bonnes performances (encore !), mais ne vous inquiétez pas, Socket.IO peut toujours gérer beaucoup de clients, et c'est suffisant pour nous. ;)
(Si vous êtes vraiment préoccupé par les performances, vous pouvez totalement reproduire la logique suivante avec ws ou même faire évoluer vos instances Socket.IO avec un adaptateur Redis.)

Docker

Enfin, mais non des moindres. Pour le déploiement, j'ai utilisé des conteneurs Docker (en utilisant des images Alpine NodeJS pour le client et le serveur, ce qui est suffisant).

Cela facilite la gestion et le déploiement de vos projets, vous pouvez également les coupler avec Kubernetes pour des projets plus importants en fonction de vos besoins et contraintes.

Construisons !

Commençons par créer notre monorepo / setup, initier un nouveau projet yarn et spécifier que vous utiliserez des espaces de travail;

{
  "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"
  }
}

(vous pouvez trouver ce fichier ici)

Déclarer un tel package.json permettra à Yarn de comprendre que vous travaillez avec plusieurs "sous-projets" (workspaces), par convention je le nomme "workspaces" pour être explicite (dans d'autres projets, vous pouvez le trouver nommé "apps" / "packages" / etc...)

J'ai également ajouté des scripts personnalisés, cela rend la vie plus facile (pas besoin de cd dans les workspaces manuellement et d'exécuter des commandes), vous pouvez en ajouter autant que vous le souhaitez bien sûr.
Dans cet exemple, j'ai ajouté des scripts pour démarrer les serveurs de développement et de production pour les projets client et serveur.

Ensuite, vous pouvez ajouter vos "sous-projets" dans le dossier workspaces. Nous aurons 3 sous-projets;

  • client
  • server
  • shared

client: Contient l'application Next.js
server: Contient l'application NestJS
shared: Contient le code qui sera utilisé à la fois par le client et le serveur

Vous pouvez initialiser ces projets comme vous le feriez normalement, assurez-vous simplement que la convention de nommage est correcte ("@name/client" / "@name/server" / "@name/shared") et que vous déclarez le workspace partagé comme une dépendance du client et du serveur (de cette façon, son code peut être utilisé par les deux autres projets).

En cas de doute, vous pouvez trouver les déclarations du package.json ici ;

Ces éléments ne sont pas gravés dans le marbre, vous pouvez les éditer et les ajuster en fonction de vos besoins bien sûr.

Gestion des websockets

Pour pouvoir travailler avec des websockets dans NestJS, vous devrez d'abord installer quelques dépendances. Exécutez cette commande dans l'espace de travail server :

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

Super ! Vous pouvez maintenant jouer avec les websockets !

Exécutez cette autre commande :

nest g gateway game/game

Cela va créer pour vous les fichiers game.gateway.ts / game.module.ts / game.gateway.spec.ts dans un module appelé game (cet article n'est pas destiné à apprendre comment fonctionne NestJS, si vous avez des difficultés avec cela, assurez-vous de suivre la documentation du framework d'abord).

Pour résumer très brièvement et grossièrement, une passerelle (gateway) est comme un contrôleur que vous trouveriez dans des applications MVC typiques.
Vous pouvez déclarer de nouveaux abonnés aux messages dans votre passerelle; ceux-ci seront appelés lorsque le client vous enverra le message correspondant.
(comme dans une application MVC, évitez d'avoir trop de logique métier directement dans votre abonné)

J'aime déclarer un simple abonné "ping" lorsque je commence un projet, juste pour m'assurer que tout fonctionne :

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

Vous vous demandez peut-être :

Qu'est-ce que "ClientEvents" et "ServerEvents" ? Ne devrions-nous pas simplement utiliser des chaînes de caractères pour reconnaître les événements passant par Socket.IO ?

Oui, vous avez raison. J'utilise deux énumérations de chaînes pour déclarer les événements. Voici à quoi elles ressemblent :

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

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

Je préfère déclarer des énumérations de chaînes plutôt que d'utiliser des interfaces et de les fournir à l'instanciation du serveur Socket.IO (même chose pour l'instanciation du client). Les interfaces sont pratiques, mais dans mon cas, j'aime travailler avec des énumérations que je peux facilement réutiliser partout dans la base de code et également attacher aux déclarations de charges utiles (nous verrons cela plus tard).
Encore une fois, si vous préférez utiliser des interfaces et les passer en tant que génériques à vos instanciations, n'hésitez pas à le faire !

Alors, pour revenir à notre premier exemple, que fait-il ?

  • Un client émet un événement via son socket vers le serveur. Cet événement est "client.ping"
  • Le serveur reçoit l'événement émis par le client; "client.ping", vérifie s'il y a un abonné (c'est le cas), et passe le client (Socket) à la méthode.
  • Le serveur émet directement un événement au même client. Cet événement est "server.pong"
  • Le client reçoit l'événement émis par le serveur; "server.pong"

Si vous pouvez comprendre ce concept, c'est super car c'est le plus important à saisir !

Notez également que l'exemple précédent aurait pu être écrit comme ceci;

@WebSocketGateway()
export class GameGateway
{
  @SubscribeMessage(ClientEvents.Ping)
  onPing(client: Socket): WsResponse<{ message: string }>
  {
     // Voici la méthode NestJS pour renvoyer des données au même client, notez également le type de retour
    return {
      event: ServerEvents.Pong,
      data: {
        message: 'pong',
      },
    };
  }
}

L'un ou l'autre, c'est le même comportement (si vous voulez renvoyer des données au même client), choisissez celui que vous préférez (selon le contexte bien sûr !).

Quel était le truc de payload dont vous parliez ?

Selon les événements, vous pourriez avoir besoin de transmettre des "données" avec un événement et c'est toujours mieux quand ces données sont typées. :) Voici comment entrer en jeu la déclaration de type pour les payloads ;

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

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

Comme vous pouvez le voir, nous avons utilisé l'enum que nous avons déclaré auparavant comme clé pour ce type, cela nous permet de faire correspondre chaque événement avec son typage de payload!

Sécurité & Validation

Voyez un autre exemple de ce que nous pouvons faire maintenant:

@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',
      },
    };
  }
}

Nous disposons d'un autocomplétion du code complet basée sur le type de retour! Cela facilitera certainement votre vie, croyez-moi.

Hé! Dans votre exemple, vous avez commencé à implémenter le jeu? Qu'est-ce que "LobbyCreateDto"? Et qu'est-ce qu'un "AuthenticatedSocket"? Ceux-ci n'étaient pas là dans les exemples précédents!

Je sais, je sais, nous allons voir ce que sont ces éléments, ne vous inquiétez pas, nous prenons des petits pas pour comprendre différents concepts. Il est important de bien les saisir d'abord. Lorsque vous verrez l'image d'ensemble, espérons que tout s'emboîtera et aura du sens! :)

Alors, qu'est-ce qu'un "AuthenticatedSocket"?

Eh bien, il est fort probable que vous deviez authentifier vos utilisateurs dans votre application/jeux. Cette étape peut être effectuée lorsqu'un client souhaite se connecter à votre serveur. Sur votre passerelle, implémentez l'interface "OnGatewayConnection":

@WebSocketGateway()
export class GameGateway implements OnGatewayConnection
{
  async handleConnection(client: Socket, ...args: any[]): Promise<void>
  {
    // À partir d'ici, vous pouvez vérifier si l'utilisateur est correctement authentifié.
    // Vous pouvez effectuer n'importe quelle opération (appel de base de données, vérification du jeton, ...).
    // Vous pouvez déconnecter le client s'il ne correspond pas aux critères d'authentification.
    // Vous pouvez également effectuer d'autres opérations, telles que l'initialisation des données attachées au socket
    // ou tout ce que vous souhaitez lors de la connexion.
  }
}

Maintenant, nous pouvons authentifier nos utilisateurs. Nous savons que chaque client a franchi cette étape. Pour être explicite dans le code, je les traite comme "authentifiés", et ils ont maintenant un nouveau type.

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

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

Comme vous pouvez le voir, dans la clé data (qui est une clé pratique fournie par Socket.IO sur l'objet Socket, vous pouvez y mettre ce que vous voulez), je déclare une sous-clé lobby, qui sera utilisée plus tard pour attacher un lobby au client.
J'ai également remplacé la méthode emit pour avoir explicitement l'énumération ServerEvents comme type d'événement émis.

Et la validation ?

Pour pouvoir valider correctement les données entrantes, ajoutez deux packages :

yarn add class-validator class-transformer

NestJS est un excellent outil, il vous permet de valider automatiquement les données entrantes transmises avec un événement, pour ce faire, vous devrez créer votre propre DTO (Data Transfer Object), cela DOIT être une classe, TypeScript une fois compilé, ne stocke pas de métadonnées sur les génériques ou les interfaces de vos DTO, donc cela peut faire échouer votre validation, ne prenez pas de risque, utilisez des classes.

Voici la classe de validation pour la création du lobby :

export class LobbyCreateDto
{
// Je m'assure qu'il s'agit d'une chaîne, mais vous avez de nombreuses règles de validation disponibles et vous pouvez créer vos propres validateurs
@IsString()
mode : 'solo' | 'duo' ;

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

J'annote également ma passerelle avec mon canal de validation :

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

Super ! Les données entrantes sont maintenant validées ! Assurez-vous toujours de valider les entrées utilisateur !

Ok, je comprends, donc on fait d'abord les bases, puis on passe à l'implémentation réelle ?

Tu as tout à fait raison ! Il est important de comprendre les bases des outils que vous utilisez, une fois que c'est fait, vous êtes prêt à implémenter votre propre logique métier.

Un peu de théorie

Vous pouvez trouver différents types de jeux ;

  • Jeu solo
  • Jeu multijoueur
  • Jeu multijoueur en ligne
  • Jeu en ligne massivement multijoueur (MMO)

Un jeu solo n'implique pas d'interaction avec un serveur, vous jouez sur votre client et tout y est intégré, les interactions du jeu sont prédéfinies. (ex. : The Witcher)

Un jeu multijoueur peut être joué par plusieurs joueurs en même temps, toujours sans serveur impliqué, c'est du multijoueur local : tous les joueurs jouent sur la même machine, à partir du même client de jeu (ex. : Cuphead)

Un jeu multijoueur en ligne peut être joué par plusieurs joueurs en même temps, puisqu'il est en ligne, il implique un ou plusieurs serveurs, les clients communiquent via des protocoles de connexion persistants (TCP / UDP / WS / ...) vers le ou les serveurs et vice-versa pour jouer. (ex : Fall Guys) Voici notre cas d'utilisation.

Enfin, les MMO, qui suivent le même principe que ci-dessus mais doivent s'adapter à (potentiellement) des millions de joueurs, ils utilisent également une répartition des serveurs en fonction de la population, des fragments et des zones du monde en couches, ce qui implique des concepts beaucoup plus complexes. (ex : World of Warcraft) Mais dans notre cas, ce n'est pas nécessaire ! :)

Je vous recommande vivement de consulter les vidéos de la GDC (Game Developers Conference) si vous êtes intéressé par de tels sujets. (certaines vidéos expliquent beaucoup plus en profondeur ces concepts, c'est fascinant !)

Ok, alors quel est le problème avec notre jeu par navigateur ?

Eh bien, nous avons un serveur, nous avons des clients, nous avons un moyen de communiquer entre les deux en temps réel, maintenant nous devons savoir comment implémenter des lobbies pour que les joueurs puissent rejoindre et jouer au jeu !

Une fois que nous aurons implémenté les lobbies, vous aurez accompli une énorme partie, à partir de là, vous pourrez implémenter votre logique de jeu et inviter les utilisateurs à jouer ! :)

Gestion des lobbies, partie 1

Comme je l'ai dit au début de l'article, j'ai implémenté les lobbies et le jeu d'une manière qui a du sens pour moi, en utilisant les connaissances acquises grâce à mes expériences précédentes avec Unity et la façon dont j'imagine que les lobbies/jeux en ligne sont gérés. Vous êtes plus que bienvenu à faire des commentaires et à partager votre propre implémentation si elle est différente.

Pour mon cas d'utilisation, nous pouvons avoir beaucoup de joueurs jouant tous en même temps, mais pas nécessairement dans le même "lobby", cela signifie que nous devons séparer chaque "instance" individuellement.

De cette façon, si les joueurs A, B et C sont dans le lobby 1 en train de jouer, leurs actions n'auront pas de conséquences sur le jeu des joueurs D, E et F qui sont dans le lobby 2.

Si nous voulons des lobbies, alors il serait bien d'avoir un LobbyManager qui gérera ces lobbies.

Commençons par déclarer cela !

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
{
}

// Nettoyer périodiquement les lobbies
@Cron('*/5 * * * *')
private lobbiesCleaner(): void
{
}
}

Beaucoup de choses circulent, voyons une par une ce qu'elles font.

  • Je déclare une propriété publique server, qui contiendra le serveur WebSocket de Socket.IO (et il est attribué à partir du hook afterInit lors de l'initialisation du serveur), wsserver est nécessaire pour effectuer des opérations depuis les lobbies pour les émettre vers les clients par exemple.

  • Je déclare une autre propriété lobbies, il s'agit d'une carte qui contient tous les lobbies en cours, mappés par leur identifiant.

  • Je déclare deux méthodes initializeSocket et terminateSocket, j'aime attacher de telles méthodes aux gestionnaires, de cette façon je peux "préparer" le socket client (je l'appelle lorsque le client s'est connecté et authentifié avec succès) mais aussi exécuter du code, même comportement lorsqu'un client se déconnecte pour une raison quelconque (j'appelle terminate cette fois).

  • Je déclare la méthode createLobby, je pense que vous avez deviné ce que fait cette méthode.

  • Je déclare la méthode joinLobby, je pense que vous avez deviné ce que fait cette méthode aussi.

  • Je déclare la méthode lobbiesCleaner, cette méthode est chargée de nettoyer / effacer les lobbies périodiquement après un certain temps pour éviter les fuites de mémoire (NodeJS et JavaScript sont d'excellents outils mais n'oubliez pas que c'est un processus de longue haleine, si vous continuez à stocker des références à des objets et des données, à un moment donné, vous manquerez de mémoire et votre serveur plantera). (Vous pouvez remarquer que je l'ai annoté avec @Cron(), NestJS étant si cool, il exécutera cette méthode pour nous toutes les 5 minutes !)

Je ne montrerai pas nécessairement chaque implémentation pour ne pas polluer visuellement, si vous voulez vérifier le code réel, vous pouvez trouver le référentiel complet ici.

Voyons comment nous créerions un lobby :

  1. Un client se connecte au serveur
  2. Le joueur veut démarrer un nouveau lobby, clique sur un bouton pour créer un lobby
  3. Le client envoie l'instruction d'événement "client.lobby.create" au serveur
  4. Le serveur reçoit l'instruction, il peut effectuer toute vérification et validation pour créer ce lobby (peut-être que nous voulons que seuls les administrateurs créent des lobbies ? Nous voulons vérifier si le client n'est pas dans un autre lobby ? etc...)
  5. Le serveur crée le lobby et envoie l'événement de réussite

Attendez, attendez, attendez, vous parlez toujours de serveurs et de la façon de gérer les événements entrants, mais je ne sais toujours pas comment gérer le côté client, comment me connecter au serveur ?!

Oui, vous avez raison, passons un peu au frontend pour voir comment jouer avec WebSockets et React, de cette façon vous aurez une vue d'ensemble complète de la communication !

Gestion des événements côté client

Dans ce chapitre, nous verrons comment j'ai implémenté la gestion des événements côté client en utilisant React, si vous n'utilisez pas ce framework, vous pouvez toujours suivre, mais vous devrez probablement vous adapter à votre propre cas.

Pour interagir avec l'API WebSocket, sur React, j'ai écrit une classe wrapper autour du client socket, avec laquelle j'ai écrit un fournisseur de contexte personnalisé avec lequel j'envelopperai mon application.

Pourquoi ?

Cette approche me permet plusieurs choses ;

  • Accéder au client socket depuis n'importe où dans l'application
  • Contrôler quand je veux que le socket se connecte
  • Attacher des comportements personnalisés au client socket (que se passe-t-il si je me fais expulser par le serveur ? que se passe-t-il si le client reçoit des exceptions ? ...)
  • Attacher des écouteurs (pour écouter les événements du serveur)
  • Déclarer ma propre méthode emit (avec l'énumération ClientEvents forcée et la possibilité de saisir des données d'envoi)

Vous pouvez trouver tout cela ici.

Ensuite, à partir de vos composants, vous pouvez faire des choses comme ceci :

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

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

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

Si vous essayez ce code, ouvrez votre console, allez dans l'onglet Réseau et vérifiez la connexion WebSocket, vous verrez le client envoyer des pings et le serveur (si vous avez l'abonné montré précédemment implémenté) répondre des pongs !

Ouais, c'est cool, mais comment puis-je transmettre des données avec l'événement ?

Il suffit de le définir comme la clé "data" dans l'objet event.emit() :

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

Cela transmettra l'heure avec l'événement ! :)

Ok, et qu'en est-il des événements entrants du serveur ?

Pour cela, vous devez déclarer des écouteurs sur le client, vous pouvez le faire comme ceci :

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>
);
}

Cet exemple affichera une notification (pong) à chaque fois que le serveur enverra un événement pong au client !

Faites attention à ne pas enregistrer plusieurs fois le même écouteur ou le même événement, vous dupliqueriez le comportement. (et peut-être introduire des effets secondaires) N'oubliez pas non plus de supprimer les écouteurs une fois le composant démonté (cela pourrait produire des effets secondaires ou des fuites de mémoire).

Enfin, si vous avez des événements « globaux », vous pouvez les écouter sur des composants d'ordre supérieur, avec une bibliothèque de gestion d'état, vous pourrez partager facilement les mises à jour vers d'autres composants.

Dans l'exemple, j'ai bien événements globaux que j'écoute sur le composant GameManager.tsx. Pour la bibliothèque de gestion d'état, j'ai utilisé Recoil.

Après tout cela, je pense que nous avons couvert le côté client, je vous encourage fortement à vérifier les liens et à consulter l'exemple de jeu pour voir comment il est implémenté exactement.

Revenons à la gestion des lobbies !

Gestion des lobbies, partie 2

Rappelons le chemin pour créer un lobby :

  1. Un client se connecte au serveur
  2. Le joueur souhaite démarrer un nouveau lobby, clique sur un bouton pour créer un lobby
  3. Le client envoie l'instruction d'événement "client.lobby.create" au serveur
  4. Le serveur reçoit l'instruction, il peut effectuer toute vérification et validation pour créer ce lobby (peut-être voulons-nous que seuls les administrateurs créent des lobbies ? Nous voulons vérifier si le client n'est pas dans un autre lobby ? etc...)
  5. Le serveur crée le lobby et envoie un événement de réussite

Eh bien, je suppose que nous pouvons commencer à mettre cela en œuvre !

En partant de l'avant :

const onCreateLobby = (mode : 'solo' | 'duo') => {
sm.emit({
event : ClientEvents.LobbyCreate,
// Dans l'exemple de projet, vous pouvez jouer à des jeux en duo ou en solo
data : {
mode : mode,
delayBetweenRounds : delayBetweenRounds,
},
});
};

Passerelle du serveur, avec un peu plus de code, y compris l'abonné pour la création du lobby :

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

afterInit(server: Server): any
{
// Passer l'instance du serveur aux gestionnaires
this.lobbyManager.server = server;

this.logger.log('Serveur de jeu initialisé !');
}

async handleConnection(client: Socket, ...args: any[]): Promise<void>
{
// Appeler les initialiseurs pour configurer le socket
this.lobbyManager.initializeSocket(client as AuthenticatedSocket); }

async handleDisconnect(client: AuthenticatedSocket): Promise<void>
{
// Gérer la terminaison du 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',
},
};
}
}

Notre 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;
}
}

Et nous avons créé un lobby ! :D

Attendez, c'est quoi un "Lobby" ? Je ne sais toujours pas ce qu'il y a dedans...

Eh bien, c'est assez instructif oui, un lobby est aussi une classe, il est responsable du regroupement des clients, de leur gestion et également de l'envoi d'événements à ses clients, voyons ce qu'il y a à l'intérieur !

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: autre logique

this.dispatchLobbyState();
}

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

// TODO: autre logique

this.dispatchLobbyState();
}

public dispatchLobbyState(): void
{
// TODO: Comment un lobby est-il représenté aux clients ?
}

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

Ici, nous voyons plusieurs choses, passons-les en revue ;

  • Je déclare une propriété id, c'est logique, nous voulons identifier nos lobbies.* Je déclare une propriété createdAt, elle sera utilisée plus tard par le LobbyManager, pour nettoyer les lobbies.

  • Je déclare une propriété clients, elle contient une carte de chaque client associé à ce lobby.

  • Je déclare une propriété instance, celle-ci est une autre classe, il s'agit en fait de l'implémentation du jeu, je la différencie du lobby puisque le lobby est censé gérer les clients et les opérations de répartition d'état, mais la logique du jeu réelle se trouve dans la classe Instance. (Cette approche rend également votre code plus réutilisable, respectez le principe SRP, il est plus facile de sortir ce morceau de code pour d'autres projets également)

  • Vous pouvez remarquer que dans le constructeur, je déclare deux propriétés, une pour passer le serveur WebSocket (à partir du LobbyManager, rappelez-vous que cela est nécessaire car le lobby devra envoyer des messages aux salles Socket.IO, et aussi un maxClients qui est, comme son nom l'indique, le nombre maximal de clients pour ce lobby.

  • Je déclare deux méthodes addClient et removeClient, je pense que vous devinez ce qu'elles font. N'hésitez pas à y attacher une logique personnalisée si vous en avez besoin ; si quelqu'un rejoint le lobby, vous souhaitez peut-être alerter les autres joueurs ? idem si quelqu'un part ?

  • Enfin, je déclare deux dernières méthodes ; dispatchLobbyState et dispatchToLobby, la dernière est utilisée pour envoyer des messages aux joueurs dans le lobby, la précédente je l'utilise pour récupérer automatiquement les informations de base sur le lobby à envoyer aux joueurs (comme le nombre de joueurs connectés, la progression de l'instance, ...) tout ce que vous voulez.

Vous pouvez trouver l'implémentation exacte de ce fichier ici.

De plus, pour être explicite sur ce qui se passe ici ; le client donne l'instruction de créer un lobby, le serveur exécute et ajoute le client directement à ce lobby, donc le client est dans un lobby, et comme côté client vous écoutiez si le client était dans un lobby ou non, son affichage s'est mis à jour automatiquement !

Pour vous donner un petit exercice, implémentez vous-même le comportement "rejoindre un lobby", c'est un excellent point de départ pour jouer avec chaque domaine que nous avons vu auparavant. (client, api, serveur, passerelle, lobby) Bien sûr, si vous ne le souhaitez pas, vous pouvez toujours consulter l'exemple pour voir comment je l'ai fait.

C'est beaucoup à digérer jusqu'à présent, n'hésitez pas à parcourir le projet d'exemple pour voir comment c'est fait. Si vous voulez faire une pause, allez-y, j'attendrai. :)


Implémentation d'instance

Tu es revenu ? Super ! On peut continuer alors, il n'y a plus grand chose à voir, promis !

L'implémentation d'instance est le jeu lui-même, mais on ne s'en soucie pas tant que ça pour être honnête, pour le projet d'exemple c'est un jeu vraiment simple pour entraîner sa mémoire, rien de fantaisiste, la seule chose dont je voulais parler c'est ses interactions avec les clients (joueurs) et le lobby.

Dans le projet d'exemple, les joueurs ne peuvent faire qu'une seule action, qui consiste à révéler des cartes, voyons comment il gère cela.

Côté client, on utilise la même interface, en passant l'index de la carte en données pour dire au serveur laquelle on a révélée.

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

Ensuite, dans la passerelle, nous écouterons cet événement :

@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, 'Vous n'êtes pas dans un lobby'); }

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

Comme vous pouvez le voir, depuis l'abonné, je vérifie d'abord si le client est dans un lobby, si ce n'est pas le cas, je renvoie une erreur. J'aurais pu ne pas renvoyer cette erreur et simplement faire :

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

Mais à des fins de démonstration, il est intéressant de montrer comment vous pouvez générer des erreurs. (Celles-ci sont gérées et affichées aux utilisateurs finaux grâce à SocketManager.ts, mais vous pouvez ajuster ce comportement pour les gérer vous-même)

Ensuite, depuis la classe Instance :

export class Instance
{
public revealCard(cardIndex: number, client: AuthenticatedSocket): void
{
// logique de jeu

this.lobby.dispatchLobbyState();
}
}

Vous pouvez voir que nous passons la variable cardIndex et l'objet client, la première consiste à identifier quelle carte doit être révélée, le client doit savoir qui a effectué l'action. Une fois toute la logique du jeu exécutée, nous faisons un appel à notre méthode Lobby.dispatchLobbyState(), cela garantira que les clients obtiennent un état mis à jour du jeu.

Si vous êtes curieux de connaître l'implémentation réelle du jeu, vous pouvez la trouver ici.

Et c'est à peu près tout, vous savez maintenant comment créer un simple jeu multijoueur en ligne basé sur le lobby ! Je vous invite à consulter exemple de projet pour voir comment il est implémenté, le peaufiner, le casser et jouer avec.

Bien sûr, l'exemple de projet reste très simple, mais il ne tient qu'à vous de créer des jeux et des applications plus complexes et plus intéressantes, vous pouvez jouer avec des timers, des intervalles, interagir avec une base de données, il n'y a pas beaucoup de limite ! :)

Gestion des lobbies, partie 3

Vous parliez du nettoyage des lobbies, que fait-il exactement ?

NodeJS est un outil intelligent, il gère automatiquement la mémoire pour vous (et pour moi), mais dans certains cas, vous devez faire attention.

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

Ici, nous avons déclaré une carte pour garder une trace des lobbies, mais cela signifie également que nous gardons des références à tous ces objets de lobbies.

NodeJS allouera de plus en plus de mémoire à chaque fois qu'il en aura besoin, jusqu'à ce que vous n'ayez plus assez de mémoire (plus de mémoire). Mais NodeJS gère également la mémoire pour vous, il "détectera" ce qui n'est plus utilisé et le "ramasse-miettes".

Voici une introduction étonnante sur le ramasse-miettes (en Ruby, mais le concept est à peu près le même dans n'importe quel langage)

Mais comme vous l'avez vu, notre carte est utilisée pour référencer les lobbies, alors que se passerait-il si les joueurs lançaient des millions de lobbies ? Vous devrez nettoyer (supprimer) périodiquement les lobbies pour que le ramasse-miettes voie que vous ne les référencez plus, donc, ramasse-miettes ceux-ci.

Pour gérer cela, nous nous appuierons sur les planificateurs de tâches NestJS. Je vous laisse installer les packages nécessaires et configurer en conséquence.

Ensuite, c'est vraiment simple ;

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

// Nettoyer périodiquement les 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);
}
}
}
}

Nous demandons à NestJS d'exécuter cette méthode toutes les 5 minutes grâce à l'annotation @Cron(), qui exécutera une vérification sur chaque lobby pour voir si la durée de vie du lobby est dépassée ou non, si c'est le cas, nous supprimons le lobby de la carte, ce qui fait qu'il n'y fait plus référence.

Cette implémentation est vraiment simple mais fonctionne bien, il vous suffit de trouver quelle est la bonne valeur de durée de vie du lobby.

Bien sûr, en fonction de votre jeu/application, vous devrez peut-être changer totalement la façon dont vous gérez les lobbies, peut-être les stocker dans une base de données et les maintenir en vie uniquement lorsque les joueurs y sont présents. C'est à vous de décider, assurez-vous simplement de surveiller les ressources de vos services.

Nous n'avons pas non plus abordé la reconnexion, le projet d'exemple reste très simple, mais pour vous donner un indice à ce sujet, vous pouvez joindre vos identifiants d'utilisateur au lobby et vérifier lors de la connexion s'ils sont déjà membres d'un lobby pour les y rattacher.

Conclusion

C'était un long article, beaucoup de choses à passer en revue et à digérer, mais vous êtes arrivé à la fin, félicitations ! :)

Je n'ai pas abordé les aspects du déploiement car ce n'est pas la partie la plus intéressante, mais si vous êtes curieux, vous pouvez consulter l'exemple de projet. Il fournit des configurations de conteneurs Docker de base.

Vous êtes libre de réutiliser ma mise en œuvre, de l'ajuster ou de vous en inspirer. Cela pourrait nécessiter des ajustements en fonction de votre cas d'utilisation personnel. Gardez également à l'esprit que cette mise en œuvre de jeu ne convient pas au modèle de boucle de jeu (https://gameprogrammingpatterns.com/game-loop.html) que vous verriez dans des moteurs de jeu comme Unreal ou Unity. Ici, le jeu réagit aux entrées des utilisateurs (ou à vos propres minuteries que vous pouvez implémenter côté serveur) et ne continue pas de lui-même. Selon vos besoins, vous pourriez avoir besoin d'implémenter une boucle de jeu (par exemple, si vous voulez créer un monde où les joueurs peuvent se déplacer et interagir).

Avoir utilisé une approche orientée objet lourd avec un framework orienté opinion comme NestJS pour gérer côté serveur a plutôt bien fonctionné, et j'ai pu ainsi mettre à l'échelle et gérer des projets beaucoup plus importants de cette manière. Si vous avez déjà implémenté de telles applications/jeux, je suis curieux d'obtenir vos commentaires !

Une dernière note : ne faites jamais confiance ou ne validez jamais les entrées des utilisateurs côté client. Prenez toujours des entrées de base des clients et validez-les à côté serveur. De cette façon, les joueurs ne peuvent pas tricher (du moins pas trop facilement).

Merci de l'avoir lu et passez une bonne journée !

Read more

Créons ensemble

Naviguer

Connecter

Bureau

1%