1Event Handling and Player Input
▶
Every interactive game — whether a side-scrolling platformer, a top-down shooter, or a simple puzzle — lives and dies by how well it responds to the player. The bridge between the player's physical actions (pressing a key, clicking a mouse, tapping a screen) and what happens inside the game world is event handling. JavaScript's event system lets you register listeners that fire a function whenever a specific interaction occurs. Understanding how to attach those listeners correctly, read the data they deliver, and translate raw input into smooth, frame-synced game actions is one of the most fundamental skills in browser-based game development.
This topic covers the full input pipeline: attaching listeners to the right target, reading keyboard and pointer state, supporting touch devices, mapping raw values to game actions, and preventing the browser's built-in behaviors from interfering with gameplay.
Attaching Event Listeners to the Document or Canvas
JavaScript dispatches input events on DOM elements. The two most common targets for a canvas game are the document object and the <canvas> element itself. Choosing the right target matters.
Keyboard events are almost always attached to document rather than to the canvas, because keyboard events fire on whichever element currently has focus — and a canvas element does not receive focus by default unless you give it a tabindex attribute. Attaching to the document guarantees you capture every keypress regardless of focus state:
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
Pointer events (mouse, touch) are better attached directly to the canvas element, because you are interested in interactions that happen within the game viewport. Attaching to the document would capture drags that began outside the canvas, which is usually undesirable:
const canvas = document.getElementById('gameCanvas');
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd);
Notice the { passive: false } option on the touch listeners. By default, modern browsers mark touch listeners as passive (meaning preventDefault() is ignored) to allow smooth scrolling. Setting passive: false opts out of that optimization and lets you suppress default touch behavior when needed — more on that shortly.
A critical habit: always store a named reference to any handler you register. Anonymous arrow functions cannot be removed later because each expression creates a new function object:
// WRONG — you can never remove this listener
canvas.addEventListener('mousedown', (e) => { /* ... */ });
// RIGHT — store the reference
function handleMouseDown(e) { /* ... */ }
canvas.addEventListener('mousedown', handleMouseDown);
// Later, if the game ends or the screen changes:
canvas.removeEventListener('mousedown', handleMouseDown);
Removing listeners is important in games with multiple screens (main menu, gameplay, pause screen) to avoid stale handlers firing during the wrong phase.
Reading Keyboard Input
The browser fires a keydown event the moment a key is pressed and a keyup event when it is released. Both events carry a key property that contains a human-readable string such as 'ArrowLeft', 'a', ' ' (spacebar), or 'Enter'.
A naive approach handles movement directly inside the event handler:
// FRAGILE — don't do this
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') player.x += 5;
});
This feels choppy. The operating system repeats keydown events at its own pace (usually with an initial delay), so movement is jerky and not tied to your game's frame rate. The correct pattern is to maintain a keys state object that records which keys are currently held down, then read that object inside the game loop each frame:
const keys = {};
document.addEventListener('keydown', (e) => {
keys[e.key] = true;
});
document.addEventListener('keyup', (e) => {
keys[e.key] = false;
});
Inside your game loop you then poll this object:
function update(dt) {
if (keys['ArrowLeft']) player.x -= player.speed * dt;
if (keys['ArrowRight']) player.x += player.speed * dt;
if (keys['ArrowUp']) player.y -= player.speed * dt;
if (keys['ArrowDown']) player.y += player.speed * dt;
}
Because the loop calls update every frame (typically 60 times per second via requestAnimationFrame), movement is smooth and perfectly frame-rate-synced. Multiple keys can be held simultaneously and all will be respected because each key has its own property in the keys object.
One subtle improvement: initialize unknown keys to false rather than leaving them undefined. Using !!keys['ArrowLeft'] or checking keys['ArrowLeft'] === true makes the intent explicit and avoids truthy/falsy confusion.
Reading Mouse Input
Mouse events provide the cursor's position through event.clientX and event.clientY, which are measured in CSS pixels from the top-left corner of the browser viewport. If your canvas is not positioned at the very top-left of the page (and it almost never is), you must convert these viewport-relative coordinates into canvas-local coordinates.
The standard technique uses getBoundingClientRect():
function getCanvasPos(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
If the canvas is CSS-scaled (its CSS size differs from its internal resolution), you must also account for that scaling:
function getCanvasPos(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
};
}
Track button state with a simple flag to distinguish click-and-hold (e.g., held fire button) from single clicks:
let mouseX = 0, mouseY = 0;
let mouseDown = false;
canvas.addEventListener('mousemove', (e) => {
const pos = getCanvasPos(canvas, e);
mouseX = pos.x;
mouseY = pos.y;
});
canvas.addEventListener('mousedown', (e) => {
mouseDown = true;
const pos = getCanvasPos(canvas, e);
mouseX = pos.x;
mouseY = pos.y;
});
canvas.addEventListener('mouseup', (e) => {
mouseDown = false;
});
You can also store per-button state using e.button (0 = left, 1 = middle, 2 = right) if your game needs to distinguish between mouse buttons.
Handling Touch Input for Mobile Play
Touch input mirrors mouse input conceptually but supports multiple simultaneous contact points (multi-touch). The three key events are touchstart, touchmove, and touchend. Each event carries two important lists:
- event.touches — all touch points currently in contact with the screen.
- event.changedTouches — only the touch points that changed in this event (new presses, movements, or lifts). This is the more useful list for most handlers.
Each touch point is an object with clientX and clientY (among others). Apply the same bounding-rect conversion used for mouse events:
canvas.addEventListener('touchstart', (e) => {
e.preventDefault(); // prevent scroll / zoom
const rect = canvas.getBoundingClientRect();
for (const touch of e.changedTouches) {
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
handleTap(x, y);
}
}, { passive: false });
For a simple game that needs to support both mouse and touch with minimal branching, you can write a unified pointer handler that maps touch events to the same internal state as mouse events:
function pointerStart(x, y) { mouseDown = true; mouseX = x; mouseY = y; }
function pointerMove (x, y) { mouseX = x; mouseY = y; }
function pointerEnd () { mouseDown = false; }
canvas.addEventListener('mousedown', (e) => { const p = getCanvasPos(canvas,e); pointerStart(p.x, p.y); });
canvas.addEventListener('mousemove', (e) => { const p = getCanvasPos(canvas,e); pointerMove (p.x, p.y); });
canvas.addEventListener('mouseup', () => { pointerEnd(); });
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const t = e.changedTouches[0];
const p = getCanvasPos(canvas, t);
pointerStart(p.x, p.y);
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const t = e.changedTouches[0];
const p = getCanvasPos(canvas, t);
pointerMove(p.x, p.y);
}, { passive: false });
canvas.addEventListener('touchend', () => { pointerEnd(); });
This way, the rest of your game code only needs to check mouseX, mouseY, and mouseDown — it doesn't need to know whether the player is using a mouse or a finger.
Translating Raw Input into Game Actions
Raw input values (key strings, pixel coordinates) should never be scattered throughout your game logic. Instead, define a clear input-to-action mapping and let the game loop read an abstract action state. This separation makes it easy to rebind controls or add gamepad support later without rewriting gameplay code.
A simple action map might look like this:
const actions = {
moveLeft: false,
moveRight: false,
jump: false,
fire: false
};
function updateActions() {
actions.moveLeft = !!keys['ArrowLeft'] || !!keys['a'];
actions.moveRight = !!keys['ArrowRight'] || !!keys['d'];
actions.jump = !!keys['ArrowUp'] || !!keys['w'];
actions.fire = mouseDown;
}
Call updateActions() at the start of each frame update, then drive movement and behavior from actions:
function update(dt) {
updateActions();
if (actions.moveLeft) player.vx = -player.speed;
else if (actions.moveRight) player.vx = player.speed;
else player.vx = 0;
if (actions.jump && player.onGround) {
player.vy = -player.jumpForce;
player.onGround = false;
}
player.x += player.vx * dt;
player.y += player.vy * dt;
}
Notice that movement is applied by modifying velocity (e.g., player.vx, player.vy) and then integrating position each frame, rather than directly setting player.x. This is the standard game-physics pattern: it composes naturally with gravity, friction, and collision response.
For one-time actions (firing a bullet, jumping, toggling a menu), you need to detect the transition from unpressed to pressed, not just the pressed state. A key that is held down will remain true in the keys object every frame, but you only want to fire once per press. Track the previous frame's state:
const prevKeys = {};
document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup', (e) => { keys[e.key] = false; });
function justPressed(key) {
return keys[key] === true && prevKeys[key] !== true;
}
function update(dt) {
if (justPressed(' ')) fireWeapon(); // only on the first frame spacebar is held
// At the end of the frame, copy current state to previous
Object.assign(prevKeys, keys);
}
justPressed returns true only on the single frame when the key transitions from not held to held, preventing rapid-fire behaviour from a single press.
Preventing Default Browser Behavior
Many keys and gestures that are natural for gameplay already have built-in browser behaviors that conflict with your game. The table below summarizes the most common offenders and how to suppress them:
| Input | Default Browser Action | Fix |
|---|---|---|
| Arrow keys | Scroll the page up/down/left/right | Call e.preventDefault() in the keydown handler when an arrow key is detected |
| Spacebar | Scroll the page down; submit focused forms | Call e.preventDefault() in the keydown handler when e.key === ' ' |
| Backspace | Navigate back in browser history | Call e.preventDefault() if used as a game control |
| Right-click | Opens browser context menu | Call e.preventDefault() in a contextmenu listener on the canvas |
| Touch swipe | Scroll the page, trigger pull-to-refresh | Call e.preventDefault() in touchstart and touchmove (requires passive: false) |
| Pinch gesture | Pinch-to-zoom the browser viewport | Call e.preventDefault() in touchstart and touchmove (requires passive: false) |
A practical keydown handler that suppresses the most common problem keys:
const GAME_KEYS = new Set([
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
' ', 'w', 'a', 's', 'd'
]);
document.addEventListener('keydown', (e) => {
if (GAME_KEYS.has(e.key)) {
e.preventDefault();
}
keys[e.key] = true;
});
Using a Set makes the check O(1) and keeps the list easy to maintain. Only keys your game actually uses are suppressed, leaving the rest (such as F5 for reload or Ctrl+Tab for tab switching) available to the user.
An additional consideration for touch games: you can also prevent zooming at the HTML level by adding a viewport meta tag, but this should be combined with — not used instead of — preventDefault() in your touch handlers, because the meta tag alone doesn't block all gesture behaviors in every browser:
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no">
Together, the techniques in this topic form a complete input pipeline: listeners attached to the right targets, state tracked in plain objects, coordinates mapped into canvas space, actions abstracted from raw values, one-time events detected via state transitions, and browser defaults cleanly suppressed. Every feature of your game that responds to the player flows through this foundation.