Appearance
@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 β ActionsEach 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:
| Signal | Device |
|---|---|
'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/getButtonDoublePressgetAxis(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:
| Mode | Description |
|---|---|
| Button β Button | Direct mapping |
| Axis β Axis | With optional scale and deadzone |
| Axis β Button | Threshold 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 otherspushFrame(options)/popFrame()- overlay stack for menus/dialogswithFrame(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 systemextract(actions)- serialise the current state to a profilemerge(base, override)- combine profiles (override wins on conflicts)
Dependencies β
| Package | Purpose |
|---|---|
@carrot/signals | Event system (Signal<T>) |
@carrot/logging | Optional logging via Log |
@carrot/maths | Easing functions |
Build β
bash
npm run build # tscOutputs 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 */ }
}2. Action Mapping (Recommended) β
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 active10. 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 controller12. 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.