Skip to content

Input ​

Systems read actions, not keys. The ActionMap resource maps each action to keys, mouse buttons and gamepad inputs, and the input plugin polls them into the Input resource once per frame.

Reading input ​

ts
export const PlayerMove = system([Mut(Velocity), IsPlayer], ([v], { res }) => {
  const input = res.get(Input)
  v.x = input.axis.x * 5
  v.z = -input.axis.y * 5
  if (input.pressed('jump')) v.y = 6
})
axis{ x, y }: right − left and up − down (or the left stick), length at most 1
down(a)True while the action is held
pressed(a)True on the step (in Fixed) or frame (elsewhere) it went down
released(a)True on the step or frame it went up
value(a)Analog value 0..1 (triggers, stick directions); 0 or 1 for keys
pointerx, y (canvas pixels), ndcX, ndcY (−1..1, y up), dx, dy, wheel, down, locked
pointerLockSet to true to capture the mouse (mouse look)

pressed and released are edge-triggered exactly once: once per press in Fixed, however many steps a frame runs, and once per frame elsewhere. A press and release inside one frame still counts.

Mouse look and pointer lock ​

Set pointerLock on the Input resource to capture the mouse while the game wants it:

ts
export const CaptureMouse = system(({ res }) => {
  res.mut(Input).pointerLock = res.get(Screen).is('playing')
})

export const MouseLook = system([Mut(Aim)], ([aim], { res }) => {
  const p = res.get(Input).pointer
  if (!p.locked) return
  aim.yaw -= p.dx * 0.002
  aim.pitch -= p.dy * 0.002
})

While pointerLock is true, Kiln requests pointer lock on the canvas: at once when the page has a recent click (a Start button), and otherwise on the next click into the canvas. Setting it back to false releases the mouse, so menus get a cursor. The browser can drop the lock on its own (Escape, switching tabs); pointer.locked follows the real state, so a game can pause when it goes false.

While locked, the cursor position (x, y) stays put and dx, dy carry the raw mouse movement. Both locked and the movement are recorded, so mouse-look sessions replay frame for frame.

Actions ​

ActionKeyboard / mouseGamepad
left right up downWASD, arrow keysD-pad, left stick
jumpSpaceA
fireLeft mouseRT
altRight mouseLT
interactEX
sprintLeft ShiftB
pauseEscapeStart

A binding is a KeyboardEvent.code ('KeyW', 'Space', 'ArrowLeft'), 'Mouse0'..'Mouse2', a gamepad button ('Pad:A', 'Pad:Start', 'Pad:Up', 'Pad:RT', ...) or a stick direction ('Pad:LX+', 'Pad:LY-', 'Pad:RX+', ...). Stick input has a 0.15 deadzone.

Add or change actions with inputPlugin({ actions }) in place of InputPlugin, or at runtime through res.mut(ActionMap) (for a rebinding menu):

ts
inputPlugin({ actions: { ...DEFAULT_ACTIONS, dash: ['KeyQ', 'Pad:RB'] } })

Scripted input ​

Tests and tools drive input through app.input:

ts
app.input.script([
  { frame: 1, hold: 'right', frames: 30 },   // frames count from the script call; 1 = next frame
  { frame: 10, tap: 'jump' },                // down for one frame
  { frame: 40, press: 'fire' }, { frame: 50, release: 'fire' },
  { frame: 45, press: 'alt', value: 0.5 },   // analog
  { frame: 60, axis: { x: 0, y: 1 } },       // override the move axis; null hands it back
  { frame: 70, key: 'KeyE' },                // a raw key through the action map ({ key, up: true } releases)
  { frame: 80, pointer: { x: 400, y: 300, down: true } },
  { frame: 90, pointer: { dx: 12, dy: -3, locked: true } },  // locked mouse look: movement, no position
])

Shortcuts take effect on the next step: app.input.press(a), .release(a), .tap(a), .setAxis(x, y), .clearAxis(), .reset().

kiln run --script input.json plays a script from a JSON file.

app.input.record() records what the game sees as a script (stop() returns it, peek() copies it so far), which script() replays. Session recordings use the same idea for whole play sessions.