html5-canvas-game-mobile-adaptation

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

HTML5 Canvas Game → Mobile Portrait Adaptation

Adapt a desktop-only HTML5 Canvas game for mobile portrait play with on-screen touch controls, without rewriting core game logic.

When to Use

Strategy: Inject, Don't Rewrite

The key insight: most Canvas games use simple input Sets/Maps (heldKeys, pressedKeys) that get checked each frame. Touch controls just inject into those same data structures. Zero game logic changes needed.

Steps

0. Security Audit (if using third-party code)

Before deploying any open-source game, scan for malicious code:

checks = {
    "eval/Function": r"eval\(|new\s+Function\(",
    "fetch/XHR/beacon": r"fetch\(|XMLHttpRequest|navigator\.sendBeacon",
    "cookies/storage": r"document\.cookie|localStorage|sessionStorage",
    "crypto_mining": r"CoinHive|coinhive|cryptonight",
    "obfuscated": r"atob\(|btoa\(",
    "data_exfil": r"navigator\.userAgent|screen\.width",
}

Keyboard listeners (keydown/keyup) are expected in games — not a red flag. No package.json = pure static site = safer.

1. Audit the Input System

Find how the game tracks input state. Common patterns:

// Look for these — they're your injection targets
const heldKeys = new Set();     // keys currently held
const pressedKeys = new Set();  // keys pressed this frame

Export these so touch code can access them:

export const heldKeys = new Set();  // just add 'export'

2. Mobile Detection

export const isMobile = () =>
    window.innerHeight > window.innerWidth && 'ontouchstart' in window;

3. Narrow the Camera (Zoom In)

If the game has a SCENE_WIDTH / viewport concept, make it dynamic:

const DESKTOP_SCENE_WIDTH = 382;
const MOBILE_SCENE_WIDTH = 250;  // ~65% of desktop = zoomed in
export let SCENE_WIDTH = isMobile() ? MOBILE_SCENE_WIDTH : DESKTOP_SCENE_WIDTH;
// Keep SCENE_HEIGHT unchanged — only narrow horizontally
export const SCENE_HEIGHT = 224;  // DO NOT shrink height (causes letterboxing)

If the game has SCROLL_BOUNDARY (camera pan threshold), tighten it proportionally:

export const SCROLL_BOUNDARY = isMobile() ? 65 : 100;

If fighters/characters have a starting distance, reduce it:

export const FIGHTER_START_DISTANCE = isMobile() ? 50 : 88;

Critical: keep SCENE_HEIGHT the same as desktop. Shrinking both dimensions creates massive letterboxing on portrait screens.

4. Scale the HUD

HUD elements (health bars, scores, timers) are usually hardcoded to desktop pixel positions.

⚠️ WARNING: Do NOT use context.scale() as a wrapper — many Canvas games have sprite-drawing utilities that call context.setTransform(1,0,0,1,0,0) to reset transforms after flipping sprites. This silently clobbers any parent scale transform, so only the first draw call in the wrapper gets scaled. This was discovered through extensive debugging — the HUD appeared to render but was off-screen/clipped because most draw calls ran at the original 382px coordinates on a 250px canvas.

The correct approach: multiply all coordinates by a scale factor directly.

const HUD_BASE_WIDTH = 382;  // original design width
const S = SCENE_WIDTH / HUD_BASE_WIDTH;  // e.g., 250/382 = 0.654

// For the HUD overlay class, override its drawFrame to scale positions AND sprite sizes:
drawFrame(context, frameKey, x, y, direction = 1) {
    const [sourceX, sourceY, sourceWidth, sourceHeight] = this.frames.get(frameKey);
    context.save();
    context.scale(direction, 1);
    context.drawImage(
        this.image,
        sourceX, sourceY, sourceWidth, sourceHeight,
        (x * S) * direction, y * S,        // position scaled
        sourceWidth * S, sourceHeight * S   // dimensions scaled
    );
    context.restore();  // save/restore instead of setTransform!
}

