Skip to content
PN Scripts

Games

Keyboard, gamepad and remappable controls in a browser game: physical keys, dead zones and honest prompts

  • PN Scripts
  • 8 min read

Controls are the first thing a player judges and the last thing many browser games get right. A game that uses WASD by letter is unplayable on a French keyboard, a game that needs Escape to pause fights with the browser's full-screen exit, and a game that shows keyboard hints while you hold a gamepad looks unfinished. This guide walks through the input layer of our 3D browser games: how keys are read so every layout works, how the Gamepad API is polled, how rebinding is stored, and which accessibility settings sit next to it. The code is shortened from the games' own input module.

#Read physical keys, show local labels

A KeyboardEvent carries two different things. key is the character the key produces on the current layout, and code is the physical position of the key, named after the US layout. For movement and actions you want the position: the key below 2 and next to Tab is KeyW whether the layout calls it W, Z on AZERTY or a Cyrillic letter on a Bulgarian layout. Bind actions to codes:

export function defaultBindings() {
  return {
    keys: {
      up: ['KeyW', 'ArrowUp'], down: ['KeyS', 'ArrowDown'],
      left: ['KeyA', 'ArrowLeft'], right: ['KeyD', 'ArrowRight'],
      strike: ['Space', 'KeyJ'], raise: ['KeyK'], leap: ['ShiftLeft', 'ShiftRight', 'KeyL'],
      pause: ['KeyP', 'Escape'], mute: ['KeyM'],
    },
    pad: { up: [12], down: [13], strike: [0, 7], raise: [2, 6], leap: [1, 5], pause: [9] },
  };
}

The opposite problem appears in the hints. If the game says "press W" to a player whose key is labelled Z, they look for the wrong key. Chromium browsers offer navigator.keyboard.getLayoutMap(), which maps a code to the label on the player's layout. It is marked experimental and is missing in Firefox and Safari, so use it when it exists and fall back to the code's name:

let layoutMap = null;
navigator.keyboard?.getLayoutMap?.().then((m) => (layoutMap = m)).catch(() => {});

export function keyLabel(code) {
  const named = { Space: 'Space', ShiftLeft: 'Shift', ArrowUp: '↑', Escape: 'Esc' };
  if (named[code]) return named[code];
  const local = layoutMap?.get(code);
  if (local) return local.toUpperCase();
  if (code.startsWith('Key')) return code.slice(3);
  if (code.startsWith('Digit')) return code.slice(5);
  return code;
}

#Do not fight the browser

A game in a web page shares the keyboard with the browser, and a few rules prevent the most common complaints:

  • Never require Escape. Browsers use it to leave full screen and to release a locked pointer, and many game portals reserve it. Our pause key is P; Escape works only as an extra alias, and the game is fully playable without it.
  • Leave modifier shortcuts alone. If Ctrl, Alt or the Command key is held, the handler returns at once, so Ctrl+W closes the tab as the player expects and Ctrl+R reloads.
  • Stop scrolling, not typing. Space and the arrow keys scroll the page, so the game calls preventDefault() on them, but only when the event is not aimed at a text field in a menu.
  • Release keys on blur. If the player switches windows while holding D, the browser never sends the key-up. Clearing all held keys on blur stops the character from walking off on its own.
  • Count presses once. Ignore event.repeat for actions, so holding Space does not fire a strike thirty times a second.

#Poll the gamepad every frame

The Gamepad API has events for connecting and disconnecting a controller, but not for buttons. You read the current state yourself, once per frame, and compare it with the previous frame to find new presses. Controllers that the browser recognises use the standard mapping: button 0 is the bottom face button (A on an Xbox pad), 1 is B, 9 is Menu, 12 to 15 are the d-pad, and axes 0 to 3 are the two sticks.

pollPad() {
  const pad = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected);
  if (!pad) return;
  // a dead zone, rescaled so movement still starts at zero and reaches one
  const dz = (v) => (Math.abs(v) < 0.18 ? 0 : (v - Math.sign(v) * 0.18) / 0.82);
  this.stickL = [dz(pad.axes[0] ?? 0), -dz(pad.axes[1] ?? 0)];
  this.stickR = [dz(pad.axes[2] ?? 0), -dz(pad.axes[3] ?? 0)];
  const now = pad.buttons.map((b) => b.pressed || b.value > 0.5);
  for (const [action, buttons] of Object.entries(this.bind.pad)) {
    for (const i of buttons) if (now[i] && !this.prev[i]) this.pressed.add(action);
  }
  this.prev = now;
}

Two details are easy to miss. Sticks never rest exactly at zero, so without a dead zone the character drifts; and a plain cut-off makes the first few degrees of tilt jump straight to 18 percent speed, which is why the value is rescaled. Triggers are analog buttons, and some controllers report pressed very early in their travel, so we also accept a value above one half.

#Show the prompts for the device in hand

The game keeps one variable for the kind of input the player is using: keyboard and mouse, touch, or gamepad. It changes on every real event: a key press or mouse movement switches to keyboard, a tap to touch, a stick tilt or button to gamepad. Every on-screen hint reads that variable, so the tutorial says "Space" a moment ago and "A" as soon as you pick up a controller. A phone starts in touch mode, but a laptop with a touch screen stays on mouse while you use the mouse. This sounds cosmetic, and it is one of the clearest signals of a finished game.

#Let players rebind everything

Remapping is not a luxury. Left-handed players, people with limited mobility, players with unusual keyboards and anyone who disagrees with your defaults need it. In our settings each action shows its current key and button; selecting it shows "Press a key or button…", and the next key or button becomes the binding:

  • Escape cancels instead of being bound.
  • A gamepad button only counts after 250 milliseconds, so the press that opened the prompt is not captured as the new binding.
  • A key that already belongs to another action is taken away from it, so two actions never share a key by accident.
  • One button restores the defaults.

Bindings are saved with the other settings. When the save is loaded, the stored bindings are merged over the defaults, so an action added in a later version still gets its default key instead of being unbound. Our article on save slots for a browser game in localStorage shows that merge in detail.

#Accessibility settings that belong next to controls

Following the Game Accessibility Guidelines, the controls screen is only part of it. Billowpost, our balloon game, offers inverted vertical camera, reduced motion (no camera shake and no flashes), high contrast for the interface, a colour-blind mode that adds counted marks to each wind band on the height ladder, so bands are told apart without colour, three text sizes, an assist that holds your height when you let go of the burner, and a game speed of 100, 85 or 70 percent. Several of those are cheap only because the rules run on a fixed time step; our fixed-timestep loop article explains why.

Touch is its own topic. Our guide to touch controls that also work with keyboard and gamepad is written for Godot 4, but the ideas carry over, and the Godot add-ons Pipalo and Podkanka package them for that engine. Our browser games are in the PN Scripts games catalogue.

#Frequently asked questions

Should I use event.key or event.code for game controls?

Use event.code for actions, because it names the physical key and works on every layout. Use event.key only for text input, and show labels from the player's layout where the browser provides them.

Why do gamepad buttons not fire events?

The Gamepad API only sends events when a controller connects or disconnects. Read navigator.getGamepads() once per frame and compare each button with the previous frame to detect presses.

What dead zone should a browser game use?

Around 0.15 to 0.2 of the stick's range works for most controllers. Rescale the value after the dead zone so movement starts from zero and still reaches full speed at the edge.

Is it safe to use Escape as the pause key?

Not as the only one. Browsers use Escape to leave full screen and release the pointer, and some portals reserve it. Offer another pause key and keep Escape as an alias.

#Sources

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile and API development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here