Interaction Manager: Keyboard & Gamepad Input
This article introduces the Interaction Manager library and walks through its actions , keyboard , and gamepad modules so you can add responsive keyboard and gamepad input to your UI without managing a polling loop or duplicating handler logic.
Game UIs need to respond to keyboard and gamepad at the same time, and both should trigger the same logic. Wiring this from scratch means juggling
keydown listeners alongside a manual polling loop against navigator.getGamepads() (the browser doesn’t fire gamepad button events on its own) and
keeping the two in sync as the project grows. The coherent-gameface-interaction-manager (IM) library handles those low-level
concerns and exposes a small set of modules for keyboard, gamepad, spatial navigation, drag and drop, touch and others.
This article covers actions , keyboard , and gamepad IM modules.
npm install coherent-gameface-interaction-managerImport the modules your screen uses:
import { actions, keyboard, gamepad } from "coherent-gameface-interaction-manager"Include the pre-built script from the package directory:
<script src="./node_modules/coherent-gameface-interaction-manager/umd/interaction-manager.js"></script>The library attaches its modules to the global scope:
const { actions, keyboard, gamepad } = window.interactionManagerFor the complete module reference, see the Interaction Manager documentation.
How the modules work together
Section titled “How the modules work together”The library is built around a simple flow: keyboard and gamepad listen for physical input, and the actions module is what they call into. You
write your handler logic once, register it under a name, and point both inputs at that name:
- Write a handler function for some UI behavior (
confirmSelection,openInventory, etc.). - Register that function under a name with
actions.register("onConfirm", confirmSelection). - Wire
keyboard.on()andgamepad.on()to that name instead of passing the function directly.
When the player presses Enter or the A button, IM looks up "onConfirm" and runs your handler. Whichever input fires first, the same code path runs.
The same name can also be triggered programmatically via actions.execute("onConfirm"), which is how other modules like spatial navigation and touch
dispatch into your handlers without holding a direct function reference.
The actions module
Section titled “The actions module”The actions module lets you register a named callback once and call it from anywhere in your code. Both keyboard and
gamepad accept an action name as a callback instead of a function, which means your handler lives in one place regardless of how many input sources
trigger it.
Register a handler under a name with actions.register():
import { actions } from "coherent-gameface-interaction-manager";
actions.register("openInventory", openInventory);Call it by name from anywhere:
actions.execute("openInventory");execute also accepts an optional value, forwarded as the first argument to the callback:
actions.register("focusSlot", focusSlot); // function focusSlot(slotId: string) { ... }actions.execute("focusSlot", "slot-12");Remove the registration when the view unmounts so the name does not persist across screens:
actions.remove("openInventory");The keyboard module
Section titled “The keyboard module”keyboard.on() binds a key or combination to a callback or registered action name. Every key in the keys array must be held simultaneously for the binding to fire.
import { keyboard } from "coherent-gameface-interaction-manager";
// Bind a single key to a registered actionkeyboard.on({ keys: ["Enter"], callback: "onConfirm", type: "press",});You can pass a function reference directly when the handler is local to one screen:
keyboard.on({ keys: ["Enter"], callback: handleConfirm, type: "press",});Keys can be written as strings, numeric keycodes, or values from the global KEYS object available on window. All three
reference the same key:
keyboard.on({ keys: ["A"], callback: "onConfirm", type: "press" });keyboard.on({ keys: [65], callback: "onConfirm", type: "press" });keyboard.on({ keys: [KEYS.A], callback: "onConfirm", type: "press" });Press, hold, and lift
Section titled “Press, hold, and lift”The type property controls when the callback fires:
type | Fires when |
|---|---|
| press | The key is pressed then released (once per cycle) |
| hold | The key is held down (on every tick while held) |
| lift | The key is released (single key only) |
Pass an array to combine types. Because both types call the same function, you can use a flag to distinguish the first fire (press) from the continuous ones (hold):
let isCharging = false;
function chargeAbility() { if (!isCharging) { isCharging = true; // press: first fire, begin the charge return; } buildCharge(); // hold: every subsequent tick, accumulate charge}
keyboard.on({ keys: ["Space"], callback: chargeAbility, type: ["press", "hold"],});Key combinations
Section titled “Key combinations”More than one key in the array creates a combination. All listed keys must be held at the same time:
keyboard.on({ keys: ["Control", "S"], callback: saveSettings, type: "press",});Registering multiple callbacks on the same key
Section titled “Registering multiple callbacks on the same key”You can attach more than one callback to the same key and type. They all run in registration order every time the key fires:
keyboard.on({ keys: ["Escape"], callback: closeContextMenu, type: "press" });keyboard.on({ keys: ["Escape"], callback: closeSidebar, type: "press" });This is useful in menus where one key needs to do different things depending on what is currently active. Escape might close a context menu, collapse a search bar, or exit a sub-panel, and each of those behaviors lives in a separate, focused callback.
The gamepad module
Section titled “The gamepad module”Unlike keyboard events, the browser’s Gamepad API does not fire events when button states change. Reading gamepad input requires polling
navigator.getGamepads() on a timer and comparing each button’s state to the previous tick. The gamepad module runs that loop
and calls your callbacks when it detects a change.
Enable polling before registering any bindings:
gamepad.enabled = true;The default polling interval is 200ms. Lower it on any screen where input feels sluggish:
gamepad.pollingInterval = 16; // roughly once per frame at 60fpsBinding buttons
Section titled “Binding buttons” gamepad.on() accepts the same callback shapes as keyboard.on(): a registered action name or a function reference.
For menu input, set type: “press” . The default type is hold , which fires on every poll tick while the button stays down.
import { gamepad } from "coherent-gameface-interaction-manager";
gamepad.enabled = true;
gamepad.on({ actions: ["face-button-down"], callback: "onConfirm", type: "press",});The actions array here is a list of button aliases, not the IM actions module. Aliases come in three flavors: generic (face-button-down),
PlayStation-specific (playstation.x), and Xbox-specific (xbox.a). Using a platform alias is purely a readability choice. Writing xbox.a does not
restrict the binding to Xbox controllers: the library maps it to button index 0 and the binding responds to any connected gamepad.
You can find the full alias reference in the documentation.
Requiring multiple buttons at once works the same way as key combinations. Every alias in the array must be active simultaneously:
gamepad.on({ actions: ["pad-down", "right-shoulder"], callback: openDebugPanel, type: "press",});Analog sticks
Section titled “Analog sticks”Stick input uses the same gamepad.on() call but with joystick aliases and a different callback signature.
Stick bindings only support type: “hold” .
A full stick alias passes both axes to the callback as a [number, number] tuple. Values follow the Gamepad API range of -1 to 1, with axes[0] as
horizontal and axes[1] as vertical:
// axes[0] = horizontal (-1 left, 1 right), axes[1] = vertical (-1 up, 1 down)function handleMapPan(axes: [number, number]) { const [x, y] = axes; panMapBy(x, y);}
gamepad.on({ actions: ["right.joystick"], type: "hold", callback: handleMapPan,});Directional aliases only fire when the stick is pushed fully in that direction, making them the preferred choice for discrete directional input like navigating a list:
gamepad.on({ actions: ["left.joystick.up"], type: "hold", callback: scrollUp });gamepad.on({ actions: ["left.joystick.down"], type: "hold", callback: scrollDown });The documentation lists all available directional aliases for both sticks.
Removing bindings
Section titled “Removing bindings”keyboard.off() and gamepad.off() work identically. Pass only the keys or aliases array to remove every callback registered for that combination:
keyboard.off(["Enter"]);gamepad.off(["face-button-down"]);To remove one specific callback while keeping others on the same key or button, pass the function reference as the second argument. This requires the
callback to be stored in a named variable, not an inline arrow function (the same way removeEventListener works):
keyboard.on({ keys: ["Escape"], callback: closeContextMenu, type: "press" });keyboard.on({ keys: ["Escape"], callback: closeSidebar, type: "press" });
// Only closeContextMenu is removed, closeSidebar keeps runningkeyboard.off(["Escape"], closeContextMenu);
// Same pattern for gamepadgamepad.off(["face-button-down"], handleConfirm);Wiring it all together
Section titled “Wiring it all together”The following walkthrough registers one handler and points both keyboard and gamepad at it. The setup and teardown functions pair with a pause menu that mounts and unmounts during gameplay.
-
Build the menu markup
Section titled “Build the menu markup”Each button carries a
data-menu-actionattribute that the confirm handler reads from the currently focused element to determine what to do.PauseMenu.tsx import Navigation from '@components/Utility/Navigation/Navigation';import Button from '@components/Basic/Button/Button';const PauseMenu = () => (<Navigation scope="pause-menu"><Navigation.Area name="pause-menu" selector=".menu-item" focused><Button class="menu-item" data-menu-action="resume">Resume</Button><Button class="menu-item" data-menu-action="options">Options</Button><Button class="menu-item" data-menu-action="quit">Quit</Button></Navigation.Area></Navigation>);pause-menu.html <div class="pause-menu"><button class="menu-item" data-menu-action="resume">Resume</button><button class="menu-item" data-menu-action="options">Options</button><button class="menu-item" data-menu-action="quit">Quit</button></div> -
Write the confirm handler
Section titled “Write the confirm handler”The handler reads the focused element’s attribute and dispatches to the matching function:
src/input/confirm.js const menuHandlers = {resume: () => engine.call("ResumeGame"),options: showOptionsPanel,quit: () => engine.call("QuitToTitle"),};export function confirmSelection() {const focused = document.querySelector(".menu-item:focus");const action = focused?.dataset.menuAction;if (action && menuHandlers[action]) menuHandlers[action]();} -
Register the action and wire both inputs
Section titled “Register the action and wire both inputs”confirmSelectionis registered once. Bothkeyboard.on()andgamepad.on()reference the same action name:src/input/setup-pause-input.js import { actions, keyboard, gamepad } from "coherent-gameface-interaction-manager";import { confirmSelection } from "./confirm";export function setupPauseInput() {actions.register("onConfirm", confirmSelection);keyboard.on({keys: ["Enter"],callback: "onConfirm",type: "press",});gamepad.enabled = true;gamepad.on({actions: ["face-button-down"],callback: "onConfirm",type: "press",});} -
Clean up when the menu closes
Section titled “Clean up when the menu closes”Removing bindings prevents input from leaking into screens that mount after this one closes:
src/input/teardown-pause-input.js import { actions, keyboard, gamepad } from "coherent-gameface-interaction-manager";export function teardownPauseInput() {actions.remove("onConfirm");keyboard.off(["Enter"]);gamepad.off(["face-button-down"]);gamepad.enabled = false;}
Call setupPauseInput() when the pause menu mounts and teardownPauseInput() when it unmounts.
Registered action vs. function reference
Section titled “Registered action vs. function reference”Both keyboard.on() and gamepad.on() accept either a registered action name or a direct function reference. The right choice depends on whether the
handler needs to be triggered from outside the input setup:
callback value | Best for |
|---|---|
Registered action name ("onConfirm") | Handler also needs to fire from spatial navigation, touch, or actions.execute() elsewhere |
Function reference (confirmSelection) | Handler is local to one screen, never triggered programmatically |
© 2026 Coherent Labs. All rights reserved.