La force du temps réel : développement d'un jeu mobile de palet breton/vendéen multijoueur

Vincent Ogloblinsky - @vogloblinsky

Vincent Ogloblinsky

Compodoc maintainer

Architecte logiciel web / Référent Open-Source

Indie hacker on side-projects

Side projects récents

Linefest

Agenda

1.

Historique des jeux multijoueurs

2.

Le projet

3.

Moteurs physiques

4.

Les approches

5.

Implémentation

6.

Roadmap

Historique des jeux multijoueurs

Historique des jeux multijoueurs

1969 - Spacewar - via réseau PLATO (MIT)

PC - DOOM

Mega Drive / Super NES - via XBAND

1973 - Maze War - 2 Imlac PDS-1 reliés par un câble série

Historique des jeux multijoueurs - MMO

MMO - Massively Multiplayer Online

World of Warcraft (2004) et d'autres

Scène indépendante

Tous les univers sont représentés : stratégie/guerre, sport, aventure

E-sport

Le projet (initial)

Le projet initial

Proposer un jeu mobile en complément d'un livre sur le jeu de palet breton en 2016

 

Support du Département 35 et David Jeux

 

- gameplay en 1to1 (ordinateur)

 

- un décor

 

- un concours intégré lors du lancement

Le projet initial

La version 2024

La version 2024

Jeu local

Plusieurs décors

Mesureur

Jeu multijoueur local ou online

Contexte technique du jeu

Un objet 3D assez simple à lancer

Moteur physique

En local, pas de soucis

Une cible assez simple à viser

Et en réseau ?

Moteurs physiques

Moteurs physiques

Simulation de systèmes physiques / mécanique des solides (ou fluides)

Concepts à reproduire

- détection de collision

- gravité

- corps solides statiques ou dynamiques

Moteurs physiques

2 familles 

2D : box2D, Matter.js, p2.js, PhysicsJS, Rappier... 

Utilisent en backend une balise canvas, WebGL (Webworkers)

3D

Javascript pur : Cannon JS, Oimo.js

Portage C++ ou Rust : ammo.js (C++), Rapier (Rust)

Moteurs physiques 3D

2 sous-familles 

Non déterministiques : ammo.js (portage de Bullet engine)

 

Par nature, rapidité et stabilité (mais possible - Référence)

Déterministiques : Rapier (et cross-platform)

 

Modulo : des plateformes supportant "IEEE 754-2008 floating-points" : JS ✅

Les approches

Les approches

3 stratégies possibles

Simulation physique clientside

    + synchronisation serveur des coordonnées du palet

Simulation physique clientside déterministique

    + lancement sur les 2 devices du palet

Echanges serveurs amoindris

Nombreux échanges serveurs

Simulation serverside

Coûteuse et dépendance forte à un backend

➡️

Les approches

Latence réseau

Le principe de tour par tour nous affranchit de cette problématique

Gestion de l'état du jeu à prendre en compte

Clientside déterministique

Clientside non-déterministique

z: 2.0788457

z: 2.0788371

Implémentation

Architecture du code

Contrainte :

 

Même base de code pour 3 usages : 1to1, multijoueur local, multijoueur réseau

Clientside déterministique

z: -1.0435467958450317

z: -1.0435467958450317

Et si DumbGPT & co nous codait ça ?

Tu es un développeur web de jeu vidéo. Le jeu vidéo à développer est un jeu de palet breton, joué principalement en Bretagne mais aussi en Vendée.

 

Règles : ...

 

Contraintes techniques :

- tu vas utiliser le moteur de jeu Playcanvas.

- tu vas coder tout cela en TypeScript

- tu vas utiliser une "clean architecture" afin de bien séparer les choses, et de minimiser les dépendances entre les briques logicielles.

Petit prompt assez précis sur le contexte, les règles, les technologies, les contraintes

Et si DumbGPT & co nous codait ça ?

- tu auras à ta disposition un environnement de jeu 3D créé auparavant dans l'éditeur Playcanvas. Dans cet environnement tu as une planche qui est un volume 3D simpliste, statique, auquel est attachée une gestion de collision. tu auras également dans cet environnement un modèle 3D de palet déjà placé à la bonne distance.

- pour la partie multijoueur, tu utiliseras le framework Colyseus

 

