Comment créer un jeu multi-utilisateurs en temps réel à partir de zéro

Publié: 2022-03-10
Résumé rapide ↬ Cet article met en lumière le processus, les décisions techniques et les leçons apprises derrière la construction du jeu en temps réel Autowuzzler. Apprenez à partager l'état du jeu sur plusieurs clients en temps réel avec Colyseus, effectuez des calculs physiques avec Matter.js, stockez des données dans Supabase.io et créez le front-end avec SvelteKit.

Alors que la pandémie persistait, l'équipe soudainement éloignée avec laquelle je travaille est devenue de plus en plus privée de baby-foot. J'ai réfléchi à la façon de jouer au baby-foot dans un environnement éloigné, mais il était clair que simplement reconstruire les règles du baby-foot sur un écran ne serait pas très amusant.

Ce qui est amusant, c'est de taper dans un ballon en utilisant des petites voitures - une réalisation faite alors que je jouais avec mon enfant de 2 ans. La même nuit, j'ai entrepris de construire le premier prototype d'un jeu qui allait devenir Autowuzzler .

L'idée est simple : les joueurs dirigent des voitures miniatures virtuelles dans une arène de haut en bas qui ressemble à un baby-foot. La première équipe à marquer 10 buts gagne.

Bien sûr, l'idée d'utiliser des voitures pour jouer au football n'est pas unique, mais deux idées principales devraient différencier Autowuzzler : je voulais reconstruire une partie de l'apparence et de la sensation de jouer sur un baby-foot physique, et je voulais m'assurer que c'est aussi facile que possible d'inviter des amis ou des coéquipiers à un jeu occasionnel rapide.

Dans cet article, je décrirai le processus derrière la création d'Autowuzzler , les outils et les frameworks que j'ai choisis, et je partagerai quelques détails de mise en œuvre et les leçons que j'ai apprises.

Interface utilisateur du jeu montrant un arrière-plan de table de baby-foot, six voitures dans deux équipes et un ballon.
Autowuzzler (bêta) avec six joueurs simultanés dans deux équipes. ( Grand aperçu )

Premier prototype fonctionnel (terrible)

Le premier prototype a été construit à l'aide du moteur de jeu open source Phaser.js, principalement pour le moteur physique inclus et parce que j'en avais déjà une certaine expérience. L'étape du jeu était intégrée dans une application Next.js, encore une fois parce que j'avais déjà une solide compréhension de Next.js et que je voulais me concentrer principalement sur le jeu.

Comme le jeu doit prendre en charge plusieurs joueurs en temps réel , j'ai utilisé Express en tant que courtier WebSockets. C'est là que ça devient délicat, cependant.

Étant donné que les calculs physiques étaient effectués sur le client dans le jeu Phaser, j'ai choisi une logique simple, mais évidemment erronée : le premier client connecté avait le privilège douteux de faire les calculs physiques pour tous les objets du jeu, en envoyant les résultats au serveur express, qui à son tour a diffusé les positions, angles et forces mis à jour aux clients de l'autre joueur. Les autres clients appliqueraient alors les modifications aux objets du jeu.

Cela a conduit à la situation où le premier joueur a pu voir la physique se produire en temps réel (cela se passe localement dans son navigateur, après tout), tandis que tous les autres joueurs étaient en retard d'au moins 30 millisecondes (le taux de diffusion que j'ai choisi ), ou - si la connexion réseau du premier joueur était lente - bien pire.

Si cela vous semble être une mauvaise architecture, vous avez tout à fait raison. Cependant, j'ai accepté ce fait en faveur d'obtenir rapidement quelque chose de jouable pour déterminer si le jeu est réellement amusant à jouer.

Valider l'idée, vider le prototype

Aussi imparfaite que soit la mise en œuvre, elle était suffisamment jouable pour inviter des amis à un premier essai routier. Les commentaires ont été très positifs , la principale préoccupation étant - sans surprise - les performances en temps réel. D'autres problèmes inhérents incluaient la situation où le premier joueur (rappelez-vous, celui en charge de tout ) a quitté le jeu - qui devrait prendre le relais ? À ce stade, il n'y avait qu'une seule salle de jeu, donc n'importe qui rejoindrait le même jeu. J'étais également un peu préoccupé par la taille du bundle introduite par la bibliothèque Phaser.js.

