Skip to content

@carrot/input ​

ts

Browser input system for the Carrot engine. Covers raw device access (keyboard, mouse, touch, gamepad) and a full action mapping layer that decouples control intent from physical inputs.

Installation ​

ts
import { Input } from '@carrot/input';

const input = new Input(document.body);

The Input constructor attaches event listeners to the given element (defaults to document.body) and creates a default player (player 0) that accepts all devices.

Architecture ​

Raw Devices  β†’  InputActionBindings  β†’  InputActions  β†’  InputContexts
(keyboard,       (physical β†’ virtual      (named actions,   (switchable
 mouse, touch,    translation + easing)    aggregated)        groups)
 gamepad)

Signal Chain ​

Raw devices β†’ [frame poll] β†’ Chord evaluation β†’ Virtual signals (Chord:*) β†’ Action bindings β†’ Actions

Each frame, input.update(delta) polls raw devices, evaluates chords, then updates all players' action systems. The action system resolves bindings, applies translations (axis↔button, easing, ASR envelopes), aggregates values, and fires action events.

Signal Namespacing ​

Physical signals are namespaced by device to avoid clashes:

SignalDevice
'KeyW'Keyboard (no prefix needed)
'Mouse:Button0'Mouse button
'Mouse:MoveX'Mouse axis
'Gamepad:Axis1'Gamepad (index 0)
'Gamepad1:Button0'Gamepad at index 1
'Touch:TouchActive'Touch
'Chord:PowerShot'Registered chord

Module Breakdown ​

Devices (src/devices/) ​

All devices extend InputDevice, which provides a unified polling API:

  • getButton(signal) / getButtonDown / getButtonUp / getButtonHold / getButtonDoublePress
  • getAxis(signal, deadzone?)
  • getSignal(signal) - axis or button as a number
  • Event signals: onButtonDown, onButtonUp, onAxisMove, onAnyInput

Keyboard - InputDeviceKeyboard with signal constants in Keyboard, KeyboardMedia, KeyboardBrowser, KeyboardSystem, KeyboardRare.

Mouse - InputDeviceMouse with constants in Mouse (MoveX, MoveY, ScrollX, ScrollY, PositionX, PositionY, Button0–2).

Touch - InputDeviceTouch with constants in Touch (TouchActive, TouchX, TouchY, TouchDeltaX, TouchDeltaY).

Gamepads - InputDeviceGamepads manages connected gamepads. Each InputDeviceGamepad exposes family, model, capabilities, and vibration. Instances are cached by index + id so reconnection reuses the same object. Signal constants: GamepadGeneric, GamepadXbox, GamepadPlaystation, GamepadSwitch.

Gamepad Detection ​

Model detection uses vendor/product IDs and id-string heuristics from the Gamepad API:

  • Family: 'Xbox' | 'PlayStation' | 'Switch' | 'Steam' | 'Unknown'
  • Model: 'XboxSeries' | 'XboxOne' | 'Xbox360' | 'DualSense' | 'DualShock4' | 'SwitchPro' | 'JoyConPair' | 'SteamDeck' | 'SteamController' | 'Generic'

Capability flags (InputDeviceCapability): None, Rumble, Gyro, Touchpad, BackButtons, AnalogTriggers. Queried via bitwise hasCapability().

Actions (src/actions/) ​

InputActions owns all contexts and bindings. Actions are defined within contexts and queried by name - only active contexts respond.

InputActionBinding translates physical signals to action values. Four translation modes:

ModeDescription
Button β†’ ButtonDirect mapping
Axis β†’ AxisWith optional scale and deadzone
Axis β†’ ButtonThreshold with hysteresis (onThreshold / offThreshold)
Button β†’ Axis (ASR)Attack/Sustain/Release envelope with configurable timing, acceleration, and easing

Easing can be applied per-binding (shapes raw input before aggregation) or per-action (shapes the final aggregated value).

When multiple bindings feed the same action, the value with the highest absolute magnitude wins.

Contexts (src/contexts/) ​

InputContext groups related actions. InputContexts manages enable/disable state and provides:

  • exclusive(name) - enables one context, disables all others
  • pushFrame(options) / popFrame() - overlay stack for menus/dialogs
  • withFrame(options, fn) - callback-scoped frame with guaranteed cleanup

Disabled contexts return 0/false from all queries unless { force: true } is passed.

Chords (src/chords/) ​

InputChordManager registers multi-signal combinations as virtual Chord:* signals. Chords are always buttons. Analog constituents use a simple activation threshold (default 0.5). ButtonDown fires when the last required constituent activates.