Voici les derniers éléments de contexte :

le jeu doit être jouable dans 3 modes :

- en mode humain versus ordinateur, l'ordinateur étant une simpliste intelligence artificielle qui simulera un adversaire, plus ou moins forte

- en mode humain contre humain, sur le même device, chacun à son tour

- en mode humain contre humain, c'est à dire en multijoueur, connectés en réseau

ChatGPT 4

Développer un jeu de palet breton en utilisant PlayCanvas et TypeScript, tout en respectant les contraintes de la "clean architecture" et les différents modes de jeu, est un projet ambitieux. Voici une structure de base pour commencer. Notez que le code ci-dessous est un point de départ et qu'il nécessitera des ajustements et des ajouts selon les spécificités et les comportements du jeu.

 

Code du domaine : Palet, Player, GameRules

 

Code de la couche applicative : GameUseCase

 

Infrastructure : PlayCanvasGame (lien avec PlayCanvas)

 

Présentation : GameUI

ChatGPT 4

Conclusion

 

Ce code pose les bases de l'architecture du jeu, incluant la gestion des joueurs, des palets, et des règles de jeu.

La partie visuelle avec PlayCanvas et les interactions réseau avec Colyseus devront être intégrées et développées davantage pour finaliser le jeu.

Cela comprend la gestion des collisions, les animations, l'interface utilisateur avancée et la logique multijoueur en temps réel.

Mistral - Codestral

I'm an assistant and I'm unable to write or execute code directly.

 

However, I can certainly help you outline a plan for your game development project.

Anthropic - Claude

Je comprends que vous souhaitez développer un jeu de palet breton avec les spécifications que vous avez fournies. Étant donné la complexité du projet, je vais vous proposer une approche par étapes pour commencer le développement.

Nous allons d'abord mettre en place la structure de base du projet en utilisant une architecture propre, puis nous implémenterons progressivement les fonctionnalités.

 

Code du domaine : Palet, Master, Player, Game

 

Code de la couche applicative : GameUseCase, GameService

 

Infrastructure : PlayCanvasAdapter, ColyseusAdapter

 

Présentation : GameController

 

Anthropic - Claude

Pour implémenter complètement le jeu, vous devrez développer chacun de ces composants en détail. Voici les prochaines étapes que je suggère :

  1. Implémentez la logique de base du jeu dans GameService.
  2. Créez l'interface utilisateur avec Playcanvas en utilisant PlaycanvasAdapter.
  3. Ajoutez la gestion des collisions et de la physique pour les palets.
  4. Implémentez les différents modes de jeu (joueur vs IA, joueur vs joueur local, multijoueur en réseau).
  5. Intégrez Colyseus pour le mode multijoueur en réseau.

Anthropic - Claude

// src/application/GameService.ts
import { Game, Player, Palet, Master } from '../domain/entities';
import { GameUseCase } from '../domain/usecases/GameUseCase';
import { Vector3 } from '../types/Vector3';

export class GameService implements GameUseCase {
  private game: Game;
  private readonly WINNING_SCORE = 12;
  private readonly WINNING_SCORE_TIEBREAKER = 15;
  private readonly DISTANCE_TO_BOARD = 5; // en mètres
  private readonly PALETS_PER_PLAYER = 4;

  constructor() {
    this.game = this.initializeGame();
  }

  private initializeGame(): Game {
    const player1 = new Player('1', this.createPalets());
    const player2 = new Player('2', this.createPalets());
    const master = new Master(new Vector3(0, 0, 0));
    return new Game([player1, player2], master, 0, [0, 0]);
  }

  private createPalets(): Palet[] {
    return Array.from({ length: this.PALETS_PER_PLAYER }, (_, i) => 
      new Palet(`${i}`, new Vector3(0, 0, -this.DISTANCE_TO_BOARD))
    );
  }

  startGame(): void {
    this.game = this.initializeGame();
    this.determineMasterThrower();
  }

  private determineMasterThrower(): void {
    // Simuler le lancer d'un palet pour chaque joueur
    const distancePlayer1 = Math.random() * this.DISTANCE_TO_BOARD;
    const distancePlayer2 = Math.random() * this.DISTANCE_TO_BOARD;
    this.game.currentPlayerIndex = distancePlayer1 < distancePlayer2 ? 0 : 1;
  }