Il était temps de jeter le prototype et de commencer avec une nouvelle configuration et un objectif clair.

Configuration du projet

De toute évidence, l'approche « le premier client règle tout » devait être remplacée par une solution dans laquelle l'état du jeu vit sur le serveur . Dans mes recherches, je suis tombé sur Colyseus, qui semblait être l'outil parfait pour le travail.

Pour les autres blocs de construction principaux du jeu, j'ai choisi :

  • Matter.js en tant que moteur physique au lieu de Phaser.js car il s'exécute dans Node et Autowuzzler ne nécessite pas de cadre de jeu complet.
  • SvelteKit en tant que framework d'application au lieu de Next.js, car il vient de passer en version bêta publique à ce moment-là. (En plus : j'adore travailler avec Svelte.)
  • Supabase.io pour stocker les codes PIN de jeu créés par l'utilisateur.

Examinons ces blocs de construction plus en détail.

Plus après saut! Continuez à lire ci-dessous ↓

État de jeu synchronisé et centralisé avec Colyseus

Colyseus est un framework de jeu multijoueur basé sur Node.js et Express. À la base, il fournit :

  • Synchronisation de l'état entre les clients d'une manière faisant autorité ;
  • Communication efficace en temps réel à l'aide de WebSockets en envoyant uniquement les données modifiées ;
  • Configurations multi-pièces ;
  • Bibliothèques clientes pour JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3 ;
  • Crochets de cycle de vie, par exemple, la salle est créée, les jonctions d'utilisateurs, les départs d'utilisateurs, etc.
  • Envoi de messages, soit sous forme de messages diffusés à tous les utilisateurs de la salle, soit à un seul utilisateur ;
  • Un panneau de surveillance intégré et un outil de test de charge.

Remarque : La documentation Colyseus facilite la prise en main d'un serveur barebones Colyseus en fournissant un script d' npm init et un référentiel d'exemples.

Création d'un schéma

L'entité principale d'une application Colyseus est la salle de jeu, qui contient l'état d'une instance de salle unique et de tous ses objets de jeu. Dans le cas d' Autowuzzler , il s'agit d'une session de jeu avec :

  • deux equipes,
  • un nombre fini de joueurs,
  • une balle.

Un schéma doit être défini pour toutes les propriétés des objets du jeu qui doivent être synchronisés entre les clients . Par exemple, nous voulons que la balle se synchronise, et nous devons donc créer un schéma pour la balle :

 class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });

Dans l'exemple ci-dessus, une nouvelle classe qui étend la classe de schéma fournie par Colyseus est créée ; dans le constructeur, toutes les propriétés reçoivent une valeur initiale. La position et le mouvement de la balle sont décrits à l'aide des cinq propriétés : x , y , angle , velocityX, velocityY . De plus, nous devons spécifier les types de chaque propriété . Cet exemple utilise la syntaxe JavaScript, mais vous pouvez également utiliser la syntaxe TypeScript légèrement plus compacte.

Les types de propriétés peuvent être soit des types primitifs :

  • string
  • boolean
  • number (ainsi que des types entiers et flottants plus efficaces)

ou types complexes :

  • ArraySchema (similaire à Array en JavaScript)
  • MapSchema (similaire à Map en JavaScript)
  • SetSchema (similaire à Set en JavaScript)
  • CollectionSchema (similaire à ArraySchema, mais sans contrôle sur les index)

La classe Ball ci-dessus a cinq propriétés de type number : ses coordonnées ( x , y ), son angle courant et le vecteur velocityX ( VelocityX , velocityY ).

Le schéma pour les joueurs est similaire, mais inclut quelques propriétés supplémentaires pour stocker le nom du joueur et le numéro de l'équipe, qui doivent être fournis lors de la création d'une instance Player :

 class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });

