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
La version 2024
Jeu local (solo vs ordi)
Mode jeu classique ou cible
Plusieurs décors fictifs et 3 réels
Jeu multijoueur local ou online
Mesureur (machine learning + Tensorflow.js)
Contexte technique du jeu
Un objet 3D assez simple à lancer
Et le 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
Contraintes :
Même base de code pour les 3 usages : 1to1, multijoueur local, multijoueur réseau
Chaque couche la plus isolée possible
Le moins de librairie possible
Changement de librairie le moins douloureux possible
Architecture du code
Moteur physique
Moteur 3D
GUI
Game Manager
Game Settings
Gestion multijoueur
Game rules
Architecture du code
Clientside déterministique
Contraintes :
2 moteurs : un 3D et l'autre physique
Physique des objets : masse, densité, volume
Physique extérieur : collisions
Le moteur physique pilote le moteur graphique (synchronisation à chaque frame)
Clientside déterministique
z: -1.0435467958450317
z: -1.0435467958450317
IA for the win ? 😂
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 Babylon.js 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 : 3DGameEngine (lien avec Babylon.js)
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 Babylon.js 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 : BabylonJsAdapter, 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 :
- Implémentez la logique de base du jeu dans GameService.
- Créez l'interface utilisateur avec Babylon.js en utilisant BabylonJSAdapter.
- Ajoutez la gestion des collisions et de la physique pour les palets.
- Implémentez les différents modes de jeu (joueur vs IA, joueur vs joueur local, multijoueur en réseau).
- 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/BabylonJSAdapter.ts
import * as bj from '@babylonjs/core';
import { Vector3 } from '../types/Vector3';
import { Palet, Master, Game } from '../domain/entities';
export class BabylonJSAdapter {
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 bj.Entity();
this.camera.addComponent('camera', {
clearColor: new bj.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 bj.Entity();
this.light.addComponent('light', {
type: 'directional',
color: new bj.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 babylonJSAdapter: BabylonJSAdapter,
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.babylonJSAdapter.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 babylonJSAdapter
export class babylonJSAdapter {
// ... 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
Sondage Dev Test Days :
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.
- 279