---
name: html5-canvas-game-multiplayer
description: Add online multiplayer to HTML5 Canvas games via WebSocket. P1-as-host architecture with state sync — no game logic rewrite needed. Covers room system, input relay, state serialization, and UI overlays.
version: 1.0.0
tags: [html5, canvas, multiplayer, websocket, game, online, realtime]
---
# HTML5 Canvas Game → Online Multiplayer
Add real-time online multiplayer to a single-player/local HTML5 Canvas game without rewriting game logic.
## When to Use
- User wants two players in different browsers to play the same Canvas game
- Game already has a working local input system (keyboard/gamepad/touch)
- Game has deterministic frame-by-frame updates
- Game state is small enough to serialize each frame (~1-2KB)
## Architecture: P1-as-Host with Input Relay
**Why not a headless server game loop?**
Canvas games run their simulation entirely in the browser (sprites, physics, animation, rendering). Extracting the game loop to Node.js is a massive rewrite. Instead:
- **P1 (host)** runs the full game simulation in their browser
- **P2 (remote)** sends button inputs → relayed to P1 via WebSocket
- **P1** injects P2's inputs into the game loop, runs simulation, sends state back
- **P2** receives state and renders it (no simulation, just drawing)
- **Server** is a dumb relay (~130 lines) — zero game logic
```
P2 Browser Server (WS relay) P1 Browser
────────── ──────────────── ──────────
tap buttons →
{inputs: P2keys} ────→ relay to P1 ──────────→ inject P2 keys
run game loop
extract state
relay to P2 ←────────── {state: gameState}
receive state ←──────
render on canvas
```
## Implementation
### 1. WebSocket Relay Server (~130 lines)
Add `ws` package to existing Express server. Key design:
```javascript
const { WebSocketServer, WebSocket } = require('ws');
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
const rooms = new Map();
// Room: { code, p1: ws, p2: ws, state: 'waiting'|'playing' }
// Message types: create-room, join-room, inputs, state, rematch, opponent-disconnected
```
Room system:
- P1 sends `create-room` → server generates 6-char code, stores P1 socket
- P2 sends `join-room` with code → server assigns P2, sends `start` to both
- During play: server relays `inputs` (P2→P1) and `state` (P1→P2)
- On disconnect: notify opponent, clean up room
### 2. MultiplayerClient (browser singleton)
```javascript
export class MultiplayerClient {
ws = null;
player = null; // 1 or 2
room = null;
remoteInputs = {}; // P2's inputs (used by P1)
latestState = null; // game state (used by P2)
// callbacks: onStart, onRoomCreated, onOpponentDisconnect, onError, onRematch
}
export const multiplayerClient = new MultiplayerClient();
```
### 3. Input Injection (for P1 receiving P2's inputs)
Add to the game's InputHandler:
```javascript
export const setRemoteInputs = (playerId, remoteInputs) => {
for (const control of controlNames) {
const keyCode = controls[playerId].keyboard[control];
if (remoteInputs[control]) {
heldKeys.add(keyCode);
} else {
heldKeys.delete(keyCode);
pressedKeys.delete(keyCode);
}
}
};
export const getCurrentInputs = (playerId) => {
const inputs = {};
for (const control of controlNames) {
const keyCode = controls[playerId].keyboard[control];
inputs[control] = heldKeys.has(keyCode);
}
return inputs;
};
```
### 4. State Extraction & Application (BattleScene)
```javascript
// P1 calls this after each frame
extractState() {
return {
fighters: this.fighters.map(f => ({
x: f.position.x, y: f.position.y,
state: f.currentState, frame: f.animationFrame,
dir: f.direction, shake: f.hurtShake,
victory: f.victory || false,
})),
camera: { x: this.camera.position.x, y: this.camera.position.y },
hp: gameState.fighters.map(f => f.hitPoints),
scores: gameState.fighters.map(f => f.score),
timer: this.overlays[0]?.time,
winnerId: this.winnerId,
drawOrder: this.FighterDrawOrder,
};
}
// P2 calls this when receiving state
applyState(state) {
state.fighters.forEach((fs, i) => {
this.fighters[i].position.x = fs.x;
this.fighters[i].position.y = fs.y;
this.fighters[i].currentState = fs.state;
this.fighters[i].animationFrame = fs.frame;
this.fighters[i].direction = fs.dir;
this.fighters[i].hurtShake = fs.shake;
this.fighters[i].victory = fs.victory;
});
// + camera, hp, scores, timer, winner, drawOrder
}
```
### 5. Game Loop Integration
```javascript
// In the main game frame loop:
if (multiplayerMode && multiplayerClient.isRemote()) {
// P2: apply received state, draw, send inputs
const state = multiplayerClient.consumeState();
if (state) scene.applyState(state);
scene.draw(context);
multiplayerClient.sendInputs(getCurrentInputs(1));
} else {
// Normal: update + draw (local play or P1 host)
scene.update(frameTime);
scene.draw(context);
if (multiplayerMode && multiplayerClient.isHost()) {
setRemoteInputs(1, multiplayerClient.getRemoteInputs());
multiplayerClient.sendState(scene.extractState());
}
}
```
### 6. Lobby UI — Use HTML, NOT Canvas
**⚠️ Lesson learned:** Do NOT draw interactive UI (buttons, links, room codes) on the Canvas.
- Canvas text inherits `image-rendering: pixelated` → illegible
- Hit detection for canvas-drawn buttons requires coordinate mapping that's fragile with CSS scaling (object-fit, dvh units, etc.)
- Users can't tap-to-copy share links drawn on canvas
**Use HTML overlays instead:**
```html
ONLINE BATTLE
ROOM: ABC123
https://game.com?room=ABC123
Waiting for opponent...
```
Style with CSS (clean, readable fonts), toggle visibility with `.active` class.
For the lobby scene, just fill the canvas with a dark background and let the HTML overlay do the work.
**Button placement on mobile:** Don't use `position: fixed` — it overlaps touch controls. Use normal document flow (place the button div between canvas and touch-controls in HTML).
### 7. URL-based Room Joining
```javascript
// Check URL params on load
const params = new URLSearchParams(window.location.search);
const roomParam = params.get('room');
if (roomParam || params.has('online')) {
// Enter multiplayer mode
}
// After creating room, update URL without reload:
window.history.replaceState({}, '', `?room=${code}`);
```
Share link format: `https://game.com?room=ABCDEF`
### 8. nginx WebSocket Support
Ensure nginx config has WebSocket upgrade headers:
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
```
### 9. Sound Sync for Remote Player
P2 doesn't run game logic, so sounds triggered from state transitions (attacks, hits, hadouken) never fire. Solution: buffer sound events on P1 and include them in state broadcast.
**SoundHandler pattern:**
```javascript
let soundEventBuffer = [];
let isRemoteMode = false;
export const setSoundRemoteMode = (remote) => { isRemoteMode = remote; };
export const collectSoundEvents = () => { const e = soundEventBuffer; soundEventBuffer = []; return e; };
export const playSoundEvents = (events) => {
for (const evt of events) {
const el = document.getElementById(evt.id);
if (el) { el.volume = evt.vol; el.play().catch(() => {}); }
}
};
export const playSound = (sound, volume = 0.7) => {
if (isRemoteMode) return; // P2 skips — will play from state sync
sound.volume = volume;
sound.play().catch(() => {});
if (sound.id) soundEventBuffer.push({ id: sound.id, vol: volume });
};
```
- P1: `extractState()` includes `sounds: collectSoundEvents()`
- P2: `applyState()` calls `playSoundEvents(state.sounds)`
- P2 sets `setSoundRemoteMode(true)` on battle start to prevent double-play
- Background music needs manual start for P2 (since it fires in stage constructor before state sync begins)
**Audio autoplay policy:** Browsers block `play()` before user interaction. Always `.catch(() => {})` on play calls, and unlock audio on first touch/click/keydown:
```javascript
const unlockAudio = () => {
document.querySelectorAll('audio').forEach(a => a.play().then(() => a.pause()).catch(() => {}));
window.removeEventListener('click', unlockAudio);
window.removeEventListener('touchstart', unlockAudio);
};
window.addEventListener('click', unlockAudio);
window.addEventListener('touchstart', unlockAudio);
```
### 10. HUD Internal State for P2
HUD overlays (like health bars) may have internal animation state (e.g. a `healthBars[]` array that "rolls up" from 0 to full). When applying state for P2, you must sync this internal state too, not just the game state HP values:
```javascript
// In applyState:
if (this.overlays[0]?.healthBars) {
this.overlays[0].healthBars[i].hitPoints = hp;
this.overlays[0].startingHealthRollUpDone = true; // skip roll-up animation
}
```
Without this, P2 sees empty/wrong health bars even though gameState.fighters[i].hitPoints is correct.
## Performance Optimizations
Apply these in order of impact. Phase 1 is quick, Phase 2 makes the biggest visible difference.
### Phase 1: Quick Bandwidth Wins
**Send state every 2nd frame** (not every frame):
```javascript
this.stateFrameCounter++;
if (this.stateFrameCounter % 2 === 0) {
multiplayerClient.sendState(scene.extractState());
}
```
Halves message volume. Combined with interpolation (Phase 2), P2 still looks smooth.
**Throttle P2 inputs — only send on change:**
```javascript
sendInputs(inputs) {
const inputStr = JSON.stringify(inputs);
if (inputStr === this._lastSentInputs) return; // skip if unchanged
this._lastSentInputs = inputStr;
this.ws.send(JSON.stringify({ type: 'inputs', inputs }));
}
```
**Shorten JSON keys** (~30% smaller payload):
```javascript
// Instead of: { state: 'idle', frame: 2, direction: 1, shake: 0, victory: false }
// Use: { s: 'idle', f: 2, d: 1, h: 0, v: false }
```
**Skip empty arrays** (sounds only present when there are events):
```javascript
let s;
return {
fighters: [...],
c: { x, y },
...( (s = collectSoundEvents()).length ? { snd: s } : {} ),
};
```
**Delta compression** — only send fields that changed:
```javascript
// Always send: fighters (positions change every frame), camera
// Only send if changed: hp, scores, timer, winnerId, drawOrder
if (JSON.stringify(prev.hp) !== JSON.stringify(full.hp)) delta.hp = full.hp;
```
P2's `applyState()` must handle missing fields (= unchanged, keep previous value).
### Phase 2: Client-Side Interpolation (Biggest Smoothness Win)
Without interpolation, P2 snaps to each received position — visibly choppy when network jitters. With interpolation, P2 buffers states and lerps between them.
```javascript
// In MultiplayerClient:
_stateBuffer = [];
_interpDelay = 80; // ms of visual delay for smooth interpolation
// On receiving state:
state._receiveTime = performance.now();
this._stateBuffer.push(state);
if (this._stateBuffer.length > 5) this._stateBuffer.shift();
// Each render frame, interpolate:
getInterpolatedState() {
const renderTime = performance.now() - this._interpDelay;
// Find two buffered states that straddle renderTime
let older, newer;
for (let i = 0; i < buf.length - 1; i++) {
if (buf[i]._receiveTime <= renderTime && buf[i+1]._receiveTime >= renderTime) {
older = buf[i]; newer = buf[i+1]; break;
}
}
if (!older || !newer) return { state: buf[buf.length-1] }; // fallback: latest
const t = (renderTime - older._receiveTime) / (newer._receiveTime - older._receiveTime);
return {
state: {
fighters: older.fighters.map((oldF, i) => ({
x: oldF.x + (newer.fighters[i].x - oldF.x) * t, // lerp positions
y: oldF.y + (newer.fighters[i].y - oldF.y) * t,
s: newer.fighters[i].s, // snap discrete values
f: newer.fighters[i].f,
d: newer.fighters[i].d,
// ...
})),
c: { /* lerp camera */ },
hp: newer.hp, t: newer.t, // snap metadata
}
};
}
```
**Key design decisions:**
- Lerp: fighter positions (x, y) and camera position — these change smoothly
- Snap: animation state, frame index, direction, hurtShake, victory — these are discrete
- 80ms buffer delay = ~2.5 frames at 30fps — good balance of smoothness vs latency
- Clean old states from buffer once passed: `while (buf[0]._receiveTime < renderTime - 200) buf.shift()`
- Guard against delta states missing `fighters`/`c` keys when finding interpolation pairs
### Combined Impact
- ~60% bandwidth reduction (delta + short keys + skip empties + every-2nd-frame)
- Much smoother P2 rendering (interpolation vs frame snapping)
- Virtually no extra CPU cost
## Pitfalls
1. **Browser WebSocket receives Blob/ArrayBuffer, not string** — The `ws` npm package sends text, but browsers may receive it as Blob or ArrayBuffer depending on the `binaryType` setting. Always handle all three:
```javascript
ws.binaryType = 'arraybuffer';
ws.onmessage = async (event) => {
let text;
if (typeof event.data === 'string') text = event.data;
else if (event.data instanceof Blob) text = await event.data.text();
else if (event.data instanceof ArrayBuffer) text = new TextDecoder().decode(event.data);
const msg = JSON.parse(text);
};
```
2. **Canvas-drawn buttons are unreliable on mobile** — Click/touch coordinate mapping to canvas internal coordinates breaks when CSS uses `object-fit: fill`, `dvh` units, or the viewport scales differently than expected. Use real HTML buttons with `stopPropagation()` to prevent them from also triggering canvas click handlers.
3. **Touch controller player switching** — When P2 joins on mobile, switch their touch controller to inject P2 keyboard codes (controls[1]) not P1 codes.
4. **State payload size** — At 30fps, you're sending ~30 messages/second. Keep state minimal (positions, animation frame indices, HP) — don't send full sprite data or sound triggers.
5. **Latency** — P2 feels ~50-100ms delay on remote connections (1.5-3 frames at 30fps). Acceptable for casual play. For competitive, would need input prediction (stretch goal).
6. **Room cleanup** — Always clean up rooms on WebSocket close/error. Send `opponent-disconnected` to the remaining player.
7. **Scene transition hiding UI elements** — If the game has a `startScene()` method that hides HTML overlays (like an "Online Battle" button), guard it so it doesn't hide elements when transitioning TO the start screen — only when leaving it. Otherwise the button appears briefly then vanishes:
```javascript
startScene = (SceneClass) => {
this.scene = new SceneClass(this.changeScene); // StartScene constructor shows button
if (SceneClass !== StartScene) { // DON'T hide for StartScene
btn.classList.add('hidden');
}
};
```
## Files Summary
| File | Purpose |
|------|---------|
| server.js | Express + WebSocket server with room relay |
| MultiplayerClient.js | Browser-side WS connection + state management |
| LobbyScene.js | Room creation/joining (HTML overlay, not canvas) |
| InputHandler.js (modified) | setRemoteInputs() + getCurrentInputs() |
| BattleScene.js (modified) | extractState() + applyState() |
| StreetFighterGame.js (modified) | Multiplayer mode branching in game loop |
| index.html (modified) | Lobby overlay HTML + online button |
| style.css (modified) | Lobby overlay + button styling |