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
scalewhile held; a gamepad axis contributes its value after a rescaled dead zone, timesscale; a mouse axis contributes the frame's accumulated delta timesscale. - A
Bindingis one source:device,code,scale,dead_zone. Devices are the module constantsDEVICE_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.mojolists the ones the sample uses), mouse button numbers, gamepad button and axis indices. Mouse axis codes are0= 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.
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:
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_sensitivityRules the implementation follows (all asserted in tests/input/test_input_map.mojo):
processignores key repeats: a repeat never produces a newjust_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_releasedandaxisraise"input: unknown action/axis"for a name that was never defined. Define every control up front, including ones that start unbound.axisclamps 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(andrebind_action, and a capture bylisten_action) releases an action that was held through the removed bindings, with ajust_releasededge;bind_actionwith a source that is already down presses it, with ajust_pressededge. 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 givenscale; 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:
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_VERSIONis 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 arekey,mouse_button,mouse_axis,gamepad_button,gamepad_axis; any other name raises (keyboardis 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_floatfromcinder/scene/format.mojo: the shortest decimal that round-trips to the sameFloat32, otherwise the IEEE bits as0x....parse_floatreads both, soloaded.to_text() == textholds 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"):
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_frameexactly once per frame, after every reader has run. Calling it beforeGame.updateloses everyjust_pressededge;Apporders this correctly. - While the debug overlay has the mouse or keyboard (
DebugUi.wants_mouse/wants_keyboard),Appdoes 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 outside0..7, gamepad buttons outside0..31and gamepad axes outside0..7are ignored byprocessand read as not pressed / zero; bindings with such codes (negative ones included) never match. - Rebinding an action that is held (by
listen_actionorclear_action_bindings) releases it with ajust_releasededge; 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 withEvent.empty(EventKind.key())and setsscancode,down,repeat; use the same approach to drive a map without a window.sample/game/game.mojo:default_bindings(), the per-frame reads inupdate, and the pause menu's rebinding flow.tests/sample/test_sample_game.mojochecks thatinput.txtpersists.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.