Input: the action map, remapping, and where a player's keys are kept
Three documents meet here.
Three documents meet here. The action map is the scene's, in meta.input, and belongs to the author. The overrides file is the player's, and belongs to whoever is holding the keyboard. The API is ctx.input, which reads the first, writes the second, and never confuses them.
The action map
Contexts → actions → the devices that drive them. A scene that declares no meta.input runs DEFAULT_INPUT_MAP from @argus/contract, so a scene with no input block plays:
| Context | Actions |
|---|---|
gameplay | move (axis2, WASD / left stick), look (axis2, mouse delta / right stick), jump (Space / south), crouch (LControl / east), sprint (LShift / left-thumb), interact (E / west) |
menu | cancel (Escape / east), confirm (Return / south), navigate (axis2, arrows / dpad) |
Menus drawn by the UI layer read uiNavigate, uiActivate and uiBack, not the default map's navigate, confirm and cancel. A scene that wants keys or a pad to drive its UI screens binds those three names in meta.input (docs/UI.md, Input).
One action names up to four sources — keys, mouse, gamepad, touch — and they are additive, with the strongest winning: an action bound to both the keyboard and a stick reads whichever is further from neutral this tick, so a player may swap devices mid-frame and the engine tracks no "active device". Face buttons are named by position, not by letter, so one map fits an Xbox pad, a DualSense and a Switch pad: south is A / Cross / B.
dpad reads the D-pad however the pad reports it: its four buttons (XInput, the browser's standard mapping) and its axes (an evdev hat), the strongest winning, never summed. The menu's uiNavigate takes the D-pad buttons, the D-pad axes and the left stick whichever of dpad / left-stick it names (docs/UI.md, Input). Under a non-modal screen, a game action on the D-pad or the left stick gives way to uiNavigate while the screen is up (the pad's directions are the screen's); the right stick and the buttons stay the game's.
How many keys an action takes follows from its kind: axis2 takes four, in WASD order (+y, -x, -y, +x); axis1 takes two, negative then positive (["A", "D"] reads -1 on A and +1 on D); button takes any number and ORs them together. An axis also takes alternate sets back to back, any of which drives it: "keys": ["A", "D", "Left", "Right"] steers on A/D and on the arrows, and ["W", "A", "S", "D", "Up", "Left", "Down", "Right"] moves on both. Two keys for the same direction held together are one full push, not two. A list that is not a whole number of sets binds nothing (reported, not refused: the critic's input-unbound).
A mouse is an angle, a stick is a rate. A stick held at full push keeps turning the view, so a camera turns by it times a speed and dt (the rigs' yawSpeedDeg/pitchSpeedDeg, 180°/s and 120°/s at full push). The mouse's delta is how far the hand moved this tick, so a camera turns by exactly that angle, once: pixels × degrees per pixel, with no dt and no top speed. An action's sensitivity is the stick's multiplier (default 1) and the mouse's degrees per pixel (default 0.1°/px when the action gives none; the built-in map's look sets 0.1). ctx.input.axisParts(name) reads the two apart — { rate, delta }, delta in degrees — and the built-in camera rigs and the character controller turn by rate × speed × dt + delta. ctx.input.axis(name) still folds the mouse in as a rate (pixels ÷ 8 × sensitivity), as it always has, so a script that turns a view from axis("look") caps a flick at its own top speed; turn from axisParts instead. The critic's feel/look-capped probe sweeps a synthetic mouse (POST /input mouse) and fails a view that has a top speed.
Key names are the engine's (W, 5, Space, Return, LShift, LControl, Up, F1), in any case, and every key also answers to its DOM KeyboardEvent.code (KeyW, Digit5, Enter, ShiftLeft, ArrowUp) and to the names other game engines give keys (Alpha5, LeftShift, LeftControl, UpArrow). They are the same key, so a map written from either habit binds what it says; docs and recipes use the engine's spelling.
In the editor, Window ▸ Input edits this map as a table (contexts, actions, keys by pressing them, gamepad from a list, shared keys warned about, unknown keys refused with input-key-unknown's sentence), each change one undo step: docs/EDITOR.md, "The Input table". An agent edits the same map with scene.patch on /meta/input.
A behaviour reads actions, never keys. That is what makes remapping a data change rather than a code change, and it is why hot reload can rebind a running scene.
active is where a run starts. Load, restart and ctx.session.loadScene start in the context meta.input.active names (else gameplay, else the first context). A hot reload of meta.input keeps the context the player is in, even when the edit is to active itself; only deleting the live context moves it, to the new map's active.
Remapping
const input = ctx.input
if (!input) return
input.rebind("jump", "K")
rebind(action, binding) changes one action of the live context — the one the player is looking at when they press the key. The binding is written in whichever of three ways fits:
| Written as | Means |
|---|---|
"K" | one token. Resolved as a key name first, then a gamepad source, then a mouse one. |
"key:Left", "pad:south", "mouse:left" | the same, with the device said outright. |
["W", "A", "S", "D"] | the whole key list. An axis2 takes four of them, an axis1 two, or alternate sets of them back to back. |
{ keys, mouse, gamepad } | the halves by name. A half not mentioned is left alone; null clears one. |
The prefixes exist for the three names two devices share: left, right and middle are arrow keys and mouse buttons, and a bare token resolves to the key. Everything else is unambiguous — no key is called south and no pad source is called Space.
bindings(action) reads the map back as { kind, keys, mouse, gamepad }, or null when the live context binds no such action. It reports the map as played, overrides included, so a rebinding screen and the game can never disagree about which key jump is on. actions() lists what to show.
Another context. A settings screen usually runs in its own context (menu), so the live context is not the one whose keys it shows. Both calls take the context as a last argument:
input.bindings("jump", "gameplay") // gameplay's jump, read from the menu
input.rebind("jump", "K", "gameplay") // gameplay's jump is now K; the menu stays live
Without it they mean the live context, as before. A context the map does not have throws input-unknown-context (below) rather than reading as "binds nothing". The override is written under that context in the file, as a rebind made from inside it would be.
resetBindings() drops every override and empties the file: a "restore defaults" button.
Refusals
A rebind that bound nothing would leave the player with a dead action and no way to find out why, so an unresolvable name throws instead. The Error has name === "InputBindingError" and a message that opens with a code:
| Code | When |
|---|---|
input-unknown-action | the context (the live one, or the one named) binds no action by that name |
input-unknown-context | rebind or bindings named a context the map does not have |
input-unknown-key | a keys entry, or a key:-prefixed token, is not a key name |
input-unknown-binding | a bare token matched no device, or a pad:/mouse: name is not one |
input-binding-arity | a number of keys that is not a whole number of the kind's sets (four for axis2, two for axis1); an empty list is always allowed: unbinding the keyboard half is a thing a player does |
Nothing changes when a rebind throws — not the map, not the file. A behaviour may catch it and show a prompt, which is what a remapping screen does with a key it cannot accept.
Conflicts
A key may drive two actions. rebind("jump", "W") in a scene whose move is still W A S D leaves W doing both, and the engine allows it and says so, naming both actions and the shared key on stderr:
argus: warning "jump" and "move" now share ["W"] in the "gameplay" context; one key drives both
Reported rather than refused, because two actions on one key is a legitimate thing to author — a confirm that is also a jump — and nothing in the engine knows which of the two the player meant to give up. A remapping screen that offers "press a key" is the usual way to produce one by accident, and it is the screen, not the engine, that is in a position to ask. Mouse and gamepad sources are checked the same way.
Note the deliberate asymmetry with the rest of ctx.input: reading an action the map does not bind is neutral rather than an error, because a behaviour must not need a guard around pressed("sprint") for a scene that has no sprint. Writing one is a mistake, and mistakes throw.
Where the overrides live
A rebind is written to a JSON file the runtime is given at launch. It is small, readable, and holds only what was actually rebound:
{
"version": 1,
"contexts": {
"gameplay": {
"jump": { "keys": ["K"] }
}
}
}
Each entry names an action the scene already has and overrides the halves it mentions, in exactly the spelling meta.input uses for the same thing. An override cannot invent an action, change a kind, or add a context: a player remaps controls, they do not author them. An entry naming something the scene has since dropped is ignored rather than refused — a scene may legitimately lose an action between two runs, and losing the whole file over it would throw away the rest of the player's bindings.
The file is read once at load, after the scene's own map is compiled and before the first tick, so the very first frame of a run is already remapped. It is rewritten immediately on every rebind, so a player who alt-F4s straight after remapping keeps the remap.
A file the engine cannot use is never a launch it cannot make
A truncated, hand-mangled or future-versioned overrides file warns and is ignored. The scene's own bindings stand, the warning names the file, and the launch goes ahead:
argus: warning ignoring input overrides at /…/.argus/input.json and playing on the scene's own
bindings: … EOF while parsing a string at line 4 column 10; delete the file, or rebind once to
replace it
The path is still adopted, so the next rebind writes a clean file over the broken one and the player is not stuck with it. This used to propagate out of World::load_file instead: in the editor that was a Play button that did nothing and no interface that could clear it, and in an exported build it was a game that would not start. A player's own bindings are the least important thing in a launch, so they are what gives way.
Two sessions on one project
The write is atomic: a temporary file in the same directory, renamed over the target. A reader never sees half a document, and a process killed mid-rebind leaves either the old file or the new one — never the truncated JSON of the paragraph above.
It does not merge, and the rule is last writer wins over the whole file. Editor Play and an argus run on the same project are the ordinary way to have two: the second session to save carries the bindings it loaded plus its own, and the first session's later rebinds are gone. Merging per save was the alternative and is worse — it would make "I unbound that" unfixable for as long as another session still held the binding, because the other session's copy would keep putting it back.
Who passes the path
| Launch | File |
|---|---|
argus run <scene> --input-overrides <file> | whatever the flag names; the folder is created on the first rebind |
| editor Play | <project>/.argus/input.json — beside the project, not under .argus/editor, because the bindings belong to whoever is playing |
argus run <scene> with no flag | none. The scene plays on its own map and a rebind lasts until the process exits, with one line on stderr saying so |
| the exported launcher | <unpack dir>/input.json |
The editor's path and argus run --input-overrides <project>/.argus/input.json are the same file on purpose: a remap made in Play is the remap that a launch of the same project starts with.
The exported launcher passes one. packages/export/launcher spawns argus run <target> --serve … --input-overrides <unpack dir>/input.json, so a rebind in an exported build is there the next time the player starts it — which is what acceptance bullet 1's "remap an action, restart, verify the mapping persists" asks for on the one path an end user takes. The unpack directory is per-user (XDG_CACHE_HOME/LOCALAPPDATA/~/.cache, under argus-export/<payload id>) and already writable; the file is created by the first rebind. An engine build that predates the flag ignores it rather than failing.
The payload id changes when the build does, so a new version of a game starts a new overrides file. That is a deliberate limit of the simplest thing that works, not an oversight: carrying bindings across versions needs a per-title identity the payload does not yet have.
Touch
A phone, a tablet and a touch laptop have no keyboard to bind, so touch is the fourth source an action can name: a region of the screen and what a finger in it does. It is data like the other three, and a script still reads move and jump, never a finger.
"move": { "kind": "axis2", "keys": ["W", "A", "S", "D"],
"touch": { "mode": "stick", "region": { "x": 0, "y": 0.4, "w": 0.4, "h": 0.6 } } },
"look": { "kind": "axis2", "mouse": "delta",
"touch": { "mode": "drag", "region": { "x": 0.4, "y": 0, "w": 0.6, "h": 1 } } },
"jump": { "kind": "button", "keys": ["Space"],
"touch": { "mode": "button", "region": { "x": 0.8, "y": 0.7, "w": 0.2, "h": 0.3 } } },
"interact": { "kind": "button",
"touch": { "mode": "tap", "region": { "x": 0.4, "y": 0, "w": 0.6, "h": 0.5 } } }
Regions are normalised: x, y, w, h are fractions of the screen, origin at the top left, so one region fits every resolution, density and orientation. A region is live while its context is, so a menu context with no touch regions gives the screen back to the UI. safeArea (default true) says the region is relative to the platform's safe area, clear of a notch or a home indicator; no platform reports insets yet, so today the safe area is the whole screen and the field is carried, not read.
mode | kind | reads |
|---|---|---|
stick | axis2 (axis1 reads x) | the first finger that landed in the region. Deflection is its offset from where it landed, or from the region's centre with "fixed": true, full at half the region's size, +y up, clamped to the unit circle, through the action's deadzone |
drag | axis2 (axis1 reads x) | the finger's travel, as an angle, like the mouse's delta: the screen counts as 1800 reference pixels across, so a full-screen swipe is 180° at the default 0.1°/px, and sensitivity is degrees per reference pixel exactly as for the mouse. Read it from axisParts(name).delta |
button | button | held while a finger that landed in the region is down, even if it slides out; a press and lift between two ticks is still one tick held. toggle works |
tap | button | held for one tick when a finger that landed in the region is lifted within 20 ticks (a third of a second) having moved less than 0.03 of the screen; a long press, a swipe or a system-cancelled touch is not a tap |
Ownership is by where a finger landed, not where it is: a finger that lands on the stick and wanders across the jump button is still the stick, and one that lands elsewhere and slides onto a button does not press it. Ten fingers are followed at once. Sources stay additive, strongest wins, so an action with keys and touch plays on either. A source that cannot work (a stick on a button action, a region with no size or entirely off the screen) binds nothing and is reported in ActionMap::problems, like a misspelt key. Regions should not overlap; two that do both take a finger that lands in the overlap.
The UI treats touch as a pointer. Every finger reaches the action map, but a finger that lands in no live region of the live context is the UI's pointer for as long as it is down: it presses, drags and releases the primary button of the same pointer the mouse drives, so buttons, sliders and scroll lists work with no touch code. Only the first such finger points; a second one outside every region does nothing, which is what the extra fingers are for on a screen with virtual controls. A finger that lands on a control is the control's, and a region wins over a UI element under it.
On desktop a touch screen (real on Windows) sends winit's WindowEvent::Touch, which the window normalises by its size into DeviceEvent::Touch { id, phase, pos }, the same event the mapper takes from a test or a hold. Focus loss lifts every finger, as it releases keys.
Headless and in a self-test, Holds::place_touch puts a synthetic finger down at a position, walks it in a line to another over N ticks and lifts it, through the same Holds that POST /input and --self-test place holds with:
{ "touch": [0.2, 0.8], "to": [0.2, 0.5], "frames": 60 }
a self-test step (docs/SELF-TEST.md) with positions normalised to the screen, to defaulting to where it landed and id (default 0) naming the finger. A scenario's steps run one after another, so two fingers at once are not expressible there; place_touch places as many as it is given. The step fails, as an action does, when nothing reads touch within bindTimeout. fixtures/selftest/touch-stick-and-button.json walks a stick and presses a button.
Devices
Gamepads are gilrs behind the gamepad feature, on by default, in editor Play and in the export. Note that argus: gamepads open means the backend opened, not that a pad is present.
The mouse drives look through DeviceEvent::MouseMotion: the window loop accumulates winit's motion, ActionMapper::tick folds it into the look action once a tick as an angle (AxisParts::delta, degrees), and the character controller and the rigs turn by that angle. A mouse that stops moving stops turning the character in the same tick. POST /input's mouse event feeds the same path headlessly. crates/app/tests/d4_review_probes.rs drives both axes and a mouse button through that path with no feature flag, so the suite as CI runs it has mouse evidence.
Focus loss releases everything held in both the editor and the standalone window: a synthetic up for every held key and mouse button, and every stick and pad button released, so a player who alt-tabs mid-sprint does not come back still sprinting. Covered by crates/editor/tests/focus.rs and crates/app/tests/window.rs — and, because that second file is behind --features window, which the ordinary suite does not set, by d4_review_probes.rs::losing_focus_releases_held_keys_and_mouse_travel as well.
A restart keeps what is held. It is not a key-up; see docs/API.md.
Synthetic input (POST /input) and device input merge rather than overwrite, keeping whichever is further from neutral, so a human at the window and an agent posting holds can both be driving and neither cancels the other. Input has to be re-posted every tick to stay held, exactly as a device poll behaves — which is why a test that wants to read ctx.input after driving a key calls Sim::input_tick rather than World::step, whose last act is to roll the tick's input over.
The pointer
ctx.input.pointer is where the mouse points: { view, x, y }, the split-screen view under it and pixels of that view's own cell (the space ctx.camera.pick takes), or null. The window loop hands the cursor's physical pixels to Sim::note_pointer, which finds the cell under it through ViewLayout::rect and writes the result into InputState.pointer once per tick, before the tick reads input. It is a level: it holds still between moves, and the cursor leaving the window takes it away. POST /input pointer writes the same source (a cell pixel, "off": true to clear), so a harness and a person share one mouse.
It is recorded: a replay holds the resolved pointer, not the window cursor (see replay.rs, "The pointer"), and a playback puts the recorded one back before the tick whatever this run's mouse is doing. A touch finger is not a pointer here; the web player does not feed one yet.
Local players (couch co-op)
A scene that declares meta.players seats up to four people on one machine, each on their own device, each driving their own entity. Without the block nothing changes: one player hears every key and every pad.
"meta": { "players": { "max": 4, "join": "press", "keyboard": "player" } }
| field | values | meaning |
|---|---|---|
max | 1 to 4 | most players seated at once |
join | press (default), auto, script | press: a free pad presses South (A) or Start, the keyboard Return or Space, a keyboard half any key it binds. auto: every pad that connects joins. script: only ctx.players.join seats anyone. The joining press is consumed. |
keyboard | player (default), split, none | player: the keyboard and mouse are player 1's at load. split: two players, left (player 1) and right. none: the keyboard seats no one; the first pad to join is player 1. |
Which entity a player drives. spawn { for: "player", player: 2 } beside another component makes an entity player 2's (absent player is 1). A behaviour can rebind at run time: ctx.players.bind(2, kart), which is how a game that spawns its karts after the lobby hands each player theirs. A behaviour on player 2's entity reads ctx.input exactly as a single player's does; it never learns which pad that is.
Split screen. A view per player is ctx.views (ViewsHandle, docs/API.md): ctx.views.setLayout("left-right"), then ctx.views.follow(1, "p1") and ctx.views.follow(2, "p2"). A camera with output: "viewport" is a minimap or a picture in picture drawn over the screen, not a player's view. The whole pattern, two players on a split keyboard and a split screen with a self-test that drives both: argus://recipes/couch-coop.
Testing two players. critic.play drives player 1 only: a goal only player 2 can reach is not reached there, and a win that needs both never fires. A self-test step drives player 2 with "player": 2 (docs/SELF-TEST.md) once the game has seated them. Headless no pad or key joins anyone, so a scenario's game seats player 2 itself (join: "script"): ctx.players.join("keyboard-right") with keyboard: "split", or ctx.players.join("remote"), which also makes the session networked (Remote players, below). Live, drive player 2 with play.input and player 2's entity, or POST /input with "player": 2.
Split keyboard. Both halves hear every key; the map makes them different. A player on a half reads the context NAME/left or NAME/right in place of NAME wherever the scene declares one:
"contexts": {
"gameplay": { "move": { "kind": "axis2", "keys": ["W", "A", "S", "D"] } },
"gameplay/left": { "move": { "kind": "axis2", "keys": ["W", "A", "S", "D"] } },
"gameplay/right": { "move": { "kind": "axis2", "keys": ["Up", "Left", "Down", "Right"] } }
}
The mouse goes with the whole keyboard or its left half.
Menus. Any seated player drives a menu. A player who drives an entity is read from the input state there, as player 1 is, so a replay, POST /input with player and a remote seat's frames move a menu exactly as that player's pad would. One with no entity yet (a lobby) is read from their seat: their own pad, or a remote seat's or a replay's frames for them, so a playback and a remote player move a menu there too (replay format 4 records it as a "seat" line).
Contexts. Every player follows player 1's live context (a menu opening switches everyone); ctx.players.setContext(n, "menu") gives player n (2 and up) its own, and null hands it back.
Unplugging. A pad that disconnects keeps its seat, marked not connected, with what it held released. The same pad coming back takes the seat back; while a seat waits, the next free pad to press join takes it rather than a new seat.
Remote players. ctx.players.join("remote") seats a player with no device on this machine (any keyboard rule, up to max; its device is "remote"). No key or pad reaches it; its input arrives as frames: a peer's, a replay's, or what a test or tool sends (Sim::feed_player_input, by player number: to the entity they drive, or to their seat while they drive none). It joins, leaves, binds and raises the same events as a local player.
A remote player makes the session networked, and a networked session is deterministic from the tick the first one sits down: it computes the same thing on every computer, whatever its core count. Its crowd is the Rust one on any number of threads (a 2-core computer otherwise runs Detour's); navagents walking in another crowd move over where they stand, still walking to their targets. A replay does it on the same tick. It stays deterministic when the remote player leaves. A tiled navmesh keeps Detour's crowd for now, and the log says so. argus run --deterministic starts a session that way, for a local test of a networked game.
Events. player-joined ({ player, device, pad? }), player-left, player-disconnected and player-reconnected ({ player }), raised by the engine (by null) at the top of the tick after the change.
ctx.players. list() ({ number, device, pad, connected, entity } in number order), count, max, joining / setJoining(open), join(device, pad?), leave(n), bind(n, entity | null), of(entity) (0 when no player drives it), setContext(n, name | null), context(n).
Replays. A replay file stores the seat table on each tick it or the set of connected pads changes ({"t":N,"players":[…],"events":[…],"pads":[…]}, the events being what that tick raised, so a join and a leave within one tick play back as both, and pads the pads the device layer reported, so a script's ctx.players.join("pad", id) gets the same answer on playback) beside the input per entity, so a recorded co-op session plays back with the same players, bindings and events and no device attached.
Determinism. Seats are kept in number order and pads are named by their backend id; two runs fed the same device events build the same table and the same input, tick for tick.
Menus. Any seated player drives a shared menu: the UI reads every player's navigate, activate and back, so a second pad moves the same focus as the first. A screen that wants a cursor per player does not need one from the UI: each player's own presses are readable on the entity they drive (below), so a slot-based screen keeps one choice per player in the script and shows it in that player's slot.
Join screen. @<brand>/gameplay ships the pattern as two behaviours. join-screen goes on any one entity; it puts up a join-screen UI screen of up to four slots (max), each reading "Press A / Return to join" until the engine seats a player, then showing the device (Pad 1, Keyboard, Keyboard (left half)), Joined or Ready, or Controller unplugged while a pad is out. join-slot goes on each entity that carries spawn { "for": "player", "player": N }; it reads that player's own ctx.input and raises join-ready (the uiActivate action, toggling ready), join-leave (uiBack, which calls ctx.players.leave) and join-choice (uiNavigate sideways, one step per push). When every seated player is ready and at least minPlayers (default 1) are seated, join-screen raises join-start once with { players: [{ number, device, pad, choice }] }, closes joining and pops the screen (closeJoining, popOnStart); startDelay seconds of sim time can sit between the last ready and the start, and anyone joining or un-readying cancels it. The context must bind the actions, for example "uiActivate": { "kind": "button", "keys": ["Return"], "gamepad": "south" } and "uiBack": { …, "gamepad": "east" }; the joining press itself is consumed by the engine, so the next South press is the ready press.
{ "id": "lobby", "components": [{ "type": "script", "module": "join-screen", "props": { "minPlayers": 2, "choices": ["red", "green", "blue"] } }] },
{ "id": "slot1", "components": [{ "type": "spawn", "for": "player", "player": 1 }, { "type": "script", "module": "join-slot" }] }
choices gives each seated player one entry to cycle before readying (never one another holds, with uniqueChoices, the default); it rides in join-start as choice. Text is the join.* keys of meta.strings where the scene defines them, else the strings prop, else English defaults. A leave clears that seat's ready, a seat that joins fresh is never born ready, an unplugged pad cannot be ready and blocks the start until it returns. The screen opens joining when it comes up, so a restart after a start gives a lobby that can seat people again. Everything runs on sim time and the seat table; two runs fed the same seats and presses write the same screen.
Not yet: per-player rebinding overrides (the overrides file is player 1's), and seating local players on the web player (join, bind, setContext, setJoining, joining, max and context throw WebUnsupportedError there; list, count, of and leave work).