Glyphs (src/glyphs/) ​

InputGlyphs resolves signal names to GlyphDescriptor objects (icon key + text label) for UI display. Glyph maps are registered per device category + family. forAction(name) resolves through the active context's bindings to find the appropriate glyph for the current device.

Players (src/players/) ​

InputPlayer encapsulates per-player actions, contexts, glyphs, and active device tracking. Player 0 is created automatically. The single-player API (input.actions, input.contexts, input.glyphs) delegates to player 0.

Additional players are created via input.addPlayer() and can be assigned specific gamepads with player.assignGamepads([index]). Each player tracks its own active device independently.

Profiles (src/profiles/) ​

InputProfile is the JSON-serializable format for action schemas and bindings. InputProfileLoader provides:

  • apply(profile, actions) - load a profile into the action system
  • extract(actions) - serialise the current state to a profile
  • merge(base, override) - combine profiles (override wins on conflicts)

Dependencies ​

PackagePurpose
@carrot/signalsEvent system (Signal<T>)
@carrot/loggingOptional logging via Log
@carrot/mathsEasing functions

Build ​

bash
npm run build    # tsc

Outputs to dist/ as ESM with type declarations.


Usage Guide ​

Full browser input handling - raw device polling, action mapping, gamepad detection, chords, glyphs, multiplayer, and JSON profile support.

Import ​

ts
import { Input } from '@carrot/input';

const input = new Input(document.body);

Call input.update(delta) once per frame (typically in your game loop).

Common Patterns ​

1. Raw Device Polling ​

Query devices directly when you need low-level access:

ts
// Keyboard
if (input.keyboard.getButtonDown(Keyboard.KeyW)) { /* just pressed */ }
if (input.keyboard.getButton(Keyboard.Space))     { /* held */ }
if (input.keyboard.getButtonHold(Keyboard.ShiftLeft, 500)) { /* held 500ms+ */ }

// Mouse
const dx = input.mouse.getAxis(Mouse.MoveX);
const dy = input.mouse.getAxis(Mouse.MoveY);
if (input.mouse.getButtonDown(Mouse.Button0)) { /* left click */ }

// Touch
if (input.touch.getButton(Touch.TouchActive)) {
  const x = input.touch.getAxis(Touch.TouchX);
  const y = input.touch.getAxis(Touch.TouchY);
}

// Gamepad
const gp = input.getGamepad(0);
if (gp) {
  const stickY = gp.getAxis(GamepadXbox.LeftStickY);
  if (gp.getButtonDown(GamepadXbox.A)) { /* jump */ }
}

Define named actions and bind physical inputs - decouples game logic from hardware:

ts
// Define contexts and actions
const onFoot = input.actions.defineContext('onFoot');
onFoot.defineAxis('forward');
onFoot.defineAxis('strafe');
onFoot.defineButton('jump');
onFoot.defineButton('sprint');

// Bind inputs
input.actions.bind('onFoot', 'forward', { signal: 'KeyW', scale: 1 });
input.actions.bind('onFoot', 'forward', { signal: 'KeyS', scale: -1 });
input.actions.bind('onFoot', 'forward', 'Gamepad:Axis1');
input.actions.bind('onFoot', 'jump', 'Space');
input.actions.bind('onFoot', 'jump', 'Gamepad:Button0');

// Query in game loop
const moveZ = input.actions.getAxis('forward');
if (input.actions.getButtonDown('jump')) { /* jump */ }

3. Button β†’ Axis (ASR Envelope) ​

Produce smooth analog values from digital buttons using Attack/Sustain/Release:

ts
input.actions.bind('onFoot', 'forward', {
  signal: 'KeyW',
  buttonToAxis: {
    scale: 1,
    attackMs: 100,       // ramp up over 100ms
    releaseMs: 150,      // decay over 150ms
    acceleration: 0,     // no acceleration curve
    easing: 'easeInOutQuad',
  },
});

4. Axis β†’ Button (Threshold) ​

Fire a virtual button when an analog input crosses a threshold:

ts
input.actions.bind('driving', 'accelerate', {
  signal: 'Gamepad:Axis5',   // right trigger
  axisToButton: {
    onThreshold: 0.3,        // activate above 30%
    offThreshold: 0.2,       // release below 20% (hysteresis)
  },
});

5. Easing ​

Shape input curves at the binding level or the action level:

