Guide

GuideInputGamepad

Gamepad

Read controller input and support multiple connected gamepads.

Intermediate~6 min read

What you'll learn

  • read controller buttons and axes
  • support multiple connected gamepads

Before you start

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

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 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 { 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.

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:

examples/guides/gamepad/capability-probe.ts
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:

examples/guides/gamepad/versus.ts
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.

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:

examples/guides/gamepad/capability-probe.ts
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:

examples/guides/gamepad/capability-probe.ts
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

GamepadGamepadOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, type Gamepad, GamepadAxis, GamepadButton, type InputChannel, lerp, type RenderingContext, Scene, Sprite, Spritesheet, type SpritesheetData, Vector } from '@codexo/exojs';

class GamepadScene extends Scene {
  private activePad: Gamepad | null = null;
  private buttons!: Spritesheet;
  private buttonColor = new Color(255, 255, 255, 0.25);
  private mappingButtons = new Map<InputChannel, Sprite>();
  private mappingFunctions = new Map<InputChannel, (value: number) => void>();
  private resetFunctions: (() => void)[] = [];
  private padBindings: { unbind(): void }[] = [];
  private status!: Sprite;
  private container!: Container;

  override async load(): Promise<void> {
    const app = this.app;
    const buttonsData = (await this.loader.load(Asset.type('json', 'json/buttons.json'))) as SpritesheetData;

    this.buttons = new Spritesheet(this.loader.get('image/buttons.png'), buttonsData);
    const { width, height } = app;
    this.status = this.createStatus(width, height);
    this.container = this.createGamepad(width, height);

    for (const sprite of this.mappingButtons.values()) {
      sprite.setTint(this.buttonColor);
    }

    app.input.onGamepadConnected.add(pad => this.handleGamepadConnected(pad));
    app.input.onGamepadDisconnected.add(pad => this.handleGamepadDisconnected(pad));

    for (const pad of app.input.gamepads) {
      if (pad.connected) {
        this.setActivePad(pad);
        break;
      }
    }
  }

  override draw(context: RenderingContext): void {
    context.render(this.status);
    context.render(this.container);
  }

  private handleGamepadConnected(pad: Gamepad): void {
    if (!this.activePad) {
      this.setActivePad(pad);
    }
  }

  private handleGamepadDisconnected(pad: Gamepad): void {
    if (this.activePad !== pad) {
      return;
    }

    const app = this.app;
    const next = app.input.gamepads.find(other => other !== pad && other.connected) || null;
    this.setActivePad(next);
  }

  private setActivePad(pad: Gamepad | null): 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(width: number, height: number): Sprite {
    const status = this.buttons.getFrameSprite('status');

    status.setAnchor(0.5);
    status.setPosition(width / 2, height / 5);
    status.setTint(this.buttonColor);

    return status;
  }

  private createGamepad(width: number, height: number): Container {
    const container = new Container();

    container.addChild(this.createDPadField(width, height));
    container.addChild(this.createFaceButtons(width, height));
    container.addChild(this.createShoulderButtons(width, height));
    container.addChild(this.createMenuButtons(width, height));
    container.addChild(this.createJoysticks(width, height));

    return container;
  }

  private createDPadField(width: number, height: number): Container {
    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.setPosition(width / 5, height / 2);

    return container;
  }

  private createFaceButtons(width: number, height: number): Container {
    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.setPosition(width * 0.8, height / 2);

    return container;
  }

  private createShoulderButtons(width: number, height: number): Container {
    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.setPosition(width / 2, height / 5);

    return container;
  }

  private createMenuButtons(width: number, height: number): Container {
    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.setPosition(width / 2, height / 2);

    return container;
  }

  private createJoysticks(width: number, height: number): Container {
    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.setPosition(width / 2, height * 0.65);

    return container;
  }
}

const app = new Application({
  scenes: { GamepadScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(GamepadScene);

Visual gamepad state display: buttons, sticks, D-pad, and triggers mapped to on-screen sprites.

Multi GamepadGamepadOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type Gamepad, GamepadAxis, type RenderingContext, Scene, type Seconds, Sprite, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

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: Gamepad;
  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: Player[] = [];
  private hasPad = false;
  private connectPrompt!: Text;
  private hud!: ReturnType<typeof mountControls>;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.players = app.input.gamepads.map((pad, index) => {
      const sprite = new Sprite(this.loader.get('image/ship-a.png'))
        .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 = app.input.gamepads.some(pad => pad.connected);
    app.input.onGamepadConnected.add(() => (this.hasPad = true));
    app.input.onGamepadDisconnected.add(() => (this.hasPad = 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();
    app.input.onGamepadConnected.add(() => this.refreshHud());
    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: Seconds): void {
    for (const player of this.players) {
      if (!player.pad.connected) {
        continue;
      }

      player.sprite.move(player.move.x * 260 * delta, player.move.y * 260 * delta);
    }
  }

  override draw(context: RenderingContext): void {
    for (const player of this.players) {
      if (player.pad.connected) {
        context.render(player.sprite);
      }
    }

    if (!this.hasPad) {
      context.render(this.connectPrompt);
    }
  }
}

const app = new Application({
  scenes: { MultiGamepadScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(10, 12, 20),
  loader: {
    basePath: 'assets/',
  },
});

await app.start(MultiGamepadScene);

Four sprites, each controlled by a separate gamepad slot via aggregate signed stick axes.

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.