Mobile-friendly web games
Mobile is the biggest gaming platform. A few adjustments make your web game work great on phones and tablets.
1) Viewport setup
Prevent zoom and ensure proper scaling:
html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">2) Fullscreen canvas
css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
touch-action: none; /* Prevent browser gestures */
}
#game {
display: block;
width: 100%;
height: 100%;
}3) Touch event handling
js
const canvas = document.getElementById('game')
// Prevent default touch behaviors
canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false })
canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false })
// Track touches
const touches = new Map()
canvas.addEventListener('touchstart', (e) => {
for (const touch of e.changedTouches) {
touches.set(touch.identifier, {
x: touch.clientX,
y: touch.clientY,
startX: touch.clientX,
startY: touch.clientY,
})
}
})
canvas.addEventListener('touchmove', (e) => {
for (const touch of e.changedTouches) {
const t = touches.get(touch.identifier)
if (t) {
t.x = touch.clientX
t.y = touch.clientY
}
}
})
canvas.addEventListener('touchend', (e) => {
for (const touch of e.changedTouches) {
touches.delete(touch.identifier)
}
})4) Virtual joystick
js
class VirtualJoystick {
constructor(x, y, radius) {
this.baseX = x
this.baseY = y
this.radius = radius
this.knobRadius = radius * 0.4
this.active = false
this.touchId = null
this.dx = 0
this.dy = 0
}
handleTouchStart(touch) {
const dist = Math.hypot(touch.clientX - this.baseX, touch.clientY - this.baseY)
if (dist < this.radius) {
this.active = true
this.touchId = touch.identifier
this.updatePosition(touch.clientX, touch.clientY)
}
}
handleTouchMove(touch) {
if (touch.identifier === this.touchId) {
this.updatePosition(touch.clientX, touch.clientY)
}
}
handleTouchEnd(touch) {
if (touch.identifier === this.touchId) {
this.active = false
this.touchId = null
this.dx = 0
this.dy = 0
}
}
updatePosition(x, y) {
let dx = x - this.baseX
let dy = y - this.baseY
const dist = Math.hypot(dx, dy)
if (dist > this.radius) {
dx = (dx / dist) * this.radius
dy = (dy / dist) * this.radius
}
this.dx = dx / this.radius
this.dy = dy / this.radius
}
draw(ctx) {
// Base circle
ctx.beginPath()
ctx.arc(this.baseX, this.baseY, this.radius, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'
ctx.fill()
// Knob
const knobX = this.baseX + this.dx * this.radius
const knobY = this.baseY + this.dy * this.radius
ctx.beginPath()
ctx.arc(knobX, knobY, this.knobRadius, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
ctx.fill()
}
getInput() {
return { x: this.dx, y: this.dy }
}
}5) Touch buttons
js
class TouchButton {
constructor(x, y, radius, label) {
this.x = x
this.y = y
this.radius = radius
this.label = label
this.pressed = false
this.touchId = null
}
contains(px, py) {
return Math.hypot(px - this.x, py - this.y) < this.radius
}
handleTouchStart(touch) {
if (this.contains(touch.clientX, touch.clientY)) {
this.pressed = true
this.touchId = touch.identifier
}
}
handleTouchEnd(touch) {
if (touch.identifier === this.touchId) {
this.pressed = false
this.touchId = null
}
}
draw(ctx) {
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.fillStyle = this.pressed ? 'rgba(255, 255, 255, 0.5)' : 'rgba(255, 255, 255, 0.2)'
ctx.fill()
ctx.font = 'bold 24px sans-serif'
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(this.label, this.x, this.y)
}
}6) Orientation handling
js
function checkOrientation() {
const isLandscape = window.innerWidth > window.innerHeight
if (!isLandscape) {
showRotatePrompt()
} else {
hideRotatePrompt()
resizeCanvas()
}
}
window.addEventListener('resize', checkOrientation)
window.addEventListener('orientationchange', checkOrientation)
function showRotatePrompt() {
document.getElementById('rotate-prompt').style.display = 'flex'
}
function hideRotatePrompt() {
document.getElementById('rotate-prompt').style.display = 'none'
}7) Performance optimization
js
// Reduce resolution on low-end devices
function getDeviceScale() {
const dpr = window.devicePixelRatio || 1
const isLowEnd = navigator.hardwareConcurrency <= 2
return isLowEnd ? Math.min(dpr, 1.5) : Math.min(dpr, 2)
}
// Throttle expensive operations
let lastHeavyUpdate = 0
function maybeDoHeavyWork(timestamp) {
if (timestamp - lastHeavyUpdate > 100) { // 10 fps for heavy stuff
doExpensiveCalculations()
lastHeavyUpdate = timestamp
}
}
// Use lower quality assets
function getAssetQuality() {
const memory = navigator.deviceMemory || 4
if (memory <= 2) return 'low'
if (memory <= 4) return 'medium'
return 'high'
}8) Battery considerations
js
// Check battery and reduce effects
async function checkBattery() {
if ('getBattery' in navigator) {
const battery = await navigator.getBattery()
if (battery.level < 0.2 && !battery.charging) {
enableBatterySaver()
}
battery.addEventListener('levelchange', () => {
if (battery.level < 0.2 && !battery.charging) {
enableBatterySaver()
}
})
}
}
function enableBatterySaver() {
game.particleCount = Math.floor(game.particleCount * 0.5)
game.targetFps = 30
console.log('Battery saver enabled')
}9) Fullscreen API
js
async function requestFullscreen() {
const elem = document.documentElement
try {
if (elem.requestFullscreen) {
await elem.requestFullscreen()
} else if (elem.webkitRequestFullscreen) {
await elem.webkitRequestFullscreen()
}
// Lock orientation
if (screen.orientation?.lock) {
await screen.orientation.lock('landscape')
}
} catch (err) {
console.warn('Fullscreen failed:', err)
}
}
// Call on user interaction
document.getElementById('play-btn').addEventListener('click', () => {
requestFullscreen()
startGame()
})10) Testing checklist
Before shipping:
- Touch responsiveness — Test on actual devices, not just emulators
- Various screen sizes — Phone, tablet, iPad Pro
- Both orientations — Or lock to one
- Low-end devices — Test on 2-3 year old phones
- Slow networks — Test on 3G simulation
- Safari iOS — Has quirks with audio, fullscreen
- Chrome Android — Most common browser
- Interruptions — Phone calls, notifications
- Memory pressure — Does it recover after background tab?
js
// Debug touch visualization
function drawTouchDebug(ctx) {
for (const [id, touch] of touches) {
ctx.beginPath()
ctx.arc(touch.x, touch.y, 30, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'
ctx.fill()
ctx.fillStyle = '#fff'
ctx.fillText(id.toString(), touch.x, touch.y)
}
}