Skip to content

Principes de base du multijoueur avec WebSocket

Créer un jeu multijoueur avec WebSocket : architecture serveur, salons de jeu et synchronisation de l’état

WebSocket permet une communication en temps réel pour les jeux multijoueurs. Ce tutoriel présente les principes fondamentaux côté client.

1) Se connecter à un serveur

js
const ws = new WebSocket('wss://game-server.example.com')

ws.onopen = () => {
  console.log('Connected')
  ws.send(JSON.stringify({ type: 'join', name: 'Player1' }))
}

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data)
  handleMessage(msg)
}

ws.onclose = () => {
  console.log('Disconnected')
}

ws.onerror = (err) => {
  console.error('WebSocket error:', err)
}

2) Concevoir le protocole de messages

Définissez un protocole simple :

js
// Client -> Server
{ type: 'join', name: 'Player1' }
{ type: 'input', keys: { left: true, jump: true } }
{ type: 'chat', message: 'Hello!' }

// Server -> Client
{ type: 'state', players: [...], entities: [...] }
{ type: 'playerJoined', id: 'abc', name: 'Player2' }
{ type: 'playerLeft', id: 'abc' }

3) Classe de gestion des connexions

js
class GameConnection {
  constructor(url) {
    this.url = url
    this.ws = null
    this.handlers = new Map()
    this.reconnectDelay = 1000
  }
  
  connect() {
    this.ws = new WebSocket(this.url)
    
    this.ws.onopen = () => {
      this.reconnectDelay = 1000
      this.emit('connected')
    }
    
    this.ws.onmessage = (e) => {
      const msg = JSON.parse(e.data)
      this.emit(msg.type, msg)
    }
    
    this.ws.onclose = () => {
      this.emit('disconnected')
      this.scheduleReconnect()
    }
    
    this.ws.onerror = () => {
      this.ws.close()
    }
  }
  
  scheduleReconnect() {
    setTimeout(() => {
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
      this.connect()
    }, this.reconnectDelay)
  }
  
  send(type, data = {}) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, ...data }))
    }
  }
  
  on(type, handler) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, [])
    }
    this.handlers.get(type).push(handler)
  }
  
  emit(type, data) {
    const handlers = this.handlers.get(type) || []
    handlers.forEach(h => h(data))
  }
  
  disconnect() {
    this.ws?.close()
  }
}

4) Envoyer les commandes du joueur

Envoyez les commandes, pas les positions. Laissez le serveur faire autorité :

js
const connection = new GameConnection('wss://server.example.com')

function sendInput(input) {
  connection.send('input', {
    left: input.left,
    right: input.right,
    jump: input.jump,
    shoot: input.shoot,
    seq: inputSequence++,
  })
}

// Send input at a fixed rate (e.g., 20 times/sec)
setInterval(() => {
  sendInput(currentInput)
}, 50)

5) Recevoir l’état du jeu

js
let gameState = { players: [], entities: [] }

connection.on('state', (msg) => {
  gameState = msg
})

connection.on('playerJoined', (msg) => {
  gameState.players.push({ id: msg.id, name: msg.name })
})

connection.on('playerLeft', (msg) => {
  gameState.players = gameState.players.filter(p => p.id !== msg.id)
})

6) Interpolation pour des mouvements fluides

Le serveur envoie des instantanés. Interpolez entre eux :

js
class EntityInterpolator {
  constructor() {
    this.buffer = [] // { timestamp, state }
    this.renderDelay = 100 // ms behind server
  }
  
  addSnapshot(timestamp, state) {
    this.buffer.push({ timestamp, state })
    
    // Keep only recent snapshots
    while (this.buffer.length > 10) {
      this.buffer.shift()
    }
  }
  
  getState(currentTime) {
    const renderTime = currentTime - this.renderDelay
    
    // Find surrounding snapshots
    let before = null
    let after = null
    
    for (let i = 0; i < this.buffer.length - 1; i++) {
      if (this.buffer[i].timestamp <= renderTime && 
          this.buffer[i + 1].timestamp >= renderTime) {
        before = this.buffer[i]
        after = this.buffer[i + 1]
        break
      }
    }
    
    if (!before || !after) {
      return this.buffer[this.buffer.length - 1]?.state
    }
    
    // Interpolate
    const t = (renderTime - before.timestamp) / (after.timestamp - before.timestamp)
    return this.lerp(before.state, after.state, t)
  }
  
