--- 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
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 |