Enfin, le schéma de la Room Autowuzzler connecte les classes précédemment définies : une instance de salle a plusieurs équipes (stockées dans un ArraySchema). Il contient également une seule balle, nous créons donc une nouvelle instance Ball dans le constructeur de RoomSchema. Les joueurs sont stockés dans un MapSchema pour une récupération rapide à l'aide de leurs identifiants.

 class RoomSchema extends Schema { constructor() { super(); this.teams = new ArraySchema(); this.ball = new Ball(); this.players = new MapSchema(); } } defineTypes(RoomSchema, { teams: [Team], // an Array of Team ball: Ball, // a single Ball instance players: { map: Player } // a Map of Players });
Remarque : La définition de la classe Team est omise.

Configuration multi-pièces ("Match-Making")

N'importe qui peut rejoindre un jeu Autowuzzler s'il possède un code PIN de jeu valide. Notre serveur Colyseus crée une nouvelle instance de salle pour chaque session de jeu dès que le premier joueur rejoint et abandonne la salle lorsque le dernier joueur la quitte.

Le processus d' affectation des joueurs à la salle de jeu souhaitée est appelé "match-making". Colyseus le rend très simple à mettre en place en utilisant la méthode filterBy lors de la définition d'une nouvelle pièce :

 gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);

Désormais, tous les joueurs rejoignant le jeu avec le même gamePIN (nous verrons comment « rejoindre » plus tard) se retrouveront dans la même salle de jeu ! Toutes les mises à jour d'état et autres messages diffusés sont limités aux joueurs dans la même pièce.

Physique dans une application Colyseus

Colyseus fournit de nombreux éléments prêts à l'emploi pour être rapidement opérationnel avec un serveur de jeu faisant autorité, mais laisse au développeur le soin de créer les mécanismes de jeu réels, y compris la physique. Phaser.js, que j'ai utilisé dans le prototype, ne peut pas être exécuté dans un environnement sans navigateur, mais le moteur physique intégré Matter.js de Phaser.js peut s'exécuter sur Node.js.

Avec Matter.js, vous définissez un monde physique avec certaines propriétés physiques comme sa taille et sa gravité. Il fournit plusieurs méthodes pour créer des objets physiques primitifs qui interagissent les uns avec les autres en adhérant aux lois (simulées) de la physique, y compris la masse, les collisions, le mouvement avec frottement, etc. Vous pouvez déplacer des objets en appliquant une force , comme vous le feriez dans le monde réel.

Un « monde » Matter.js est au cœur du jeu Autowuzzler ; il définit la vitesse à laquelle les voitures se déplacent, le rebond du ballon, l'emplacement des buts et ce qui se passe si quelqu'un tire un but.

 let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);

Code simplifié pour ajouter un objet de jeu "balle" à la scène dans Matter.js.

Une fois les règles définies, Matter.js peut s'exécuter avec ou sans rendu réel sur un écran. Pour Autowuzzler , j'utilise cette fonctionnalité pour réutiliser le code du monde physique à la fois pour le serveur et le client — avec plusieurs différences clés :