  throwMaster(playerId: string, position: Vector3): boolean {
    if (playerId !== this.game.players[this.game.currentPlayerIndex].id) {
      return false;
    }

    if (this.isValidMasterPosition(position)) {
      this.game.master.position = position;
      return true;
    }

    return false;
  }

  private isValidMasterPosition(position: Vector3): boolean {
    // Vérifier si la position du maître est sur la planche
    // Cette logique dépendra de la taille exacte de votre planche dans le jeu
    return position.x >= -0.5 && position.x <= 0.5 && 
           position.z >= -0.5 && position.z <= 0.5;
  }

  throwPalet(playerId: string, paletId: string, position: Vector3): void {
    const player = this.game.players.find(p => p.id === playerId);
    if (!player) return;

    const palet = player.palets.find(p => p.id === paletId);
    if (!palet) return;

    palet.position = position;

    this.switchTurn();
  }

  private switchTurn(): void {
    this.game.currentPlayerIndex = 1 - this.game.currentPlayerIndex;
  }

  calculateScore(): void {
    const distances = this.game.players.map(player => 
      player.palets.map(palet => this.distanceBetween(palet.position, this.game.master.position))
    );

    let scoringPlayer = distances[0][0] < distances[1][0] ? 0 : 1;
    let points = 0;

    for (let i = 0; i < this.PALETS_PER_PLAYER; i++) {
      if (distances[scoringPlayer][i] < distances[1-scoringPlayer][i]) {
        points++;
      } else {
        break;
      }
    }

    this.game.score[scoringPlayer] += points;
  }

  private distanceBetween(pos1: Vector3, pos2: Vector3): number {
    const dx = pos1.x - pos2.x;
    const dy = pos1.y - pos2.y;
    const dz = pos1.z - pos2.z;
    return Math.sqrt(dx*dx + dy*dy + dz*dz);
  }

  isGameOver(): boolean {
    const [score1, score2] = this.game.score;
    const maxScore = this.isTiebreaker() ? this.WINNING_SCORE_TIEBREAKER : this.WINNING_SCORE;
    return score1 >= maxScore || score2 >= maxScore;
  }

  private isTiebreaker(): boolean {
    return this.game.score[0] === this.WINNING_SCORE - 1 && 
           this.game.score[1] === this.WINNING_SCORE - 1;
  }

  getGameState(): Game {
    return this.game;
  }
}

Anthropic - Claude

// src/infrastructure/PlaycanvasAdapter.ts
import * as pc from 'playcanvas';
import { Vector3 } from '../types/Vector3';
import { Palet, Master, Game } from '../domain/entities';

export class PlaycanvasAdapter {
  private app: pc.Application;
  private scene: pc.Scene;
  private camera: pc.CameraComponent;
  private light: pc.LightComponent;
  private paletEntities: Map<string, pc.Entity> = new Map();
  private masterEntity: pc.Entity;
  private boardEntity: pc.Entity;

  constructor(canvas: HTMLCanvasElement) {
    this.app = new pc.Application(canvas);
    this.scene = this.app.scene;
    this.setupScene();
  }

  private setupScene(): void {
    // Configurer la caméra
    this.camera = new pc.Entity();
    this.camera.addComponent('camera', {
      clearColor: new pc.Color(0.1, 0.1, 0.1)
    });
    this.camera.translateLocal(0, 10, 15);
    this.camera.lookAt(0, 0, 0);
    this.scene.addChild(this.camera);

    // Ajouter une lumière
    this.light = new pc.Entity();
    this.light.addComponent('light', {
      type: 'directional',
      color: new pc.Color(1, 1, 1),
      castShadows: true
    });
    this.light.setEulerAngles(45, 0, 0);
    this.scene.addChild(this.light);

    // Ajouter la planche
    this.boardEntity = this.createBoard();
    this.scene.addChild(this.boardEntity);

    // Démarrer le rendu
    this.app.start();
  }

  private createBoard(): pc.Entity {
    const board = new pc.Entity();
    board.addComponent('model', {
      type: 'box',
      material: new pc.StandardMaterial()
    });
    board.setLocalScale(2, 0.1, 1);
    return board;
  }

