npm package: @spud.gg/api
Description: spud! the api.
Author: spud
Latest version: 0.0.24

# spud!

## what is this?

Spud makes the browser [Gamepad API](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API) more fun to use. Add it to your game loop and get access to tools for reacting to button presses, working with joysticks, triggering haptics, identifying the controller layout, and much more.

## installation

```sh
# with npm
npm i @spud.gg/api

# ... or the equivalent call to bun, pnpm, yarn, etc.
```

## features

[![MIT License](https://badgen.net/static/license/MIT/blue)](https://opensource.org/license/mit) [![npm version](https://badgen.net/npm/v/@spud.gg/api)](https://www.npmjs.com/package/@spud.gg/api)

- Built-in controller deadzone handling with axis normalization
- Simplified wrappers for haptics
- Simplified gamepad button handling
- Utilities for snapping analog sticks to 4 or 8 directions
- Detect gamepad layout and base model
- Web Audio helpers for sound effects and music playback

## stability

- pre-0.1.0: expect regular breaking changes
- pre-1.0.0: breaking changes will use a new minor version
- post-1.0.0: breaking changes will use a new major version

## table of contents

- [Basic Usage](#basic-usage)
- [Gamepads API Reference](#gamepads-api-reference)
  - [Player Controllers](#player-controllers)
  - [Constants and Types](#constants-and-types)
  - [Player Groups and Aliases](#player-groups)
    - [`anyPlayer`](#anyplayer)
    - [`connectedPlayers`](#connectedplayers)
    - [`players`](#players)
    - [`singlePlayer`](#singleplayer)
- [Audio API Reference](#audio-api-reference)
  - [createSounds](#createsounds)
  - [createMusic](#createmusic)
  - [Global Volume and Mix](#global-volume-and-mix)

## basic usage

<details><summary>Complete, minimal example game loop</summary>

```ts
import { gamepads } from '@spud.gg/api';

// set up full-page HTMLCanvasElement
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
Object.assign(canvas.style, { position: 'fixed', inset: '0', width: '100%', height: '100%' });

document.body.appendChild(canvas);

// we'll update this state via the left analog stick
const state = {
  playerX: 100,
  playerY: 20,
};

const game = {
  update() {
    const { x, y } = gamepads.singlePlayer.leftStick;
    state.playerX += x;
    state.playerY += y;
  },
  draw() {
    // scale canvas for dpi
    const canvasRect = canvas.getBoundingClientRect();
    canvas.width = canvasRect.width * window.devicePixelRatio;
    canvas.height = canvasRect.height * window.devicePixelRatio;
    ctx.scale(devicePixelRatio, devicePixelRatio);

    // clear canvas
    ctx.fillStyle = 'black';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // draw a 20x20px square at [playerX, playerY]
    ctx.fillStyle = 'green';
    ctx.fillRect(state.playerX, state.playerY, 20, 20);

    // show player info text
    ctx.fillStyle = 'white';
    ctx.fillText(`player: ${gamepads.singlePlayer.index}`, 10, 30);
    ctx.fillText(`controllers: ${gamepads.playerCount}`, 10, 50);
  },
};

function gameLoop() {
  // apply the state updates from gamepad inputs
  game.update();

  // call this function once, at the end of your update cycle, in order to save
  // a snapshot of the previous buttons down and other state. this is how we're
  // able to provide utilities like `buttonJustReleased` which compare current
  // and prior gamepad states.
  gamepads.clearInputs();

  // render the updated game state to the canvas
  game.draw();

  // loop infinitely
  requestAnimationFrame(gameLoop);
}

gameLoop();
```

</details>

<details><summary>Kitchen sink example</summary>

```ts
import { spud, gamepads, Button, HapticIntensity } from '@spud.gg/api';

function update() {
  // count the number of connected gamepads
  console.log(`there are ${gamepads.playerCount} connected controllers`);

  // or target just a specific player
  const isPlayer3PressingSelect = gamepads.p3.isButtonDown(Button.Select);

  // if p1 just pressed the left dpad button
  if (gamepads.p1.buttonJustPressed(Button.DpadLeft)) {
    // provide 50ms of haptic feedback
    gamepads.p1.rumble(50);

    // and read player-specific settings from the spud parent window (with defaults if not on spud.gg)
    console.log(spud.p1.name, spud.p1.color);
  }

  // if all connected players are simultaneously pressing Start
  if (gamepads.connectedPlayers.isButtonDown(Button.Start)) {
    // ...
  }

  // read current axis values
  const {
    // normalized x and y to account for deadzones
    x,
    y,
    // raw axis values from the gamepad
    rawX,
    rawY,
    // normalized x and y values that snap to 4-way or 8-way directions
    snap4,
    snap8,
  } = gamepads.p1.leftStick;

  // simplified access to trigger axes
  const { value, rawValue, rumble } = gamepads.p1.leftTrigger;

  // start a 100ms Heavy haptic in p1's left trigger
  rumble(100, HapticIntensity.Heavy);

  // if any player just pressed the A (Xbox) or ✕ (PlayStation) button...
  if (gamepads.anyPlayer.buttonJustPressed(Button.South)) {
    // ...
  }

  // in addition, you always have access to the raw Gamepad, so this:
  gamepads.p1.gamepad?.vibrationActuator.reset();
  // ...is the same as this:
  gamepads.p1.hapticReset();

  // loop through only the connected gamepads
  gamepads.connectedPlayers.list.forEach((player) => {
    console.log(
      player.gamepadInfo.baseLayout, // eg. "Nintendo" or "Xbox"
      player.gamepadInfo.model, // a standard gamepad model, eg. "Xbox360", even for off-brand controllers
      player.gamepadInfo.productName, // the specific gamepad model, eg. "Xbox Elite Series 2" or "Afterglow AX.1 Gamepad for Xbox 360"
      player.index, // 0-3, since browsers support up to 4 gamepads
    );
  });
}

function gameLoop() {
  update();

  // call this function once, at the end of your update cycle, in order to save
  // a snapshot of the previous buttons down and other state. this is how we're
  // able to provide utilities like `buttonJustReleased` which compare current
  // and prior gamepad states.
  gamepads.clearInputs();

  game.draw(); // assume you've already written some code to render to a canvas

  // loop infinitely
  requestAnimationFrame(gameLoop);
}

gameLoop();
```

</details>

## `gamepads` api reference

### player controllers

Each player (`gamepads.p1`–`gamepads.p4`) provides:

- `index` - `0` | `1` | `2` | `3`
- `gamepad` - Raw [Gamepad](https://developer.mozilla.org/en-US/docs/Web/API/Gamepad) object
- `gamepadInfo` - Object with data about the player's gamepad, including:
  - `vendorId` – Hex identifier for the gamepad vendor from the gamepad's `id` field
  - `productId` – Hex identifier for the gamepad product from the gamepad's `id` field
  - `model` – A `GamepadModel` string. This is a generic model enum, such as "Xbox360", which off-brand controllers such as the "Afterglow AX.1 Gamepad for Xbox 360" will also map to.
  - `baseLayout` - A `GamepadBaseLayout` value equal to `Nintendo` | `PlayStation` | `Xbox` | `Generic`
  - `id`, `index`, `mapping`, `timestamp` – Properties that come straight from the browser gamepad fields
- `justConnected` - `true` if the gamepad just connected this frame
- `justDisconnected` - `true` if the gamepad just disconnected this frame
- `isButtonDown(button)` - Checks if a button is currently pressed
- `wasButtonDown(button)` - Checks if a button was pressed in the previous frame
- `buttonJustPressed(button)` - Checks if a button was just pressed (current frame && not previous)
- `buttonJustReleased(button)` - Checks if a button was just released (previous frame && not current)
- `buttonHeld(button)` - Checks if a button is being held (pressed in both current and previous frame)
- `buttonsDown` - A `Set` of all currently pressed buttons
- `previousButtonsDown` - A `Set` of all buttons pressed in the previous frame
- `leftStick`/`rightStick` - Analog stick accessors with properties:
  - `x`/`y` - Normalized values that account for deadzones
  - `rawX`/`rawY` - Raw axis values directly from the gamepad
  - `magnitude` - Distance from center of the analog stick (0 to 1)
  - `angle` - Angle in radians (-π to π)
  - `angleDegrees` - Angle in degrees (0 to 360), clockwise from right
  - `cardinal` - Nearest cardinal direction (N/NE/E/SE/S/SW/W/NW)
  - `snap4` - Normalized values that snap to 4-way directions (up/down/left/right)
  - `snap8` - Normalized values that snap to 8-way directions (including diagonals)
  - `deadzone` - The amount of deadzone for the stick
  - `motion(options?)` - Detect directional motion with hysteresis to prevent jittery state changes
    - `options.activate` - Threshold for activating motion (0-1, default: 0.75)
    - `options.deactivate` - Threshold for deactivating motion (0-1, default: 0.25)
    - `options.diagonals` - If true, returns 8-way directions; if false, returns 4-way (default: false)
    - Returns (4-way): `{ justMoved: { Up, Down, Left, Right }, direction: { x, y } }`
    - Returns (8-way): `{ justMoved: { N, NE, E, SE, S, SW, W, NW }, direction: CardinalDirection | 'Neutral' }`
- `leftTrigger`/`rightTrigger` - Trigger accessors with properties:
  - `value` - Normalized value that accounts for deadzone
  - `rawValue` - Raw axis value directly from the gamepad
  - `deadzone` - The amount of deadzone for the trigger
  - `rumble(duration?, intensity?)` - Method to provide haptic feedback to the trigger
    - `duration`: number - Duration in milliseconds (optional, default: `30`)
    - `intensity`: HapticIntensity | number - Intensity of the rumble (optional, default: `Balanced`)
- `rumble(duration?, intensity?)` - Easy vibration effect
  - `duration`: number - Duration in milliseconds (optional, default: `30`)
  - `intensity`: HapticIntensity - Intensity of the rumble (optional, default: `Balanced`)
- `haptic({ duration?, strong?, weak? })` - Fine-grained [haptic](https://developer.mozilla.org/en-US/docs/Web/API/GamepadHapticActuator) control
  - `duration`: number - Duration of haptic effect in milliseconds (optional, default: `0`)
  - `strong`: number - Intensity of strong actuator (`0`-`1`) (optional, default: `0`)
  - `weak`: number - Intensity of weak actuator (`0`-`1`) (optional, default: `0`)
- `hapticReset()` - Cancels any ongoing haptic feedback

#### examples

```ts
// check if a button is currently pressed
if (gamepads.p1.isButtonDown(Button.South)) {
  console.log('A/✕ button is being pressed');
}

// check for buttons that were just pressed this frame
if (gamepads.p2.buttonJustPressed(Button.North)) {
  console.log('Y/△ button was just pressed');
}

// using analog stick values with deadzone handling
const { x, y } = gamepads.p1.leftStick;
yourGame.moveCharacter(x, y); // values are normalized with deadzone applied

// using analog stick with 8-way directional snapping
const { x: snapX, y: snapY } = gamepads.p1.rightStick.snap8;
yourGame.aimWeapon(snapX, snapY);

// using analog stick geometric properties
const { magnitude, angle, cardinal } = gamepads.p1.leftStick;
if (magnitude > 0.8) yourGame.sprint();
yourGame.rotateCamera(angle);
if (cardinal === 'NE') yourGame.openNorthEastDoor();

// using motion detection for menu navigation
const { justMoved } = gamepads.p1.leftStick.motion();
if (justMoved[Motion.Up]) yourGame.selectPreviousMenuItem();
if (justMoved[Motion.Down]) yourGame.selectNextMenuItem();

// using 8-way motion for aiming with custom activation/deactivation thresholds
const { direction, didJustMove } = gamepads.p1.rightStick.motion({
  diagonals: true, // 8-way
  activate: 0.5,
  deactivate: 0.2,
});
if (didJustMove) {
  yourGame.aimWeapon(direction);
}

// using trigger values
if (gamepads.p1.rightTrigger.value > 0.8) {
  yourGame.fireWeapon();

  // apply haptic feedback to the trigger
  gamepads.p1.rightTrigger.rumble(100, HapticIntensity.Heavy);
}

// apply general haptic feedback
gamepads.p1.rumble(200, HapticIntensity.Balanced);

// custom haptic control
gamepads.p1.haptic({
  duration: 300,
  strong: 0.7,
  weak: 0.3,
});

gamepads.p1.leftTrigger.rumble();
gamepads.p1.rightTrigger.rumble(50, HapticIntensity.Heavy);

// cancel all haptic effects
gamepads.p1.hapticReset();
```

### constants and types

- **`Button`** - Button constants that map to their [index in the standard mapping](https://w3c.github.io/gamepad/#remapping). eg. DpadUp, Start, Select, LeftShoulder, North/South/East/West (maps to Xbox Y/A/X/B or PlayStation △/✕/□/○ or Nintendo X/B/A/Y), etc.
- **`Axis`** - Gamepad axes -- `Axis.LeftStickX`, `Axis.LeftStickY`, `Axis.LeftTrigger`, etc.
- **`Player`** - `Player.P1`, `Player.P2`, `Player.P3`, `Player.P4`
- **`GamepadBaseLayout`** - Controller base layouts -- `GamepadBaseLayout.Xbox`, `GamepadBaseLayout.PlayStation`, `GamepadBaseLayout.Nintendo`, `GamepadBaseLayout.Generic`
- **`GamepadModel`** - Common controller models, which several specific products can map to -- `GamepadModel.Xbox360`, `GamepadModel.PlayStationDualSense`, `GamepadModel.SwitchPro`, `GamepadModel.SteamDeck`, etc.
- **`HapticIntensity`** - `HapticIntensity.Light`, `HapticIntensity.Balanced`, `HapticIntensity.Heavy`
- **`Motion`** - Directional movement -- `Motion.Up`, `Motion.Down`, `Motion.Left`, `Motion.Right`
- **`CardinalDirection`** - 8-way directions for joystick movements -- `CardinalDirection.N`, `CardinalDirection.NE`, `CardinalDirection.S`, etc.

#### examples

```ts
// button constants
if (gamepads.p1.buttonJustPressed(Button.South)) yourGame.jump();
if (gamepads.p1.isButtonDown(Button.DpadLeft)) yourGame.moveLeft();

// haptic intensity levels
gamepads.p1.rumble(100, HapticIntensity.Light);
gamepads.p1.rumble(100, HapticIntensity.Balanced);
gamepads.p1.rumble(100, HapticIntensity.Heavy);

// gamepad info constants
const gamepadInfo = gamepads.p1.gamepadInfo;
if (gamepadInfo.baseLayout === GamepadBaseLayout.Nintendo) {
  // probably flip A/B and X/Y
}
if (gamepadInfo.model === GamepadModel.PlayStationDualSense) {
  // show touchpad in controller layout
}

// motion and direction constants (4-way)
const { direction, didJustMove } = gamepads.p1.leftStick.motion();
if (direction.x === Motion.Left) yourGame.moveLeft();
if (direction.y === Motion.Up) yourGame.moveUp();

// cardinal directions for 8-way movement
const { direction: cardinalDir } = gamepads.p1.leftStick.motion({ diagonals: true });
if (cardinalDir === CardinalDirection.NE) yourGame.moveNorthEast();
```

### player groups

utilities for handling multiple players at once.

#### `anyPlayer`

- `isButtonDown(button)` - Checks if any connected player is pressing the button
- `wasButtonDown(button)` - Checks if any connected player was pressing the button in the previous frame
- `buttonJustPressed(button)` - Checks if any connected player just pressed the button
- `buttonJustReleased(button)` - Checks if any connected player just released the button
- `buttonHeld(button)` - Checks if any connected player is holding the button

#### `connectedPlayers`

- `list` - Array of only the connected players (gamepads that actually exist)
- `isButtonDown(button)` - Checks if all connected players are pressing the button
- `buttonHeld(button)` - Checks if all connected players are holding the button
- `haptic({ duration?, strong?, weak? })` - Apply haptic feedback to all connected players
  - `duration`: number - Duration of haptic effect in milliseconds (optional, default: `0`)
  - `strong`: number - Intensity of strong actuator (`0`-`1`) (optional, default: `0`)
  - `weak`: number - Intensity of weak actuator (`0`-`1`) (optional, default: `0`)
- `rumble(duration?, intensity?)` - Applies rumble effect to all connected players
  - `duration`: number - Duration in milliseconds (optional, default: `30`)
  - `intensity`: HapticIntensity - Intensity of the rumble (optional, default: `Balanced`)
- `leftTrigger.rumble(duration?, intensity?)` - Trigger rumble for all connected players
  - `duration`: number - Duration in milliseconds (optional, default: `30`)
  - `intensity`: HapticIntensity - Intensity of the rumble (optional, default: `Balanced`)
- `rightTrigger.rumble(duration?, intensity?)` - Trigger rumble for all connected players
  - `duration`: number - Duration in milliseconds (optional, default: `30`)
  - `intensity`: HapticIntensity - Intensity of the rumble (optional, default: `Balanced`)

#### `singlePlayer`

`gamepads.singleplayer` is a reference to the most recently active gamepad's player. So if `gamepads.p2` was the most recently active player, `gamepads.singlePlayer === gamepads.p2`. This allows you to easily create single-player games without needing to worry about configuring each controller. The player can seamlessly switch between any connected controllers, or connect a new one and continue playing without any additional setup. Anyone can pick up any connected controller and immediately start playing.

#### examples with player groups

```ts
// start the game when all players press start
if (gamepads.connectedPlayers.isButtonDown(Button.Start)) {
  yourGame.startGame();
}

// charge a special move when all players press a button simultaneously
if (gamepads.connectedPlayers.buttonHeld(Button.South)) {
  yourGame.chargeTeamSpecialMove();
}

// apply haptic feedback to all connected players
gamepads.connectedPlayers.rumble(500, HapticIntensity.Heavy);

// or a custom haptic pattern for all players
gamepads.connectedPlayers.haptic({
  duration: 200,
  strong: 0.8,
  weak: 0.4,
});

// 30ms trigger rumble haptic feedback for all connected players
gamepads.connectedPlayers.leftTrigger.rumble();
gamepads.connectedPlayers.rightTrigger.rumble();

// pause the game when any player presses the Select/Back button
if (gamepads.anyPlayer.buttonJustPressed(Button.Select)) {
  spud.pauseGame();
}

// don't worry about p1, p2, etc. for a single-player game.
// instead just use whichever connected controller is actually triggering inputs.
const isJumping = gamepads.singlePlayer.isButtonDown(Button.South);

// loop through just the connected controllers
gamepads.connectedPlayers.list.forEach((player) => {
  console.log(
    `${spud.players[player.index].name}: ${player.gamepadInfo.productName} ${player.gamepadInfo.model} controller "${player.gamepadInfo.id}"`,
  );
});
```

There's also the `playerCount` property showing the number of currently connected controllers. It's effectively the same as `gamepads.connectedPlayers.list.length`.

```ts
console.log(`${gamepads.playerCount} controller(s) connected`);
```

#### `players`

Currently only includes the `.list` property, which returns a tuple containing four `Player` objects that have nullable `gamepad` properties.

`players.list` is the same exact thing as `connectedPlayers.list` but also includes disconnected players. Unlike `connectedPlayers.list` this will:

- always return an array of length 4
- possibly include `null` values for the `gamepad` property on each player in the array

## `audio` api reference

Spud includes Web Audio helpers for games. Use `createSounds` for short, pre-buffered, low-latency sound effects with options for per-play `volume`, `detune`, and `playbackRate`. Use `createMusic` for larger audio files that don't need to be fully pre-buffered before playback begins, and for which precise playback latency control is less critical.

The built-in audio methods will be no-ops when Spud is in demo mode, so you don't need to worry about conditionally triggering audio in your update loop.

### createSounds

Pre-buffers short sound effects into memory for precise timing.

- `audio.createSounds({ [name]: { url, volume? = 0.5 } })` returns a function you call with a sound name.
- `sounds(name).play(options?)` plays once with optional `{ volume? = defaultVolume, detune? = 0, playbackRate? = 1 }`.
- `play()` returns `{ success: true, gain, source }` or `{ success: false, reason }` where `reason` is `buffering` or `spud.isDemoMode`.
  - `gain` is the volume control for this specific playback source instance and is multiplied internally by the sound effect's initial setup gain, the sfxGain, and the masterGain. ie: `playback source volume = source.gain * sound.gain * sfxGain * masterGain`.
  - `source` is the `AudioBufferSourceNode` which you can manually apply more advanced processing effects to
- Each `play()` call creates a new source, so overlapping SFX are fine.

#### examples

```ts
import { audio } from '@spud.gg/api';

const sounds = audio.createSounds({
  shoot: { url: shootAudioUrl, volume: 0.5 }, // the default volume
  hit: { url: hitAudioUrl },
});

// in your update loop
sounds('shoot').play();

// with optional per-play overrides
sounds('hit').play({
  volume: 0.8,
  detune: -100 + Math.random() * 200,
  playbackRate: 0.5 + Math.random() * 0.5,
});
```

### createMusic

Streams longer tracks through an `<audio>` element and routes them through Web Audio without waiting to buffer the entire track ahead of time.

- `audio.createMusic({ url, volume? = 0.5, loop? = false })` returns a handle for playback.

Each audio playback handle has the following properties:

- `play()` returns `Promise<{ success: true }>` or `Promise<{ success: false, reason, error? }>`.
- `pause()` pauses without resetting the playhead, `stop()` pauses and resets.
- `fadeTo({ volume, duration? = 0.25 })` smoothly ramps the volume over time.
- `volume` (between 0 and 1)
- `currentTime` (current playhead timestamp in seconds)
- `duration` (audio duration in seconds)
- `isPaused`
- `global`
  - `masterGain`
  - `sfxGain`
  - `musicGain`

#### examples

```ts
import { audio } from '@spud.gg/api';

const music = audio.createMusic({ url: menuAudioUrl, volume: 0.3, loop: true });
await music.play();
music.fadeTo({ volume: 0.6, duration: 1.5 });
```

### global volume and mix

Both `createSounds` and `createMusic` share the same `AudioContext` and gain nodes.

- `masterGain` controls global volume/mute.
- `sfxGain` controls volume for sound effects created by `createSounds`.
- `musicGain` controls volume for music tracks created by `createMusic`.

```ts
import { audio } from '@spud.gg/api';

const sounds = audio.createSounds({ shoot: { url: shootAudioUrl } });
const music = audio.createMusic({ url: menuAudioUrl, loop: true });

// you might control these within a settings menu:
sounds.global.masterGain.gain.value = 0.6;
sounds.global.sfxGain.gain.value = 0.8;
music.global.musicGain.gain.value = 0.4;
```

<!--

## spud platform integration

The API provides built-in integration with the spud.gg platform:

- `spud.isPaused` - Boolean indicating if the game is currently paused. In addition to being set via `pauseGame()` this can be set from outside your game by the spud.gg platform.
- `spud.isDemoMode` - Boolean indicating if the game is running in demo mode. (Demo mode is used to provide a game preview on spud.gg's game grid and in the game detail modal.)
- `spud.pauseGame()` - Sends a pause message to the parent spud.gg window, if applicable, and sets `isPaused` to `true`

In addition, `spud.p1.name` will be the player's customized 1-3 character name rather than just "P1".

#### examples

```ts
import { spud } from '@spud.gg/api';

// check if the game is paused (set by the platform)
if (spud.isPaused) {
  yourGame.showPauseScreen();
  return;
}

// pause the game and notify the platform
pauseButton.addEventListener('click', () => {
  spud.pauseGame();
});

// check if running in demo mode
if (spud.isDemoMode) {
  yourGame.showDemoGameplay();
}

// if for some reason you want to remove event listeners for pause/resume/playerInfo:
spud.disconnect();
```

Integrated into your game loop, it should look something like this:

```ts
import { spud, gamepads } from '@spud.gg/api';
import { yourGame } from './your-example-project';

// (optional)
// listen for pause/resume/other events from the parent spud.gg window
if (!spud.isListening) {
  spud.listen();
}

function gameLoop() {
  if (spud.isPaused) {
    yourGame.drawPauseOverlay();
    requestAnimationFrame(gameLoop);
    return;
  }

  yourGame.update();
  gamepads.clearInputs();

  yourGame.draw();
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);
```

-->

## credits

- Controller layouts and IDs https://github.com/electrovir/gamepad-type
- Gamepad axis normalization formulas and default controller deadzones https://github.com/ensemblejs/gamepad-api-mappings
- Gamepad vendor/model IDs [linux-usb](http://www.linux-usb.org/usb.ids), [linux xpad driver source](https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c#L134), [linux joydev source](https://github.com/torvalds/linux/blob/master/drivers/input/joydev.c#L751), [elnormous/gamepads](https://github.com/elnormous/gamepads/tree/4b84620959d6d847b6264bd530e936d68e153142), [sdl game controller db](https://github.com/mdqinc/SDL_GameControllerDB/blob/master/gamecontrollerdb.txt), [nondebug vendor id list](https://gist.githubusercontent.com/nondebug/aec93dff7f0f1969f4cc2291b24a3171/raw/25d6cfd6f41716f909b089558c9e4941b1726c41/known_gamepads.txt),
  [360Controller plist](https://raw.githubusercontent.com/360Controller/360Controller/master/360Controller/Info.plist), [unity 3d input mapper](https://raw.githubusercontent.com/winalex/Unity3d-InputMapper/master/Assets/StreamingAssets/profiles.txt), [controller tools](https://github.com/isitLoVe/ControllerTools/blob/bc73feaf4b6eb532682072b6e08ccbc61bd70609/backend/src/api/xbox.rs)
- [Easing functions](https://easings.net/)

<!--
## roadmap

(in no particular order)

- sprite helpers
- easings / springs
- spud.gg integration:
  - service worker for offlining large assets (and the entire game)
  - communicate local high scores / leaderboard / achievements with parent window
  - integrations with engines and native code, looking into unity/godot/odin/wasm/emscripten
- gamepad stuff:
  - events:
    - controller connected/disconnected
    - controller battery alerts on state change (WebHID)
  - improved joycon support:
    - treat L+R controllers as one gamepad, if possible
      - prompt user and ask if they want to treat as one or two players
    - alternatively, use OS-level buddy controller support
  - webHID:
    - get controller battery levels
      - alerts like "P1 L controller battery below 10%" or "P2 battery: 1%"
      - tbd: not making it a huge pain for the user to add devices
    - support dualshock touchpads/accelerometer/gyroscope
    - wiimotes?
    - LEDs (joycon, dualshock, others?)
      - there's also the GamepadLightIndicator proposal that doesn't require HID
  - test and verify gamepad.touchEvents on PS controllers
  - working with [godot](https://docs.godotengine.org/en/stable/tutorials/platform/web/javascript_bridge.html), [unity](https://docs.unity3d.com/6000.0/Documentation/Manual/webgl-interactingwithbrowserscripting.html), and other engines
-->