Skip to content

WebSocket multiplayer basics

WebSockets enable real-time communication for multiplayer games. This tutorial covers the client-side fundamentals.

1) Connecting to a server

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) Message protocol design

Define a simple protocol:

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) Connection manager class

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) Sending player input

Send inputs, not positions. Let the server be authoritative:

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) Receiving game state

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 for smooth movement

Server sends snapshots. Interpolate between them:

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) Client-side prediction

For responsive controls, predict locally and reconcile with server:

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) Handling latency display

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) Binary messages for efficiency

For high-frequency updates, use binary:

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) Basic server example (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)
  }
}