Track keyboard state with keydown and keyup, move a player from that state each frame, and avoid stuck keys.
Why this matters
Controls are where players first touch your design. If the character keeps walking after a key is released, if pressing left and right together does something odd, or if the arrow keys scroll the page, the prototype feels broken no matter how good the idea is. Good input code is small, but it follows a few rules that are worth learning once.
Events versus state
The browser reports keys as events: keydown when a key goes down (and repeatedly while it is held, after a delay), keyup when it is released. A game, though, asks a different question every frame: is the key held right now?
The standard bridge is a keys object that events update and the game loop reads:
const keys = {};
window.addEventListener("keydown", (e) => {
keys[e.key] = true;
});
window.addEventListener("keyup", (e) => {
keys[e.key] = false;
});
Then movement happens in update(dt), once per frame, from the current state:
function update(dt) {
let dir = 0;
if (keys.ArrowLeft || keys.a) dir -= 1;
if (keys.ArrowRight || keys.d) dir += 1;
player.x += dir * player.speed * dt;
}
Why not move the player inside the keydown handler? Because key repeat is controlled by the operating system: there is a pause before repeats start, and the repeat rate varies by machine and user settings. Movement driven by events stutters and runs at different speeds for different players. Movement driven by state is smooth and uses your delta time.
Opposite keys cancel
Using a dir value that adds -1 and +1 gives a sensible answer when both are held: 0, standing still. Some games prefer "last key pressed wins", which you can build by recording the order of presses, but cancelling is a fine default and is predictable.
key versus code
e.key is the character the key produces ("a", "A", "ArrowLeft"). e.code is the physical key position ("KeyA", "ArrowLeft"). For WASD movement, e.code is better: on a French AZERTY keyboard the key in the W position produces "z", but its code is still "KeyW". Also note e.key changes with Shift ("a" vs "A"); lowercase it if you use it for letters. For arrow keys both are the same.
Stop the page scrolling
Arrow keys and Space scroll the page by default, which yanks your game out of view. Call preventDefault() for the keys your game uses:
const GAME_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " "];
window.addEventListener("keydown", (e) => {
if (GAME_KEYS.includes(e.key)) e.preventDefault();
keys[e.key] = true;
});
Only block the keys you need; blocking everything breaks shortcuts and accessibility tools.
Stuck keys and blur
If a player holds Right and then clicks another window (or alt-tabs), your page never receives the keyup. When they come back, the character runs forever. Fix it by clearing the state when the window loses focus:
window.addEventListener("blur", () => {
for (const k in keys) keys[k] = false;
});
Actions versus held keys
Some inputs are actions that should happen once per press: jump, fire, open a menu. Holding the key should not fire every frame. One approach is to record "pressed this frame" on keydown when e.repeat is false, and clear it at the end of each update. You will use this for jumping in the physics lesson.
Remapping and accessibility
Designers should treat controls as data, not as letters scattered through the code:
const bindings = { left: ["ArrowLeft", "a"], right: ["ArrowRight", "d"] };
const isDown = (action) => bindings[action].some((k) => keys[k]);
A table like this makes remapping a settings feature instead of a rewrite, and remappable controls are one of the most requested accessibility options in games. The Game Accessibility Guidelines (gameaccessibilityguidelines.com) list it as a basic recommendation.
Common mistakes
- Moving inside
keydown. Speed then depends on OS key repeat. - No
blurhandler. Keys stick after alt-tab. - Listening on the canvas without focus. Canvas elements do not receive key events unless focusable (
tabindex="0") and focused; listening onwindowavoids the problem in prototypes. - Checking
e.key === "a"while Shift is held (it is "A"). - Calling
preventDefaulton every key, which blocks browser shortcuts.
Quick recap
- Events (
keydown/keyup) update akeysstate object. update(dt)reads that state every frame and moves with delta time.- Opposite directions cancel; clear keys on
blur. preventDefaultonly for game keys; prefere.codefor WASD layouts.- Treat bindings as data so they can be remapped.
Try it
Open the Do tab to wire up keydown, keyup and blur handlers and drive the player from key state.