  createPalet(palet: Palet): void {
    const entity = new pc.Entity();
    entity.addComponent('model', {
      type: 'cylinder',
      material: new pc.StandardMaterial()
    });
    entity.setLocalScale(0.1, 0.02, 0.1);
    this.updatePaletPosition(palet);
    this.scene.addChild(entity);
    this.paletEntities.set(palet.id, entity);
  }

  createMaster(master: Master): void {
    this.masterEntity = new pc.Entity();
    this.masterEntity.addComponent('model', {
      type: 'cylinder',
      material: new pc.StandardMaterial()
    });
    this.masterEntity.setLocalScale(0.05, 0.01, 0.05);
    this.updateMasterPosition(master);
    this.scene.addChild(this.masterEntity);
  }

  updatePaletPosition(palet: Palet): void {
    const entity = this.paletEntities.get(palet.id);
    if (entity) {
      entity.setPosition(palet.position.x, palet.position.y, palet.position.z);
    }
  }

  updateMasterPosition(master: Master): void {
    this.masterEntity.setPosition(master.position.x, master.position.y, master.position.z);
  }

  animateThrow(from: Vector3, to: Vector3, duration: number, onComplete: () => void): void {
    // Implémenter l'animation de lancer ici
    // Vous pouvez utiliser les tweens de Playcanvas ou une autre méthode d'animation
  }

  updateGameState(game: Game): void {
    game.players.forEach(player => {
      player.palets.forEach(palet => {
        if (!this.paletEntities.has(palet.id)) {
          this.createPalet(palet);
        } else {
          this.updatePaletPosition(palet);
        }
      });
    });

    this.updateMasterPosition(game.master);
  }

  // Méthode pour gérer les entrées utilisateur (clic pour lancer un palet)
  setupInputHandling(onPaletThrow: (position: Vector3) => void): void {
    this.app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => {
      const from = this.camera.getPosition();
      const to = this.camera.screenToWorld(event.x, event.y, this.camera.farClip);
      const result = this.scene.raycast(from, to);
      if (result) {
        onPaletThrow(result.point);
      }
    });
  }
}

Anthropic - Claude

// src/presentation/GameController.ts
export class GameController {
  constructor(
    private gameService: GameService,
    private playcanvasAdapter: PlaycanvasAdapter,
    private colyseusAdapter: ColyseusAdapter
  ) {
    this.setupMultiplayer();
  }

  private async setupMultiplayer() {
    try {
      await this.colyseusAdapter.joinOrCreateRoom("palet_breton");
      
      this.colyseusAdapter.setOnGameStateChange((game: Game) => {
        this.gameService.updateGameState(game);
        this.playcanvasAdapter.updateGameState(game);
      });

      this.colyseusAdapter.setOnPlayerJoin((playerId: string) => {
        console.log(`Player ${playerId} joined the game`);
        // Mise à jour de l'interface utilisateur, etc.
      });

      this.colyseusAdapter.setOnPlayerLeave((playerId: string) => {
        console.log(`Player ${playerId} left the game`);
        // Gestion du départ du joueur
      });

    } catch (e) {
      console.error("Erreur lors de la configuration du multijoueur:", e);
    }
  }

  throwPalet(playerId: string, paletId: string, position: Vector3) {
    // Envoyer l'action au serveur
    this.colyseusAdapter.sendAction("throwPalet", { playerId, paletId, position });
  }

  // Autres méthodes du contrôleur...
}

Anthropic - Claude - Moteur physique

quel moteur physique utilises-tu ?

comment vas-tu gérer la partie multijoueur pour qu'un lancer sur le device du premier joueur soit aussi retranscrite sur le device du deuxième joueur ?

PlayCanvas intègre nativement le moteur physique ammo.js (une version de Bullet Physics compilée en JavaScript). Nous devrions l'utiliser pour notre jeu.

Pour assurer que le lancer d'un palet sur le device du premier joueur soit correctement retranscrit sur le device du deuxième joueur, nous allons utiliser une approche de "simulation autoritaire côté serveur" combinée à une "prédiction côté client".

Cette approche permet une réponse immédiate sur le device du joueur qui lance, tout en assurant que tous les clients finissent par avoir le même état de jeu, déterminé de manière autoritaire par le serveur. Les petites différences dues à la latence sont corrigées en douceur pour maintenir une expérience fluide.

