DocsGuides

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.
View as MarkdownSource: docs/guides/input.md