Keyboard and Mouse Controls
Desktop players expect keyboard and mouse controls to feel as good as a native game. The browser gives you all the APIs to achieve that, but the default behavior is designed for web pages, not games. Key repeat events fire when keys are held, browser shortcuts intercept gameplay keys, and mouse coordinates are relative to the viewport rather than the game canvas. This guide covers each issue and how to solve it.
Build a Key State Tracker
Game logic runs on a frame loop, typically at 60 frames per second via requestAnimationFrame. Each frame, the game needs to know which keys are currently held down. The browser fires discrete keydown and keyup events, but the game loop cannot wait for events. It needs to poll the current state.
Create a Set (or a plain object) that represents currently pressed keys. On keydown, add event.code to the set. On keyup, remove event.code from the set. On each frame, the game checks whether specific codes are present: if the set has "KeyW," the player is holding the forward key. If it has "Space," the player is holding jump. This pattern decouples event timing from frame timing and prevents double-counting keys that the browser reports multiple times per frame.
Ignore key repeat events by checking event.repeat at the start of the keydown handler. When a key is held, the browser fires repeated keydown events after a brief delay, at the operating system's repeat rate. These repeat events are useful for text input (holding backspace to delete multiple characters) but harmful for games. A "toggle inventory" action bound to the I key would flicker rapidly if repeat events are processed. Filtering them out with a simple if (event.repeat) return keeps game input clean.
For actions that should trigger once per press (like jumping or using an item), track a separate "just pressed" state. On keydown (non-repeat), add the code to both the "held" set and a "just pressed" set. At the end of each frame, clear the "just pressed" set. This lets the game distinguish between holding a key (for movement) and pressing it once (for actions), which is a fundamental distinction in almost every game.
Use event.code for Physical Key Positions
The KeyboardEvent object provides three key-identification properties: event.key, event.code, and the deprecated event.keyCode. For game controls, event.code is the correct choice because it identifies the physical position of the key on the keyboard, not the character it produces.
On a standard QWERTY keyboard, pressing the key labeled "W" fires event.code "KeyW" and event.key "w". On a French AZERTY keyboard, the same physical key fires event.code "KeyW" but event.key "z" (because Z is in the W position on AZERTY). If you bind movement to event.key "w", French players will need to reach for a key in an awkward position to move forward. Binding to event.code "KeyW" means the same physical key works regardless of layout.
Arrow keys, modifier keys, and function keys also have consistent code values: "ArrowUp," "ArrowLeft," "ShiftLeft," "ControlRight," "Escape," and so on. Use these for secondary bindings or menu navigation. The full list of code values is defined in the UI Events KeyboardEvent code Values specification and is consistent across all modern browsers.
When displaying key bindings in the UI (like "Press W to move forward"), you may want to show the actual character on the player's keyboard rather than the physical position. Use event.key for display purposes and event.code for internal binding. This way, a French player sees "Press Z to move forward" (matching their physical key label) while the binding itself works correctly on any layout.
Handle Focus Loss
If a player presses a movement key, then switches to another browser tab or clicks outside the game, the keyup event never fires in the game window. The key state tracker still thinks the key is held, and the player's character keeps moving when they return to the game. This is one of the most common input bugs in web games.
The fix is to listen for the blur event on the window object and clear the entire key state when it fires. Clear both the "held" set and the "just pressed" set. When the player returns to the tab, they start with a clean input state and must re-press any keys they want active. This matches the behavior players expect: returning to a game should not resume movement that started before the tab switch.
The visibilitychange event on the document is an alternative trigger. When document.hidden becomes true, the page is no longer visible, and clearing input state is appropriate. Some developers listen for both blur and visibilitychange to cover all edge cases, including situations where the browser minimizes the window without the game element losing focus (which can happen with certain OS-level shortcuts).
Track Mouse Position and Buttons
For games that use the mouse for aiming, clicking, or dragging, maintain a mouse state object with the current canvas-relative position and a set of pressed buttons. On mousemove, update the position by subtracting the canvas bounding rect from clientX and clientY, then scaling for any difference between display size and internal resolution. On mousedown, add event.button to the pressed buttons set (0 for left, 1 for middle, 2 for right). On mouseup, remove it.
Read the mouse state in the game loop just like the keyboard state. The game should never react to individual mouse events directly because events can fire multiple times per frame, and processing them individually creates frame-rate-dependent behavior. Store the latest position and button state, and read it once per frame.
For wheel input (zooming, scrolling through items, cycling weapons), listen for the wheel event and accumulate the deltaY value. On each frame, read the accumulated delta and reset it to zero. This prevents missed scroll events when the player scrolls rapidly and multiple wheel events fire between frames.
Suppress the browser's context menu on right-click by calling preventDefault() on the contextmenu event when the canvas has focus. Without this, right-clicking to use a secondary action will open the browser's context menu over the game. Only suppress the context menu on the canvas element, not the entire page, so players can still right-click on other parts of the UI.
Implement Pointer Lock for First-Person Games
First-person and third-person games need unlimited mouse rotation without the cursor hitting screen edges. The Pointer Lock API solves this by hiding the cursor, confining it to the game element, and reporting raw movement deltas (movementX, movementY) on each mousemove event instead of absolute screen coordinates.
Request pointer lock by calling canvas.requestPointerLock() in response to a user gesture, typically a click on the canvas. The browser will not grant pointer lock without a user gesture for security reasons. Once the lock is granted, the document fires a pointerlockchange event where document.pointerLockElement equals the canvas. From this point, every mousemove event on the document carries movementX and movementY deltas that you add to your camera rotation angles.
The player can exit pointer lock at any time by pressing Escape. When this happens, document.pointerLockElement becomes null, and another pointerlockchange event fires. The game should detect this, pause or overlay a menu, and display a "click to resume" prompt. Attempting to re-lock immediately after the player presses Escape will fail in most browsers due to a deliberate cooldown that prevents games from trapping the cursor against the player's will.
Apply sensitivity scaling to the movement deltas before using them for camera rotation. Raw delta values vary between operating systems and mouse DPI settings, so a sensitivity multiplier (often exposed as a slider in the options menu) lets each player adjust the turning speed to their preference. A starting value of 0.002 radians per pixel of movement is a reasonable default for most games.
Suppress Conflicting Browser Shortcuts
Certain key combinations trigger browser actions that interrupt gameplay. The most disruptive ones are F5 (reload), Ctrl+R (reload), Ctrl+W (close tab), Ctrl+T (new tab), and F11 (fullscreen toggle). While you should not override all browser shortcuts (players rely on them), some are unavoidable conflicts with common game bindings.
To suppress a specific key combination, call event.preventDefault() in the keydown handler when the combination matches. For example, if your game uses F for interact and the player accidentally hits F5, the page reloads and the game state is lost. Preventing default on "F5" when the game canvas has focus stops the reload. Be selective: only prevent keys that genuinely conflict with your bindings, and restore default behavior when the game is paused or when a text input has focus.
Some key combinations like Ctrl+W and Alt+F4 cannot be reliably intercepted in the browser for security reasons. Do not try to override these. Instead, avoid binding game actions to keys that are commonly used with Ctrl or Alt modifiers. If the game needs Ctrl or Alt as gameplay modifiers (like crouch or walk), check for the modifier key alone (event.code "ControlLeft") rather than combinations.
For fullscreen support, use the Fullscreen API (canvas.requestFullscreen()) triggered by a button in the game UI rather than an F-key binding. This avoids the F11 conflict entirely and works consistently across browsers. Fullscreen mode also gives the game more screen space, which is especially valuable for games that use Pointer Lock.
Maintain a Set-based key tracker, use event.code for layout-independent bindings, clear state on blur, track mouse position relative to the canvas, use Pointer Lock for first-person camera control, and selectively suppress browser shortcuts that conflict with gameplay. This setup gives desktop players the responsive, predictable input they expect.