Supporting Touch, Mouse and Gamepad Together

Updated June 2026
A unified input system lets your game logic read named actions like "jump" and "moveLeft" without knowing or caring whether the input came from a touchscreen, a keyboard, a mouse, or a gamepad. Building this abstraction layer is the most important architectural decision for any web game that targets multiple devices, and it makes adding new input methods trivial.

Without a unified layer, game code ends up scattered with device-specific checks. The jump function checks for spacebar AND gamepad button 0 AND a touch in the right zone. The movement function reads WASD keys AND gamepad sticks AND the virtual joystick. Every time you add a new input method or change a binding, you modify game logic in dozens of places. A unified system eliminates this by placing one abstraction between the raw APIs and the game, where the game only ever asks "what is the value of this action right now?"

Define Game Actions

Start by listing every input your game needs as a named action. Do not think about keys, buttons, or touch zones yet. Think about what the player does. A platformer might need: moveHorizontal (analog, -1 to 1), jump (digital), attack (digital), dash (digital), interact (digital), pause (digital). A twin-stick shooter might need: moveX (analog), moveY (analog), aimX (analog), aimY (analog), fire (digital), reload (digital), dodge (digital).

Each action has a type. Digital actions are either active or inactive: jump is pressed or not. Analog actions have a continuous value: moveHorizontal ranges from -1.0 (full left) to 1.0 (full right), with 0.0 meaning no input. Some actions can be both: a trigger pull might be analog (0.0 to 1.0 for variable acceleration) but also have a digital threshold (pressed when value exceeds 0.5).

Define these actions in a central configuration, separate from any binding. A simple object works well: { moveHorizontal: { type: "analog" }, jump: { type: "digital" }, attack: { type: "digital" } }. This becomes the contract between your input system and your game logic. The game logic imports and reads this set of actions, never touching raw key codes or button indices directly.

Create an Action Map

An action map connects each game action to one or more physical inputs. Each binding specifies the device type, the specific input on that device, and optionally a transformation (like inverting an axis or applying a scale). A single action can have multiple bindings across multiple devices.

For the moveHorizontal action in a platformer, the bindings might be: keyboard "KeyD" maps to +1.0, keyboard "KeyA" maps to -1.0, keyboard "ArrowRight" maps to +1.0, keyboard "ArrowLeft" maps to -1.0, gamepad axis 0 maps directly (already -1 to 1), and touch virtual-joystick X maps directly. For the jump action: keyboard "Space" maps to active, gamepad button 0 maps to active, and touch right-zone-tap maps to active.

Store the default action map as a plain data structure, not hardcoded logic. This makes it serializable, which matters for saving custom bindings to localStorage, and it makes it inspectable, which matters for the settings UI. A typical structure per binding is: { action: "jump", device: "keyboard", input: "Space" } or { action: "moveHorizontal", device: "gamepad", input: "axis0" }.

Ship sensible defaults for each supported device, but always allow the player to override them. The default map is the starting point, and the player's custom map (loaded from localStorage on startup) overrides individual bindings while keeping any defaults the player did not change.

Build Input Providers for Each Device

An input provider is a module that reads a specific browser API and writes normalized values into the shared action state. You need one provider per device type: a keyboard provider that reads the key state tracker, a mouse provider that reads mouse position and buttons, a touch provider that reads the virtual joystick and touch buttons, and a gamepad provider that polls navigator.getGamepads().

Each provider runs once per frame, in sequence, at the start of the game loop. The keyboard provider iterates its held-keys set, looks up which actions those keys are bound to in the action map, and writes the corresponding values (1.0 for positive digital, -1.0 for negative digital). The gamepad provider reads the current axis and button values, looks up the bindings, and writes the normalized values. The touch provider reads the virtual joystick's output vector and any active touch buttons.

All providers write to the same action state object. If multiple providers write to the same action, the last non-zero value wins, or you can use a priority system where the most recently active device takes precedence. In practice, the simple approach of "latest non-zero value wins" works well because players rarely use two devices simultaneously, and when they do (keyboard and mouse together), the inputs target different actions.

Keep providers isolated from each other. The keyboard provider should not know about the gamepad provider. Each one translates its device's raw API into the action vocabulary, nothing more. This isolation makes it straightforward to add new providers later (for example, a WebXR hand-tracking provider or a voice command provider) without touching existing code.