// For fillRect calls (health bar damage, etc):
context.fillRect(32 * S, 21 * S, barWidth * S, 9 * S);

// For score/text labels with spacing:
drawScoreLabel(context, label, x) {
    for (const index in label) {
        this.drawFrame(context, `score-${label[index]}`, x + 12 * index, 1);
        // x and spacing are in 382px coordinates — drawFrame multiplies by S
    }
}

Key: scale BOTH position AND sprite/rect dimensions. If you only scale positions but not sprite sizes, health bars overlap (a 145px sprite starting at x=20 extends to 165px, and the mirrored bar at x=136 starts inside it).

For centered overlay text/images (e.g. "RYU WINS"), use dynamic centering:

// BAD — hardcoded for 382px width
context.drawImage(img, srcX, srcY, srcW, srcH, 120, 60, 140, 30);

// GOOD — centers for any SCENE_WIDTH  
const x = (SCENE_WIDTH - textWidth) / 2;
context.drawImage(img, srcX, srcY, srcW, srcH, x, 60, textWidth, textHeight);

5. Build Touch Controller (HTML/CSS, not Canvas)

HTML buttons are better than Canvas for touch controls: - Native touch events with multi-touch - Easy to style and resize - No hit-testing math needed

Layout:

[P1] [P2]           ← player tab switcher
[D-PAD]  [LP][MP][HP]  ← d-pad left, 6 buttons right
         [LK][MK][HK]

Touch → Input bridge:

pressKey(control) {
    const code = controls[this.activePlayer].keyboard[control];
    heldKeys.add(code);  // same Set the keyboard handler uses
}
releaseKey(control) {
    const code = controls[this.activePlayer].keyboard[control];
    heldKeys.delete(code);
    pressedKeys.delete(code);
}

6. CSS Layout (The Tricky Part)

What actually works (after extensive trial and error):

The canvas internal resolution (e.g. 250x224) has a near-square aspect ratio. On a narrow portrait phone, width:100%; height:auto makes the canvas consume most of the screen height, pushing touch controls off-screen. The only reliable approach is to force explicit dimensions:

@media (orientation: portrait) and (hover: none) and (pointer: coarse) {
    main {
        height: 58vh;
        height: 58dvh;   /* dvh accounts for mobile browser chrome */
        width: 100vw;
        background: #000;
        overflow: hidden;
    }
    canvas {
        display: block;
        width: 100vw !important;
        height: 58vh !important;
        height: 58dvh !important;
        object-fit: fill;   /* stretch to fill — slight distortion OK for pixel art */
    }
    #touch-controls {
        display: flex;
        flex-direction: column;
        height: 42vh;
        height: 42dvh;
        width: 100vw;
    }
}

What DOESN'T work (all tried and failed): - object-fit: cover → crops canvas edges, clips HUD health bars off-screen - object-fit: contain → leaves black letterbox bars above/below canvas - width: 100%; height: auto → canvas too tall, pushes controls completely off-screen - height: 100%; width: auto → canvas narrower than screen, wastes horizontal space - flex: 1 on touch controls with auto canvas → controls get zero height - Shrinking SCENE_HEIGHT alongside SCENE_WIDTH → massive letterboxing (the scene draws to the bottom, leaving empty space above) - body { display: flex; flex-direction: column } with auto canvas → same overflow issue

The winning formula: Fixed dvh heights with !important + object-fit: fill. The slight pixel stretch is invisible with image-rendering: pixelated on retro games. Use dvh units (not vh) so the layout adapts when mobile browser chrome appears/disappears.

7. Mobile CSS Media Query

Only show touch controls on actual mobile portrait devices:

#touch-controls { display: none; }

@media (orientation: portrait) and (hover: none) and (pointer: coarse) {
    #touch-controls { display: flex; }
    /* ... mobile layout rules ... */
}

8. HTML Meta Tags

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />

And on body:

touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;

