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
- Publier un jeu web qui se charge rapidement
- Gestion des commandes de jeu
- Web Workers pour la logique de jeu
- COOP/COEP et SharedArrayBuffer — les en-têtes requis pour SharedArrayBuffer dans les contextes multijoueurs
- Conception de jeux coopératifs en 2026 — pourquoi les jeux coopératifs de type « friend slop » explosent et comment le multijoueur dans le navigateur s’intègre à cette tendance
- Game jams et hackathons — les jeux multijoueurs créés lors de jams attirent davantage l’attention
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