Adapt a desktop-only HTML5 Canvas game for mobile portrait play with on-screen touch controls, without rewriting core game logic.
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.
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.
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'
export const isMobile = () =>
window.innerHeight > window.innerWidth && 'ontouchstart' in window;
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.
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);
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);
}
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.
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 ... */
}
<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;
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.
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.
Express 5 catch-all route — app.get('*', ...) crashes with "Missing parameter name at index 1". Use app.get('/{*splat}', ...) instead.
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'));
});
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.
Don't forget diagonal inputs — D-pad needs multi-touch support so players can press up+right simultaneously. Track touches by touch.identifier.
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.
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.
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.
| 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 |
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).
playSound() buffers events {id, vol} per framedocument.getElementById(id).play()sound.play().catch(() => {}) — always catch. Unlock on first click/touch/keydown.
Browser WS may deliver Blob or ArrayBuffer. Handle: ws.binaryType = 'arraybuffer' then decode.
state→s, frame→f, etc, ~30% smaller)| 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 |