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=coverto the meta viewport tag — required forenv(safe-area-inset-bottom)to return non-zero values on iPhones with notches. - Moved the entire
#touch-controlsdiv from inside</main>to after it, at the body level. Now it lives outside the scaled container entirely.
style.css
-
#touch-controls:position: absolute→position: fixed. Now truly viewport-anchored. -
bottom: 92px→bottom: 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: 9→z-index: 10. Still below overlays (shastra: 60, loading: 70) but above canvas. -
.touch-btn-sm:36px→44px,font-size: 15px→17px— 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 callsblock/unblock('tutorial')internally. - This architecture handles all future overlay states (shastra, viraama, end screen) without boolean collision.
src/main.js
-
scaleGame(): switched towindow.visualViewport?.height ?? window.innerHeight. The regularinnerHeightdoes not exclude the mobile browser URL bar;visualViewport.heightdoes. This prevents the canvas from overflowing under the address bar. - Added
window.visualViewport?.addEventListener('resize', debouncedScale)— fires when the URL bar shows/hides, whichwindow.resizeoften 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 bothstartBtnandtutorialBtnclick handlers. -
touch.block('shastra')/touch.unblock('shastra')added totoggleShastra().
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: fixedis relative to the nearest ancestor with a CSS transform — not always the viewport. When a game container usestransform: scale(), allfixeddescendants inside it are contained by that element. -
window.innerHeightincludes the space behind the mobile URL bar.visualViewport.heightexcludes it. For game scaling on mobile, always usevisualViewport. -
100dvh(dynamic viewport height) is the CSS equivalent ofvisualViewport.height— use it in CSS, andvisualViewport.heightin 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
Top comments (0)