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

TypeScript
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'
Note

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

TypeScript
Input.key(code: string, gamepad?: number): boolean   // held right now

Level-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

TypeScript
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 reference

keydown 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:

TypeScript
const jumpCodes = new Set(['Space', 'GamepadSouth'])
Input.on('keydown', e => { if (jumpCodes.has(e.code) && !e.repeat) console.log('jump') })

Mouse

TypeScript
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.locked

delta 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:

TypeScript
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:

TypeScript
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.

Note

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

TypeScript
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)

TypeScript
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

TypeScript
InputChannel.MouseX            // 0 — the numbered channels a host reports continuous input on

The 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

TypeScript
// ✗ 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