> Cinder 0.1.0 documentation: Input (cinder/input)
> Canonical page: https://cinder.ash10.dev/docs/guides/input/ · Source: `docs/guides/input.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# Input (`cinder/input`)

`cinder/input` maps raw platform events to named gameplay controls. A game asks `pressed("jump")`
or `axis("move_x")` instead of testing scancodes, players can rebind controls at runtime, and the
whole map saves to a small text file. The package sits beside `audio`, `physics`, `anim` and
`render` in the module map (`docs/ARCHITECTURE.md`); it depends on `cinder/platform` for the
`Event` type, on `cinder/core/fs` for file I/O and on `cinder/scene/format` for its exact float
formatter. `cinder/app` owns one `InputMap` (`App.input`) and feeds it every frame.

Source: `cinder/input/map.mojo`. API reference: `docs/api/input.md`. Design decisions:
`docs/adr/0012-physics-animation-audio-input.md`, "Input" section.

## Model

- An **action** is a named button: held, just pressed, just released. It is held while any of its
  bindings is down.
- An **axis** is a named float. Its value is the sum of its bindings' contributions: a digital
  source contributes `scale` while held; a gamepad axis contributes its value after a rescaled dead
  zone, times `scale`; a mouse axis contributes the frame's accumulated delta times `scale`.
- A **`Binding`** is one source: `device`, `code`, `scale`, `dead_zone`. Devices are the module
  constants `DEVICE_KEY`, `DEVICE_MOUSE_BUTTON`, `DEVICE_MOUSE_AXIS`, `DEVICE_GAMEPAD_BUTTON`,
  `DEVICE_GAMEPAD_AXIS`. Codes are the platform's: SDL scancodes for keys (W is 26, Space is 44;
  `sample/game/game.mojo` lists the ones the sample uses), mouse button numbers, gamepad button and
  axis indices. Mouse axis codes are `0` = x delta, `1` = y delta, `2` = wheel.

Constructors on `Binding` fill in the device: `Binding.key(scancode, scale=1.0)`,
`Binding.mouse_button(button, scale=1.0)`, `Binding.mouse_axis(axis, scale=1.0)`,
`Binding.gamepad_button(button, scale=1.0)`, `Binding.gamepad_axis(axis, scale=1.0,
dead_zone=0.15)`. `same_source` compares device and code only; `==` also compares scale and dead
zone. `Binding` is `Writable`, so `String(t"{b}")` prints it in the file format (see below).

`InputMap` is a plain `Movable` struct that owns its action and axis lists plus the device state
(`keys_down` for 512 scancodes, 8 mouse buttons, 32 gamepad buttons, 8 gamepad axes, mouse deltas
and wheel). It has no native resources and no destructor; it can be replaced wholesale, which is
what the sample does when it loads a saved map into `app.input`.

## Defining a map

`bind_action` and `bind_axis` create the action or axis on first use and append the binding.
`add_action`/`add_axis` create an empty one (they raise if the name exists); `clear_*_bindings`
and `rebind_action` (clear, then bind one) edit existing ones; `action_bindings`/`axis_bindings`
return copies; `find_action`/`find_axis` return an index or `-1`.

```mojo
from cinder.input import Binding, InputMap

def default_bindings() raises -> InputMap:
    var m = InputMap()
    m.bind_axis(String("move_x"), Binding.key(KEY_D, 1.0))
    m.bind_axis(String("move_x"), Binding.key(KEY_A, -1.0))
    m.bind_axis(String("move_x"), Binding.gamepad_axis(0, 1.0))
    m.bind_axis(String("move_y"), Binding.key(KEY_W, 1.0))
    m.bind_axis(String("move_y"), Binding.key(KEY_S, -1.0))
    m.bind_axis(String("move_y"), Binding.gamepad_axis(1, -1.0))   # stick up is negative Y
    m.bind_axis(String("look_x"), Binding.mouse_axis(0, 1.0))
    m.bind_axis(String("look_x"), Binding.gamepad_axis(2, 600.0))  # pixels per second-ish
    m.bind_action(String("jump"), Binding.key(KEY_SPACE))
    m.bind_action(String("jump"), Binding.gamepad_button(0))
    m.bind_action(String("pause"), Binding.key(KEY_ESCAPE))
    return m^
```