Normalize Analog and Digital Input

Different devices produce different value ranges for the same semantic input. A keyboard key is fully on (1.0) or fully off (0.0). A gamepad stick smoothly ranges from -1.0 to 1.0. A virtual joystick outputs -1.0 to 1.0 with dead zones already applied. A gamepad trigger ranges from 0.0 to 1.0. The unified system must normalize all of these so the game logic receives consistent values.

For digital actions, normalize everything to a boolean or a 0/1 value. A keyboard press is 1. A gamepad button with pressed: true is 1. A touch button that is active is 1. For analog triggers, apply a threshold (typically 0.5) to convert to digital if the action type is digital.

For analog actions, normalize to the -1.0 to 1.0 range for axes or 0.0 to 1.0 for triggers. Keyboard input for analog actions uses the pair approach: if "KeyD" is bound to the positive side and "KeyA" to the negative side, the combined value is (+1 if D is pressed) + (-1 if A is pressed). If both are pressed, they cancel to 0. If only D is pressed, the value is 1.0. This gives keyboard players the same -1 to 1 range as stick users, though without the analog graduation.

For movement speed, some games multiply the analog magnitude by a speed constant, so keyboard movement at 1.0 is full speed and a half-tilted stick at 0.5 is half speed. This works naturally for most genres. For games where keyboard players should not be disadvantaged by the lack of analog control, you can add an acceleration ramp that brings keyboard input from 0 to 1 over several frames, simulating the feel of pushing a stick gradually.

Detect and Switch the Active Input Method

When a player uses a gamepad, the game should show gamepad button prompts ("Press A to jump"). When they switch to keyboard, it should show key prompts ("Press Space to jump"). When they switch to touch, virtual controls should appear. This active device detection improves the experience significantly because players always see relevant instructions for the device they are using right now.

Track the active input method by recording which device last produced a non-zero input. When the keyboard provider writes a non-zero value, set activeDevice to "keyboard." When the gamepad provider writes a non-zero value, set it to "gamepad." When the touch provider writes a non-zero value, set it to "touch." Add a small debounce (a few frames) to prevent flickering when multiple devices produce input on the same frame.

Use the active device to control three things: which button icons to show in UI prompts, whether to show or hide the virtual joystick and touch buttons, and which sensitivity settings to apply. On device switch, fade the virtual controls in or out over 200 to 300 milliseconds rather than popping them instantly, which feels smoother. If the player is on a tablet and connects a Bluetooth gamepad, the virtual controls fade away. If they disconnect the gamepad, the virtual controls fade back in.

Store the player's preferred input method in localStorage so it persists across sessions. If the player always uses a gamepad, the game can start with gamepad prompts immediately rather than defaulting to keyboard and switching after the first button press.

Support Runtime Remapping

Remappable controls are both a usability feature and an accessibility requirement. The settings UI should list all game actions with their current binding for each device. When the player selects an action and clicks "rebind," the game enters a listening state that waits for the next input on any device and assigns that input to the selected action.

The listening flow works like this: display a "Press any key/button" prompt. Start capturing all input from all providers. When any input exceeds a threshold (a key press, a button press, or a stick deflection past 0.5), record that input as the new binding for the selected action. If the new input was already bound to a different action, either swap the bindings (so the old action gets the old key from the rebinding action) or clear the conflict and let the player rebind the displaced action manually. Show the updated binding and exit the listening state.

Save custom bindings to localStorage as a JSON object. On game startup, load the custom bindings and merge them with the defaults. Any action not customized uses the default binding. This merge approach means you can add new actions in updates and they automatically get default bindings without overwriting the player's existing customizations.

For touch controls, remapping is less about changing which button does what and more about repositioning and resizing the virtual controls. A drag-to-reposition mode that lets the player move the joystick and buttons to their preferred positions covers this. Save the positions to localStorage alongside the button bindings.

Key Takeaway

Define actions first, then map devices to actions, not the other way around. Build isolated providers for each device, normalize all values to a common range, auto-detect the active device for UI switching, and let players remap everything. This architecture handles any combination of input devices and makes future additions effortless.