--- name: html5-canvas-game-mobile-adaptation description: Adapt desktop HTML5 Canvas games for mobile portrait play with touch controls. Covers camera zoom, HUD scaling, touch input injection, and layout without rewriting game logic. version: 1.0.0 tags: [html5, canvas, mobile, touch-controls, game, responsive, portrait] --- # 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 - User wants to play an HTML5 Canvas game on mobile - Game has keyboard/gamepad input and needs touch controls - Game has a fixed internal resolution that needs adapting for portrait screens - Game has a camera/viewport system that can be leveraged for zoom ## 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: ```python 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: ```javascript // 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: ```javascript export const heldKeys = new Set(); // just add 'export' ``` ### 2. Mobile Detection ```javascript 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: ```javascript 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: ```javascript export const SCROLL_BOUNDARY = isMobile() ? 65 : 100; ``` If fighters/characters have a starting distance, reduce it: ```javascript 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.** ```javascript 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: ```javascript // 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: ```javascript 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: ```css @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: ```css #touch-controls { display: none; } @media (orientation: portrait) and (hover: none) and (pointer: coarse) { #touch-controls { display: flex; } /* ... mobile layout rules ... */ } ``` ### 8. HTML Meta Tags ```html ``` And on body: ```css 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. 2. **Express 5 catch-all route** — `app.get('*', ...)` crashes with "Missing parameter name at index 1". Use `app.get('/{*splat}', ...)` instead. 3. **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: ```javascript 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')); }); ``` 4. **ES module live bindings** — `export 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. 5. **Don't forget diagonal inputs** — D-pad needs multi-touch support so players can press up+right simultaneously. Track touches by `touch.identifier`. 6. **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. 7. **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. 8. **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 - `playSound()` buffers events `{id, vol}` per frame - P1 includes in state; P2 plays by `document.getElementById(id).play()` - Set P2 to "remote mode" to block direct playSound from game logic - Start background music directly on P2 (constructor fires before 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 (`state`→`s`, `frame`→`f`, 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 |