Input & action mapping (`floptle-input`)
Input & action mapping (floptle-input)
Bind a named Action to many inputs at once — any keyboard key, any mouse button, sticks/triggers/buttons on a gamepad — then decide what the action does in a node script. Devices are an implementation detail; gameplay code talks to actions and axes, never raw keys.
Status: shipped. See ADR-0024 for the four decisions taken during implementation — chiefly that the fighting-game layer (buffering, motion inputs, SOCD) moved into the engine, and that the netcode wire now carries actions instead of keys.
Reads on: ADR-0024 Input action map · ADR-0003 Lua scripting · ADR-0005 ECS/Node facade. Sits beside:
./scene-and-nodes.md(scripts that read input) ·./ui.mdand./camera-and-dialogue.md(which own input contexts). Where it runs:../ARCHITECTURE.md§3. Cratefloptle-inputdepends onfloptle-coreplusgilrsbehind thepadsfeature; neitherwinitnorgilrsappears in its public model.
Where it sits in the frame
Devices are pumped first, right after the winit poll and before any script step
(ARCHITECTURE §3), into a RawInput. Resolution then happens twice, in two
domains:
winit events ┐ ┌─▶ resolve_frame ─▶ ActionState ─▶ update / lateUpdate
gilrs pump ├─▶ RawInput ──────────>┤ (per rendered frame)
mouse motion ┘ (levels + banked └─▶ resolve_tick ─▶ ActionState ─▶ fixedUpdate
edges) (per fixed tick) └─▶ History (buffers, motions)
update runs per rendered frame and fixedUpdate per fixed tick; they advance
at different rates, so a single runtime would let whichever ran first consume
the other's edges — press Jump on a frame between two ticks and fixedUpdate
would never see it. Each domain therefore keeps its own edge state and hold
timers.
RawInput carries two kinds of information for that reason: levels (what is
held right now) and banked edges (presses since the last window closed). The
tick domain needs the latter, because a key tapped between two ticks is already
back up by the time the tick samples.
Only the tick domain feeds history. Motion windows are counted in ticks so a player on 144 Hz and one on 60 Hz get identical leniency, and a rollback replay reproduces the same answer.
Nothing writes input state mid-pass, so two scripts reading "Jump" in the same
pass see the same answer.
Devices & sources
One flat Source enum covers everything bindable. "ALL key types / ALL mouse
buttons" is the point: we mirror winit's full key set and button range, we don't
curate a subset.
enum Source {
Key(Key), // every physical key (letters, F-keys, mods, numpad…)
Mouse(MouseButton), // Left/Right/Middle/Back/Forward
MouseAxis(MouseAxis), // MotionX, MotionY, ScrollX, ScrollY (relative deltas)
Pad { id: PadId, ctrl: PadControl }, // see below; id resolves which controller
}
enum PadControl {
Button(PadButton), // South/East/West/North, bumpers, dpad, stick clicks, start…
Axis(PadAxis), // LeftStickX/Y, RightStickX/Y, LeftZ/RightZ (triggers)
}
enum PadId { Any, Slot(u8) } // Any = "any connected pad"; Slot(n) = local player n
These are the crate's own enums, not winit's or gilrs's — that's what keeps
every rule below testable with no window and no controller. The editor
translates winit::KeyCode at the boundary; pads.rs is the only module that
mentions gilrs.
- Keyboard —
Keymirrors the physical key (layout-independent), so a binding to theWposition is stable across QWERTY/AZERTY. Itsscript_namematches the legacyinput.key("w")strings byte for byte, so the raw and action views of the keyboard never disagree. - Mouse — buttons and relative motion/scroll are first-class
Sources, so "look" (mouse motion) and "zoom" (scroll) bind exactly like a stick axis. - Gamepad —
gilrsgives normalised buttons/sticks/triggers and hot-plug. Slots are claimed by device UUID, not by position in the connected list: a pad that drops out keeps its slot and reclaims it on replug, so P1's battery dying doesn't promote P2 onto P1's character. An unplugged slot reads fully neutral rather than freezing its last pose.PadId::Anyis the single-player default and resolves to the reading player's own pad first. - An analog source bound to a digital action fires past its
threshold(0.5 by default) — that's how a trigger becomes a button.
The action map
The action map is the whole public model: digital Actions and analog Axes, each fed by a list of bindings. Add as many bindings as you like; any of them fires the action.
struct InputMap {
actions: Vec<Action>, // digital: pressed/released/held
axes1: Vec<Axis1>, // 1D analog: triggers, mouse wheel, A/D
axes2: Vec<Axis2>, // 2D analog: WASD, a stick, mouse motion
motions: Vec<Motion>, // fighting-game direction sequences
players: u8, // local player count (slots)
}
struct Action {
name: String, // "Jump"
bindings: Vec<Binding>, // ANY of these triggers it (OR)
}
struct Binding {
source: Source,
modifiers: Vec<Source>, // optional chord, e.g. [Key(ControlLeft)] for Ctrl+S
threshold: f32, // where an analog source counts as pressed
}
Digital state per action, recomputed each resolve from the OR of its bindings (a binding with modifiers is "active" only while all modifiers are held):
pressed held last frame? + held now? → true while down
just_pressed edge: up → down this frame
just_released edge: down → up this frame
held_secs f32, seconds continuously down (for hold-to-charge in script)
Axes — 1D and 2D, with deadzone & sensitivity
An axis composes sources into a value. Digital sources contribute ±1 (so WASD is a 2D axis); analog sources pass through with per-binding deadzone, curve, and sensitivity. This is how "Move" works identically on stick and keyboard.
struct Axis2 {
name: String, // "Move"
bindings: Vec<Axis2Binding>,// any contributes; engine picks the dominant source
}
enum Axis2Binding {
Stick { id: PadId, x: PadAxis, y: PadAxis, deadzone: f32, sensitivity: f32, curve: Curve },
Keys { up: Source, down: Source, left: Source, right: Source }, // WASD-style
Mouse { sensitivity: f32 }, // motion deltas
}
- Deadzone is radial for sticks (kills drift near center), per-binding so a worn stick can be tightened without touching the keyboard binding.
- Sensitivity / response curve scales magnitude (linear default; an optional
expocurve for fine aim). Output is clamped to the unit disk forAxis2. - Dominant source: when both a stick and WASD are bound to one axis, the larger-magnitude source this frame wins whole — no fighting if the player bumps both, and no summing that would let a brushed key deaden the stick. An exact tie keeps the earlier binding.
- Gate — a mouse/analog binding can require a source to be held (
gate: [Mouse(Right)]). This is what lets oneLookaxis serve both devices honestly: the mouse contributes only while you drag, so a free cursor never spins the view, while a right-stick binding on the same axis stays live at all times because a stick recentres itself. - Rate — a mouse binding reports pixels per second by default rather
than pixels-this-frame. A stick reports a position the game integrates into a
turn rate; a mouse reports a displacement that already is the turn. Dividing
by the frame time makes them the same kind of quantity, so a script writes
yaw = yaw - lookX * dtonce and it is correct — and frame-rate independent — on both. - SOCD decides what a digital axis does when opposing directions are held at once — see the fighter layer below.
RON input map — <project>/input.ron
The whole thing is RON (ARCHITECTURE §8), diffable and hand/AI-editable. It gets
its own file rather than a project.ron field because it's the one project
asset a shipped settings menu overlays at runtime, and rebinds stay diffable
that way. A missing file is not an error — the project simply has no actions
yet and raw-key scripts keep working. A file that exists but won't parse is
an error: silently substituting an empty map would unbind the whole game and
read as a hardware fault.
The real top-level name is InputMap, and it carries motions, players and
per-axis socd alongside what the original sketch below shows:
InputMap(
actions: [ Action(name: "Punch", bindings: [
Binding(source: Key(KeyJ)),
Binding(source: Pad(id: Slot(0), ctrl: Button(West))),
]) ],
axes2: [ Axis2(name: "Move", socd: LastWins, bindings: [
Keys(up: Key(KeyW), down: Key(KeyS), left: Key(KeyA), right: Key(KeyD)),
Stick(id: Any, x: LeftStickX, y: LeftStickY, deadzone: 0.15),
]) ],
motions: [ Motion(name: "qcf", dirs: [2, 3, 6], window: 12) ],
players: 2,
)
The original sketch, still accurate for actions and axes:
InputMap(
actions: [
Action(name: "Jump", bindings: [
Binding(source: Key(Space)),
Binding(source: Pad(id: Any, ctrl: Button(South))), // A / ✕
]),
Action(name: "Fire", bindings: [
Binding(source: MouseButton(Left)),
Binding(source: Pad(id: Any, ctrl: Axis(RightZ))), // right trigger as button (threshold)
]),
Action(name: "Save", bindings: [
Binding(source: Key(KeyS), modifiers: [Key(ControlLeft)]), // Ctrl+S chord
]),
],
axes1: [
Axis1(name: "Zoom", bindings: [
Axis1Binding(Mouse(MouseAxis(ScrollY), sensitivity: 1.0)),
Axis1Binding(Keys(plus: Pad(Any, Axis(RightZ)), minus: Pad(Any, Axis(LeftZ)))),
]),
],
axes2: [
Axis2(name: "Move", bindings: [
Keys(up: Key(KeyW), down: Key(KeyS), left: Key(KeyA), right: Key(KeyD)),
Stick(id: Any, x: LeftStickX, y: LeftStickY, deadzone: 0.15, sensitivity: 1.0),
]),
Axis2(name: "Look", bindings: [
Mouse(sensitivity: 0.08),
Stick(id: Any, x: RightStickX, y: RightStickY, deadzone: 0.12, sensitivity: 1.0),
]),
],
)
"Move" reads identical to a script whether the player uses WASD or the left
stick — that is the entire value proposition.
Input contexts
A context is a named, prioritized layer of action enablement that can consume input so lower layers don't see it. This is how dialogue eats the world's input without the player script knowing.
struct Context {
name: String, // "gameplay" | "menu" | "dialogue"
priority: i32, // higher wins; resolved top-down
enabled: Vec<String>, // actions/axes this layer cares about
mode: ConsumeMode, // Passthrough | Consume
}
enum ConsumeMode { Passthrough, Consume } // Consume = swallow handled inputs
Resolution each update: contexts sort by priority desc; for each input event,
the highest-priority enabled context claims it. A Consume context (a modal
menu, an active dialogue) blocks lower contexts entirely; a Passthrough overlay
(a HUD that only listens for Pause) claims its own actions and lets the rest
fall through.
priority ▼ sees input?
dialogue (Consume) ████ ← active: eats "Advance"/"Skip", blocks below
menu (Consume) ░░░░ ← inactive
gameplay (Pass) ░░░░ ← gets nothing while dialogue is up
Contexts are pushed/popped by systems and scripts: opening dialogue pushes the
dialogue context (see ./camera-and-dialogue.md);
the UI system pushes menu when a modal opens (see ./ui.md). The
stack is plain data — no callbacks needed to "give back" input.
Runtime rebinding (press-to-bind)
For a settings menu: ask the input system to capture the next input and write it onto a binding. Useful, small, not a whole subsystem.
input.start_rebind(action: "Jump", slot: 0, filter: BindFilter::AnyButton);
// next frame a button/key is captured → input.pending_rebind() == Some(Source::…)
// confirm → writes Source onto Action."Jump".bindings[0]; cancel on Esc/timeout.
BindFilter (AnyButton, KeyboardOnly, PadOnly, AxisOnly) keeps a "press
a key" prompt from grabbing stray stick drift. Rebinds serialize back to the same
RON map, so player settings are just an overlay map merged over input.ron.
Scripting API (Lua)
The curated input table (ARCHITECTURE §7) is the only thing gameplay touches —
it reads the snapshot, never devices. Action behavior is defined in the node
script, per the developer's ask:
The shipped API is flat camelCase functions on the existing input table, so
the raw polls and the action layer coexist and a project can migrate one call at
a time.
-- a player controller node script
function update(node, dt)
-- digital action: any of its bindings (Space OR gamepad South)
if input.justPressed("Jump") then node.vy = JUMP_SPEED end
-- 2D axis: WASD or left stick, deadzoned and normalised for us
local mx, my = input.axis2("Move")
node.pos = node.pos + vec3(mx, 0, -my) * SPEED * dt
-- 1D axis: trigger / scroll
zoom = zoom - input.axis1("Zoom") * dt
-- charge: held duration is tracked for you
if input.heldSecs("Fire") > 0.5 then charge() end
end
| Lua call | Returns |
|---|---|
input.action(n) |
bool — held |
input.justPressed(n) |
bool — down-edge this frame/tick |
input.justReleased(n) |
bool — up-edge this frame/tick |
input.heldSecs(n) |
number — seconds continuously held |
input.axis1(n) |
number in [-1,1] |
input.axis2(n) |
x, y in the unit disk (two returns) |
input.buffered(n, ticks) / input.consume(n, ticks) |
the input buffer |
input.motion(n [, window]) |
bool — a motion just completed |
input.dir() / input.dirHeldTicks(d) |
numpad direction, and how long held |
input.setFacing(f) / input.facing() |
mirror directions after a cross-up |
input.player(n) |
the same table, bound to local player n |
input.pushContext(n, opts) / input.popContext(n) |
manage the context stack |
input.actions() / input.bindingsOf(n) |
drive an in-game controls screen |
input.startRebind(n [, filter]) / pendingRebind() / commitRebind() / cancelRebind() |
press-to-bind from a settings menu |
Which domain a call reads is decided by the pass it runs in, not by the
call: fixedUpdate sees the tick domain (the one with history), update and
lateUpdate see the frame domain. A predicted node's update runs on the tick
clock, so it reads tick input too — that's what keeps client and server
resolving the same edges.
Editor UX — the action-map editor
A single panel in the editor (egui, dark/retro theme — ADR-0004), deliberately not a property-soup inspector. One row per action/axis; bindings are chips.
┌─ Project Settings ▸ Input ────────────────────────── input.ron ── [⟲] ┐
│ ACTIONS │
│ Jump [⌨ Space] [🎮 South] [✚] [🗑] │
│ Punch [⌨ J ] [🎮 West ] [✚] [🗑] │
│ Taunt ⚪ ⚠ unbound [✚] [🗑] │
│ USED IN SCRIPTS, NOT IN THE MAP │
│ ⚠ Block fighter.lua:42 action [add] │
│ AXES 2D │
│ Move [⌨ WASD] [🎮 L-Stick dz0.15] SOCD: Neutral ⏷ │
│ MOTIONS │
│ qcf qcb dp rdp hcf hcb dd ff bb chargeF chargeU │
├─ LIVE ─────────────────────────────────────────────────────────────────┤
│ ● Jump ○ Punch ○ Taunt Move: (+0.82, -0.11) P1 🎮 Xbox pad │
└────────────────────────────────────────────────────────────────────────┘
- The action list is scanned out of your scripts. Every action, axis and
motion the project's Lua references, deduped, with the first
file:lineon hover. An entry a script uses but the map doesn't define is flagged with ⚠ and one click adds it — that's a control which silently does nothing, and it's the failure worth surfacing above all others.⚪marks the reverse: bound, but nothing references it. - Add a binding two ways.
✚arms press-to-bind — the fast path when the device is in your hand, through the same code path a shipped game's settings menu uses; Escape always cancels.▾opens a picker listing every key, mouse button, pad button and pad axis, which needs no hardware connected at all: laying out controller bindings on a laptop, or adding P2's while only one pad is plugged in, is entirely normal. Click a chip to remove it. - add missing starter bindings fills gaps only — it binds an entry that was created from the ⚠ warning (those land unbound by design) and adds any starter action the map lacks, while leaving your own actions, bindings and SOCD choices exactly as they are.
- Live tester lights up as you mash, without entering Play. It resolves the real devices independently of gameplay input, which is deliberate: you edit bindings with the game view unfocused, which is exactly when gameplay input is neutral.
- Still polling raw keys lists every legacy
input.key(...)call site — the migration worklist, and the set of calls that read neutral under prediction. - The window is a constant width and the list scrolls at a fixed height, so adding actions never resizes it under you.
The fighter layer — buffering, motions, SOCD
Originally listed as out of scope; now shipped, because the pieces need a
per-tick history ring that a script can't reconstruct correctly under rollback
(ADR-0024 §1). floptle-input::history keeps 180 ticks per player.
Everything here reads the tick domain — call it from fixedUpdate. Motion
windows are counted in ticks so a player on 144 Hz and one on 60 Hz get
identical leniency, and a replay reproduces the same answer.
-- an input buffer: a punch pressed a few frames early still lands
if input.buffered("Punch", 4) then
input.consume("Punch", 4) -- spend it, so it fires ONCE
punch()
end
-- a special: motion first, so qcf+P never comes out as a bare punch
if input.motion("qcf") and input.buffered("Punch", 4) then ... end
input.dir() -- numpad notation, 1-9 (5 = neutral, 6 = forward)
input.dirHeldTicks(4) -- build your own charge/leniency rules
input.setFacing(-1) -- mirror directions after a cross-up
Numpad notation, from the character's point of view:
7 8 9 up-back up up-forward
4 5 6 = back neutral forward
1 2 3 dn-back down dn-forward
setFacing is how "forward" stays meaningful after the characters swap sides:
directions are mirrored before they reach the history, so motion("qcf") keeps
meaning toward the opponent. The engine has no opinion about who faces where.
Seeded motions: qcf qcb dp rdp hcf hcb dd ff bb chargeF
chargeU. Edit their directions and windows in input.ron.
SOCD — what happens when opposing directions are held at once, which a
leverless controller does trivially — is per-axis in the map: Neutral (both
cancel; the tournament standard, and the default), LastWins (the newer
direction takes over with no neutral frame, so a player can pivot), or
Positive/Negative priority.
What stays out: the move list. Which motion plus which button makes which
attack is the game's business — see assets/scripts/fighter.lua for a worked
example including local versus off a single script.
The shipped default scripts
freelook, first_person, third_person, third_person_camera, character
and sword are all written against the starter names, so every one of them
plays on a keyboard, on a gamepad, or on both at once with nothing to
configure. A new project seeds input.ron alongside them.
Two tests guard that, because a drifting name here means a brand-new project whose camera silently cannot move:
- every action/axis/motion the shipped scripts reference exists in
InputMap::starter()(input_scan.rs), and - none of them polls a raw device any more.
Notable conversions:
| Was | Now |
|---|---|
input.key("w") … four keys, hand-normalised |
input.axis2("Move") |
input.button(1) + input.mouse_delta() |
input.axis2("Look"), gated + rate-converted |
input.pressed("space") |
input.justPressed("Jump") |
input.key("shift") |
input.action("Sprint") |
input.scroll() |
input.axis1("Zoom") |
| double-tap W/A/S/D to run | double-tap detected on the Move axis leaving rest — so a stick flick arms it exactly like tapping a key |
third_person_camera reads LookFree instead of Look while it owns the
cursor (shift lock, first person): same bindings, minus the hold-to-drag gate,
because there is no free pointer left to protect.
Local multiplayer
input.player(n) returns the whole API bound to another local player (1-based).
Both characters run the same script; the slot rides in as an ordinary param:
defaults = { player = 1 }
local me = input.player(params.player)
if me.justPressed("Punch") then ... end
Pad slots are claimed by device UUID, so a player whose battery dies mid-match returns to their own character on replug instead of everyone being renumbered. Set the player count in Project Settings → Input.
Two players, one keyboard
Pads sort themselves out — PadId::Any resolves to each slot's own device. A
keyboard cannot, because there is only one of it: an unscoped Key(KeyJ) on
Punch punches for both fighters. So a binding can name a player:
Action(name: "Light", bindings: [
Binding(source: Key(KeyJ), player: Some(0)), // P1
Binding(source: Key(Digit1), player: Some(1)), // P2
Binding(source: Pad(id: Any, ctrl: Button(West))), // either player's pad
]),
Axis2(name: "Move", socd: Neutral, bindings: [
Keys(up: KeyW, down: KeyS, left: KeyA, right: KeyD, player: Some(0)),
Keys(up: ArrowUp, down: ArrowDown, left: ArrowLeft, right: ArrowRight, player: Some(1)),
Stick(id: Any, x: LeftStickX, y: LeftStickY, deadzone: 0.15),
]),
Omitted (the overwhelming default) means every player, so nothing changes for a
single-player project. In the editor, right-click a binding chip to scope it;
scoped chips read ⌨ J P1. An action with an unscoped keyboard binding in a
players > 1 project is flagged ▲, because "both characters jump off one press"
is otherwise found by watching it happen.
The scope lives on the binding, never the action. The action list is what the
netcode indexes positionally and what InputMap::hash() covers, so scoping a
binding cannot invalidate a session.
It also fixes motions for player 2. dir(), dirHeldTicks and every motion read
one map-level axis (motion_axis, default "Move"), resolved per player — so
one Move axis carrying WASD for P1 and the arrows for P2 gives each of them
their own quarter-circle. Two separate axes (Move / Move2) would not: the
recogniser would read P1's for everyone.
Multiplayer: actions ride the wire
NetInput carries an action bitmask plus axis values — no keys. A pad player
and a keyboard player who press "Jump" produce byte-identical commands, so the
one-script prediction model holds across devices.
Two consequences worth knowing:
- Raw
input.key/pressed/released/button/clickedread neutral on aPredictednode (client, server and replay alike). An unmigrated controller visibly does nothing rather than quietly desyncing. The Input settings list every raw-key call site so the migration has a worklist. - Actions are indexed by their position in
input.ron, so the handshake comparesInputMap::hash()and refuses a mismatched peer. Personal rebinds don't affect the hash — rebinding never locks you out of a session.
Out of scope
- A combo / move-list DSL. The engine gives you edges, hold times, buffers and motions; which of those makes which attack is the game's.
- Full input recording / replay (deterministic capture for demos/TAS). The fixed-step sim makes this possible later, but it's not a launch feature.
- On-screen virtual touch controls — a UI-system concern if mobile ever lands
(see
./ui.md), not part of the action map.