@nativescript/canvas-gamepad
Game controller input through the web Gamepad API: navigator.getGamepads(), plus the gamepadconnected and gamepaddisconnected events. Code written for the browser runs without changes.
npm install @nativescript/canvas-gamepad @nativescript/canvas-polyfillImport the polyfill once, before anything that reads input:
// app.ts
import '@nativescript/canvas-polyfill';When @nativescript/canvas-gamepad is installed, the polyfill backs navigator.getGamepads() and the window gamepad events with it. Without the package, navigator.getGamepads() returns an empty array.
Live demo
The demo below runs in this page using your browser's Gamepad API. The same file runs unchanged in a NativeScript app.
Runs in this page with the browser's own Gamepad API. Connect a controller, then click Show demo.
To run it on a device, put the file next to your page and start it when the canvas is ready:
<canvas:Canvas width="100%" height="100%" ready="canvasReady" unloaded="canvasUnloaded" />import '@nativescript/canvas-polyfill';
import { startGamepadTester } from './gamepad-tester';
let stop: () => void;
export function canvasReady(args) {
stop = startGamepadTester(args.object);
}
export function canvasUnloaded() {
stop?.();
}gamepad-tester.ts
// Uses only web APIs, so it runs unchanged in a browser and in NativeScript
// with @nativescript/canvas-polyfill and @nativescript/canvas-gamepad installed.
const WIDTH = 480;
const HEIGHT = 270;
const BACKGROUND = '#0f141c';
const IDLE = '#273142';
const OUTLINE = '#4b5563';
const TEXT = '#e5e7eb';
const MUTED = '#94a3b8';
const ACTIVE = '#f75930';
export function startGamepadTester(canvas: HTMLCanvasElement): () => void {
const ctx = canvas.getContext('2d')!;
let frame = 0;
const onConnected = (e: GamepadEvent) => console.log(`gamepad ${e.gamepad.index} connected: ${e.gamepad.id}`);
const onDisconnected = (e: GamepadEvent) => console.log(`gamepad ${e.gamepad.index} disconnected`);
window.addEventListener('gamepadconnected', onConnected);
window.addEventListener('gamepaddisconnected', onDisconnected);
const draw = () => {
// Fit a 480x270 layout into whatever size the canvas has.
const scale = Math.min(canvas.width / WIDTH, canvas.height / HEIGHT);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = BACKGROUND;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scale, 0, 0, scale, (canvas.width - WIDTH * scale) / 2, (canvas.height - HEIGHT * scale) / 2);
// Poll every frame: this is how the Gamepad API is meant to be read.
const pads = navigator.getGamepads().filter((pad): pad is Gamepad => pad !== null && pad.connected);
if (pads.length > 0) {
drawPad(ctx, pads[0], pads.length - 1);
} else {
drawWaiting(ctx);
}
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('gamepadconnected', onConnected);
window.removeEventListener('gamepaddisconnected', onDisconnected);
};
}
function drawWaiting(ctx: CanvasRenderingContext2D) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = TEXT;
ctx.font = 'bold 16px sans-serif';
ctx.fillText('Connect a controller and press any button', WIDTH / 2, HEIGHT / 2 - 10);
ctx.fillStyle = MUTED;
ctx.font = '12px sans-serif';
ctx.fillText('navigator.getGamepads() has no connected pads yet', WIDTH / 2, HEIGHT / 2 + 16);
}
function drawPad(ctx: CanvasRenderingContext2D, pad: Gamepad, others: number) {
const b = pad.buttons;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = TEXT;
ctx.font = 'bold 12px sans-serif';
ctx.fillText(pad.id.length > 60 ? pad.id.slice(0, 59) + '…' : pad.id, 16, 18);
// Standard mapping: https://w3c.github.io/gamepad/#remapping
trigger(ctx, b[6], 40, 40, 'LT');
trigger(ctx, b[7], 320, 40, 'RT');
shoulder(ctx, b[4], 40, 62, 'LB');
shoulder(ctx, b[5], 320, 62, 'RB');
stick(ctx, pad.axes[0], pad.axes[1], b[10], 110, 140, 'L');
stick(ctx, pad.axes[2], pad.axes[3], b[11], 300, 188, 'R');
dpad(ctx, b[12], b[13], b[14], b[15], 180, 192);
button(ctx, b[3], 370, 110, 'Y');
button(ctx, b[2], 345, 135, 'X');
button(ctx, b[1], 395, 135, 'B');
button(ctx, b[0], 370, 160, 'A');
pill(ctx, b[8], 205, 110, 'Select');
pill(ctx, b[9], 275, 110, 'Start');
if (b.length > 16) {
button(ctx, b[16], 240, 145, 'Home');
}
ctx.textAlign = 'left';
ctx.fillStyle = MUTED;
ctx.font = '11px sans-serif';
const summary = `index ${pad.index} · mapping "${pad.mapping}" · ${pad.axes.length} axes · ${b.length} buttons`;
ctx.fillText(others > 0 ? `${summary} · ${others} more connected` : summary, 16, HEIGHT - 14);
}
function trigger(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
const value = input?.value ?? 0;
ctx.fillStyle = IDLE;
ctx.fillRect(x, y - 7, 100, 14);
ctx.fillStyle = ACTIVE;
ctx.fillRect(x, y - 7, 100 * value, 14);
ctx.textAlign = 'left';
ctx.fillStyle = TEXT;
ctx.font = '11px sans-serif';
ctx.fillText(`${label} ${value.toFixed(2)}`, x + 106, y);
}
function shoulder(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
ctx.fillRect(x, y - 8, 100, 16);
ctx.textAlign = 'center';
ctx.fillStyle = TEXT;
ctx.font = '11px sans-serif';
ctx.fillText(label, x + 50, y);
}
function stick(ctx: CanvasRenderingContext2D, ax = 0, ay = 0, press: GamepadButton | undefined, x: number, y: number, label: string) {
const radius = 34;
ctx.fillStyle = IDLE;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = press?.pressed ? ACTIVE : OUTLINE;
ctx.stroke();
// Axes run from -1 to 1, with +y pointing down.
ctx.fillStyle = ACTIVE;
ctx.beginPath();
ctx.arc(x + ax * (radius - 8), y + ay * (radius - 8), 8, 0, Math.PI * 2);
ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = MUTED;
ctx.font = '10px sans-serif';
ctx.fillText(`${label} ${ax.toFixed(2)}, ${ay.toFixed(2)}`, x, y + radius + 12);
}
function dpad(
ctx: CanvasRenderingContext2D,
up: GamepadButton | undefined,
down: GamepadButton | undefined,
left: GamepadButton | undefined,
right: GamepadButton | undefined,
x: number,
y: number,
) {
const size = 18;
const cells: [GamepadButton | undefined, number, number][] = [
[up, 0, -1],
[down, 0, 1],
[left, -1, 0],
[right, 1, 0],
];
for (const [input, dx, dy] of cells) {
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
ctx.fillRect(x + dx * size - size / 2, y + dy * size - size / 2, size, size);
}
ctx.fillStyle = IDLE;
ctx.fillRect(x - size / 2, y - size / 2, size, size);
}
function button(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
ctx.beginPath();
ctx.arc(x, y, label.length > 1 ? 16 : 12, 0, Math.PI * 2);
ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = TEXT;
ctx.font = label.length > 1 ? '10px sans-serif' : 'bold 12px sans-serif';
ctx.fillText(label, x, y);
}
function pill(ctx: CanvasRenderingContext2D, input: GamepadButton | undefined, x: number, y: number, label: string) {
ctx.fillStyle = input?.pressed ? ACTIVE : IDLE;
ctx.fillRect(x - 24, y - 8, 48, 16);
ctx.textAlign = 'center';
ctx.fillStyle = TEXT;
ctx.font = '10px sans-serif';
ctx.fillText(label, x, y);
}Reading input
The Gamepad API is polled, not event driven. Read navigator.getGamepads() once per frame, usually from requestAnimationFrame:
function frame() {
const pad = navigator.getGamepads()[0];
if (pad) {
player.x += pad.axes[0] * speed;
player.y += pad.axes[1] * speed;
if (pad.buttons[0].pressed) player.jump();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);The array always has four slots. An empty slot is null, and a controller keeps its index for as long as it stays connected.
Standard mapping
Every controller is reported with mapping === 'standard', so button and axis indices mean the same thing on every platform and in every browser:
| Index | Button | Index | Button | |
|---|---|---|---|---|
| 0 | A (bottom face) | 9 | Start / Menu | |
| 1 | B (right face) | 10 | Left stick press | |
| 2 | X (left face) | 11 | Right stick press | |
| 3 | Y (top face) | 12 | D-pad up | |
| 4 | Left bumper | 13 | D-pad down | |
| 5 | Right bumper | 14 | D-pad left | |
| 6 | Left trigger | 15 | D-pad right | |
| 7 | Right trigger | 16 | Home / Guide (not on Windows) | |
| 8 | Select / Back / Options |
| Axis | Value |
|---|---|
| 0, 1 | Left stick x and y |
| 2, 3 | Right stick x and y |
Axes range from -1 to 1, with positive y pointing down. Triggers are buttons with an analog value from 0 to 1. Each button has pressed, touched and value.
Dead zones
Axis values are passed through raw, as in browsers. A stick at rest rarely reports exactly 0, so apply your own dead zone:
function deadZone(value: number, threshold = 0.15) {
return Math.abs(value) < threshold ? 0 : value;
}
const x = deadZone(pad.axes[0]);
const y = deadZone(pad.axes[1]);A press, not a hold
buttons[i].pressed is true for as long as the button is held. To act once per press, compare against the previous frame. Gamepad objects are updated in place (like Firefox, and unlike Chrome's snapshots), so keep plain booleans instead of the old objects:
const previous: boolean[][] = [];
function justPressed(pad: Gamepad, button: number) {
const last = (previous[pad.index] ??= []);
const now = pad.buttons[button].pressed;
const result = now && !last[button];
last[button] = now;
return result;
}
if (justPressed(pad, 9)) togglePause();This pattern works in every browser and on every platform.
Connection events
window.addEventListener('gamepadconnected', (e) => {
console.log(`controller ${e.gamepad.index} connected: ${e.gamepad.id}`);
});
window.addEventListener('gamepaddisconnected', (e) => {
console.log(`controller ${e.gamepad.index} disconnected`);
});Use the events as notifications, for example to show a "controller connected" message or to pause when a controller drops. Use navigator.getGamepads() as the source of truth. Monitoring starts with the first gamepad listener or getGamepads() call, and at that point every controller that is already connected is reported. As in browsers, each connection is announced once: a listener added later is not told about controllers that are already connected.
Without the polyfill
If you don't want window, document and the other browser globals, import the package directly. It has the same Gamepad objects, just not hung off navigator:
| With the polyfill | Without |
|---|---|
navigator.getGamepads() | getGamepads() |
window.addEventListener('gamepadconnected', fn) | gamepads.addListener(fn), then check e.type |
window.removeEventListener(...) | gamepads.removeListener(fn) |
Native monitoring starts on the first getGamepads() call or addListener(), not on import.
Example: move a dot with the left stick
This uses only @nativescript/core and @nativescript/canvas. The left stick moves the dot, A changes its colour, and the connection listener updates a label:
<Page xmlns="http://schemas.nativescript.org/tns.xsd" xmlns:canvas="@nativescript/canvas" unloaded="onUnloaded">
<GridLayout rows="auto, *">
<Label id="status" text="Connect a controller" class="p-4" />
<canvas:Canvas row="1" width="100%" height="100%" ready="canvasReady" />
</GridLayout>
</Page>import type { EventData, Label } from '@nativescript/core';
import type { Canvas } from '@nativescript/canvas';
import { gamepads, getGamepads, type GamepadEvent } from '@nativescript/canvas-gamepad';
const COLORS = ['#f75930', '#22c55e', '#3b82f6', '#eab308'];
let frame = 0;
let status: Label;
function deadZone(value: number, threshold = 0.15) {
return Math.abs(value) < threshold ? 0 : value;
}
function onConnection(e: GamepadEvent) {
const connected = getGamepads().filter((pad) => pad !== null).length;
status.text = e.type === 'gamepadconnected'
? `${e.gamepad.id} connected`
: connected > 0 ? `${connected} controller(s) connected` : 'Connect a controller';
}
export function canvasReady(args: EventData) {
const canvas = args.object as Canvas;
status = canvas.page.getViewById<Label>('status');
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const dot = { x: canvas.width / 2, y: canvas.height / 2, color: 0 };
let wasPressed = false;
gamepads.addListener(onConnection);
const draw = () => {
const pad = getGamepads().find((p) => p !== null);
if (pad) {
const speed = canvas.width / 100;
dot.x = Math.min(canvas.width, Math.max(0, dot.x + deadZone(pad.axes[0]) * speed));
dot.y = Math.min(canvas.height, Math.max(0, dot.y + deadZone(pad.axes[1]) * speed));
// Act on the press, not on every frame the button is held.
const pressed = pad.buttons[0].pressed;
if (pressed && !wasPressed) {
dot.color = (dot.color + 1) % COLORS.length;
}
wasPressed = pressed;
}
ctx.fillStyle = '#0f141c';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = COLORS[dot.color];
ctx.beginPath();
ctx.arc(dot.x, dot.y, canvas.width / 20, 0, Math.PI * 2);
ctx.fill();
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
}
export function onUnloaded() {
cancelAnimationFrame(frame);
gamepads.removeListener(onConnection);
}requestAnimationFrame here is the global from @nativescript/core, so the loop needs nothing from the polyfill.
Platform notes
| Platform | Source | Notes |
|---|---|---|
| iOS, tvOS, visionOS | GameController (GCExtendedGamepad) | The package sets valueChangedHandler on each controller, replacing any handler your own code set. |
| Android | Gamepad and joystick KeyEvent / MotionEvent | Input from connected controllers is consumed at the activity window, so B no longer triggers Back while the API is in use. |
| Windows | Windows.Gaming.Input.Gamepad | 16 buttons: there is no Guide button. |
On tvOS the Siri Remote is not a gamepad. It keeps sending keydown and keyup events, as described in Events and Input.
Differences from browsers
- No button press required. Browsers hide controllers until one is used on the page. Here a connected controller is reported immediately.
- Live objects. Each slot holds the same
Gamepadobject between calls, updated in place. Copyaxesorbuttonsif you need to compare frames. timestampis theperformance.now()value at the firstgetGamepads()call after the controller's state changed.- No rumble.
vibrationActuatorisnullandhapticActuatorsis empty. - Up to four controllers, all using the standard mapping.
idformat differs by platform, for exampleXbox Wireless Controller (STANDARD GAMEPAD)on iOS. Show it to users, but don't parse it.