This is `default_bindings()` from `sample/game/game.mojo`, shortened. The large scale on the
gamepad look axes exists because mouse and stick feed the same axis: mouse deltas are pixels per
frame, so a stick at full deflection needs a comparable magnitude.

## Feeding events and reading state

Call `process(ev)` for every `Event` from `Platform.poll`, read the map during the frame, and call
`end_frame()` once after reading. `App.run_frame` does all three: `_poll` forwards events to
`App.input` (skipping mouse and key presses, motion and wheel while the ImGui overlay wants them;
releases always get through, see `ui_consumes_event`) and
`end_frame` runs at the end of the frame, after `Game.update` and rendering. A `Game` only reads:

```mojo
def update(mut self, mut app: App, dt: Float32) raises:
    var mx = app.input.axis(String("move_x"))
    var my = app.input.axis(String("move_y"))
    if app.input.just_pressed(String("pause")):
        self.paused = not self.paused
    if app.input.pressed(String("walk")):
        speed *= 0.5
    self.camera_yaw -= app.input.axis(String("look_x")) * self.settings.mouse_sensitivity
```

Rules the implementation follows (all asserted in `tests/input/test_input_map.mojo`):

- `process` ignores key repeats: a repeat never produces a new `just_pressed`.
- An action stays held while any binding is down; releasing one of two held bindings does not fire
  `just_released`.
- `pressed`, `just_pressed`, `just_released` and `axis` raise `"input: unknown action/axis"` for a
  name that was never defined. Define every control up front, including ones that start unbound.
- `axis` clamps to `[-1, 1]` when the axis has only digital bindings (W and S cancel to 0). As soon
  as a mouse or gamepad axis binding exists the sum is returned unclamped, because mouse deltas are
  pixels.
- Mouse deltas and the wheel accumulate across all events of a frame and reset in `end_frame`.
- Gamepad axes keep their last reported value; they do not reset per frame.
- Changing an action's bindings recomputes its held state at once: `clear_action_bindings` (and
  `rebind_action`, and a capture by `listen_action`) releases an action that was held through the
  removed bindings, with a `just_released` edge; `bind_action` with a source that is already down
  presses it, with a `just_pressed` edge. An action can never stay held with no binding to release it.
- A binding whose code is negative or past the tracked range (a hand-edited bindings file can carry
  one) never reads as pressed and contributes nothing to an axis; it never wraps around to another
  key or axis.

### Dead zones

A gamepad axis binding with `dead_zone = d` ignores `|v| <= d` and rescales the rest so the dead
zone edge maps to 0 and full deflection to 1: `sign(v) * min((|v| - d) / (1 - d), 1) * scale`.
With `Binding.gamepad_axis(1, -1.0, 0.2)`, a value of 0.1 reads 0, -1.0 reads 1.0 and 0.6 reads
-0.5. The default dead zone is 0.15; digital bindings carry `dead_zone = 0.0`.

## Runtime rebinding by listening

A settings menu rebinds a control by arming a capture and letting the player press something:

- `listen_action(name)`: the next key, mouse button or gamepad button press replaces all of the
  action's bindings. The capturing press itself does not fire the action, and an action that was
  held through its old bindings is released at that moment.
- `listen_axis(name, scale=1.0)`: the next key, button or gamepad axis event (an axis must exceed
  0.5 so stick noise does not bind) is appended to the axis with the given `scale`; the captured
  gamepad axis keeps the default dead zone.
- `is_listening()` reports whether a capture is armed; `cancel_listening()` disarms it. Mouse
  motion and wheel never bind anything while listening.

The sample's pause menu (`_draw_pause_menu` in `sample/game/game.mojo`) does this and saves the map
when the capture completes:

```mojo
if lib[].ImGui_Selectable(self.ui.c(label)):
    self.listening = String(name)
    app.input.listen_action(String(name))
if self.listening.byte_length() > 0 and not app.input.is_listening():
    self.listening = String()
    app.input.save(self.bindings_path())
```

Note that `App._poll` drops key presses while ImGui wants the keyboard, so a menu built on an
ImGui text field would never see the captured key; the sample's menu uses selectables, which do not
take keyboard focus.

## File format

`to_text()` produces the file, `from_text(text)` parses it, `save(path)` and `InputMap.load(path)`
wrap them with `write_text`/`read_text`. The format is line based and diffable:

