Skip to content

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:

js
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.

js
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:

js
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:

js
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:

js
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:

js
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:

js
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:

js
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.

js
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:

js
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.