Pitfalls

  1. setTransform() clobbers parent scale transforms — This is the #1 gotcha. Many Canvas games use context.setTransform(1,0,0,1,0,0) after sprite draws (to undo direction flipping). This resets ALL transforms, including any context.scale() wrapper you added for HUD scaling. The fix: don't use canvas scale transforms for HUD scaling. Instead, multiply coordinates by S = SCENE_WIDTH / BASE_WIDTH directly at each draw call. Use context.save()/restore() instead of setTransform() in your own draw methods.

  2. Case-sensitive filenames — Repos developed on macOS/Windows may have import/src mismatches (e.g., Stage.js vs stage.js, ken.png vs Ken.png). Linux serves 404 for wrong case. Check all imports against actual filenames after cloning.

  3. Express 5 catch-all routeapp.get('*', ...) crashes with "Missing parameter name at index 1". Use app.get('/{*splat}', ...) instead.

  4. Static file MIME types — Express catch-all serving index.html for .js module requests causes "MIME type text/html" errors. Serve static files FIRST, and only fall back to index.html for paths with no file extension:

app.use(express.static(__dirname));
app.get('/{*splat}', (req, res, next) => {
    if (path.extname(req.path)) return res.status(404).send('Not found');
    res.sendFile(path.join(__dirname, 'index.html'));
});
  1. ES module live bindingsexport let SCENE_WIDTH creates a live binding. Importers always read the current value. export const freezes at declaration time. Use let if you need runtime mutation, const if set-once-at-load is fine.

  2. Don't forget diagonal inputs — D-pad needs multi-touch support so players can press up+right simultaneously. Track touches by touch.identifier.

  3. Prevent zoom/scroll — Mobile browsers zoom on double-tap and scroll on drag. Use touch-action: none on the controls container and preventDefault() on all touch events.

  4. Interactive UI (buttons, links, text) should be HTML, not canvas-drawn — Canvas text inherits image-rendering: pixelated making it illegible. Hit detection for canvas-drawn buttons breaks when CSS uses object-fit: fill or dvh units. Use HTML overlays with stopPropagation() to prevent canvas click handlers from firing when tapping buttons.

  5. Fixed-position HTML elements overlap touch controls on mobile — Don't use position: fixed; bottom: 0 for buttons — they'll cover the d-pad/action buttons. Use normal document flow and place the element between the canvas and touch-controls divs in HTML.

Files Typically Modified

File Change
Stage constants Dynamic SCENE_WIDTH, SCROLL_BOUNDARY
Input handler Export heldKeys/pressedKeys Sets
Canvas/context init Dynamic canvas dimensions
Fighter/character constants Closer starting positions
HUD/overlay Scale transform wrapper
Start screen/menus Scale transform wrapper
index.html Touch controls div, meta tags
style.css Mobile media query, flexbox layout

Online Multiplayer (P1-Host via WebSocket)

Architecture

P1 runs full game simulation. P2 sends inputs via WebSocket relay server. P1 injects P2 inputs, broadcasts state. Server is a dumb relay (~130 lines).

Sound Sync

Audio Autoplay

sound.play().catch(() => {}) — always catch. Unlock on first click/touch/keydown.

WebSocket Blob Parsing

Browser WS may deliver Blob or ArrayBuffer. Handle: ws.binaryType = 'arraybuffer' then decode.

Performance Optimizations

  1. Send state every 2nd frame (halves bandwidth)
  2. Short JSON keys (states, framef, etc, ~30% smaller)
  3. Delta compression (only send HP/timer/scores when changed)
  4. Throttle P2 inputs (only send on change)
  5. Client-side interpolation: buffer states, lerp positions (80ms delay), snap discrete values

Files Typically Created

File Purpose
TouchController.js Touch events → keyboard Set injection, P1/P2 switching
MultiplayerClient.js WebSocket connection, state buffering, interpolation
LobbyScene.js Room creation/joining UI (use HTML overlay, not canvas)
multiplayer-server.js WebSocket relay with room management