Nous avons récemment dû construire quelques jeux éducatifs en ligne (navigateur) pour un client. Les projets étaient passionnants, mais il existe peu de ressources pour apprendre à réaliser de tels projets. Après en avoir créé plusieurs, livrés et joués par les utilisateurs, je partage ici la pile technologique et l'architecture que j'ai utilisées.
Vous pouvez découvrir un exemple de jeu simple sur https://memory-cards.mazz.lol/ et son dépôt sur https://github.com/Mazzzoni/memory-cards. Cet article suppose une compréhension de base de TypeScript, React, des WebSockets et de la programmation orientée objet.
Contexte
Ces jeux nécessitaient de gérer plusieurs joueurs dans le même lobby, une communication en temps réel et un état synchronisé. J'ai écarté PHP (mon langage principal) au profit d'un langage au paradigme asynchrone mature. Ayant déjà utilisé NodeJS pour des scrapers et des bots, je savais qu'il gérait bien l'asynchrone, et TypeScript est facile à manipuler. Côté client, je suis un grand fan de React.
La pile
- Yarn Workspaces pour gérer client et serveur dans un monorepo et partager du code entre eux.
- Next.js / React pour l'application client. La liaison de données sur une connexion WebSocket permet un jeu parfaitement synchronisé. UI avec Tailwind et Mantine.
- NestJS / NodeJS côté serveur pour gérer les instances de jeu et les interactions clients. Avantages de NodeJS sur Go ici : pas besoin de boucle de jeu à faible latence (jeux de société), partage de code TypeScript client/serveur, et un paradigme orienté objet familier.
- Socket.IO par-dessus les WebSockets : reconnexion, gestion des salles, repli sur long polling.
- Docker pour le déploiement (images Alpine NodeJS).
Construisons
On commence par le monorepo, en initiant un projet Yarn avec des workspaces :
{
"name": "@mazz/memory-cards",
"private": true,
"version": "1.0.0",
"license": "MIT",
"workspaces": {
"packages": ["workspaces/*"]
}
}On ajoute trois sous-projets dans le dossier workspaces : client (Next.js), server (NestJS) et shared (code partagé par les deux). On déclare le workspace shared comme dépendance du client et du serveur.
Gestion des WebSockets
Dans NestJS, une passerelle (gateway) est comme un contrôleur MVC : on y déclare des abonnés aux messages, appelés lorsque le client envoie le message correspondant. Pour typer les événements, j'utilise des énumérations de chaînes (ClientEvents, ServerEvents) que je relie à leur type de payload :
export type ServerPayloads = {
[ServerEvents.Pong]: {
message: string;
};
[ServerEvents.GameMessage]: {
// ...
};
};Sécurité et validation
Vous devrez probablement authentifier vos utilisateurs à la connexion, via l'interface OnGatewayConnection. Une fois authentifié, le client a un nouveau type :
export type AuthenticatedSocket = Socket & {
data: {
lobby: null | Lobby;
};
emit: <T>(...args: any[]) => void;
};Pour valider les données entrantes, NestJS permet une validation automatique via des DTO. Ceux-ci DOIVENT être des classes : TypeScript ne conserve pas les métadonnées des interfaces à la compilation. Validez toujours les entrées utilisateur.
Un peu de théorie
Il existe plusieurs types de jeux : solo, multijoueur local, multijoueur en ligne (notre cas, avec serveur et protocoles persistants) et MMO. Pour notre jeu par navigateur, nous avons un serveur, des clients et un moyen de communiquer en temps réel. Il reste à implémenter les lobbies.
Gestion des lobbies
Pour séparer chaque partie, on introduit un LobbyManager qui gère les lobbies :
export class LobbyManager {
public server: Server;
private readonly lobbies: Map<Lobby['id'], Lobby> = new Map();
// initializeSocket / terminateSocket / createLobby / joinLobby / lobbiesCleaner
}Le chemin pour créer un lobby : le client se connecte, clique pour créer un lobby, envoie l'événement client.lobby.create, le serveur valide puis crée le lobby et renvoie l'événement de réussite.
Côté client (React), j'ai écrit une classe wrapper autour du client socket et un fournisseur de contexte pour accéder au socket partout, contrôler la connexion et attacher des écouteurs. Depuis un composant :
export default function Game() {
const { sm } = useSocketManager();
const onPing = () => {
sm.emit({ event: ClientEvents.Ping });
};
return <button onClick={onPing}>ping</button>;
}Pour les événements entrants, on déclare des écouteurs, et on les retire au démontage du composant pour éviter fuites et effets de bord :
useEffect(() => {
const onPong = (data) => showNotification({ message: data.message });
sm.registerListener(ServerEvents.Pong, onPong);
return () => sm.removeListener(ServerEvents.Pong, onPong);
}, []);Le Lobby est aussi une classe : il regroupe les clients, les gère et leur envoie des événements :
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();
}
}La logique de jeu réelle vit dans une classe Instance séparée (principe de responsabilité unique), ce qui rend le code plus réutilisable.
Nettoyage des lobbies
NodeJS gère la mémoire, mais notre Map garde des références aux lobbies : il faut donc les nettoyer périodiquement pour que le ramasse-miettes fasse son travail. Avec les planificateurs de tâches NestJS :
@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
C'était un long article, mais vous voilà au bout. Vous savez maintenant créer un jeu multijoueur en ligne simple basé sur un lobby. Cette implémentation ne convient pas au modèle de boucle de jeu des moteurs comme Unreal ou Unity : ici, le jeu réagit aux entrées des utilisateurs et ne tourne pas de lui-même. Une approche orientée objet avec NestJS côté serveur a très bien fonctionné pour passer à l'échelle.
Une dernière note : ne faites jamais confiance aux entrées des utilisateurs côté client. Validez toujours côté serveur, pour que les joueurs ne puissent pas tricher.
Le dépôt complet de l'exemple est disponible sur https://github.com/Mazzzoni/memory-cards. Merci de votre lecture.
Article écrit par François Steinel.