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