```
cinder-input 1
action jump key 44 1 0
action jump gamepad_button 0 1 0
action fire mouse_button 1 1 0
action unbound
axis move_y key 26 1 0
axis move_y key 22 -1 0
axis move_y gamepad_axis 1 -1 0.2
axis look_x mouse_axis 0 0.01 0
```

- Line 1 is the header `cinder-input <version>`; `INPUT_MAP_VERSION` is 1 and any other version
  is rejected (`"input map version 7 (this engine reads 1)"`).
- Each later line is `action|axis <name>` followed either by nothing (an entry without bindings,
  which still exists after loading) or by `<device> <code> <scale> <dead_zone>`. Device names are
  `key`, `mouse_button`, `mouse_axis`, `gamepad_button`, `gamepad_axis`; any other name raises
  (`keyboard` is an error, as the test checks). Lines with a token count other than 2 or 6 raise.
- Actions come first, then axes, each in definition order; bindings keep their order.
- Floats are written with `format_float` from `cinder/scene/format.mojo`: the shortest decimal that
  round-trips to the same `Float32`, otherwise the IEEE bits as `0x...`. `parse_float` reads both,
  so `loaded.to_text() == text` holds exactly and bindings compare equal bit for bit after a round
  trip (`test_save_and_load_round_trip`).
- Blank lines are skipped; there are no comments. Names cannot contain spaces because fields are
  split on single spaces.

The sample stores its map as `input.txt` next to `settings.txt` in its save directory and falls back
to `default_bindings()` when the file is missing or fails to parse (ADR-0014, "Settings live in two
files"):

```mojo
if file_exists(self.bindings_path()):
    try:
        app.input = InputMap.load(self.bindings_path())
    except:
        app.input = default_bindings()
else:
    app.input = default_bindings()
```

Loading does not merge: a file saved by an older build that lacks a newly added action makes
`pressed` raise for that action. Add missing actions after loading if you extend the controls.

## Threading

`InputMap` is main-thread state. Events arrive from `Platform.poll` on the main thread (the SDL
event pump lives there, `docs/THREADING.md`), `process` and the queries run on the main thread
inside `App.run_frame`, and no other thread touches the map. Nothing in the package calls into
the native library.

## Pitfalls

- Call `end_frame` exactly once per frame, after every reader has run. Calling it before
  `Game.update` loses every `just_pressed` edge; `App` orders this correctly.
- While the debug overlay has the mouse or keyboard (`DebugUi.wants_mouse`/`wants_keyboard`),
  `App` does not forward presses, motion or wheel from them to the map, so a key pressed while
  typing in an ImGui field does not move the player. Releases are always forwarded, so a key or
  button held when the overlay takes focus is released in the map when the player lets go.
- Scancodes outside `0..511`, mouse buttons outside `0..7`, gamepad buttons outside `0..31` and
  gamepad axes outside `0..7` are ignored by `process` and read as not pressed / zero; bindings with
  such codes (negative ones included) never match.
- Rebinding an action that is held (by `listen_action` or `clear_action_bindings`) releases it with
  a `just_released` edge; game code that reacts to edges sees a normal release.
- Device codes are platform scancodes, not key names or characters; the file is not portable across
  keyboard layouts in the sense of "the key labelled W", only in the sense of "the key at W's
  position".

## Tests and references

- `tests/input/test_input_map.mojo` (`pixi run test-input`): edges per frame, key repeats, multiple
  bindings per action, digital axis clamping, dead zone rescaling, mouse accumulation and reset,
  listening for actions and axes, `cancel_listening`, `rebind_action`, and the exact save/load round
  trip including rejected versions, device names and headers, negative codes from a file, and the
  release of an action whose bindings are cleared or captured while it is held. The test builds events with
  `Event.empty(EventKind.key())` and sets `scancode`, `down`, `repeat`; use the same approach to
  drive a map without a window.
- `sample/game/game.mojo`: `default_bindings()`, the per-frame reads in `update`, and the pause
  menu's rebinding flow. `tests/sample/test_sample_game.mojo` checks that `input.txt` persists.
- `docs/adr/0012-physics-animation-audio-input.md`: bindings as (device, code, scale, dead zone),
  rebinding by listening, exact floats in the text file.
- `docs/adr/0013-tools-and-app-runtime.md`, decision 7: event routing between ImGui and the map.
