I run 2playerfun.com, a site dedicated entirely to two-player browser games where both players share one keyboard. It's a niche that turned out to have more technical depth than I expected.
When I started, I assumed local multiplayer was the easy case. No netcode. No latency. No matchmaking. Just two players, one keyboard, browser-side state. What could go wrong?
What could go wrong, it turns out, is that two people pressing keys on a single keyboard at the same time is a surprisingly hostile environment.
This post is about what I've learned. If you're building a local-multiplayer browser game, or you've just always wondered why every two-player web game uses WASD and arrows specifically, read on.
Keyboard hardware actually matters
The thing nobody tells you when you start: keyboards have a property called N-key rollover (NKRO), and most keyboards don't have very good NKRO.
In practice this means: a cheap membrane keyboard might only register two or three simultaneous key presses correctly. If Player 1 is holding W and A to move diagonally, and Player 2 presses an arrow key at the same time, the keyboard might just not send the third event. Or worse, it might send a "ghost" event for a key that wasn't actually pressed.
This is hardware. There's nothing you can do about it from the browser. But there are things you can do that make the problem less common.
Why every 2P game uses WASD + Arrows
If you've played any local multiplayer browser game, you've seen this layout:
- Player 1: WASD (movement) + nearby action keys (Q, E, F, Space)
- Player 2: Arrow keys (movement) + nearby action keys (Enter, comma, period, M, /)
This isn't an accident. WASD and arrow keys live on opposite sides of the keyboard, on different rows of the underlying key matrix. On most keyboards, this means they're far less likely to ghost into each other than, say, WASD and TFGH would be.
You can almost always press WASD + arrows + 2–3 nearby action keys simultaneously without issues, even on a cheap keyboard. That's why the convention exists. It's a hardware-aware layout choice that the genre converged on through trial and error.
Lesson: if you're inventing your own scheme, don't put both players' keys in the same physical region of the keyboard. Use the WASD/arrow split unless you have a strong reason not to.
Browser event handling — the things that bit me
Once you've got your key layout, you have to actually capture the inputs in the browser. Here's where I learned a few things the hard way.
1. Use keydown and keyup, not keypress
The keypress event is deprecated and doesn't fire for non-character keys like arrows or modifiers. Use keydown for "key went down" and keyup for "key was released":
window.addEventListener('keydown', (e) => {
// Handle press
});
window.addEventListener('keyup', (e) => {
// Handle release
});
2. Use event.code, not event.key or event.keyCode
-
event.keyCodeis deprecated. -
event.keyreturns the character ("a","A","ArrowLeft"), which changes with keyboard layout (AZERTY vs QWERTY) and shift state. -
event.codereturns the physical key location ("KeyA","ArrowLeft"), which is consistent across layouts.
For game input, you almost always want the physical key, not the character. Use event.code:
window.addEventListener('keydown', (e) => {
switch (e.code) {
case 'KeyW': player1.up = true; break;
case 'ArrowUp': player2.up = true; break;
}
});
3. Handle key repeat correctly
When a key is held, the browser fires keydown repeatedly. For movement keys this is fine — your game loop reads the state every frame. For action keys (jump, shoot), you want to fire the action only on initial press, not on every repeat.
Two common patterns:
// Pattern A: skip repeats with event.repeat
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
if (e.code === 'Space') player1.jump();
});
// Pattern B: track held keys, fire action on transition
const heldKeys = new Set();
window.addEventListener('keydown', (e) => {
if (heldKeys.has(e.code)) return;
heldKeys.add(e.code);
if (e.code === 'Space') player1.jump();
});
window.addEventListener('keyup', (e) => {
heldKeys.delete(e.code);
});
I prefer Pattern B because it gives you a clean "what's currently held" state for movement, plus reliable transition detection for actions, from the same data structure.
4. preventDefault aggressively (but not indiscriminately)
Browsers have default behavior for many keys:
- Space scrolls the page.
- Arrow keys scroll the page.
- Tab moves focus.
-
/may open browser search. - F1–F12 do various OS/browser things.
If your game uses any of these for input, call event.preventDefault() or the browser hijacks the key:
const gameKeys = new Set([
'Space',
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'KeyW', 'KeyA', 'KeyS', 'KeyD',
'Enter',
]);
window.addEventListener('keydown', (e) => {
if (gameKeys.has(e.code)) {
e.preventDefault();
}
// ... handle game input
});
Whitelist your game keys. Don't blanket-preventDefault everything — users still need Ctrl+R, Ctrl+Tab, and so on for browser functions. I've seen games that swallow Tab and break focus navigation site-wide. Don't be that game.
5. Focus matters
Your game needs focus to receive keyboard events. If you're rendering into a <canvas>, the canvas itself doesn't receive keyboard events by default. Two options:
- Listen on
window— simplest, but globally captures keys. - Make the canvas focusable with
tabindex="0"and listen on the canvas — better for embeddable games.
For a dedicated 2P game page, window is fine. For games embedded inside a larger page, scope to the canvas.
The input architecture I actually use
Here's the pattern I use across 2playerfun games:
const players = {
p1: { up: false, down: false, left: false, right: false, action: false },
p2: { up: false, down: false, left: false, right: false, action: false },
};
const keyMap = {
KeyW: ['p1', 'up'],
KeyS: ['p1', 'down'],
KeyA: ['p1', 'left'],
KeyD: ['p1', 'right'],
Space: ['p1', 'action'],
ArrowUp: ['p2', 'up'],
ArrowDown: ['p2', 'down'],
ArrowLeft: ['p2', 'left'],
ArrowRight: ['p2', 'right'],
Enter: ['p2', 'action'],
};
window.addEventListener('keydown', (e) => {
const mapping = keyMap[e.code];
if (!mapping) return;
e.preventDefault();
const [who, what] = mapping;
players[who][what] = true;
});
window.addEventListener('keyup', (e) => {
const mapping = keyMap[e.code];
if (!mapping) return;
const [who, what] = mapping;
players[who][what] = false;
});
// In game loop:
function update(dt) {
if (players.p1.up) p1Sprite.y -= speed * dt;
if (players.p1.down) p1Sprite.y += speed * dt;
if (players.p1.left) p1Sprite.x -= speed * dt;
if (players.p1.right) p1Sprite.x += speed * dt;
// ... same for p2
}
The key idea: keep input state in a structured object that the game loop reads, instead of mutating game state directly in event handlers. This decouples input from game logic and makes a few things much easier:
-
Customizable key bindings — just rebuild
keyMap. - Replay systems — serialize the players state per frame.
- AI opponents — let the AI write to the state object the same way the keyboard does.
- Networked multiplayer later — the state object is what you'd send over the wire.
This pattern looks trivial in isolation, but the savings compound over a dozen games.
The mobile/touch reality
Honest answer: local multiplayer on mobile is mostly broken.
In theory you can put two touch zones on screen — one player on the left, one on the right. In practice this works for very simple games (tap-to-jump, swipe-to-move) but breaks down for anything needing simultaneous directional + action input. The device is also held by one person, which adds awkward physical dynamics.
For 2playerfun, my pragmatic answer is: most games are desktop-only, and mobile users see a polite "this game works best on a desktop with two players sharing a keyboard" message. Trying to force mobile two-player support has been more trouble than it's worth, and the audience that wants couch co-op is mostly on laptops anyway.
What I wish I'd known earlier
Test on a cheap keyboard. Your nice mechanical board with full NKRO will tell you nothing about what 90% of your users experience. Buy a $15 USB membrane keyboard and test 2P games on it. You will find ghosting issues you didn't know existed.
Use event.code from day one. Switching from event.key or event.keyCode later means rewriting input across every game. Save yourself the rework.
Don't blanket-preventDefault. Be specific about which keys your game uses, and let the rest behave normally.
Build the input layer once, reuse it. The keyMap → state object → game-loop-reads-state pattern works for almost every local 2P game I've built. Once it's working, copy it forward.
Wrapping up
Local multiplayer browser games feel like a niche from 2008, but the genre is having a quiet renaissance. People want to play games together in person again. Sharing a couch and a laptop is a perfectly valid form of multiplayer, and the browser is an extremely accessible platform for it — no installs, no accounts, no friend codes, no matchmaking.
If you're building one, I hope some of this saves you a few hours.
If you're just looking to play one, 2playerfun.com has a couple hundred of them, all using roughly the input architecture above.
Top comments (0)