Skip to content

Input

Everything a player presses goes through actions (kke::InputMap), modelled on Escape from Tarkov's binding screen. Games never ask "is W down?"; they ask "how much is move?", "was jump pressed?", "is crouch on?". Players can bind anything to anything, any number of times, and nothing is hardcoded except Esc (so nobody can lock themselves out of the menu).

Piece What it does Where
kke::InputDevices Every keyboard, mouse, gamepad and raw joystick SDL sees; stable identities; sensors; rumble/LED; rebinding capture engine/include/kke/InputDevices.h
kke::InputMap Actions, bindings, triggers, chords, analog shaping, contexts, JSON engine/include/kke/InputMap.h (pure logic, 13 tests)
kke::InputModule Owns both, one map per local player, loads/saves input.json, standard character actions, left-handed preset, UI navigation actions, virtual test devices engine/include/kke/modules/InputModule.h
Input screen Live tester for every peripheral + the bindings editor rmlui_demo → Input tab (games/rmlui_demo/InputScreen.*, ui/input.rml)

Bindings

A binding = a source, optional modifiers, a trigger, and shaping.

Sources (anything SDL reports): keyboard keys (by physical position, named by your layout), mouse buttons 1-32, wheel up/down/left/right, mouse motion X/Y, gamepad buttons (incl. paddles R4/L4/R5/L5, QAM/Share/Misc, touchpad click), gamepad axes (whole or either half), raw joystick buttons, axes (whole or half) and hat directions on any device (HOTAS, wheels, pedals, button boxes), gyro pitch/yaw/roll, accelerometer, touchpad X/Y.

Triggers (per binding, so one key can do several things):

Trigger Fires Example
Press on the down edge; held while down Jump
Release on the up edge throw on release
Hold once held for holdTime (default 0.35 s); held until release Tarkov hold-to-lean
Tap on release if shorter than tapTime reload
DoubleTap second press within doubleTapWindow quick reload (drop mag)
Continuous the whole time (and analog value) move, sprint (hold)
Toggle each press flips it crouch, sprint on L3

Rules that make this robust: - Tap waits for a double tap only when that same input also has a DoubleTap binding, and fires when the window runs out. A double tap never also fires the single tap. - Tap + Hold on one key work together (tap = toggle lean, hold = hold lean): the tap fires only if released before tapTime, the hold only after holdTime. - Chords: modifiers are any sources that must be held (Ctrl+E, LB+A, a HOSAS "shift" button, a pedal). The most specific chord wins: while Alt is held, Alt+T fires and a plain T binding stays quiet. - Axes and buttons interchange: half a stick or a trigger past its threshold presses a button action; keys drive axes with a scale (W = +1, S = -1 on move.y). Two keys held on one axis sum and clamp. - Shaping on analog sources: deadzone (radial for stick pairs), a response curve (value^curve: >1 = finer near the centre), invert, and scale (sensitivity). - Contexts: every action belongs to one ("game", "ui", "vehicle"...); disable a context and its bindings go silent (menus open, rebinding in progress, typing in a text field). - Conflicts (same input, same modifiers, same trigger, two actions in one context) are shown in red but allowed: sometimes it's what you want.

Devices and identical twins (HOSAS)

Each device gets a stable key: vendor:product + serial number, or the USB port (Linux: the sysfs topology, e.g. pci0000:00/0000:00:14.0/usb1/1-2/1-2:1.0, not /dev/input/eventN, which changes between boots; Windows: the HID instance path), or the path, or the name + an ordinal. Bindings to a raw joystick name that device, so two identical sticks keep separate bindings.

Identical devices are grouped and numbered #1/#2 by port, so the same stick stays #1 as long as it stays in the same USB port. In the Input screen, touch a stick and its card lights up; give it a name ("Left stick"); if you swapped ports, press Swap twin and the two trade names and bindings. Names and swaps live in input.json under devices.

Gamepads are also raw joysticks, so buttons SDL doesn't map into its standard layout (extra paddles on some pads) are still bindable as raw buttons; the capture prefers the standard name when there is one.

Steam Controller (2026): SDL 3.4 maps its R4/L4/R5/L5 as paddles and QAM as Misc 1, with gyro. KKE turns on SDL's Steam HIDAPI driver (SDL_HINT_JOYSTICK_HIDAPI_STEAM). If Steam Input is running for the game you'll see "Steam Virtual Gamepad" instead (the real device is hidden by Steam): turn Steam Input off for the game to use the gyro and paddles directly.

Rebinding (capture)

