Handling Input in Web Games
The Browser Input Stack
Native game engines provide a single, unified input layer that abstracts away hardware differences. The browser does not. Instead, the browser exposes several independent APIs, each designed for a different class of input device. For general web applications, this separation makes sense because forms, scrolling, and navigation each need different behaviors. For games, it creates extra work because a single player action (like "move left") can originate from at least four different hardware sources.
The five core input APIs are Touch Events for finger interaction on touchscreens, KeyboardEvent for physical and virtual keyboard input, MouseEvent for cursor positioning and clicks, PointerEvent as a unified abstraction over touch, mouse, and pen, and the Gamepad API for USB and Bluetooth game controllers. Each API fires events at the browser's event loop, and the game reads those events to update its internal state. Understanding when each API fires, what data it provides, and where its limitations lie is the foundation of solid web game input.
Touch Events
The Touch Events API is the original interface for multi-touch input on mobile devices. It predates Pointer Events and remains the standard for games that need fine control over multiple simultaneous touch points. The four events are touchstart (a finger touches the screen), touchmove (a finger moves while touching), touchend (a finger lifts off), and touchcancel (the system cancels the touch, often because the browser took over for a gesture like pull-to-refresh).
Each event carries three TouchList arrays. The touches list contains every active touch point on the entire page. The targetTouches list contains only the touch points that started on the same element as the event's target. The changedTouches list contains the specific touch points that caused this event to fire. For a game canvas, targetTouches is usually the most useful because it filters out touches on UI overlays or other DOM elements.
Each Touch object in these lists provides clientX and clientY coordinates relative to the viewport, pageX and pageY coordinates relative to the full page, an identifier integer that stays constant for the entire life of that touch point (from touchstart to touchend), and the target element where the touch began. The identifier is critical for tracking individual fingers across events, which is how virtual joysticks distinguish the movement finger from the action finger.
The biggest gotcha with Touch Events is default browser behavior. If you listen for touchmove without calling preventDefault(), the browser will scroll the page. If you listen for touchstart without calling preventDefault(), the browser may fire a synthetic click event 300 milliseconds later. The CSS property touch-action: none on the game element is the declarative alternative to preventDefault(), and it also eliminates the 300ms delay by telling the browser that no scrolling, zooming, or panning should happen on that element.
Keyboard Events
The KeyboardEvent interface fires keydown when a key is pressed, keyup when a key is released, and keypress for character input (though keypress is deprecated and should not be used). For game controls, keydown and keyup are the relevant events. The game maintains a set of currently pressed keys, adding on keydown and removing on keyup, then reads this set each frame to determine player actions.
Three properties identify the key. The event.key property returns the character or key name as a string, like "a", "Enter", or "ArrowLeft." It reflects the user's keyboard layout, so the physical key in the "A" position on an AZERTY keyboard returns "q" instead of "a." The event.code property returns the physical key position, like "KeyA" or "ArrowLeft," regardless of layout. For game controls, code is almost always the correct choice because WASD-style bindings refer to physical key positions, not characters. The event.keyCode property is a legacy integer code that is deprecated and inconsistent across browsers.
When a key is held down, the browser fires repeated keydown events after an initial delay (usually around 500 milliseconds) followed by repeat events at the OS key-repeat rate (usually 30 to 60 per second). These repeat events have event.repeat set to true. Games should ignore repeat events entirely and only track the initial keydown and the final keyup. Processing repeat events would cause actions like "toggle inventory" to flip rapidly while the key is held.
One important subtlety: if the player presses a key while the game window has focus, then switches to another tab or window, the keyup event never fires in the game tab. The game thinks the key is still held, and the character keeps moving when the player returns. The solution is to listen for the blur event on the window and clear all pressed keys when focus is lost.
Mouse Events
MouseEvent fires for cursor movement (mousemove), button presses (mousedown), button releases (mouseup), clicks (click, dblclick), context menu requests (contextmenu), and scrolling (wheel). For games, mousemove provides continuous aiming or cursor positioning, mousedown and mouseup track button state for shooting or selecting, and wheel handles zoom or weapon switching.
Mouse coordinates come in several flavors. The clientX and clientY properties give viewport-relative coordinates. The pageX and pageY properties include scroll offsets. The offsetX and offsetY properties give coordinates relative to the target element's padding edge, which is often the most convenient for canvas games. The movementX and movementY properties on mousemove events report the delta since the last event, which is useful for camera rotation without needing to compute deltas manually.
The Pointer Lock API is essential for first-person games. Calling element.requestPointerLock() captures the mouse cursor, hides it, and delivers unlimited movementX and movementY deltas without the cursor hitting screen edges. Pointer lock requires a user gesture to activate, and the player can exit by pressing Escape. Games should listen for the pointerlockchange event on document to detect when the lock is acquired or lost, and display a "click to resume" prompt when the lock is lost.
Right-click behavior needs deliberate handling. By default, the browser opens a context menu on right-click. Games that use right-click for actions (secondary fire, interact, or cancel) should call preventDefault() on the contextmenu event to suppress the menu. Some players rely on right-click for browser functions, so this suppression should only apply when the game canvas has focus.
Pointer Events
The Pointer Events API unifies touch, mouse, and pen input into a single event model. The events are pointerdown, pointermove, pointerup, pointercancel, pointerenter, pointerleave, and pointerover. Each event includes a pointerType property that identifies the source as "mouse," "touch," or "pen," plus a pointerId that uniquely identifies each active pointer.
For simple games that do not need multi-touch gesture tracking, Pointer Events can simplify the code by letting one set of event handlers cover both mouse and touch. The pointerType property allows different behavior for different devices when needed. However, Pointer Events flatten multi-touch into separate pointer IDs, which makes complex multi-touch gestures (like pinch-zoom or two-finger rotation) harder to implement than with Touch Events, where all active touches are available in a single list.
A practical approach for many web games is to use Touch Events for the virtual joystick and action buttons (where multi-touch tracking is essential), KeyboardEvent and MouseEvent for desktop input, and the gamepadconnected event plus polling for gamepad input. Pointer Events are most useful as the base layer for games that only need single-touch or games where mouse-and-touch unification is more important than multi-touch precision.
The Gamepad API
Unlike every other input API in the browser, the Gamepad API does not fire events for button presses or stick movement. Instead, games call navigator.getGamepads() on each animation frame to retrieve the current state of all connected controllers. Each Gamepad object in the returned array has a buttons array (with pressed boolean and value float per button) and an axes array (with float values from -1.0 to 1.0 per axis).
The two available events are gamepadconnected and gamepaddisconnected, both on the window object. These are useful for displaying connection notifications and identifying the controller type through the Gamepad.id string. The id string varies by browser and OS, so mapping it to a friendly name ("Xbox Controller," "PlayStation DualSense") requires a lookup table or pattern matching on known substrings.
Standard-mapping gamepads report buttons in a consistent order defined by the W3C specification: face buttons (A/B/X/Y or Cross/Circle/Square/Triangle) at indices 0 through 3, shoulder buttons at 4 and 5, triggers at 6 and 7, select and start at 8 and 9, stick clicks at 10 and 11, d-pad at 12 through 15, and home at 16. The axes follow left-stick-X, left-stick-Y, right-stick-X, right-stick-Y at indices 0 through 3. Non-standard controllers may report in a different order, which is why offering a remap UI is valuable.
Choosing the Right API Combination
For a 2D action game or platformer that needs to work on both mobile and desktop, the recommended combination is Touch Events for mobile virtual controls (joystick and buttons), KeyboardEvent for desktop keyboard bindings, MouseEvent for desktop mouse aiming or clicking, and Gamepad API polling for controller support. This covers the vast majority of players without introducing unnecessary complexity.
For a 3D first-person game, add Pointer Lock for mouse look on desktop. For a strategy or simulation game where touch input is point-and-click rather than dual-stick, Pointer Events can simplify things by handling both mouse clicks and touch taps through a single code path.
The key principle is to let the input API layer translate hardware events into a simple internal state (keys held, positions, button presses) and let the game loop read that state each frame. This decoupling keeps the game logic clean and makes adding new input methods straightforward.
Use Touch Events for multi-touch virtual controls, KeyboardEvent.code for physical key tracking, MouseEvent with Pointer Lock for first-person aiming, and poll navigator.getGamepads() each frame for controller support. Keep all of them writing to the same internal state so your game logic stays device-agnostic.