Il faudra ajuster les détails de cette implémentation en fonction des spécificités exactes de votre jeu et de vos besoins en termes de précision et de réactivité.

Anthropic - Claude - Moteur physique

simulatePaletThrow(paletId, initialPosition, initialVelocity) {
    // Utiliser un moteur physique côté serveur (par exemple matter.js) pour simuler le lancer
    // Mettre à jour this.state avec le résultat final
}

Matter.js est un moteur physique 2D...

comment codes-tu la partie lancer sur le device du premier joueur ?

Anthropic - Claude - Moteur physique

// Dans PlaycanvasAdapter

export class PlaycanvasAdapter {
  // ... autres propriétés ...
  private currentPalet: pc.Entity | null = null;
  private throwStartPosition: pc.Vec3 | null = null;
  private throwPowerIndicator: pc.Entity;

  // ... autres méthodes ...

  setupPaletThrow(onThrowComplete: (paletId: string, position: Vector3, velocity: Vector3) => void): void {
    this.throwPowerIndicator = this.createThrowPowerIndicator();
    this.scene.addChild(this.throwPowerIndicator);

    this.app.mouse.on(pc.EVENT_MOUSEDOWN, this.onMouseDown.bind(this));
    this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove.bind(this));
    this.app.mouse.on(pc.EVENT_MOUSEUP, (event) => this.onMouseUp(event, onThrowComplete));
  }

  private createThrowPowerIndicator(): pc.Entity {
    const indicator = new pc.Entity();
    indicator.addComponent('model', {
      type: 'cylinder',
      material: new pc.StandardMaterial()
    });
    indicator.setLocalScale(0.05, 1, 0.05);
    indicator.enabled = false;
    return indicator;
  }

  private onMouseDown(event: pc.MouseEvent): void {
    if (this.currentPalet) return;

    const from = this.camera.getPosition();
    const to = this.camera.screenToWorld(event.x, event.y, this.camera.farClip);
    const result = this.app.systems.rigidbody.raycastFirst(from, to);

    if (result && result.entity.tags.has('palet')) {
      this.currentPalet = result.entity;
      this.throwStartPosition = this.currentPalet.getPosition().clone();
      this.throwPowerIndicator.setPosition(this.throwStartPosition);
      this.throwPowerIndicator.enabled = true;
    }
  }

  private onMouseMove(event: pc.MouseEvent): void {
    if (!this.currentPalet || !this.throwStartPosition) return;

    const from = this.camera.getPosition();
    const to = this.camera.screenToWorld(event.x, event.y, this.camera.farClip);
    const direction = to.sub(from).normalize();
    const distance = this.throwStartPosition.distance(this.camera.getPosition());
    const power = Math.min(distance * 0.1, 5); // Limiter la puissance maximale

    this.throwPowerIndicator.setLocalScale(0.05, power, 0.05);
    this.throwPowerIndicator.lookAt(this.throwStartPosition.add(direction.scale(power)));
  }

  private onMouseUp(event: pc.MouseEvent, onThrowComplete: (paletId: string, position: Vector3, velocity: Vector3) => void): void {
    if (!this.currentPalet || !this.throwStartPosition) return;

    const from = this.camera.getPosition();
    const to = this.camera.screenToWorld(event.x, event.y, this.camera.farClip);
    const direction = to.sub(from).normalize();
    const distance = this.throwStartPosition.distance(this.camera.getPosition());
    const power = Math.min(distance * 0.1, 5); // Limiter la puissance maximale

    const velocity = direction.scale(power * 10); // Ajuster ce multiplicateur selon vos besoins
    this.currentPalet.rigidbody.linearVelocity = velocity;

    const paletId = this.currentPalet.name; // Assurez-vous que chaque palet a un nom unique
    const position = this.currentPalet.getPosition();

    onThrowComplete(paletId, 
                    new Vector3(position.x, position.y, position.z), 
                    new Vector3(velocity.x, velocity.y, velocity.z));

    this.currentPalet = null;
    this.throwStartPosition = null;
    this.throwPowerIndicator.enabled = false;
  }
}

// Dans GameController

export class GameController {
  // ... autres propriétés et méthodes ...

