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
const pad0 = app.input.gamepads[0]; // always exists, may be disconnected
const pad1 = app.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 manager:
app.input.hasGamepad // true when at least one pad is connected
app.input.connectedGamepadCount // how many slots are occupied
app.input.firstConnectedGamepad // first in slot order, or null
app.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 slot
pad0.onConnect.add(() => {
console.log('Controller connected to slot 0');
});
pad0.onDisconnect.add(() => {
console.log('Slot 0 disconnected');
});
// Global — fires for any slot
app.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.
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:
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:
pad.onActive(GamepadAxis.LeftStickX, value => {
this.move.x = value; // -1..1, already deadzoned
});
pad.onStop(GamepadAxis.LeftStickX, () => {
this.move.x = 0;
});
pad.onActive(GamepadAxis.LeftStickY, value => {
this.move.y = value;
});
pad.onStop(GamepadAxis.LeftStickY, () => {
this.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:
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:
if (pad.canVibrate) {
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:
init(loader) {
const pad = app.input.firstConnectedGamepad;
if (pad) {
this.bindPad(pad);
}
app.input.onGamepadConnected.add(p => {
if (!this._activePad) this.bindPad(p);
});
}
Examples
import { Application, Color, Container, GamepadAxis, GamepadButton, Json, lerp, Scene, Sprite, Spritesheet, Texture, Vector } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class GamepadScene extends Scene {
private activePad: any = null;
private buttons!: Spritesheet;
private buttonColor = new Color(255, 255, 255, 0.25);
private mappingButtons = new Map<any, any>();
private mappingFunctions = new Map<any, any>();
private resetFunctions: Array<() => void> = [];
private padBindings: Array<{ unbind(): void }> = [];
private status!: Sprite;
private container!: Container;
override async load(loader): Promise<void> {
await loader.load(Texture, { buttons: 'image/buttons.png' });
await loader.load(Json, { buttons: 'json/buttons.json' });
}
override init(loader): void {
this.buttons = new Spritesheet(loader.get(Texture, 'buttons'), loader.get(Json, 'buttons'));
this.status = this.createStatus();
this.container = this.createGamepad();
for (const sprite of this.mappingButtons.values()) {
sprite.setTint(this.buttonColor);
}
this.app.input.onGamepadConnected.add(pad => this.handleGamepadConnected(pad));
this.app.input.onGamepadDisconnected.add(pad => this.handleGamepadDisconnected(pad));
for (const pad of this.app.input.gamepads) {
if (pad.connected) {
this.setActivePad(pad);
break;
}
}
}
override draw(context): void {
context.backend.clear();
context.render(this.status);
context.render(this.container);
}
private handleGamepadConnected(pad): void {
if (!this.activePad) {
this.setActivePad(pad);
}
}
private handleGamepadDisconnected(pad): void {
if (this.activePad !== pad) {
return;
}
const next = this.app.input.gamepads.find((other: any) => other !== pad && other.connected) || null;
this.setActivePad(next);
}
private setActivePad(pad): void {
for (const binding of this.padBindings) {
binding.unbind();
}
this.padBindings.length = 0;
this.activePad = pad;
if (!pad) {
this.status.setTint(this.buttonColor);
this.resetVisualState();
return;
}
this.status.setTint(Color.white);
for (const [channel, sprite] of this.mappingButtons.entries()) {
this.padBindings.push(
pad.onActive(channel, v => {
sprite.tint.a = lerp(0.25, 1, v);
}),
pad.onStop(channel, () => {
sprite.tint.a = this.buttonColor.a;
}),
);
}
for (const [channel, fn] of this.mappingFunctions.entries()) {
this.padBindings.push(
pad.onActive(channel, v => {
fn(v);
}),
pad.onStop(channel, () => {
fn(0);
}),
);
}
}
private resetVisualState(): void {
for (const sprite of this.mappingButtons.values()) {
sprite.tint.a = this.buttonColor.a;
}
for (const reset of this.resetFunctions) {
reset();
}
}
private createStatus(): Sprite {
const { width, height } = this.app.canvas;
const status = this.buttons.getFrameSprite('status');
status.setAnchor(0.5);
status.setPosition(width / 2, height / 5);
status.setTint(this.buttonColor);
return status;
}
private createGamepad(): Container {
const container = new Container();
container.addChild(this.createDPadField());
container.addChild(this.createFaceButtons());
container.addChild(this.createShoulderButtons());
container.addChild(this.createMenuButtons());
container.addChild(this.createJoysticks());
return container;
}
private createDPadField(): Container {
const { width, height } = this.app.canvas;
const mappedButtons = this.mappingButtons;
const container = new Container();
const dPad = this.buttons.getFrameSprite('dpad');
const dPadUp = this.buttons.getFrameSprite('DPadUp');
const dPadDown = this.buttons.getFrameSprite('DPadDown');
const dPadLeft = this.buttons.getFrameSprite('DPadLeft');
const dPadRight = this.buttons.getFrameSprite('DPadRight');
mappedButtons.set(GamepadButton.DPadUp, dPadUp);
mappedButtons.set(GamepadButton.DPadDown, dPadDown);
mappedButtons.set(GamepadButton.DPadLeft, dPadLeft);
mappedButtons.set(GamepadButton.DPadRight, dPadRight);
dPad.setTint(this.buttonColor);
dPad.setScale(1.75);
dPadUp.setScale(1.75);
dPadDown.setScale(1.75);
dPadLeft.setScale(1.75);
dPadRight.setScale(1.75);
container.addChild(dPad);
container.addChild(dPadUp);
container.addChild(dPadDown);
container.addChild(dPadLeft);
container.addChild(dPadRight);
container.setAnchor(0.5);
container.setPosition(width / 5, height / 2);
return container;
}
private createFaceButtons(): Container {
const { width, height } = this.app.canvas;
const mappedButtons = this.mappingButtons;
const container = new Container();
const buttonTop = this.buttons.getFrameSprite('FaceTop');
const buttonLeft = this.buttons.getFrameSprite('FaceLeft');
const buttonRight = this.buttons.getFrameSprite('FaceRight');
const buttonBottom = this.buttons.getFrameSprite('FaceBottom');
mappedButtons.set(GamepadButton.North, buttonTop);
mappedButtons.set(GamepadButton.West, buttonLeft);
mappedButtons.set(GamepadButton.East, buttonRight);
mappedButtons.set(GamepadButton.South, buttonBottom);
buttonTop.setScale(0.75);
buttonTop.setPosition(50, 0);
buttonLeft.setScale(0.75);
buttonLeft.setPosition(0, 50);
buttonRight.setScale(0.75);
buttonRight.setPosition(100, 50);
buttonBottom.setScale(0.75);
buttonBottom.setPosition(50, 100);
container.addChild(buttonTop);
container.addChild(buttonLeft);
container.addChild(buttonRight);
container.addChild(buttonBottom);
container.setAnchor(0.5);
container.setPosition(width * 0.8, height / 2);
return container;
}
private createShoulderButtons(): Container {
const { width, height } = this.app.canvas;
const mappedButtons = this.mappingButtons;
const container = new Container();
const leftButton = this.buttons.getFrameSprite('ShoulderLeftBottom');
const rightButton = this.buttons.getFrameSprite('ShoulderRightBottom');
const leftTrigger = this.buttons.getFrameSprite('ShoulderLeftTop');
const rightTrigger = this.buttons.getFrameSprite('ShoulderRightTop');
mappedButtons.set(GamepadButton.LeftShoulder, leftButton);
mappedButtons.set(GamepadButton.RightShoulder, rightButton);
mappedButtons.set(GamepadButton.LeftTrigger, leftTrigger);
mappedButtons.set(GamepadButton.RightTrigger, rightTrigger);
leftButton.setPosition(0, 75);
rightButton.setAnchor(0.5, 0);
rightButton.setPosition(width * 0.65, 75);
rightTrigger.setAnchor(0.5, 0);
rightTrigger.setPosition(width * 0.65, 0);
container.addChild(leftButton);
container.addChild(rightButton);
container.addChild(leftTrigger);
container.addChild(rightTrigger);
container.setAnchor(0.5);
container.setPosition(width / 2, height / 5);
return container;
}
private createMenuButtons(): Container {
const { width, height } = this.app.canvas;
const mappedButtons = this.mappingButtons;
const container = new Container();
const selectButton = this.buttons.getFrameSprite('Select');
const startButton = this.buttons.getFrameSprite('Start');
mappedButtons.set(GamepadButton.Select, selectButton);
mappedButtons.set(GamepadButton.Start, startButton);
startButton.setAnchor(1, 0);
startButton.setPosition(width * 0.3, 0);
container.addChild(selectButton);
container.addChild(startButton);
container.setAnchor(0.5);
container.setPosition(width / 2, height / 2);
return container;
}
private createJoysticks(): Container {
const { width, height } = this.app.canvas;
const mappedButtons = this.mappingButtons;
const mappingFunctions = this.mappingFunctions;
const container = new Container();
const leftStick = this.buttons.getFrameSprite('LeftStick');
const rightStick = this.buttons.getFrameSprite('RightStick');
const startLeft = new Vector(0, 0);
const startRight = new Vector(width * 0.3, 0);
const range = 35;
mappedButtons.set(GamepadButton.LeftStick, leftStick);
mappedButtons.set(GamepadButton.RightStick, rightStick);
mappingFunctions.set(GamepadAxis.LeftStickX, (value: number) => (leftStick.x = startLeft.x + value * range));
mappingFunctions.set(GamepadAxis.LeftStickY, (value: number) => (leftStick.y = startLeft.y + value * range));
mappingFunctions.set(GamepadAxis.RightStickX, (value: number) => (rightStick.x = startRight.x + value * range));
mappingFunctions.set(GamepadAxis.RightStickY, (value: number) => (rightStick.y = startRight.y + value * range));
this.resetFunctions.push(() => {
leftStick.setPosition(startLeft.x, startLeft.y);
rightStick.setPosition(startRight.x, startRight.y);
});
leftStick.setPosition(startLeft.x, startLeft.y);
rightStick.setPosition(startRight.x, startRight.y);
container.addChild(leftStick);
container.addChild(rightStick);
container.setAnchor(0.5, 0);
container.setPosition(width / 2, height * 0.65);
return container;
}
}
app.start(new GamepadScene());
Visual gamepad state display: buttons, sticks, D-pad, and triggers mapped to on-screen sprites.
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.