Monde physique sur le serveur :

  • reçoit les entrées de l'utilisateur (événements du clavier pour diriger une voiture) via Colyseus et applique la force appropriée sur l'objet du jeu (la voiture de l'utilisateur) ;
  • effectue tous les calculs physiques pour tous les objets (joueurs et ballon), y compris la détection des collisions ;
  • communique l'état mis à jour de chaque objet de jeu à Colyseus, qui à son tour le diffuse aux clients ;
  • est mis à jour toutes les 16,6 millisecondes (= 60 images par seconde), déclenché par notre serveur Colyseus.

Monde physique sur le client :

  • ne manipule pas directement les objets du jeu ;
  • reçoit l'état mis à jour pour chaque objet de jeu de Colyseus ;
  • applique les changements de position, de vitesse et d'angle après avoir reçu l'état mis à jour ;
  • envoie les entrées de l'utilisateur (événements du clavier pour diriger une voiture) à Colyseus ;
  • charge les sprites du jeu et utilise un moteur de rendu pour dessiner le monde physique sur un élément de canevas ;
  • saute la détection de collision (en utilisant l'option isSensor pour les objets);
  • mises à jour en utilisant requestAnimationFrame, idéalement à 60 fps.
Schéma montrant deux blocs principaux : Colyseus Server App et SvelteKit App. L'application Colyseus Server contient le bloc Autowuzzler Room, l'application SvelteKit contient le bloc Colyseus Client. Les deux blocs principaux partagent un bloc nommé Physics World (Matter.js)
Principales unités logiques de l'architecture Autowuzzler : le monde physique est partagé entre le serveur Colyseus et l'application cliente SvelteKit. ( Grand aperçu )

Maintenant, avec toute la magie qui se produit sur le serveur, le client ne gère que l'entrée et dessine l'état qu'il reçoit du serveur vers l'écran. A une exception près :

Interpolation sur le client

Puisque nous réutilisons le même monde physique Matter.js sur le client, nous pouvons améliorer les performances expérimentées avec une simple astuce. Plutôt que de mettre à jour uniquement la position d'un objet de jeu, nous synchronisons également la vitesse de l'objet . De cette façon, l'objet continue de se déplacer sur sa trajectoire même si la prochaine mise à jour du serveur prend plus de temps que d'habitude. Ainsi, plutôt que de déplacer des objets par étapes discrètes de la position A à la position B, nous modifions leur position et les faisons se déplacer dans une certaine direction.

Cycle de la vie

La classe Autowuzzler Room est l'endroit où la logique concernée par les différentes phases d'une salle Colyseus est gérée. Colyseus fournit plusieurs méthodes de cycle de vie :

  • onCreate : lorsqu'une nouvelle salle est créée (généralement lorsque le premier client se connecte) ;
  • onAuth : comme crochet d'autorisation pour autoriser ou refuser l'entrée dans la salle ;
  • onJoin : lorsqu'un client se connecte à la salle ;
  • onLeave : lorsqu'un client se déconnecte de la salle ;
  • onDispose : lorsque la pièce est supprimée.

La salle Autowuzzler crée une nouvelle instance du monde de la physique (voir la section "La physique dans une application Colyseus") dès sa création ( onCreate ) et ajoute un joueur au monde lorsqu'un client se connecte ( onJoin ). Il met ensuite à jour le monde physique 60 fois par seconde (toutes les 16,6 millisecondes) en utilisant la méthode setSimulationInterval (notre boucle de jeu principale) :

 // deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));

Les objets physiques sont indépendants des objets Colyseus, ce qui nous laisse avec deux permutations du même objet de jeu (comme la balle), c'est-à-dire un objet dans le monde physique et un objet Colyseus qui peut être synchronisé.

Dès que l'objet physique change, ses propriétés mises à jour doivent être appliquées à l'objet Colyseus. Nous pouvons y parvenir en écoutant l'événement afterUpdate de afterUpdate et en définissant les valeurs à partir de là :

 Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })

Il y a une autre copie des objets dont nous devons nous occuper : les objets du jeu dans le jeu en contact avec l'utilisateur .

Diagramme montrant les trois versions d'un objet de jeu : Colyseus Schema Objects, Matter.js Physics Objects, Client Matter.js Physics Objects. Matter.js met à jour la version Colyseus de l'objet, Colyseus se synchronise avec l'objet physique Matter.js du client.
Autowuzzler conserve trois copies de chaque objet physique, une version faisant autorité (objet Colyseus), une version dans le monde physique Matter.js et une version sur le client. ( Grand aperçu )

Application côté client

Maintenant que nous avons une application sur le serveur qui gère la synchronisation de l'état du jeu pour plusieurs salles ainsi que les calculs physiques, concentrons-nous sur la construction du site Web et de l'interface de jeu proprement dite . L'interface Autowuzzler a les responsabilités suivantes :

  • permet aux utilisateurs de créer et de partager des codes PIN de jeu pour accéder à des salles individuelles ;
  • envoie les codes PIN de jeu créés à une base de données Supabase pour la persistance ;
  • fournit une page facultative "Rejoindre un jeu" pour que les joueurs saisissent le code PIN du jeu ;
  • valide les codes PIN du jeu lorsqu'un joueur rejoint un jeu ;
  • héberge et rend le jeu réel sur une URL partageable (c'est-à-dire unique);
  • se connecte au serveur Colyseus et gère les mises à jour d'état ;
  • fournit une page de destination ("marketing").

Pour la mise en œuvre de ces tâches, j'ai choisi SvelteKit plutôt que Next.js pour les raisons suivantes :

Pourquoi SvelteKit ?

Je voulais développer une autre application en utilisant Svelte depuis que j'ai construit neolightsout. Lorsque SvelteKit (le cadre d'application officiel de Svelte) est entré en version bêta publique, j'ai décidé de construire Autowuzzler avec et d'accepter tous les maux de tête liés à l'utilisation d'une nouvelle version bêta - la joie d'utiliser Svelte le compense clairement.

