Gamepad
Read controller input and support multiple connected gamepads.
Gamepad
ExoJS manages gamepads through four stable slot mailboxes. Each slot is a Gamepad instance that lives for the application’s full lifetime — a physical controller moves in when connected, moves out when disconnected, and your listeners stay attached through both transitions. Address pads by slot (0–3, stable across reconnect) rather than by browser index (which the browser may reassign).
The four-slot model
import type { Application } from '@codexo/exojs';
declare const application: Application;
const pad0 = application.input.gamepads[0]; // always exists, may be disconnected
const pad1 = application.input.getGamepad(1); // same thing, reads more clearly
if (pad0.connected) {
console.log(pad0.info.name); // e.g. "Xbox Wireless Controller"
}
The gamepads array is always length 4. Check pad.connected before reading pad state. Convenience accessors on the input system:
import type { Application } from '@codexo/exojs';
declare const application: Application;
application.input.hasGamepad // true when at least one pad is connected
application.input.connectedGamepadCount // how many slots are occupied
application.input.firstConnectedGamepad // first in slot order, or null
application.input.connectedGamepads // subset of gamepads[] that are connected
A gamepad is invisible until its first button press
On most browsers a connected controller isn’t reported until the user presses a button — a security restriction. Don’t assume gamepads is populated in init; check app.input.connectedGamepads, and if it’s empty, wire your setup to run from the onGamepadConnected signal instead.
Lifecycle: connect and disconnect
Subscribe to lifecycle signals at either level:
import type { Application } from '@codexo/exojs';
declare const application: Application;
const pad0 = application.input.getGamepad(0);
// Per-slot — fires only for that specific slot
pad0.onConnect.add(() => {
console.log('Controller connected to slot 0');
});
pad0.onDisconnect.add(() => {
console.log('Slot 0 disconnected');
});
// Global — fires for any slot
application.input.onGamepadConnected.add(pad => {
console.log(`Controller connected to slot ${pad.slot}`);
});
application.input.onGamepadDisconnected.add(pad => {
console.log(`Slot ${pad.slot} disconnected`);
});
Listeners survive disconnect/reconnect cycles. A binding registered on an empty slot activates automatically when a pad connects later.
Slot strategies
Two strategies control how slots fill and empty, set via ApplicationOptions.gamepadSlotStrategy:
'sticky'(default): Each pad keeps its slot. A disconnect leaves a gap; the next pad fills the lowest empty slot. This preserves button-prompts like “Press A (controller 1)” across reconnect.'compact': On disconnect, higher-numbered slots shift down to keepgamepads[0..N-1]densely populated. “Controller 2” becomes “controller 1” if the first player disconnects. Use this for local multiplayer where player number maps directly to slot.
When a compact shift occurs, pad.onPadReassigned fires on the receiving slot with the source slot it moved from.
Buttons
The GamepadButton namespace provides 24 named button channels. Buttons use cross-platform semantic names rather than per-console labels:
import { Gamepad, GamepadButton } from '@codexo/exojs';
declare const pad: Gamepad;
declare const player: {
jump(): void;
attack(value: number): void;
stopAttack(): void;
};
// --- Binding-style listeners (attach to a specific pad) ---
pad.onTrigger(GamepadButton.South, () => {
player.jump(); // A on Xbox, ✕ on PlayStation, B on Switch
});
pad.onActive(GamepadButton.West, value => {
player.attack(value); // X on Xbox, □ on PlayStation, Y on Switch
});
pad.onStop(GamepadButton.West, () => {
player.stopAttack();
});
// --- Signal-style listeners (per-button transition events) ---
pad.onButtonDown.add((button, value) => {
console.log(`${button.constructor.name} pressed at ${value}`);
});
pad.onButtonUp.add((button, value) => {
console.log(`${button.constructor.name} released`);
});
The buttons you usually reach for:
| Constant | Conventional use |
|---|---|
South |
Primary action (Xbox A, PS ✕) — jump, confirm |
East |
Secondary action (Xbox B, PS ○) — cancel, back |
West |
Tertiary action (Xbox X, PS □) — attack, interact |
North |
Quaternary action (Xbox Y, PS △) — special, menu |
DPadUp/Down/Left/Right |
Menu navigation, weapon select |
LeftShoulder / RightShoulder |
Bumpers — modifier, dash |
LeftTrigger / RightTrigger |
Analog triggers (0–1) — accelerate, aim |
LeftStick / RightStick |
Stick clicks (L3 / R3) — sprint |
Select / Start |
Menu / pause buttons |
The GamepadButton API reference lists the full 24-button namespace including Guide, Share, Capture, Touchpad, and Paddle1–Paddle4 for Elite/Edge/Steam Deck controllers.
Not every pad has every input
Paddles, a second stick, touchpads and rumble are all controller-specific — a detached Joy-Con has no right stick, a basic pad has no paddles or actuators. Gate optional inputs with pad.hasChannel(channel) and rumble with pad.canVibrate before wiring them, so absent hardware fails gracefully rather than silently.
Axes
The GamepadAxis namespace provides both split-direction and aggregate signed channels:
Split-direction (0–1, “button-style”):
GamepadAxis.LeftStickLeft GamepadAxis.LeftStickRight
GamepadAxis.LeftStickUp GamepadAxis.LeftStickDown
GamepadAxis.RightStickLeft GamepadAxis.RightStickRight
GamepadAxis.RightStickUp GamepadAxis.RightStickDown
Each fires when pushed in its direction. Use these when you want onActive/onStop bindings that mirror keyboard-style input.
Aggregate signed (-1 to 1, “stick-style”):
GamepadAxis.LeftStickX GamepadAxis.LeftStickY
GamepadAxis.RightStickX GamepadAxis.RightStickY
A single signed value per axis — negative is left/up, positive is right/down. The callback receives the full -1..1 range, making a single line of code drive 2D movement:
import { Gamepad, GamepadAxis } from '@codexo/exojs';
declare const pad: Gamepad;
const move = { x: 0, y: 0 };
pad.onActive(GamepadAxis.LeftStickX, value => {
move.x = value; // -1..1, already deadzoned
});
pad.onStop(GamepadAxis.LeftStickX, () => {
move.x = 0;
});
pad.onActive(GamepadAxis.LeftStickY, value => {
move.y = value;
});
pad.onStop(GamepadAxis.LeftStickY, () => {
move.y = 0;
});
Aggregate channels are bipolar — they preserve the full signed range and apply a deadzone (default 0.2). Values within the deadzone read as 0.
Touchpad channels (TouchpadX/Y, Touchpad2X/Y) cover PlayStation and Steam Deck touchpad surfaces in 0..1 range.
Capability detection
Not every controller has every button or axis. A detached Joy-Con has no right stick; a basic pad has no paddles. Use hasChannel() to gate optional bindings:
override init(): void {
const pad = this.app.input.getGamepad(0);
if (pad.hasChannel(GamepadAxis.RightStickX)) {
pad.onActive(GamepadAxis.RightStickX, value => {
this.camera.rotate(value * 2);
});
}
if (pad.hasChannel(GamepadButton.Paddle1)) {
pad.onActive(GamepadButton.Paddle1, () => {
this.player.dash();
});
}
}Binding to a channel the pad doesn’t declare is harmless — the listener simply never fires — but hasChannel() lets you offer alternative controls or skip setup entirely.
Local multiplayer: one map per pad
An action binds the semantic control — GamepadButton.South, not “player 2’s South”. Which physical pad it reads from is the owning ActionMap’s runtime context:
const createPlayerActions = () => ({
jump: new ButtonAction(GamepadButton.South),
move: new VectorAction({ x: GamepadAxis.LeftStickX, y: GamepadAxis.LeftStickY }),
});
class VersusScene extends Scene {
override init(): void {
const p1 = new ActionMap(createPlayerActions(), { gamepad: this.inputs.getGamepad(0) });
const p2 = new ActionMap(createPlayerActions(), { gamepad: this.inputs.getGamepad(1) });
this.inputs.attach(p1);
this.inputs.attach(p2);
}
}The two maps are completely independent, and neither binding mentions a slot — which is exactly why both players can share one saved control scheme. A pad is runtime context, never part of a binding and never serialized.
Keyboard and gamepad multiplayer works the same way, with one map per player: one built from Keyboard channels and one given a gamepad. ExoJS deliberately derives no player identity of its own — “keyboard plus pad 0” means whatever your game decides it means.
Slots are runtime, not identity
Gamepad.slot is stable for the application’s lifetime, which makes it a good runtime handle — but it says nothing about which human is holding the device, so it never reaches a save file. Persist your own player-to-slot assignment if you need one.
Device family and button prompts
Gameplay binds GamepadButton.South regardless of hardware. A prompt UI needs the opposite: what does THIS device print on that button, and which icon set should it draw?
import type { Gamepad, GamepadMappingFamily } from '@codexo/exojs';
declare const pad: Gamepad;
declare const icons: Partial<Record<GamepadMappingFamily, string>>;
const iconSet = pad.family === null ? 'keyboard' : icons[pad.family];
const label = pad.getLabel('ButtonSouth'); // "A" on Xbox, "Cross" on PlayStation, "B" on Switch
family is the value to key your own artwork on — Xbox, PlayStation, Switch Pro, Joy-Con (left and right separately), Steam Controller, Steam Deck, arcade stick, or the generic dual-analog fallback. getLabel resolves against the connected device rather than only its family, so a DualShock 4 reports Share and a DualSense Create for the same Select control.
ExoJS ships no glyph assets. Choosing and drawing the artwork stays yours; family and getLabel are what make that choice a one-liner.
Describing an unrecognised device
A mapping is data: a family, an index space, and the buttons and axes that device reports. Describe a controller the built-in definitions do not cover by writing one and registering a GamepadDefinition for it — no subclassing is involved.
import { GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily } from '@codexo/exojs';
const dancePad = new GamepadMapping({
family: GamepadMappingFamily.GenericDualAnalog,
buttons: [
new GamepadButton(0, GamepadButton.DPadUp),
new GamepadButton(1, GamepadButton.DPadDown),
new GamepadButton(2, GamepadButton.DPadLeft),
new GamepadButton(3, GamepadButton.DPadRight),
],
axes: [],
});
Pass layout: GamepadMappingLayout.Raw when the indices follow the device’s raw HID report order rather than the W3C standard layout; a raw mapping is discarded automatically if the browser turns out to normalise that device after all.
Vibration
The Web Gamepad API exposes dual-rumble actuators on most modern controllers. Check support and trigger effects:
override init(): void {
const pad = this.app.input.getGamepad(0);
if (pad.canVibrate) {
void pad.vibrate({
duration: 200, // ms
weakMagnitude: 0.5, // low-frequency rumble 0..1
strongMagnitude: 0.8, // high-frequency rumble 0..1
startDelay: 0,
});
}
// Stop rumble early
pad.stopVibration();
}vibrate() is async and resolves when the effect finishes or is cancelled. Call stopVibration() to cut a running effect short (e.g. when the player releases a trigger). Both methods are silent no-ops on unsupported hardware.
Per-pad vs. global listeners
Two patterns coexist:
| Pattern | How | Lifecycle |
|---|---|---|
| Per-pad bindings | pad.onTrigger(button, cb) |
Tied to one slot. Unbind with .unbind(). |
| Per-pad signals | pad.onButtonDown.add(cb) |
Tied to one slot. Remove with .remove(cb). |
| Global signals | app.input.onAnyGamepadButtonDown.add(cb) |
Fires for every slot. Filter on pad.slot. |
Per-pad bindings are the typical choice for game logic — the player number maps to a slot, and bindings are registered once in init. Global signals are useful for debug overlays and device-config UIs that need to see every connected pad at once.
Connected-gamepad detection at init
The gamepads array is populated before init runs, but the browser’s gamepad API only reports connected pads after a button press on some platforms (a browser security restriction). In init, check app.input.connectedGamepads; if empty, wait for the onGamepadConnected signal:
override init(): void {
const pad = this.app.input.firstConnectedGamepad;
if (pad) {
this.bindPad(pad);
}
this.app.input.onGamepadConnected.add(p => {
if (!this._activePad) this.bindPad(p);
});
}Examples
Visual gamepad state display: buttons, sticks, D-pad, and triggers mapped to on-screen sprites.
Four sprites, each controlled by a separate gamepad slot via aggregate signed stick axes.
Try it
Playground
Where to go next
The next chapter, Chords and sequences, covers ChordAction and SequenceAction — requiring several channels at once and recognizing ordered command patterns, across keyboard and gamepad alike. For unifying keyboard and gamepad behind intent-driven action names first, see action mapping back in the keyboard chapter.


