Input
Keyboard, mouse and gamepad behind one global. Two kinds of signal, two shapes of API:
- Buttons — anything that goes down and up: keyboard keys, mouse buttons, gamepad buttons.
One code vocabulary, one held-poll (
Input.key), one event pair (keydown/keyup). A mouse button is a key. So is a gamepad button. - Continuous — mouse motion and wheel, sticks, triggers. Polled every frame, never events.
Rule of thumb: continuous → poll every frame, in an aspect's or a
system's update; discrete → Input.on(...). There are no one-frame
"pressed" polls.
At a glance
const scene = new Scene()
const player = Mesh.box({ material: Material.lit({ color: '#4f7cff' }) })
scene.add(player)
class Controls extends System<'controls'> {
update(dt: number) { // continuous → poll every frame
const move = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) + Input.gamepad(0).axis('leftX')
player.x += move * 5 * dt // world units per second
player.eulerAngles = player.eulerAngles.add([0, -Input.mouse.delta.x * 0.2, 0]) // mouse look, degrees
}
}
scene.system(Controls)
scene.open()
Input.on('keydown', e => { // discrete → an event
if (e.code === 'Space' || e.code === 'GamepadSouth') player.y += 1
if (e.code === 'MouseLeft' && !Input.mouse.locked) Input.mouse.lock() // lock needs a user gesture
})Button codes
| Device | Codes |
|---|---|
| Keyboard | KeyboardEvent.code — the physical key: 'KeyW', 'Space', 'ArrowLeft', 'ShiftLeft', 'Numpad5', 'F1'. WASD is KeyW/A/S/D on every layout. |
| Mouse | 'MouseLeft', 'MouseRight', 'MouseMiddle', 'MouseBack', 'MouseForward' |
| Gamepad | W3C standard layout: 'GamepadSouth' (A / Cross), 'GamepadEast' (B / Circle), 'GamepadWest', 'GamepadNorth', 'GamepadL1', 'GamepadR1', 'GamepadL2', 'GamepadR2' (triggers as buttons), 'GamepadSelect', 'GamepadStart', 'GamepadL3', 'GamepadR3', 'GamepadUp', 'GamepadDown', 'GamepadLeft', 'GamepadRight' |
host-gated — every part is optional, and a host without it reads "nothing held", zero
motion and lock() refused, never an error. The desktop host and the web have keyboard, mouse
and pads; on phones iOS has none of them yet and Android reports keys (Input.key, key events)
but no mouse or pad channels. A headless run drives all of them from a flow (key, keyDown,
look, mouseTo, wheel, stick, gamepad steps).
Polling
Input.key(code: string, gamepad?: number): boolean // held right nowLevel-triggered: true every frame the button is down. Multiply movement by dt (seconds).
gamepad scopes a Gamepad* code to one pad (Input.key('GamepadSouth', 1)); omitted means
any connected pad. Keyboard and mouse codes ignore it.
Events
Input.on('keydown', (e: InputKeyEvent) => { }) // e.code, e.source ('keyboard' | 'mouse' | 'gamepad'),
Input.on('keyup', (e: InputKeyEvent) => { }) // e.gamepad (pad index for gamepad buttons), e.repeat
Input.on('gamepadconnected', e => e.gamepad)
Input.on('gamepaddisconnected', e => e.gamepad)
Input.off(name, listener) // same function referencekeydown fires once per press (and again with e.repeat === true while the OS auto-repeats a
held key — ignore those for one-shot actions). keyup is the release — charged shots, bow draws.
e.gamepad is undefined for keyboard and mouse codes. Events reach your handler no later than
the end of the frame they arrived in, so a flag a handler sets is seen by the next frame's
update (on native hosts a poll can see the new state one frame before the event arrives). They
fire even when the press happened between two frames (a poll could miss it). Registering the same
listener twice adds it once.
Bindings are app code — there is no action map. Compare e.code, or OR a few Input.keys:
const jumpCodes = new Set(['Space', 'GamepadSouth'])
Input.on('keydown', e => { if (jumpCodes.has(e.code) && !e.repeat) console.log('jump') })Mouse
Input.mouse.position // { x, y } — cursor inside the viewport, logical px
Input.mouse.delta // { x, y } — motion during the previous frame (raw/unaccelerated where the OS offers it)
Input.mouse.wheel // { x, y } — wheel notches during the previous frame (+y = wheel toward you)
Input.mouse.onUI // the primary button went down on UI and is still held
Input.mouse.lock() // hide + confine the cursor; delta keeps flowing → FPS look. Returns false if refused
Input.mouse.unlock()
Input.mouse.lockeddelta is the same number for every reader in a frame and resets at the frame boundary. The
browser grants pointer lock only from a user gesture — call lock() inside a keydown handler
(for 'MouseLeft' is the usual choice). Native hosts release the lock on focus loss and re-acquire
it when focus returns; Escape does not unlock by itself there — handle it:
Input.on('keydown', e => { if (e.code === 'Escape') Input.mouse.unlock() })onUI is true while a press that started on something the UI claims — a button, a scrollable,
an input, a modal backdrop — is held, wherever the cursor drifts until the release. Gate
free-look on it and a drag that began on a HUD control never turns the camera:
class Look extends System<'look'> {
update() {
if (Input.mouse.onUI) return // a drag that began on the HUD is not look
const cam = this.scene.camera
cam.eulerAngles = cam.eulerAngles.add([0, -Input.mouse.delta.x * 0.2, 0]) // degrees
}
}It is always false while locked, and for a press that fell through to the scene.
a headless run drives the mouse from a flow (look, mouseTo, wheel), but its
onUI always reads false.
Taps and drags on objects (buttons, 3D nodes, sprites) stay on the pointer events —
Input.mouse is for free-look and cursor-as-crosshair, not for hit-testing UI.
Gamepads
Input.gamepad(i = 0) // pad 0..3 — always returns an object; check .connected
.index // i
.connected
.axis('leftX' | 'leftY' | 'rightX' | 'rightY', deadzone = 0.15) // −1..1, +Y = down; 0 inside the deadzone
.axis('leftTrigger' | 'rightTrigger') // 0..1
.button(code) // same as Input.key(code, i)
.rumble(strong, weak = strong, durationMs = 200) // both 0..1 → false when there are no motors
.stopRumble()
Input.gamepads() // connected indices, e.g. [0, 2]deadzone zeroes small stick drift; pass 0 for the raw value. Local co-op: player n reads
Input.gamepad(n) and filters events on e.gamepad === n. Press-to-join is a keydown whose
e.gamepad you haven't assigned yet.
A pad's index is stable for as long as it stays plugged in (pad 1 is still pad 1 after pad 0
leaves). gamepadconnected fires for pads that arrive while the project runs — one that was
already there fires nothing, so read Input.gamepads() once at start as well. A window that loses
focus has no gamepad: held buttons release (keyup), sticks read 0, and what is still physically
held comes back as keydown when focus returns.
Rumble (gamepad vibration)
Input.on('keydown', e => { if (e.code === 'GamepadR2') Input.gamepad(e.gamepad).rumble(0.2, 0.8, 120) }) // a shot: light + sharp
class EngineRumble extends System<'engineRumble'> {
offRoad = false
speed = 0 // world units per second
update() { if (this.offRoad) Input.gamepad(0).rumble(this.speed / 60, 0, 100) } // continuous: re-issue
}strong is the heavy low-frequency motor (impacts, engines), weak the light high-frequency one
(shots, UI ticks). A new call replaces the running effect, so a continuous effect is simply
re-issued every frame with a short duration — it stops by itself the moment you stop asking.
durationMs is capped at 5000. The host also stops the motors on focus loss and when the project
is swapped; there is nothing to clean up. rumble() returns false when the pad or the platform
has no motors — never an error, so call it unconditionally.
InputChannel
InputChannel.MouseX // 0 — the numbered channels a host reports continuous input onThe host contract's channel table, behind Input.mouse and Input.gamepad(i): MouseX 0,
MouseY 1, MouseDX 2, MouseDY 3, WheelX 4, WheelY 5, PointerLocked 6, GamepadCount 7,
PointerOnUI 8; pad p's block starts at GamepadBase + GamepadStride · p (16 + 16·p) with the
offsets PadConnected 0, PadLeftX 1, PadLeftY 2, PadRightX 3, PadRightY 4,
PadLeftTrigger 5, PadRightTrigger 6. It is exported for hosts and tests; an app reads the same
values through the Input members above.
Pitfalls
// ✗ level-triggered poll for a one-shot action — jumps every frame while Space is held
if (Input.key('Space')) player.y += 1
// ✓ an event
Input.on('keydown', e => { if (e.code === 'Space') player.y += 1 })
// ✗ character codes — layout-dependent and not what Input.key expects
Input.key('w')
// ✓ physical key codes
Input.key('KeyW')
// ✗ lock() at startup — the browser refuses without a gesture
Input.mouse.lock()
// ✓ from the first click
Input.on('keydown', e => { if (e.code === 'MouseLeft' && !Input.mouse.locked) Input.mouse.lock() })See also
- Pointer events & gestures — taps, drags and
ev.track()on elements and nodes - Systems / Aspects — where per-frame polling lives
- Time —
dtin seconds,Time.scale, pause