Input
This file covers the input domain: the device-abstracted action layer, the one SDL-aware backend under it, rebinding and persistence, touch and virtual controls, and the tick boundary that hands quantized commands to the deterministic simulation.
1. Input (device-abstracted actions, Phase 1)
Gameplay and editor code bind to named actions ("Move",
"Jump"), never to a device control — a control is what a binding names, in data, once.
Keyboard/mouse, gamepad, and touch all reduce to three action shapes: Button, Axis1D,
Axis2D. No consumer branches on device type.
The header-only action layer (engine/domain/input/include/SushiEngine/input/) carries zero
SDL, zero SYCL, and no runtime link — the same discipline sushiengine_render’s abstract side
keeps. Its pieces are seven single-responsibility objects (SRP by construction):
-
engine/domain/input/include/SushiEngine/input/controls.hpp— the engine-owned control enums.Keyis numbered by USB HID keyboard usage IDs (the table SDL scancodes are built on), so the numbering is physical-position based and layout-independent, and the SDL translator reinterprets a scancode as aKeyrather than looking it up. Every ordinal here is a serialized wire value and must never renumber. AControlPath(family + ordinal) is the atom a binding stores. -
engine/domain/input/include/SushiEngine/input/input_event.hpp— the single trivially-copyableInputEventrecord every source emits, theEventTypetags, the stableDeviceIdslot scheme (keyboard 0, mouse 1, gamepads from 2), and a floatVector2(distinct fromUI::Vector2, which is double). -
engine/domain/input/include/SushiEngine/input/source.hpp—IInputSource(one method: drain this frame’s events) and the header-onlyScriptedInputSource. Because all state is folded from events above the source, a scripted, SDL, and (later) virtual source are indistinguishable downstream — Liskov holds by construction, which is what lets the whole layer run headless in tests with no mocks. -
engine/domain/input/include/SushiEngine/input/device_registry.hpp—DeviceRegistry, the single owner of “what is held now”, folded from the event stream. Level state (keys, buttons, stick/trigger values) persists; relative state (mouse delta, wheel) accumulates within a frame and resets at its start — the split the tick-boundary sampler (Phase 2) needs. -
engine/domain/input/include/SushiEngine/input/bindings.hpp— bindings as data:Binding,Deadzone(radial for whole sticks, axial for triggers),ChordGate(Ctrl+Z), and the composite/Vector2bindings. Every evaluator is a pure function of the registry, run through a fixed processor order (deadzone → invert → scale, then composite assembly and diagonal normalization), so a keyboard composite and an analog stick feed one action through identical downstream math. A new binding shape is a new struct and evaluator, no consumer change — OCP as data. -
engine/domain/input/include/SushiEngine/input/action_map.hpp—Action/ActionType,InputContextwith fluent builders (add_axis2d("Move").bind_composite(W,S,A,D).bind(GamepadAxis::LeftStick, ...)), andActionMapper. The mapper holds a priority-ordered context stack and resolves every action once per host frame into anActionSnapshot; a higher context consumes the controls its actions reference, so pushing a"Menu"context masks gameplay movement with no consumer testing a flag. AnInputGatemirrors ImGui’sWantCapture*flags to suppress key- or mouse-sourced actions in one place. A button action’spressededge is the bound control’s own down transition taken with itsChordGatealready satisfied, soCtrl+Sfires on S pressed under a held Ctrl and not on Ctrl arriving while S is already down;heldremains the plain level conjunction, andreleasedalso takes the level fall so a chord broken by letting go of the modifier still ends the action. -
engine/domain/input/include/SushiEngine/input/input_manager.hpp— theInputManagerfaçade.begin_frame()drains every registered source, folds the events into the registry, and resolves the mapper. It never touches theWorldand never registers a system.
The compiled backend (engine/domain/input/source/, sushiengine_input_backend STATIC). The
mirror of sushiengine_render’s recipe: links SDL2::SDL2 only, no SYCL, no runtime, C++17. It
holds the one SDL-aware input component, Input::SDLInputTranslator
(engine/domain/input/source/backend/sdl/sdl_input_translator.*), which turns already-pumped
SDL_Event records into engine InputEvents. It does not pump SDL — the single
SDL_PollEvent loop stays in SDLWindow (engine/foundation/platform/source/sdl_window.cpp),
and the translator registers on the window’s event-handler seam alongside ImGui. That seam grew
from one handler to a handler list: IPlatformWindow::add_event_handler appends, so ImGui still
sees events first. The native SDL_Event crosses the translator’s header as const void*, so
SDL leaks into no consumer translation unit — the editor’s “only SDL-aware components” set grows
from two (SDLWindow, ImGuiBackend) to three.
Gamepad and haptics. A controller is a device family, not a special case: the same
"Move"/"Jump" bindings drive keyboard or pad. SDLWindow inits SDL_INIT_GAMECONTROLLER (core
SDL2) and the translator opens/closes controllers on hot-plug, translating their button and axis
events to the same InputEvent shapes a stick binding already reads (SDL’s button and axis ordinals
match the engine enums, so the translation is a reinterpretation). A controller’s identity to the
engine is its DeviceId slot, allocated by GamepadSlotTable
(engine/domain/input/include/SushiEngine/input/gamepad.hpp) — a header-only, SDL-free, unit-tested
policy that hands a pad the lowest free slot and keeps it across an unplug/replug of the same
ordering, so bindings and player assignments survive a reconnect. The translator also implements
IHapticsSink (engine/domain/input/include/SushiEngine/input/haptics.hpp), so the object gameplay
drives rumble through is the same one it never sees as SDL — SDL_GameControllerRumble behind
rumble(device, low, high, duration).
Rebinding and persistence. Because a binding is data, changing one changes no consumer code —
the property engine/domain/input/include/SushiEngine/input/rebinding.hpp makes operational.
RebindingListener captures the next control of an expected shape (a button rebind is deaf to axis
noise; an axis rebind demands deflection past a threshold so a drifting stick cannot bind itself)
and cancels on Escape or timeout; binding_conflict warns when a captured control is already used
in the context, and set_button_binding writes it back.
Persistence is engine/domain/input/include/SushiEngine/input/bindings_json.hpp
(bindings_to_json/bindings_from_json), tolerant field-by-field with defaulted reads exactly like
the editor’s render_settings: a missing action entry keeps its compiled-in defaults, a malformed
entry is ignored rather than throwing, and unknown actions survive a round-trip. It is the only
input header that pulls in nlohmann/json, so the core action layer and any headless build that never
persists stay dependency-free. The editor holds the serialized document in
Preferences::input_bindings (as text, so the struct stays JSON-free) and nests it in
preferences.json; the game owns where its own file lives — the engine provides the functions, not
a path policy.
Touch and virtual controls. Touch decomposes into two layers, and only the first is device code.
Pointers: the translator turns SDL_FINGERDOWN/MOTION/UP into touch events (normalized → pixels,
stable finger→slot), and the DeviceRegistry folds up to MAX_TOUCH_POINTS of them; a
set_mouse_as_pointer flag folds the mouse into pointer 0 so a touch UI is developable on desktop,
and primary_pointer() is the one pointer the engine UI reads (via the opt-in
engine/domain/input/include/SushiEngine/input/ui_pointer.hpp adapter) instead of the host
hand-feeding UI::PointerInput.
Virtual controls: VirtualControlSource
(engine/domain/input/include/SushiEngine/input/virtual_controls.hpp) owns a screen-space layout of
sticks and buttons and, each frame, claims the pointers inside them and emits ordinary
gamepad-shaped events — a virtual stick is GamepadAxis::LeftStick on a dedicated slot, so a
"Move" binding resolves it through the exact path a hardware stick takes. It runs on the manager’s
second-pass virtual-source stage (add_virtual_source), polled after the primary fold so it reads
this frame’s pointer state and its output is folded back for the mapper. Adding touch to a shipped
gamepad game is placing controls, not writing input code; rendering them is the engine UI’s job, and
gesture recognition is a recorded follow-on.
Editor migration. The editor’s shortcuts and tool keys, once scattered ImGui::IsKeyPressed
polls, are now two rebindable contexts
(applications/editor/source/input/editor_contexts.hpp): EditorGlobal
(Undo/Redo/Save/Copy/Cut/Paste, chorded on Control) and EditorViewport (gizmo W/E/R).
applications/editor/source/main.cpp pushes them, applies any overrides saved in
Preferences::input_bindings, and consumes input.snapshot().pressed(...) for the global
shortcuts; the toolbar reads the same snapshot for the gizmo keys (via a non-owning
ActionSnapshot* on EditorContext). The !WantTextInput guards each poll wrote by hand are
now the mapper’s single capture gate. The Preferences window gained a rebind page — list an
action, click Rebind, press a key — that runs the RebindingListener, rewrites the binding, and
serializes the set so a rebind survives a restart. Camera flight (WASD while right-mouse is held)
stays on the viewport’s own Editor::InputState seam
(applications/editor/source/input/input_state.hpp), unchanged.
Local-multiplayer routing. Binding resolution asks a DeviceAssignment — held per
ActionMapper — which device answers each control family, instead of hard-coding the first
connected pad. The default (keyboard, mouse, first pad) reproduces single-player exactly, so the
routing costs nothing until used. A local-multiplayer game gives each player a PlayerHandle
(engine/domain/input/include/SushiEngine/input/player.hpp; its own mapper, tick accumulator, and
assignment) resolving the shared contexts against its own devices, collected in a PlayerRoster
that routes claims and answers “press A to join” (join_candidates). Device events are folded once
into the one shared registry; N players is N reductions of that state, one per Command — the exact
shape SushiLoop’s “a player is an ECS entity, input a per-tick command” already prescribes.
Completion pass. Four recorded follow-ons close the system out. Buffering (the design doc’s
§2.2): the TickSampleAccumulator stamps each tick with ticks-since-press per action, so
TickSample::pressed_within(name, window) gives jump-buffer and coyote-time windows at the tick
cadence. Gestures (engine/domain/input/include/SushiEngine/input/gestures.hpp, the design doc’s
§2.4): a GestureRecognizer turns the registry’s pointers into tap/long-press/drag/pinch results,
time-driven by update(dt) and left for the consumer to map onto actions — a pure sensor.
Replay (engine/domain/input/include/SushiEngine/input/replay.hpp, the design doc’s §6): an
InputRecorder captures the frame event stream and replays it through a ScriptedInputSource,
reproducing mapper behaviour the way InputHistory reproduces the sim — the two straddle the
tick boundary, the design’s central line;
engine/domain/input/include/SushiEngine/input/replay_json.hpp is an opt-in file format. Text
(engine/domain/input/include/SushiEngine/input/text_input.hpp, the design doc’s §6): a
TextInputChannel is an active-gated UTF-8 buffer the translator feeds SDL_TEXTINPUT into, with
actions suppressed for its duration by the same capture gate — text is a channel, not an action. The
editor exposes all of it through an Edit > Input Manager window
(applications/editor/source/input/input_manager_window.cpp): contexts and their actions with the
current binding, click-to-rebind with conflict flagging, and Reset to Defaults, persisted to
preferences.
The tick boundary (engine/domain/input/include/SushiEngine/input/tick_sample.hpp). Two
consumers read the input at two cadences. The editor and any immediate-mode UI read the per-frame
ActionSnapshot. The simulation reads a per-tick TickSample, and only ever as a reduced,
quantized value — because the fixed-step loop (App::advance) runs zero, one, or several ticks per
host frame, per-frame state cannot cross directly (a tap in a zero-tick frame would vanish; one
press in a two-tick hitch would fire twice).
TickSampleAccumulator resolves this: accumulate folds each frame’s snapshot, consume (called
once per tick, from Loop::App’s sample_command lambda or from a simulation that owns its own
fixed-step loop) hands out one sample and then clears the
edges and zeroes the relative axes it returned. Three laws hold by construction — edges stay
sticky until a tick consumes them, the first tick of a burst consumes the edge while later ticks
see level only, and relative axes (mouse deltas) sum since the last tick while absolute axes
(sticks) are latest-wins. A sub-frame tap survives because the DeviceRegistry folds
press/release edges independently of the final level, so the mapper’s button edges do not
depend on a frame-boundary level change.
quantize_axis maps a normalized value to a symmetric std::int16_t before it enters the
game’s Command, so InputHistory, rollback replay, and server reconciliation all operate on
bit-identical values — prediction misses from float jitter cannot exist. The manager stays on the
OS-event thread forever, and consume_tick_sample() returns a value snapshot, which is already
the thread-crossing currency the future sim-thread split prescribes.
Delivery to the world (ITickSampleSource). Neither the editor nor the player uses Loop::App;
both step Simulation::ISimulation through Frame::compose, and ISimulation::tick takes
wall-clock seconds and runs the fixed steps behind it. The tick boundary is therefore not observable
from a host, and a host that called consume_tick_sample per frame would break the accumulator’s
one contract. The delivery is inverted instead: ITickSampleSource
(engine/domain/input/include/SushiEngine/input/tick_sample.hpp) publishes consume_tick_sample
and reset_tick_samples, InputManager implements it by forwarding to its accumulator, and
ISimulation::set_tick_input_source binds it. RuntimeSimulation pulls exactly one sample at the
head of step_once. The setter is defaulted to a no-op and a null source is a working
configuration, which is what a headless run and every test that binds nothing are.
The source travels on FrameInput::tick_input, filled by both hosts’ FrameInput derivations and
bound by compose’s step 1 before its step 2 ticks, so a host never binds it directly — a rule
tools/layering/check_host_frame_ownership.py enforces by naming set_tick_input_source in its
OWNED_CALLS. compose also discards the unconsumed accumulation on the transition from a
non-ticking frame to a ticking one, keyed on ComposeState::ticked_last_frame: edges are sticky
until a tick consumes them, and a key pressed in a paused editor is host input rather than the
world’s first act after Play.
The one consumer today: user behaviors. step_once pulls one Input::TickSample per fixed
step, at the head of the step, so every stage that runs after it reads the same tick’s input; a null
source leaves the previous sample standing. The pulled sample, the fixed step, the tick index and
the driven entity (FrameInput::driven_entity, carried by compose’s step 1 through
set_driven_entity) are handed to every entity’s behaviors as one Simulation::TickContext, so a
behavior’s on_update is where an action held on this tick is read and, if it drives a body,
integrated on this tick. set_driven_entity names the entity without itself acting on it — no
built-in consumer reads it today, so naming one changes nothing until a behavior reads
TickContext::driven_entity for itself.
Layering. The one-way SushiEngine → SushiRuntime arrow is untouched — input never sees the
runtime. Headless examples and tests use ScriptedInputSource and link neither SDL nor
sushiengine_input_backend; only a binary that opens a window needs the compiled library.
SUSHIENGINE_BUILD_INPUT gates it (forced on by SUSHIENGINE_BUILD_EDITOR). All seven roadmap
phases and the design’s recorded follow-ons (buffering, gestures, replay, text) have landed; the
editor consumes the action snapshot for its shortcuts and tool keys and configures bindings
through its Input Manager window. The one deliberately-untouched seam is the viewport’s
camera-flight Editor::InputState fill, kept on ImGui because its per-mode latching
(right-mouse-held owns WASD) is viewport state, not a binding — a good seam the design keeps
as-is.

