html5-canvas-game-multiplayer

/home/avalon/.hermes/skills/.archive/software-development/html5-canvas-game-multiplayer/SKILL.md · raw

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

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:

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:

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)

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:

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)

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

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

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

7. URL-based Room Joining

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

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:

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 });
};

Audio 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);

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:

// 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):

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

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.

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

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:
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);
};
  1. 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.

  2. Touch controller player switching — When P2 joins on mobile, switch their touch controller to inject P2 keyboard codes (controls[1]) not P1 codes.

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

  4. 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).

  5. Room cleanup — Always clean up rooms on WebSocket close/error. Send opponent-disconnected to the remaining player.

  6. 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');
    }
};

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