Add real-time online multiplayer to a single-player/local HTML5 Canvas game without rewriting game logic.
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:
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
Add ws package to existing Express server. Key design:
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
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();
Add to the game's InputHandler:
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;
};
// 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
}
// 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());
}
}
⚠️ 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:
<div id="lobby-overlay">
<h2>ONLINE BATTLE</h2>
<div id="lobby-room">ROOM: ABC123</div>
<div id="lobby-link">https://game.com?room=ABC123</div> <!-- tappable, copyable -->
<div id="lobby-waiting">Waiting for opponent...</div>
</div>
<button id="online-battle-btn">⚔ ONLINE BATTLE</button>
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).
// 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
Ensure nginx config has WebSocket upgrade headers:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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:
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 });
};
extractState() includes sounds: collectSoundEvents()applyState() calls playSoundEvents(state.sounds)setSoundRemoteMode(true) on battle start to prevent double-playAudio autoplay policy: Browsers block play() before user interaction. Always .catch(() => {}) on play calls, and unlock audio on first touch/click/keydown:
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);
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:
// 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.
Apply these in order of impact. Phase 1 is quick, Phase 2 makes the biggest visible difference.
Send state every 2nd frame (not every frame):
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:
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):
// 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):
let s;
return {
fighters: [...],
c: { x, y },
...( (s = collectSoundEvents()).length ? { snd: s } : {} ),
};
Delta compression — only send fields that changed:
// 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).
Without interpolation, P2 snaps to each received position — visibly choppy when network jitters. With interpolation, P2 buffers states and lerps between them.
// 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
ws npm package sends text, but browsers may receive it as Blob or ArrayBuffer depending on the binaryType setting. Always handle all three: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);
};
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.
Touch controller player switching — When P2 joins on mobile, switch their touch controller to inject P2 keyboard codes (controls[1]) not P1 codes.
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.
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).
Room cleanup — Always clean up rooms on WebSocket close/error. Send opponent-disconnected to the remaining player.
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:
startScene = (SceneClass) => {
this.scene = new SceneClass(this.changeScene); // StartScene constructor shows button
if (SceneClass !== StartScene) { // DON'T hide for StartScene
btn.classList.add('hidden');
}
};
| 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 |