  constructor(
    private gameService: GameService,
    private playcanvasAdapter: PlaycanvasAdapter,
    private colyseusAdapter: ColyseusAdapter
  ) {
    // ... autre code d'initialisation ...

    this.playcanvasAdapter.setupPaletThrow(this.onPaletThrown.bind(this));
  }

  private onPaletThrown(paletId: string, position: Vector3, velocity: Vector3): void {
    const currentPlayer = this.gameService.getCurrentPlayer();
    
    // Mettre à jour l'état local du jeu
    this.gameService.throwPalet(currentPlayer.id, paletId, position);
    
    // Envoyer l'action au serveur pour synchronisation
    this.colyseusAdapter.sendAction("throwPalet", { 
      playerId: currentPlayer.id, 
      paletId, 
      initialPosition: position, 
      initialVelocity: velocity 
    });
  }
}

Anthropic - Claude - Moteur physique

C'est très bien tout ça, mais ta base de code sur le lancer gère-t-elle simplement les 3 modes de jeu ?

Vous avez tout à fait raison de soulever ce point crucial. J'ai en effet omis de prendre en compte explicitement les trois modes de jeu dans l'implémentation précédente. Vous avez raison de le faire remarquer, et je vous en remercie.

Anthropic - Claude - Moteur physique

Ton approche de "simulation autoritaire côté serveur" combinée à une "prédiction côté client" est-elle bien écoconçue ?

Avantages actuels : Réduction du trafic réseau, Réactivité améliorée, Centralisation des calculs complexes

Inconvénients potentiels : Duplication des calculs, Corrections fréquentes, Complexité accrue

Une approche potentiellement plus écoconçue pourrait être un système de "tours" plus strict, où les actions des joueurs sont traitées séquentiellement, réduisant ainsi la nécessité de prédictions complexes et de corrections fréquentes.

l'approche de "simulation autoritaire côté serveur" combinée à une "prédiction côté client" puisse offrir une bonne expérience utilisateur, elle n'est pas nécessairement la plus écoconçue

Anthropic - Claude - Moteur physique

que penses-tu de cette approche pour la gestion du multijoueur sur le lancer :

utiliser un moteur physique déterministique, et ne transmettre que les informations de lancer via colyseus ?

Cette approche est particulièrement bien adaptée à un jeu comme le palet breton, où les interactions physiques sont cruciales mais relativement simples (pas de physique complexe comme des liquides ou des tissus). Elle offre un bon équilibre entre performance, cohérence et écoconception.

quel moteur physique déterministique proposerais-tu ? met à jour le code en conséquence

Vous avez raison de demander une recommandation spécifique pour un moteur physique déterministique. Après réflexion, je recommanderais d'utiliser Rapier, un moteur physique 2D et 3D écrit en Rust avec des bindings pour JavaScript/TypeScript. Rapier est connu pour sa performance et son déterminisme, ce qui le rend idéal pour notre cas d'utilisation.

Perspectives & conclusion

 

Perspectives

Gestion des 3 modes de jeu

Mode tournoi (Coupe de France, Le grand Palet)

Conclusion

Il n'y a pas d'architecture parfaite, seulement une qui répond à un problème spécifique

Keep It Simple Stupid - Bon sens paysan - Pragmatic Driven Development

Simple ne veut pas dire facile...

User centric, ship fast, test fast, fail fast

Ressources

Merci pour votre attention !

Des questions ?

Crédit photos - Unsplash.com

Un feedback ? 👉🏻 C'est par ici

Feedbacks

La force du temps réel : développement d'un jeu mobile de palet breton/vendéen multijoueur

By Vincent Ogloblinsky

La force du temps réel : développement d'un jeu mobile de palet breton/vendéen multijoueur

Développer un jeu vidéo est déjà un défi de taille, mais aborder la dimension multijoueur équivaut à affronter le boss final de Mario Bros - ça ne s'improvise pas. Dans ce talk, je vous invite à découvrir les challenges auxquels j'ai été confronté lors du développement de ce jeu. Nous explorerons les problématiques liées à la fluidité, à l'interactivité, à l'implémentation des règles du jeu, à la gestion du réseau, avec un soupçon d’isomorphisme et un zeste de DDD pour obtenir une base de code fonctionnant à la fois sur serveur et sur mobile, connecté ou hors ligne.

  • 180