Appearance
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 |
pointer | x, y (canvas pixels), ndcX, ndcY (−1..1, y up), dx, dy, wheel, down, locked |
pointerLock | Set 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
| Action | Keyboard / mouse | Gamepad |
|---|---|---|
left right up down | WASD, arrow keys | D-pad, left stick |
jump | Space | A |
fire | Left mouse | RT |
alt | Right mouse | LT |
interact | E | X |
sprint | Left Shift | B |
pause | Escape | Start |
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.