Ces fonctionnalités clés m'ont fait choisir SvelteKit plutôt que Next.js pour l'implémentation réelle de l'interface du jeu :

  • Svelte est un framework d'interface utilisateur et un compilateur et fournit donc un code minimal sans runtime client ;
  • Svelte a un langage de modèles expressif et un système de composants (préférence personnelle);
  • Svelte inclut des magasins globaux, des transitions et des animations prêtes à l'emploi, ce qui signifie : pas de fatigue décisionnelle en choisissant une boîte à outils de gestion d'état globale et une bibliothèque d'animations ;
  • Svelte prend en charge les CSS délimités dans les composants à fichier unique ;
  • SvelteKit prend en charge SSR, un routage simple mais flexible basé sur des fichiers et des routes côté serveur pour créer une API ;
  • SvelteKit permet à chaque page d'exécuter du code sur le serveur, par exemple pour récupérer les données utilisées pour afficher la page ;
  • Dispositions partagées entre les itinéraires ;
  • SvelteKit peut être exécuté dans un environnement sans serveur.

Créer et stocker des codes PIN de jeu

Avant qu'un utilisateur puisse commencer à jouer au jeu, il doit d'abord créer un code PIN de jeu. En partageant le code PIN avec d'autres, ils peuvent tous accéder à la même salle de jeux.

Capture d'écran de la section Démarrer un nouveau jeu du site Web Autowuzzler montrant le code PIN 751428 du jeu et les options permettant de copier et de partager le code PIN et l'URL du jeu.
Commencez une nouvelle partie en copiant le code PIN du jeu généré ou partagez le lien direct vers la salle de jeux. ( Grand aperçu )

Il s'agit d'un excellent cas d'utilisation pour les points de terminaison côté serveur SvelteKits en conjonction avec la fonction Sveltes onMount : le point de terminaison /api/createcode génère un code PIN de jeu, le stocke dans une base de données Supabase.io et génère le code PIN du jeu en réponse . Cette réponse est récupérée dès que le composant page de la page "créer" est monté :

Diagramme montrant trois sections : créer une page, créer un point de terminaison de code et Supabase.io. Créer une page récupère le point de terminaison dans sa fonction onMount, le point de terminaison génère un code PIN de jeu, le stocke dans Supabase.io et répond avec le code PIN du jeu. La page Créer affiche alors le code PIN du jeu.
Les codes PIN de jeu sont créés dans le terminal, stockés dans une base de données Supabase.io et affichés sur la page "Créer". ( Grand aperçu )

Stockage des codes PIN de jeu avec Supabase.io

Supabase.io est une alternative open source à Firebase. Supabase permet de créer très facilement une base de données PostgreSQL et d'y accéder soit via l'une de ses bibliothèques clientes, soit via REST.

Pour le client JavaScript, nous importons la fonction createClient et l'exécutons en utilisant les paramètres supabase_url et supabase_key que nous avons reçus lors de la création de la base de données. Pour stocker le code PIN du jeu créé à chaque appel au point de terminaison createcode , il nous suffit d'exécuter cette simple requête d' insert :

 import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);

Remarque : La supabase_url et supabase_key sont stockées dans un fichier .env. En raison de Vite - l'outil de construction au cœur de SvelteKit - il est nécessaire de préfixer les variables d'environnement avec VITE_ pour les rendre accessibles dans SvelteKit.

Accéder au jeu