Click + Add (or press A on it). Hold any modifiers first, press the input, release: the last input pressed becomes the binding and the others still held become its modifiers. Axis actions also accept moving the mouse, moving a stick (binds both axes of it) or turning the controller (gyro). Esc cancels. The button that opened the capture (pad A) is ignored until released. While listening, the game and UI contexts are off so nothing else reacts.

Left-handed

InputModule::mirrorKeyboard() (the Left-handed keys button) moves every keyboard binding to its mirror image across the keyboard: WASD -> O K L ;, Q/E -> P/I, R -> U, Left Shift/Ctrl/Alt -> Right Shift/Ctrl/Alt, 1 -> 0. Left/right move directions are swapped back so left stays left. Mouse and controller bindings are untouched (swap mouse buttons in the OS as usual). Press again to mirror back. Or bind every action by hand.

Controller navigation of menus

The "ui" context has ui.up/down/left/right (D-pad and left stick), ui.accept (A), ui.back (B), ui.prev/next (LB/RB). UiModule turns them into RmlUi focus moves and clicks (spatial navigation: RCSS nav: auto, and tab-index: auto so Accept clicks), with key repeat after 0.4 s. Keys aren't bound to ui.* by default because the keyboard already reaches RmlUi directly (arrows, Enter, Tab, Esc); add them for a custom layout. Back and prev/next are the game's to interpret.

Touch screens

One finger is the mouse: SDL's touch-to-mouse emulation turns it into mouse motion and left-button events, so ImGui, RmlUi and click/drag code work with a finger unchanged. Two fingers are gestures: kke::TouchGestures (kke/TouchGestures.h) turns raw SDL_EVENT_FINGER_* into two-finger drag, pinch and twist, and OrbitCameraModule uses them (drag turns the view, pinch zooms, twist spins it). The sandbox's Play mode also plays with a gamepad as a pointer; see docs/PLAY_TO_MAKE.md "Fingers and controllers".

Split screen

InputModule(path, players) creates one map per player; assignDevices(player, {refs}) gives each player their devices. A binding to "any gamepad" then means "any of this player's gamepads".

Drawing: Application::views() takes one camera per player, each drawn into its part of the window (kke/Viewports.h: splitScreen(1..4), side by side or stacked for two, quarters for three and four, and pictureInPicture(corner) for a small view over the others, like a map or a rear-view mirror). Empty means the one camera over the whole window, as before. Every module's render() runs once per view with that view's camera, viewport and RenderContext::viewIndex; a module that writes camera-dependent GPU data in render() keeps one copy per view (ModelModule's culled instance lists, DebugDraw's camera-facing lines). The UI overlay covers the whole window once. Limits: at most 4 views, and the screen-space liquid surface (FluidSurface, melt demo) draws only without split screen.

In kke_demo (Split screen panel, or KKE_SPLIT=2..4): players 2-4 each take the next controller, in the order they were plugged in; player 1 keeps the keyboard, mouse and every controller nobody else took. A player without a controller runs the parkour lane on their own, so split screen can be seen without any controllers. Extra players play in third person without foot/hand IK (like network players). "Overhead view" (KKE_OVERHEAD=1) adds a picture-in-picture of player 1 from above.

Testing without hardware

KKE_VIRTUAL_INPUT=hosas,pad attaches two identical virtual flight sticks and a virtual gamepad with gyro (SDL virtual joysticks); KKE_VIRTUAL_INPUT_ANIMATE=1 moves them (sticks sweep, buttons cycle, gyro turns; the pad avoids menu buttons). CI runs every demo this way. KKE_LEFT_HANDED=1 starts kke_demo mirrored.

Files

input.json next to the game:

{ "version": 1,
  "devices": { "aliases": { "231d:0200/port:...1-2:1.0": "Left stick" }, "swapped": {} },
  "players": [ { "version": 1, "bindings": [
      { "action": "jump", "source": { "kind": "key", "code": 44 }, "trigger": "press" },
      { "action": "reload.quick", "source": { "kind": "key", "code": 21 }, "trigger": "doubleTap" },
      { "action": "ammo.check", "source": { "kind": "key", "code": 23 },
        "modifiers": [ { "kind": "key", "code": 226 } ], "trigger": "press" } ] } ] }

JSON or YAML (input.yml works too, and stays YAML when the engine saves it; see DATA_FILES.md). Written atomically (temp file + rename). Unknown actions and malformed entries are skipped, never fatal; actions a file doesn't mention keep their defaults (so a game update can add actions without resetting anyone's bindings).