DEV Community

Cover image for Mobile Touch Controls: Scale-Independent Fix
Weird Codes
Weird Codes

Posted on • Originally published at weirdcodes.itch.io

Mobile Touch Controls: Scale-Independent Fix


Issue #46 — Mobile Touch Controls: Scale-Independent Fix

Date: 2026-08-08
Label: UX enhancement mobile
Milestone: v0.1

The Problem

On mobile, touch controls were nearly impossible to tap. The root cause was architectural — #touch-controls lived inside #gameContainer, which receives a transform: scale(~0.58) from scaleGame() on narrow viewports. Everything inside the container shrank together: canvas, HUD, and touch buttons alike. A 64px button became ~37px, and the 36px small buttons dropped to ~21px — well below Apple's 44px HIG minimum.

There was also a secondary issue: any element with position: fixed inside a transformed ancestor does not fix to the viewport. It fixes to the transformed container instead. This is a CSS spec rule, not a browser bug. Moving #touch-controls outside <main> was the only correct fix.

What Changed

index.html

  • Added viewport-fit=cover to the meta viewport tag — required for env(safe-area-inset-bottom) to return non-zero values on iPhones with notches.
  • Moved the entire #touch-controls div from inside </main> to after it, at the body level. Now it lives outside the scaled container entirely.

style.css

  • #touch-controls: position: absoluteposition: fixed. Now truly viewport-anchored.
  • bottom: 92pxbottom: 0. Controls sit flush at the bottom; padding handles spacing.
  • Added padding-bottom: calc(8px + env(safe-area-inset-bottom)) — iPhone home indicator clearance.
  • Added backdrop-filter: blur(6px) and a semi-transparent dark background — controls are now readable over any game scene.
  • z-index: 9z-index: 10. Still below overlays (shastra: 60, loading: 70) but above canvas.
  • .touch-btn-sm: 36px44px, font-size: 15px17px — meets Apple HIG minimum tap target.
  • New @media (hover: none) and (pointer: coarse) block: body { height: 100dvh } — uses dynamic viewport height so the URL bar's appearance/disappearance doesn't cause layout jumps.

src/touch.js

  • Replaced the single-boolean syncWithTutorial(cardVisible) approach with a blocker-set pattern.
  • _blockers = new Set(['start']) — controls start hidden (start screen is blocking). As screens are dismissed, their reason is removed from the set. Controls only show when the set is empty.
  • New public API: block(reason), unblock(reason), _applyVisibility().
  • syncWithTutorial() kept as a backward-compatible wrapper — it now calls block/unblock('tutorial') internally.
  • This architecture handles all future overlay states (shastra, viraama, end screen) without boolean collision.

src/main.js

  • scaleGame(): switched to window.visualViewport?.height ?? window.innerHeight. The regular innerHeight does not exclude the mobile browser URL bar; visualViewport.height does. This prevents the canvas from overflowing under the address bar.
  • Added window.visualViewport?.addEventListener('resize', debouncedScale) — fires when the URL bar shows/hides, which window.resize often misses on mobile.
  • Added window.addEventListener('orientationchange', () => setTimeout(scaleGame, 300)) — 300ms delay lets the browser finish the rotation reflow before recalculating scale.
  • touch.unblock('start') added to both startBtn and tutorialBtn click handlers.
  • touch.block('shastra') / touch.unblock('shastra') added to toggleShastra().

What Was Dropped

An earlier plan included align-items: flex-start on body and transform-origin: top center on #gameContainer for mobile. This caused the canvas to shift upward, leaving a large black gap below the game and above the controls. Both were reverted — align-items: center and transform-origin: center center remain as defaults, and scaleGame() uses visualViewport.height directly to account for the touch bar height without layout tricks.

A TOUCH_CTRL_H = 90 offset in scaleGame() was also removed — it reduced the available height calculation unnecessarily, making the canvas smaller than it needed to be. visualViewport.height already returns the correct available height.

Architecture Note: Why Blocker-Set?

The previous syncWithTutorial() called el.style.display = visible ? 'none' : 'flex' directly. This worked when only one condition (tutorial card) could hide the controls. Now we have at minimum four independent reasons to hide them: start screen, tutorial card, shastra overlay, and future end-screen transitions. A single boolean would require chaining conditions. The blocker-set lets each system independently assert its need to hide controls, and controls only appear when every system has released its block. No coordination required between callers.

Lessons

  • CSS position: fixed is relative to the nearest ancestor with a CSS transform — not always the viewport. When a game container uses transform: scale(), all fixed descendants inside it are contained by that element.
  • window.innerHeight includes the space behind the mobile URL bar. visualViewport.height excludes it. For game scaling on mobile, always use visualViewport.
  • 100dvh (dynamic viewport height) is the CSS equivalent of visualViewport.height — use it in CSS, and visualViewport.height in JS.
  • When multiple systems can independently hide a UI element, reach for a blocker-set rather than a single boolean. It composes cleanly and avoids hidden state conflicts.

Files Changed

index.html · style.css · src/touch.js · src/main.js

Closes

#46

Top comments (0)