Input
Getting started introduced Actions: named actions bound to keys, gamepad buttons and sticks, and touch buttons. This page covers what sits around it: key chords, a rebindable Keymap, dropping stale presses when a menu closes, and the touch helpers AimGesture and TargetLock.
Key chords
A binding may be a chord: modifiers, then a KeyboardEvent.code. Shift, Ctrl, Alt and Meta each match either side of the keyboard.
import { Actions, Keyboard } from '1681-engine';
const input = new Actions(new Keyboard(), {
next: ['Tab'],
prev: ['Shift+Tab'],
save: ['Ctrl+KeyS', 'Meta+KeyS'],
run: ['ShiftLeft'],
jump: ['Space'],
});A chord wins over its bare key: while Shift+Tab is held, next (plain Tab) is off, and it stays off until Tab itself comes up, so letting Shift go first never turns a Shift+Tab into a Tab. A bare key held with a modifier no chord names still counts: holding Shift to run does not stop the jump key. parseChord and chordHeld are the same rule for code of your own, and keyLabel('Shift+Tab') prints it for a hint on screen.
Rebinding with Keymap
Keymap is the table of which keys each action sits on, as the player has rebound them. The input, the key hints and a settings screen all read it.
import { Actions, Keyboard, Keymap, keyLabel, keymapStorage } from '1681-engine';
type Act = 'left' | 'right' | 'jump' | 'pause';
const keymap = new Keymap<Act>(
{ left: ['ArrowLeft', 'KeyA'], right: ['ArrowRight', 'KeyD'], jump: ['Space', 'KeyW'], pause: ['KeyP'] },
undefined, // reserved keys: the default, Escape, Enter and Tab
{ left: ['pad:left', 'pad:lx-'], right: ['pad:right', 'pad:lx+'], jump: ['pad:a'], pause: ['Escape', 'pad:start'] },
);
const storage = keymapStorage('my-game.keys');
keymap.load(storage.read());
const actions = new Actions<Act>(new Keyboard(), { left: [], right: [], jump: [], pause: [] });
keymap.applyTo(actions);
keymap.onChange(() => {
keymap.applyTo(actions);
storage.write(keymap.toJSON());
});
// a settings screen: the next key pressed goes on Jump
const lost = keymap.bind('jump', 'KeyJ'); // the actions that lost KeyJ, or null for a reserved key
hint.text = `Jump: ${keyLabel(keymap.keysFor('jump')[0] ?? '-')}`;The rules it keeps:
- One key, one action.
bindtakes the key off whatever held it and returns those actions, so you can tell the player ("That key was taken from: Left"). - Reserved keys are never taken. Escape, Enter and Tab belong to the interface by default;
bindrefuses them and returnsnull. - Fixed inputs (the third argument) are the ones the player does not rebind: the gamepad, touch buttons, and a reserved key an action should still answer to, such as Escape for pause.
applyTolays them after the player's keys; they are never saved and neither rule above applies to them.fixedFor(action)lists them. - A settings screen edits the first key of an action and leaves the alternates alone.
- Old saves are safe.
loadreads only actions this build knows and ignores anything malformed; a new action keeps its defaults where no saved action holds those keys.
applyTo binds only the keymap's own actions, so the Actions may hold more (menu actions on fixed inputs alongside the rebindable ones). examples/platformer/Controls.ts is a whole controls module: one keymap for the game, saved the moment it changes and handed to whichever Actions the running level owns.
Capturing the key to bind
Listen for the next keydown on window in the capture phase and stop it there, so neither the menu's Keyboard nor the level's sees the key being chosen. The platformer's pause menu does exactly that.
Gamepad buttons and "press a key"
A gamepad binding (pad:a, pad:lx-) may be the player's to change too: put it in the keymap's defaults rather than in fixed. Each device keeps its own first binding, so bind('jump', 'pad:b') replaces the jump's first pad binding and leaves its keys alone, and keysFor('jump', 'pad') is what a pad column shows. One input still serves one action, pad buttons included.
InputCapture waits for the player's next input, for a settings row's "press a key for Jump":
import { InputCapture, keyLabel, padLabel } from '1681-engine';
const capture = new InputCapture({ devices: ['keys', 'pad'] }); // Escape cancels
// every frame while it is open
capture.poll();
const binding = await capture.result; // 'KeyJ', 'pad:x', 'pad:ly-', or null when cancelled
if (binding) {
const lost = keys.bind('jump', binding);
if (lost?.length) toast(`Taken from ${lost.join(', ')}`);
}
label.text = binding?.startsWith('pad:') ? padLabel(binding) : keyLabel(binding ?? '');What is already held when the capture opens (the Enter or pad A that chose the row) does not count until it is let go; a stick counts once pushed past axisThreshold (0.6), so drift never binds; a captured key never reaches the game's own handlers. padLabel names pad bindings ("A", "Left stick left"); pass names for a game's own.
Recording and replaying input
InputRecorder keeps an Actions' every step as an InputTape, plain JSON with only the changes in it (a held key costs nothing a step). InputPlayer plays one back: each poll takes the next step from the tape instead of the devices, and when it ends the devices answer again.
import { InputRecorder, InputPlayer } from '1681-engine';
// record from the start of a run, with what the run needs to start the same
const rec = new InputRecorder(actions, { seed, level: 'forest-2', build: version });
// a crash, or "report a bug": the tape goes with the report
report.input = rec.stop();
// a developer opens it
const tape = report.input;
startRun(tape.meta!.seed, tape.meta!.level);
new InputPlayer(tape).playInto(actions, () => console.log('replay over'));Values are kept whole, so the replay reads exactly what the player gave. It plays the same game only when the game is deterministic given its input: step it on the fixed loop, seed its randomness from meta, and never read the clock or a frame's dt in what decides the outcome (the same rule as the netcode). actions.replaying says a replay is driving; actions.replay(null) stops it.
Stale presses: Actions.flush and Scene.useInput
While a pause menu sits over a level, the level's Actions is not polled. A key pressed in the menu would otherwise arrive as a press the moment the level resumes, and the Escape that closed the menu would pause it again.
class Level extends Scene {
private readonly keyboard = new Keyboard();
// registered with the scene: flushed when a scene pushed over this one pops, before onResume
private readonly input = this.useInput(new Actions(this.keyboard, BINDINGS));
}flush() forgets every pending edge: what went down or up since the last step is dropped, and what is held now counts as already held, so the next poll reports no pressed or released for it. Call it yourself for input that sat unread for any other reason.
Scene.delay(ms, fn) is the timer to use from a scene for the same reason: its timers are cancelled when the scene exits, while stage.time.delayedCall timers belong to the stage and fire after the scene has gone.
A Keyboard knows only keys pressed after it was made
A key already held when a scene builds its Keyboard reads as up until it is pressed again. Destroy the keyboard in onExit (keyboard.destroy()), or its window listeners stay.
Drag to aim: AimGesture
The MOBA way of aiming a skill on a touch screen: pressing the skill button opens a circle round it with a knob under the thumb, and where the knob sits is where the skill goes. AimGesture is the pure state of one press; the game draws the circle and reads the result.
import { AimGesture, aimPoint, type AimShape } from '1681-engine';
const blast: AimShape = { kind: 'point', range: 96 }; // or { kind: 'direction', length: 120 } for a shot or a dash
let gesture: AimGesture | null = null;
let downAt = 0;
button.addEventListener('pointerdown', () => {
gesture = new AimGesture({ radius: 90, centreCancels: !hasCancelZone });
downAt = performance.now();
});
window.addEventListener('pointermove', (e) => {
if (!gesture) return;
gesture.move(e.clientX - buttonX, e.clientY - buttonY, overCancelZone(e), performance.now() - downAt);
if (gesture.aiming) showAim(aimPoint(hero, blast, gesture.share, hero.aim)); // { aim, x, y }
});
window.addEventListener('pointerup', () => {
const end = gesture?.up(); // 'tap' | 'cast' | 'cancel'
if (end === 'tap') castAtTarget(); // a tap: the locked target, the nearest enemy, the facing
else if (end === 'cast') castAt(aimPoint(hero, blast, gesture!.share, hero.aim));
gesture = null;
});- A press that never wanders past the tap slop (10 px) is a tap. In its first
pressMs(180 ms) the slop is longer (28 px): a hard press slides as it lands, and a quick tap that slid became a drag that flew wherever the slide pointed. - Letting go over the cancel zone casts nothing. Letting go with the knob back on the button casts as a tap, or cancels with
centreCancels(the only way to cancel when there is no zone). cancellingis true while letting go now would cast nothing: light the cancel zone with it.- A point skill's reach runs from the edge of the centre ring to the rim (
spotReach), so a knob just out of the ring lands at the caster's feet.spotShareis the inverse, for aim assist and replays.
Holding a target: TargetLock
Dragging an attack button points at a body; a target button picks the nearest. The lock then holds, and every attack, skill and the facing goes at it even while a stick walks the body elsewhere, until that body is gone or leaves keepRange.
import { TargetLock } from '1681-engine';
const lock = new TargetLock(() => enemies.map((m) => ({ id: m.id, x: m.x, y: m.y })), { pickRange: 220, keepRange: 300 });
lock.pick(hero, { x: dragX, y: dragY }); // what the drag points at; pointing at nothing keeps the old lock
lock.pickNearest(hero); // the target button
const target = lock.current(hero); // the held body while it still holds, else null (and the lock is dropped)A drag scores bodies by how far off its line they stand, with distance as a lighter second weight, so a far body dead ahead beats a near one off to the side; nothing outside the cone (50° each side) is picked. nearestTarget breaks a tie on the lower id, so two clients pick alike. The callback decides what may be locked: monsters, a duel's opponent.
Drawing the aim: PixelMarks
1681-engine/pixel draws the marks in world pixels: lock rings, ground ellipses at a body's feet, spot discs, and aim lines as rows of chevrons — never a solid lane, which at most angles is a staircase of pixels and reads as broken. Shapes are painted once per size and tone and only moved; chevrons come from a pool, repainted only when their heading bucket changes.
import { PixelMarks } from '1681-engine/pixel';
const tone = { light: 0xffb0a0, rim: 0xff5a4a, dark: 0x2a0806, fill: 0xff4a3a };
const marks = new PixelMarks(stage, { tones: { aim: { ...tone, light: 0xffe9a8, rim: 0xffc84a }, lock: tone }, depth: 10 });
marks.begin();
const target = lock.current(hero);
if (target) marks.groundRing(target.x, target.y, 12, 6, 'lock');
marks.chevrons(now, hero.x, hero.y, aimAngle, 12, 90, 4, 'aim'); // creeping along the line
marks.end(); // hides whatever was shown last frame and not this oneThe pixel shapes underneath are pure and exported: ringPixels, discRuns, ellipsePixels, chevronPixels.
Haptics
Haptics buzzes a phone per kind of moment, at most once per kind's gap, so six bodies struck by one cleave are one buzz. The default player is navigator.vibrate; a native shell hands in its plugin:
const haptics = new Haptics<'crit' | 'hurt'>({ gaps: { crit: 120, hurt: 300 }, enabled: () => settings.haptics });
haptics.buzz('crit');Touch detection
isTouchUi() says whether the game is played with fingers, decided once per launch (detectTouch): a native shell, or a browser whose primary pointer is coarse. ?touch=1 or ?touch=0 in the URL forces it for testing, and so does a localStorage key the game names with setTouchOverrideKey('my-game.touch') ('1'/'0'). A native wrapper (Capacitor, an Electron kiosk) calls setNativeShell(true) before anything asks.
API
Actions · Keymap · keymapStorage · keyLabel · parseChord · Scene.useInput · AimGesture · aimPoint · TargetLock · isTouchUi