# actions reference

Map phone and physical controller input to game actions. Import from `@controller-plus/sdk/actions` or the package root. Positional IDs come from [controls](sdk-controls.md).

## Bindings

`DEFAULT_BINDINGS` contains `move`, `aim`, `primary`, `secondary`, `extra`, `special`, `pause`, `back`, `leftTrigger`, `rightTrigger`. Move combines the left stick and D-pad; aim uses right stick. Primary/secondary/extra/special are south/east/west/north; pause/back are Start/Select; triggers are positive analog axes.

`validateBindings(bindings): object` returns a structured clone. Accepts at most 64 named actions. Names match `[A-Za-z][A-Za-z0-9_.-]{0,47}`. Unknown control IDs, kinds, or invalid deadzones throw `Error`. `evaluateActions(state, bindings = DEFAULT_BINDINGS): object` evaluates without validating; returns booleans, numbers or `[x,y]` vectors.

| Binding field | Behavior |
| --- | --- |
| `kind` | `button`, `axis`, `vector2`. |
| `buttons` | Up to 16 positional IDs; button is true if maximum pressure ≥ `threshold` (default 0.5). |
| `axes` | Up to two positional axis IDs. Axis action uses the first; vector uses both. |
| `positive`, `negative` | Up to 16 button IDs each; add/subtract their maximum pressure to an axis action. |
| `digital` | For vector2, `[left, right, up, down]` button IDs. |
| `deadzone` | 0…0.8, default 0 during evaluation. |
| `invert`, `invertX`, `invertY` | Reverse an axis/vector component. |

Axis results clamp to −1…1. Vectors normalize diagonal length to at most 1. `threshold` and boolean flags are used as supplied; binding validation does not check their types. Supply finite sensible values.

## ControllerManager

`new ControllerManager({ bindings = DEFAULT_BINDINGS, timeout = 1000, now = () => performance.now() } = {})` validates bindings. `timeout` is milliseconds before input becomes stale. Public `devices` is a Map of ID to device; `players` maps a game player key to a Set of device IDs.

| Full signature | Return and behavior |
| --- | --- |
| `onChange(callback): () => boolean` | Register listener and return unsubscribe. |
| `emit(event): void` | Invoke change listeners locally; callback exceptions propagate. |
| `connect(id, { name = id, source = 'phone', player = null } = {}): Device` | String ID ≤128 characters; reuse or create device, mark connected, optionally assign. New physical devices use deadzone 0.12, others 0. |
| `assign(id, player): void` | Move device from other players to this key; null unassigns. Unknown device throws. Multiple devices may feed one player. |
| `update(id, input): void` | Create unknown device, validate 4 axes/16 buttons and ranges, copy input, refresh liveness. Does not retain motion/custom fields. |
| `configure(id, profile): void` | Merge/validate `{ deadzone, axes, buttons }`; reset input to neutral. Axis map: 4 indices 0–3; button map: 16 indices 0–15; deadzone 0…0.8. Throws for unknown device/bad profile. |
| `disconnect(id): void` | Neutralize and mark disconnected; keep device and assignment. Unknown ID ignored. |
| `state(player): ControlState` | Neutral if no live assigned device. Apply profile once; choose strongest absolute axis and maximum button across live devices. No motion. |
| `actions(player): object` | Evaluate bindings against `state(player)`. |
| `pollGamepads(gamepads = navigator?.getGamepads?.() \|\| []): void` | Update physical devices as `gamepad:N`, autoassign initially to `physical-N`; disconnect missing pads. Call each game frame. |
| `bindSession(session): () => void` | Listen to host Session `device-input`, `device-left`, `disconnected`; autoassign phone IDs to same player keys. Return cleanup function. Not a RelayRoom event adapter. |
| `neutralize(): void` | Zero all device input without changing connections/assignments. |

Device shape: `{ id, name, source, input, lastSeen, connected, profile: { deadzone, axes, buttons } }`. Connect can emit `{ type: 'connected', device }`, assignment emits `{ type: 'assigned', id, player }`, disconnect emits `{ type: 'disconnected', id }`. There is no input-update change event.

Do not feed change-only RelayRoom events into a one-second manager timeout without handling freshness separately: a held physical pad can emit no changes for much longer. Use `bindSession` for the direct host's refresh stream, or keep RelayRoom state in your own Map and clear it on leave/disconnect.

## Map an action

<!-- example: actions -->
```js
import { ControllerManager, DEFAULT_BINDINGS, validateBindings, evaluateActions,
  NEUTRAL } from '@controller-plus/sdk';
const bindings = validateBindings({ ...DEFAULT_BINDINGS,
  boost: { kind: 'button', buttons: ['face.south'] },
});
const manager = new ControllerManager({ bindings });
const off = manager.onChange(event => console.log(event.type));
manager.connect('phone-1', { name: 'Ana', player: 'driver' });
const input = NEUTRAL(); input.buttons[0] = 1;
manager.update('phone-1', input);
console.log(manager.actions('driver').boost, evaluateActions(input).primary); // true, true
manager.configure('phone-1', { deadzone: 0 });
manager.assign('phone-1', 'copilot');
manager.pollGamepads([]);
manager.disconnect('phone-1'); manager.neutralize(); off();
```