Je voulais rendre la participation à un jeu Autowuzzler aussi simple que de suivre un lien. Par conséquent, chaque salle de jeu devait avoir sa propre URL basée sur le code PIN du jeu précédemment créé , par exemple https://autowuzzler.com/play/12345.

Dans SvelteKit, les pages avec des paramètres de route dynamiques sont créées en mettant les parties dynamiques de la route entre crochets lors du nommage du fichier de page : client/src/routes/play/[gamePIN].svelte . La valeur du paramètre gamePIN deviendra alors disponible dans le composant de la page (voir la documentation SvelteKit pour plus de détails). Dans le parcours de play , nous devons nous connecter au serveur Colyseus, instancier le monde physique pour le rendre à l'écran, gérer les mises à jour des objets du jeu, écouter les entrées au clavier et afficher d'autres interfaces utilisateur comme le score, etc.

Connexion à Colyseus et mise à jour de l'état

La bibliothèque client Colyseus nous permet de connecter un client à un serveur Colyseus. Commençons par créer un nouveau Colyseus.Client en le faisant pointer vers le serveur Colyseus ( ws://localhost:2567 en développement). Rejoignez ensuite la salle avec le nom que nous avons choisi plus tôt ( autowuzzler ) et le gamePIN du paramètre route. Le paramètre gamePIN s'assure que l'utilisateur rejoint la bonne instance de salle (voir « match-making » ci-dessus).

 let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });

Étant donné que SvelteKit rend initialement les pages sur le serveur, nous devons nous assurer que ce code ne s'exécute sur le client qu'une fois la page chargée. Encore une fois, nous utilisons la fonction de cycle de vie onMount pour ce cas d'utilisation. (Si vous connaissez React, onMount est similaire au crochet useEffect avec un tableau de dépendances vide.)

 onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })

Maintenant que nous sommes connectés au serveur de jeu Colyseus, nous pouvons commencer à écouter toute modification apportée à nos objets de jeu.

Voici un exemple de la façon d'écouter un joueur rejoignant la salle ( onAdd ) et de recevoir des mises à jour consécutives de l'état de ce joueur :

 this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };

Dans la méthode updatePlayer du monde de la physique, nous mettons à jour les propriétés une par une car onChange de onChange fournit un ensemble de toutes les propriétés modifiées.

Note : Cette fonction ne fonctionne que sur la version client du monde physique, car les objets du jeu ne sont manipulés qu'indirectement via le serveur Colyseus.

 updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }

La même procédure s'applique aux autres objets du jeu (ballon et équipes) : écoutez leurs modifications et appliquez les valeurs modifiées au monde physique du client.

Jusqu'à présent, aucun objet ne bouge car nous devons toujours écouter la saisie au clavier et l'envoyer au serveur . Au lieu d'envoyer directement des événements sur chaque événement de keydown , nous maintenons une carte des touches actuellement enfoncées et envoyons des événements au serveur Colyseus dans une boucle de 50 ms. De cette façon, nous pouvons prendre en charge l'appui sur plusieurs touches en même temps et atténuer la pause qui se produit après le premier événement et les suivants lorsque la touche reste keydown :

 let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);

Maintenant, le cycle est terminé : écoutez les frappes, envoyez les commandes correspondantes au serveur Colyseus pour manipuler le monde physique sur le serveur. Le serveur Colyseus applique ensuite les nouvelles propriétés physiques à tous les objets du jeu et propage les données vers le client pour mettre à jour l'instance du jeu destinée à l'utilisateur.

Nuisances mineures

Rétrospectivement, deux choses de la catégorie personne ne m'a dit mais quelqu'un aurait dû me venir à l'esprit :

  • Une bonne compréhension du fonctionnement des moteurs physiques est bénéfique. J'ai passé un temps considérable à peaufiner les propriétés et les contraintes physiques. Même si j'ai déjà créé un petit jeu avec Phaser.js et Matter.js, il y a eu beaucoup d'essais et d'erreurs pour faire bouger les objets comme je les imaginais.
  • Le temps réel est difficile , en particulier dans les jeux basés sur la physique. Des retards mineurs aggravent considérablement l'expérience, et bien que la synchronisation de l'état entre les clients avec Colyseus fonctionne très bien, elle ne peut pas supprimer les retards de calcul et de transmission.

