Game Input Handling
Good input handling makes or breaks a game. Laggy controls, missed inputs, or confusing button mappings frustrate players before they even see your game's content. This tutorial shows how to handle keyboard, mouse, touch, and gamepad in a unified way.
Try it first:
The Input State Pattern
The naive approach is to react to events directly: when the user presses a key, do something. This causes problems. Events fire at unpredictable times, while your game loop runs at a fixed rate. If you move the player in the keydown handler, movement speed depends on key repeat rate, which varies by OS.
Instead, maintain an input state object that your game loop reads:
const input = {
left: false,
right: false,
up: false,
down: false,
action: false,
pointer: { x: 0, y: 0, down: false },
}Event handlers set these flags. The game loop reads them. This decoupling makes everything simpler: your update logic doesn't care whether input came from keyboard, touch, or gamepad.
Keyboard
Use e.code instead of e.key because it refers to the physical key position. WASD works correctly on non-QWERTY layouts.
const keyMap = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
KeyA: 'left',
KeyD: 'right',
KeyW: 'up',
KeyS: 'down',
Space: 'action',
}
window.addEventListener('keydown', (e) => {
const action = keyMap[e.code]
if (action) {
input[action] = true
e.preventDefault()
}
})
window.addEventListener('keyup', (e) => {
const action = keyMap[e.code]
if (action) input[action] = false
})The e.preventDefault() stops arrow keys from scrolling the page. Only call it for keys you're handling.
Mouse
Mouse coordinates come in screen space. You need to convert to canvas space:
const canvas = document.getElementById('game')
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect()
input.pointer.x = e.clientX - rect.left
input.pointer.y = e.clientY - rect.top
})
canvas.addEventListener('mousedown', () => {
input.pointer.down = true
input.action = true
})
canvas.addEventListener('mouseup', () => {
input.pointer.down = false
input.action = false
})If your canvas is scaled (see the responsive canvas tutorial), you'll need to divide by the scale factor to get game coordinates.
Touch
Touch works similarly to mouse, but you need e.preventDefault() to stop the browser from scrolling or zooming:
canvas.addEventListener('touchstart', (e) => {
e.preventDefault()
const touch = e.touches[0]
const rect = canvas.getBoundingClientRect()
input.pointer.x = touch.clientX - rect.left
input.pointer.y = touch.clientY - rect.top
input.pointer.down = true
input.action = true
}, { passive: false })
canvas.addEventListener('touchmove', (e) => {
e.preventDefault()
const touch = e.touches[0]
const rect = canvas.getBoundingClientRect()
input.pointer.x = touch.clientX - rect.left
input.pointer.y = touch.clientY - rect.top
}, { passive: false })
canvas.addEventListener('touchend', () => {
input.pointer.down = false
input.action = false
})The { passive: false } is required because modern browsers default to passive touch listeners for performance. Without it, preventDefault won't work.
Virtual D-Pad for Mobile
For games that need directional controls on mobile, add on-screen buttons:
function createVirtualDpad(container) {
const dpad = document.createElement('div')
dpad.className = 'virtual-dpad'
dpad.innerHTML = `
<button data-dir="up">↑</button>
<button data-dir="left">←</button>
<button data-dir="right">→</button>
<button data-dir="down">↓</button>
`
dpad.querySelectorAll('button').forEach(btn => {
const dir = btn.dataset.dir
btn.addEventListener('touchstart', (e) => {
e.preventDefault()
input[dir] = true
})
btn.addEventListener('touchend', () => input[dir] = false)
})
container.appendChild(dpad)
}Style these buttons to be large enough for thumbs (at least 44x44 pixels) and position them in the bottom corners.
Gamepad
Unlike keyboard and mouse, gamepad state isn't event-driven. You have to poll it every frame:
function pollGamepads() {
const gamepads = navigator.getGamepads()
const gp = gamepads[0]
if (gp) {
// D-pad or left stick
input.left = gp.buttons[14]?.pressed || gp.axes[0] < -0.5
input.right = gp.buttons[15]?.pressed || gp.axes[0] > 0.5
input.up = gp.buttons[12]?.pressed || gp.axes[1] < -0.5
input.down = gp.buttons[13]?.pressed || gp.axes[1] > 0.5
// A button (standard mapping)
input.action = gp.buttons[0]?.pressed
}
}Call pollGamepads() at the start of your update function. The 0.5 threshold on analog sticks creates a dead zone so small stick movements don't register as input.
You can detect when gamepads connect and disconnect:
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id)
})
window.addEventListener('gamepaddisconnected', (e) => {
console.log('Gamepad disconnected:', e.gamepad.id)
})Using Input in Your Game Loop
Now your update function just reads the input state:
function update(dt) {
pollGamepads()
if (input.left) player.x -= player.speed * dt
if (input.right) player.x += player.speed * dt
if (input.up) player.y -= player.speed * dt
if (input.down) player.y += player.speed * dt
if (input.action && player.canShoot) {
player.shoot()
}
}This works regardless of whether input came from keyboard, touch, or gamepad. The game logic doesn't need to know.
Just Pressed vs Held
Sometimes you want to detect when a button was just pressed this frame, not whether it's currently held. Jumping is a good example: you want one jump per press, not continuous jumping while the button is held.
const inputPrev = { ...input }
function wasJustPressed(action) {
return input[action] && !inputPrev[action]
}
function endFrame() {
Object.assign(inputPrev, input)
}Call endFrame() at the end of your update loop. Then use wasJustPressed('action') instead of input.action for one-shot actions.
Focus Loss
When the browser window loses focus, keyboard events stop firing. If the player was holding a key, it gets stuck on:
window.addEventListener('blur', () => {
input.left = input.right = input.up = input.down = input.action = false
})Without this, the player keeps moving when they alt-tab away.
Best Practices
Support both WASD and arrow keys since players have preferences. Allow rebinding and store the configuration in localStorage. Provide touch controls on mobile devices. Test with real gamepads because Xbox, PlayStation, and generic controllers all behave slightly differently. And always handle focus loss gracefully.
More Resources
Gamepad API deep dive covers advanced gamepad features like vibration and multiple players.
Mobile-friendly web games covers touch input in more detail.
Pointer Lock for FPS games shows how to capture the mouse for first-person controls.