ts
// Per-binding - shape the raw physical input
input.actions.bind('driving', 'steer', {
  signal: 'Gamepad:Axis0',
  easing: 'easeInQuad',  // less sensitive near centre
});

// Per-action - shape the final aggregated value
onFoot.defineAxis('steer', {
  outputEasing: 'easeInQuad',
});

6. Context Switching ​

Switch control schemes based on game state:

ts
const onFoot  = input.actions.defineContext('onFoot');
const driving = input.actions.defineContext('driving');
const menu    = input.actions.defineContext('menu');

// Exclusive - disables all others
input.contexts.exclusive('driving');

// Overlay - push/pop for menus
input.contexts.pushFrame({
  enable: ['menu'],
  disable: ['onFoot', 'driving'],
});
// ... menu is open ...
input.contexts.popFrame();  // restores previous state

// Callback-scoped - guaranteed cleanup
input.contexts.withFrame({ enable: ['menu'] }, () => {
  // menu context active here
});

7. Chords ​

Register multi-key combinations:

ts
input.chords.register('PowerShot', ['ShiftLeft', 'KeyA']);
input.actions.bind('onFoot', 'shoot', 'Chord:PowerShot');

// With analog threshold
input.chords.register('HeavyBrake', [
  'Gamepad:Button4',  // LB
  { signal: 'Gamepad:Axis5', threshold: 0.8 },  // right trigger 80%+
]);

8. Gamepad Detection and Vibration ​

Identify connected controllers and trigger haptic feedback:

ts
const gp = input.getGamepad(0);
if (gp) {
  console.log(gp.family);  // 'Xbox' | 'PlayStation' | 'Switch' | 'Steam'
  console.log(gp.model);   // 'DualSense' | 'XboxSeries' | ...

  if (hasCapability(gp.capabilities, InputDeviceCapability.Rumble)) {
    gp.vibration.rumble(0.5, 0.3, 200);    // strong, weak, duration
    gp.vibration.pulse(0.8, 100);           // symmetric one-shot
  }

  // Curve-based haptic sequence
  await gp.vibration.animate(500, 10,
    t => Math.sin(t * Math.PI),   // strong motor curve
    t => 0,                        // weak motor curve
  );
}

9. Glyphs for UI ​

Display the correct button icons for the active device:

ts
input.glyphs.register('Gamepad', 'Xbox', {
  'Button0': { iconKey: 'xbox-a', label: 'A' },
  'Button1': { iconKey: 'xbox-b', label: 'B' },
});

// Resolve for current device
const glyph = input.glyphs.forAction('jump');
// β†’ { iconKey: 'xbox-a', label: 'A' } when Xbox controller active

10. Active Device Tracking ​

React to input device changes for UI adaptation:

ts
input.onDeviceChanged.add(event => {
  console.log(`Switched from ${event.previousCategory} to ${event.category}`);
});

// Quick checks
if (input.isGamepadActive) { /* show gamepad prompts */ }
if (input.isKbmActive)     { /* show keyboard prompts */ }

// Full descriptor
const desc = input.activeDevice;
// { category, scheme, family, model, capabilities, name, vendorId, productId }

11. Multiplayer ​

Each player gets isolated input state:

ts
const p1 = input.addPlayer();
const p2 = input.addPlayer();

p1.assignGamepads([0]);
p1.acceptsKbm = false;

p2.assignGamepads([1]);
p2.acceptsKbm = false;

// Each player has independent actions, contexts, and glyphs
p1.actions.defineContext('onFoot');
p2.actions.defineContext('onFoot');

p1.activeScheme  // 'Gamepad'
p2.activeDevice  // full descriptor for player 2's controller

12. JSON Profiles ​

Save and load input configurations:

ts
import { InputProfileLoader } from '@carrot/input';

// Extract current config
const profile = InputProfileLoader.extract(input.actions);
localStorage.setItem('inputProfile', JSON.stringify(profile));

// Load saved config
const saved = JSON.parse(localStorage.getItem('inputProfile')!);
InputProfileLoader.apply(saved, input.actions);

// Merge profiles (override wins)
const merged = InputProfileLoader.merge(defaultProfile, userProfile);
InputProfileLoader.apply(merged, input.actions);

Tips ​

  • Always use the action mapping layer in game code - raw device polling is for tools and debugging.
  • Aggregation uses highest-absolute-magnitude, so multiple bindings on the same action won't double up.
  • Gamepad instances are cached by index + id - reconnecting the same controller reuses the instance.
  • Call input.update(delta) before querying any input state each frame.

Input

Carrot