Pièges et mises en garde avec SvelteKit

Depuis que j'ai utilisé SvelteKit quand il sortait tout juste du four bêta, il y avait quelques pièges et mises en garde que je voudrais souligner :

  • Il a fallu un certain temps pour comprendre que les variables d'environnement doivent être préfixées par VITE_ afin de les utiliser dans SvelteKit. Ceci est maintenant correctement documenté dans la FAQ.
  • Pour utiliser Supabase, j'ai dû ajouter Supabase aux listes de dependencies et de devDependencies de package.json. Je crois que ce n'est plus le cas.
  • La fonction de load SvelteKits s'exécute à la fois sur le serveur et sur le client !
  • Pour activer le remplacement complet du module à chaud (y compris la préservation de l'état), vous devez ajouter manuellement une ligne de commentaire <!-- @hmr:keep-all --> dans les composants de votre page. Voir FAQ pour plus de détails.

De nombreux autres frameworks auraient également été parfaits, mais je ne regrette pas d'avoir choisi SvelteKit pour ce projet. Cela m'a permis de travailler sur l'application cliente de manière très efficace - principalement parce que Svelte lui-même est très expressif et saute une grande partie du code passe-partout, mais aussi parce que Svelte a des choses comme des animations, des transitions, des CSS étendus et des magasins mondiaux intégrés. SvelteKit a fourni tous les éléments de base dont j'avais besoin (SSR, routage, routes de serveur) et bien qu'encore en version bêta, il était très stable et rapide.

Déploiement et hébergement

Au départ, j'hébergeais le serveur Colyseus (Node) sur une instance Heroku et j'ai perdu beaucoup de temps à faire fonctionner WebSockets et CORS. Il s'avère que les performances d'un petit dyno Heroku (gratuit) ne sont pas suffisantes pour un cas d'utilisation en temps réel. J'ai ensuite migré l'application Colyseus vers un petit serveur chez Linode. L'application côté client est déployée et hébergée sur Netlify via l'adaptateur SvelteKits-netlify. Pas de surprise ici : Netlify a très bien fonctionné !

Conclusion

Commencer avec un prototype vraiment simple pour valider l'idée m'a beaucoup aidé à déterminer si le projet vaut la peine d'être suivi et où se situent les défis techniques du jeu. Dans la mise en œuvre finale, Colyseus s'est occupé de tout le gros du travail de synchronisation de l'état en temps réel sur plusieurs clients, répartis dans plusieurs pièces. Il est impressionnant de voir à quelle vitesse une application multi-utilisateurs en temps réel peut être construite avec Colyseus - une fois que vous avez compris comment décrire correctement le schéma. Le panneau de surveillance intégré de Colyseus aide à résoudre les problèmes de synchronisation.

Ce qui compliquait cette configuration était la couche physique du jeu car elle introduisait une copie supplémentaire de chaque objet de jeu lié à la physique qui devait être maintenu. Le stockage des codes PIN de jeu dans Supabase.io à partir de l'application SvelteKit était très simple. Avec le recul, j'aurais pu simplement utiliser une base de données SQLite pour stocker les codes PIN du jeu, mais essayer de nouvelles choses est la moitié du plaisir lors de la création de projets parallèles.

Enfin, l'utilisation de SvelteKit pour créer l'interface du jeu m'a permis d'avancer rapidement - et avec un sourire de joie occasionnel sur mon visage.

Maintenant, allez-y et invitez vos amis à une partie d'Autowuzzler !

Lectures complémentaires sur Smashing Magazine

  • "Commencez avec React en créant un jeu Whac-A-Mole", Jhey Tompkins
  • "Comment créer un jeu de réalité virtuelle multijoueur en temps réel", Alvin Wan
  • "Écrire un moteur d'aventure textuelle multijoueur dans Node.js", Fernando Doglio
  • "L'avenir de la conception de sites Web mobiles : conception de jeux vidéo et narration", Suzanne Scacca
  • "Comment créer un jeu de course sans fin en réalité virtuelle", Alvin Wan