Skip to content
SiteEmail

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.

Terminal window
npm install coherent-gameface-interaction-manager

Import the modules your screen uses:

src/input/setup.ts
import { actions, keyboard, gamepad } from "coherent-gameface-interaction-manager"

For the complete module reference, see the Interaction Manager documentation.

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:

  1. Write a handler function for some UI behavior (confirmSelection, openInventory, etc.).
  2. Register that function under a name with actions.register("onConfirm", confirmSelection).
  3. Wire keyboard.on() and gamepad.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 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():

src/input/actions.ts
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");

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.

src/input/keyboard-bindings.ts
import { keyboard } from "coherent-gameface-interaction-manager";
// Bind a single key to a registered action
keyboard.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" });

The type property controls when the callback fires:

typeFires 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"],
});

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.

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 60fps

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.

src/input/gamepad-bindings.ts
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",
});

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:

src/input/analog-pan.ts
// 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.

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 running
keyboard.off(["Escape"], closeContextMenu);
// Same pattern for gamepad
gamepad.off(["face-button-down"], handleConfirm);

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.

  1. Each button carries a data-menu-action attribute 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>
    );
  2. 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]();
    }
  3. confirmSelection is registered once. Both keyboard.on() and gamepad.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",
    });
    }
  4. 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.

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 valueBest 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