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
const pad0 = app.input.gamepads[0]; // always exists, may be disconnectedconst pad1 = app.input.getGamepad(1); // same thing, reads more clearlyif (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 manager:
app.input.hasGamepad // true when at least one pad is connectedapp.input.connectedGamepadCount // how many slots are occupiedapp.input.firstConnectedGamepad // first in slot order, or nullapp.input.connectedGamepads // subset of gamepads[] that are connected
Lifecycle: connect and disconnect
Subscribe to lifecycle signals at either level:
// Per-slot — fires only for that specific slotpad0.onConnect.add(() => { console.log('Controller connected to slot 0');});pad0.onDisconnect.add(() => { console.log('Slot 0 disconnected');});// Global — fires for any slotapp.input.onGamepadConnected.add(pad => { console.log(`Controller connected to slot ${pad.slot}`);});app.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 keep gamepads[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 { GamepadButton } from '@codexo/exojs';const pad = app.input.getGamepad(0);// --- Binding-style listeners (attach to a specific pad) ---pad.onTrigger(GamepadButton.South, () => { this.player.jump(); // A on Xbox, ✕ on PlayStation, B on Switch});pad.onActive(GamepadButton.West, value => { this.player.attack(value); // X on Xbox, □ on PlayStation, Y on Switch});pad.onStop(GamepadButton.West, () => { this.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.
Axes
The GamepadAxis namespace provides both split-direction and aggregate signed channels:
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:
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:
if (pad.hasChannel(GamepadAxis.RightStickX)) { pad.onActive(GamepadAxis.RightStickX, value => { this.camera.rotate(value * 2); });}if (pad.hasChannel(GamepadButton.Paddle1)) { pad.onActive(GamepadButton.Paddle1, value => { 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.
Vibration
The Web Gamepad API exposes dual-rumble actuators on most modern controllers. Check support and trigger effects:
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:
init(loader) { const pad = app.input.firstConnectedGamepad; if (pad) { this.bindPad(pad); } app.input.onGamepadConnected.add(p => { if (!this._activePad) this.bindPad(p); });}
import { Application, Color, GamepadAxis, Scene, Sprite, Text, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: new Color(10, 12, 20),
loader: {
basePath: 'assets/',
},
});
const tints = [new Color(255, 140, 140), new Color(140, 255, 170), new Color(150, 180, 255), new Color(255, 230, 140)];
interface Player {
pad: any;
sprite: Sprite;
move: { x: number; y: number };
}
// Each of the four stable gamepad slots gets its own ship and its own left-stick
// bindings. Bindings persist across connect/disconnect, so we set them up once;
// only *connected* pads are moved and drawn, and an empty canvas shows a
// "connect a controller" prompt instead of a row of motionless ships.
class MultiGamepadScene extends Scene {
private players: Array<Player> = [];
private hasPad = false;
private connectPrompt!: Text;
private hud!: ReturnType<typeof mountControls>;
override async load(loader): Promise<void> {
await loader.load(Texture, { ship: 'image/ship-a.png' });
}
override init(loader): void {
const { width, height } = this.app.canvas;
this.players = this.app.input.gamepads.map((pad, index) => {
const sprite = new Sprite(loader.get(Texture, 'ship'))
.setAnchor(0.5)
.setScale(0.6)
.setPosition(width * (0.2 + index * 0.2), height / 2)
.setTint(tints[index]);
const move = { x: 0, y: 0 };
pad.onActive(GamepadAxis.LeftStickX, (value: number) => (move.x = value));
pad.onStop(GamepadAxis.LeftStickX, () => (move.x = 0));
pad.onActive(GamepadAxis.LeftStickY, (value: number) => (move.y = value));
pad.onStop(GamepadAxis.LeftStickY, () => (move.y = 0));
return { pad, sprite, move };
});
// Track controller presence with the engine's connect/disconnect signals
// and prompt with an on-screen Text while none is attached.
this.hasPad = this.app.input.gamepads.some(pad => pad.connected);
this.app.input.onGamepadConnected.add(() => (this.hasPad = true));
this.app.input.onGamepadDisconnected.add(() => (this.hasPad = this.app.input.gamepads.some(pad => pad.connected)));
this.connectPrompt = new Text('Connect one or more controllers to play', { fillColor: Color.white, fontSize: 24, align: 'center' })
.setAnchor(0.5, 0.5)
.setPosition(width / 2, height / 2);
this.hud = mountControls({
title: 'Multi Gamepad',
controls: [{ keys: 'L-Stick', action: 'move that pad’s ship' }],
status: '',
hint: 'Up to four pads, one coloured ship each.',
});
this.refreshHud();
this.app.input.onGamepadConnected.add(() => this.refreshHud());
this.app.input.onGamepadDisconnected.add(() => this.refreshHud());
}
private refreshHud(): void {
const lines = this.players.map((player, index) => {
const label = player.pad.connected ? (player.pad.info?.label ?? player.pad.info?.name ?? 'connected') : 'empty';
return `P${index + 1}: ${label}`;
});
this.hud.setStatus(lines.join(' · '));
}
override update(delta): void {
for (const player of this.players) {
if (!player.pad.connected) {
continue;
}
player.sprite.move(player.move.x * 260 * delta.seconds, player.move.y * 260 * delta.seconds);
}
}
override draw(context): void {
context.backend.clear();
for (const player of this.players) {
if (player.pad.connected) {
context.render(player.sprite);
}
}
if (!this.hasPad) {
context.render(this.connectPrompt);
}
}
}
app.start(new MultiGamepadScene());
Four sprites, each controlled by a separate gamepad slot via aggregate signed stick axes.
Where to go next
The next section, Audio basics, covers the audio pipeline — sound and music playback, volume and looping, buses, and the browser autoplay gesture. For unifying keyboard and gamepad behind intent-driven action names, see action mapping back in the keyboard chapter.