  lerp(a, b, t) {
    return {
      x: a.x + (b.x - a.x) * t,
      y: a.y + (b.y - a.y) * t,
    }
  }
}

7) Prédiction côté client

Pour des commandes réactives, effectuez une prédiction locale puis réconciliez-la avec le serveur :

js
class PredictedPlayer {
  constructor() {
    this.position = { x: 0, y: 0 }
    this.pendingInputs = []
  }
  
  applyInput(input) {
    // Apply input locally
    if (input.left) this.position.x -= 5
    if (input.right) this.position.x += 5
    
    // Store for reconciliation
    this.pendingInputs.push({ seq: input.seq, input })
  }
  
  reconcile(serverState, lastProcessedSeq) {
    // Server confirmed position
    this.position = { ...serverState.position }
    
    // Remove confirmed inputs
    this.pendingInputs = this.pendingInputs.filter(i => i.seq > lastProcessedSeq)
    
    // Re-apply unconfirmed inputs
    for (const pending of this.pendingInputs) {
      if (pending.input.left) this.position.x -= 5
      if (pending.input.right) this.position.x += 5
    }
  }
}

8) Afficher la latence

js
class LatencyMonitor {
  constructor(connection) {
    this.connection = connection
    this.pingStart = 0
    this.latency = 0
    
    connection.on('pong', () => {
      this.latency = Date.now() - this.pingStart
    })
    
    setInterval(() => this.ping(), 1000)
  }
  
  ping() {
    this.pingStart = Date.now()
    this.connection.send('ping')
  }
  
  getLatency() {
    return this.latency
  }
}

9) Messages binaires pour plus d’efficacité

Pour les mises à jour à haute fréquence, utilisez le format binaire :

js
// Sending binary
const buffer = new ArrayBuffer(12)
const view = new DataView(buffer)
view.setFloat32(0, player.x)
view.setFloat32(4, player.y)
view.setUint32(8, inputFlags)
ws.send(buffer)

// Receiving binary
ws.binaryType = 'arraybuffer'
ws.onmessage = (e) => {
  if (e.data instanceof ArrayBuffer) {
    const view = new DataView(e.data)
    const x = view.getFloat32(0)
    const y = view.getFloat32(4)
    // ...
  }
}

10) Exemple de serveur simple (Node.js)

js
import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })
const players = new Map()

wss.on('connection', (ws) => {
  const id = crypto.randomUUID()
  players.set(id, { id, x: 0, y: 0, ws })
  
  ws.on('message', (data) => {
    const msg = JSON.parse(data)
    
    if (msg.type === 'input') {
      const player = players.get(id)
      if (msg.left) player.x -= 5
      if (msg.right) player.x += 5
    }
  })
  
  ws.on('close', () => {
    players.delete(id)
    broadcast({ type: 'playerLeft', id })
  })
})

// Broadcast state 20 times/sec
setInterval(() => {
  const state = {
    type: 'state',
    players: Array.from(players.values()).map(p => ({
      id: p.id, x: p.x, y: p.y
    }))
  }
  broadcast(state)
}, 50)

function broadcast(msg) {
  const data = JSON.stringify(msg)
  for (const player of players.values()) {
    player.ws.send(data)
  }
}

À lire aussi

Ressources externes

  • MDN : API WebSocket — référence de WebSocket dans le navigateur
  • MDN : API WebRTC — connexions pair à pair pour réduire la latence
  • MDN : API WebTransport — un protocole de transport HTTP/3 plus récent, devenu compatible avec l’ensemble des navigateurs de référence en mars 2026 lorsque Safari 26.4 a ajouté sa prise en charge (il était déjà disponible dans Chrome 97+, Edge et Firefox 114+). Contrairement à une connexion WebSocket unique basée sur TCP, WebTransport fonctionne sur QUIC et propose des datagrammes non fiables ainsi que plusieurs flux indépendants. Ainsi, la perte d’un paquet ne bloque pas tous les autres par attente en tête de file. Une approche courante consiste à envoyer les mises à jour fréquentes de position par datagrammes, tout en conservant le chat ou les tableaux des scores sur un flux fiable. WebSocket reste le choix le plus compatible et tout ce qui précède reste valable, mais WebTransport mérite désormais d’être envisagé pour les jeux sensibles à la latence.
  • Documentation de Socket.IO — bibliothèque populaire servant de surcouche à WebSocket
  • Réseau multijoueur de Valve Source — modèles de réseau faisant autorité utilisés dans les jeux en production