Input Systems ()
A player on a French AZERTY keyboard, where the key in W's spot says Z, can't walk forward. A worn thumbstick, a gamepad's analog stick, makes the character drift while nobody touches it. Slow diagonal aim snaps to eight directions, and a special move fails although the player pressed the right buttons. All four are bugs in the input layer, the code between the operating system and gameplay that handles human interface devices (HID): keyboards, mice and gamepads. This page builds that layer in C++ and Rust as a small pipeline: collect the OS's input events, fold them into one snapshot per frame, clean up the analog values, and translate physical keys and buttons into game actions like Jump.
- Fold the OS's input events into one snapshot per frame, so a tap shorter than a frame still registers.
- Bind movement to physical key positions so WASD works on AZERTY and Dvorak, and feed a camera raw mouse motion.
- Read any gamepad through SDL or gilrs without the DirectInput shared-trigger trap.
- Write a scaled-radial dead zone, a response curve and a frame-rate-independent smoothing filter, and pick numbers for them.
- Put an action map between devices and gameplay, and add an input buffer so timing-sensitive moves come out.
01Where input fits
A player taps jump for 10 milliseconds. At 60 frames a second a frame lasts 16.7 ms, so if the game only asks "is the button held right now?" at the start of each frame, the whole tap can fall between two checks and the jump never happens. That's why input arrives two ways, and a good engine uses both. The platform layer pushes discrete events (key down, key up, mouse moved, gamepad connected) onto a queue, and the game loop also samples, or polls, each device's current state once per frame: is W held right now? where's the left stick?[1]
The usual shape: at the top of each frame, drain the event queue, fold the transitions into a per-frame snapshot plus an event list, and let gameplay read the snapshot. The events come from the platform layer, the code that owns the window and pumps the OS's message queue. Events are the source of truth for transitions: the exact down and up, even for a tap shorter than a frame. Polling is convenient for "held right now."
Worked example
Frame 1 starts at 0 ms and frame 2 at 16.7 ms. The jump button goes down at 3 ms and up at 13 ms. Polling at 0 ms and at 16.7 ms sees "up" both times, so a polling-only game drops the jump. The event queue holds a key-down at 3 ms and a key-up at 13 ms, so when frame 2 drains it, the snapshot records "pressed this frame" and "released this frame", and gameplay still sees the press.
02Scancodes vs keycodes
Your game binds forward to W. A player in France has an AZERTY keyboard, where the key in W's spot is labeled Z and the W key sits bottom-left. Whether forward still works under their left hand depends on which of two codes you bound. A identifies the physical key position, whatever the layout. A keycode, also called a virtual key, names the symbol the key produces under the current layout. SDL's keyboard guide is explicit: scancodes "are guaranteed to reference the physical location on the keyboard and not what is printed on it," while keycodes "name specific keys and don't care where on the user's keyboard they actually are"[3]. SDL's scancode values come straight from the USB HID usage tables, the standard numbering of key positions that USB keyboards report[2].
Worked example
The AZERTY player presses the key under their left middle finger, the one QWERTY labels W and AZERTY labels Z. SDL reports scancode SDL_SCANCODE_W (26, the HID usage ID for that position) and keycode SDLK_Z. Bound to the scancode, forward works. Bound to the keycode for W, nothing happens: the player has to reach for the bottom-left key, where QWERTY has Z, and strafe left with the key in QWERTY's Q spot.
Bind WASD to keycodes and the AZERTY player's keys in the WASD spots, labeled Z, Q, S and D, produce the wrong symbols for forward and strafe-left. Bind movement to scancodes and the keys under the left hand work on any layout. Symbolic shortcuts ("press I for inventory") want keycodes, so the letter matches the on-screen prompt[3]. winit draws the same line: physical_key (a KeyCode, the position) vs logical_key (a Key, the meaning), and its logical key is deliberately not affected by Ctrl, so Ctrl+C still reports the character "c"[4].
One more keyboard detail. Hold a key in a text box and, after a short delay, the letter repeats. That's auto-repeat: the OS keeps resending key-down events while the key stays held. It's a typing convenience, and gameplay ignores it. One-shot actions fire only when the event isn't a repeat (SDL's repeat flag, winit's KeyEvent.repeat), and held movement comes from polling state each frame[4].
03Raw mouse & pointer lock
In a first-person shooter (FPS), moving the mouse two inches should turn the camera by the same amount whether the player flicks or drags. The cursor doesn't work that way, so there are two mouse signals. The cursor position, for UI, goes through the OS's pointer acceleration (a quick flick moves the cursor farther than a slow drag over the same distance) and the Control Panel mouse-speed slider. Mouselook, steering the camera with the mouse, wants the opposite: raw, unaccelerated deltas, meaning how far the mouse itself moved since its last report.
On Windows, the Raw Input API hands you the device's unfiltered data as WM_INPUT messages, whose mouse deltas, unlike WM_MOUSEMOVE, are "not subject to the effects of the mouse speed set in the Control Panel's Mouse Properties sheet." Check the MOUSE_MOVE_ABSOLUTE flag, though: remote-desktop sessions and pen tablets report absolute positions instead of deltas[5]. winit exposes the raw deltas as DeviceEvent::MouseMotion. For the camera you also lock the cursor so it can't leave the window or stop at the screen edge. winit's CursorGrabMode::Locked pins it in place (you hide it yourself), while Confined only keeps it inside the window. Neither works everywhere (Locked isn't implemented on X11, Confined isn't on macOS), so engines try one and fall back to the other[6].
Worked example
A 1000 Hz mouse reports up to a thousand times a second while it moves: about 17 reports in each 16.7 ms frame at 60 fps (1000 / 60 = 16.7). The player sweeps right steadily at 3 counts per report, where a count is the mouse's own unit of distance. Over one frame that's about 50 counts (16.7 × 3). Sum the deltas and the camera turns by 50 counts' worth. Keep only the latest event and it turns by 3, 6% of what the hand did.
A 1000 Hz mouse delivers many move events per frame. Store only the latest one and you throw away most of the motion, so sum the deltas across the frame. The point of raw input is deltas without acceleration or speed scaling, which is what a camera wants; it doesn't make input arrive sooner. At high polling rates, GetRawInputBuffer drains the accumulated WM_INPUT events in one batch[5].
04Gamepads
Your players own Xbox, PlayStation and Switch controllers plus third-party pads, and each OS exposes them through different APIs. Think of one standard gamepad (two sticks, two triggers, a d-pad or four-way cross, plus face and shoulder buttons) and let a library map every real device onto it. The library matters because of the many backends underneath.
On Windows, XInput is the Xbox-controller API, and it reports the two triggers as independent axes. (Microsoft now recommends its newer GameInput API, a superset of XInput, DirectInput and Raw Input, for new code: GameInput overview.) DirectInput, the older DirectX joystick API, combines both triggers onto one shared axis when it reads an Xbox controller: one trigger moves the axis one way from center and the other moves it the opposite way, so pulling both fully reads the same as pulling neither. Microsoft notes this "does mean it is not possible to see all possible trigger combination values through DirectInput... To test the trigger values separately, you must use XInput"[7]. SDL hides all of it behind a standard model with separate left and right trigger axes, using a community mapping database[8]. Rust's gilrs does the same and, helpfully, names face buttons by position (South/East/North/West) to sidestep the Xbox-vs-Nintendo A/B swap[9].
Worked example
The player holds the left stick fully up and a quarter of the way right. SDL reports LEFTX = 8192 and LEFTY = −32768. Dividing by 32767, as the C++ below does, gives rawX = 0.25 and rawY = −1.00003. So up is negative Y in SDL, and the vector's length comes out at 1.03, slightly past full deflection, which is why §5 clamps the length to 1 before shaping it.
// SDL3: stick axes are int16, -32768 (up/left) to 32767 (down/right): Y grows DOWNWARD.
// Triggers are 0..32767.
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_GAMEPAD_ADDED)
gamepad = SDL_OpenGamepad(event.gdevice.which); // standard-mapped handle
}
if (gamepad) {
float rawX = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX) / 32767.0f;
float rawY = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY) / 32767.0f;
bool jump = SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_SOUTH);
}
// gilrs returns already-normalized f32 in [-1, 1].
while let Some(Event { id, event, .. }) = gilrs.next_event() {
if let EventType::Connected = event { active = Some(id); }
}
if let Some(pad) = active.map(|id| gilrs.gamepad(id)) {
let raw_x = pad.value(Axis::LeftStickX); // already -1.0..1.0
let raw_y = pad.value(Axis::LeftStickY);
let jump = pad.is_pressed(Button::South); // South == A on an Xbox pad
}
05Dead zones
Leave a worn controller on the desk and the character slowly walks off by itself. A thumbstick never rests at exactly zero, so you ignore a small region near the center, the . The shape of that region is a commonly botched piece of gamepad code, and Josh Sutphin's article is the usual reference[10].
- Axial (per-axis): zero each axis independently. This makes a square dead zone that clips diagonals: a gentle diagonal can fall inside the square on both axes and read as zero, or snap to a cardinal direction (straight up, down, left or right). Correct only for grid or 4-way movement.
- Radial: zero by the vector's length. A circular dead zone, no cardinal snapping, but the output jumps from 0 to a finite value the instant you cross the boundary.
- Scaled radial (the right default for free aim): after the radial cut, re-scale so output ramps from 0 at the boundary to 1 at full deflection.
Worked example
Use a dead zone of 0.25, the widget's default. XInput's recommended left-stick value, 7849 out of 32767, works out to 0.24, a starting point to tune rather than a rule[7]. Push the stick gently diagonal to (0.20, 0.20). Its length is 0.28, past the 0.25 radius, so the player clearly meant to move.
- Axial: each axis is 0.20, under 0.25, so both get zeroed. Output (0, 0): the push is ignored. At (0.30, 0.10) only y is zeroed, giving (0.30, 0): the diagonal snaps to straight right.
- Radial: 0.28 is outside the circle, so the raw (0.20, 0.20) passes through, length 0.28. Pull back to a length of 0.24 and the output is 0. It never takes a length between 0 and 0.25.
- Scaled radial: (0.28 − 0.25) / (1 − 0.25) = 0.04, pointing the same diagonal way: (0.03, 0.03). Movement starts from zero right at the edge.
The scaled-radial rule as a formula. It keeps the stick's direction and rebuilds its length so the output runs from 0 at the dead-zone edge to 1 at full deflection:
In words: take how far the stick is pushed, subtract the dead-zone radius, and divide by what's left of the range, so the length ramps from 0 right at the boundary to 1 at full deflection. Multiplying by the unit direction v̂ keeps the aim direction while the fraction supplies the new length. Without the divide the stick would jump straight to a finite value at the boundary; the re-scale is what lets you walk slowly. Clamp |v| to 1 first: many sticks report diagonals slightly outside the unit circle (the 1.03 in §4), which would otherwise produce outputs above 1. Hover any symbol to see what it stands for.
The per-axis square dead zone is the classic bug. Even with a radial cut, forgetting to re-scale means the stick jumps to a finite output the moment it leaves the dead zone, so slow movement is impossible. The formula above fixes both.
The widget runs all three rules on a live stick. Drag the blue dot to the example's (0.20, 0.20) and switch shapes:
Wrong answers, and why: a bigger square dead zone worsens diagonal clipping; a response curve changes sensitivity, not snapping; and both scancodes and keycodes carry up/down, they differ in physical-vs-symbolic meaning.
06Response curves & smoothing
A sniper scope in a console shooter needs tiny, precise turns from small stick movements, and a worn stick or a gyro sensor adds jitter on top. Two more shaping steps handle this, and both run on the stick's length after the dead zone. A response curve bends sensitivity: output = lengthk with k > 1 shrinks small pushes while full deflection stays at 1, so aim gets finer near the center. A low-pass filter smooths jitter by letting slow changes through and damping fast ones.
Worked example: the curve
A half-pushed stick has length 0.5. After the 0.25 dead zone that's (0.5 − 0.25) / 0.75 = 0.33. With k = 2 the curve squares it to 0.11. Full deflection still gives 1 (12 = 1), so the player keeps top speed, and a half push now moves at a third of the speed it did without the curve.
The filter is a single line, an exponentially weighted moving average: a running average where each step back in time multiplies a reading's weight by the same factor, 1 − α, so old readings fade out[16]. It computes each frame's output as a blend of the new reading and last frame's output, so a sudden jump only moves the result part of the way:
In words: each frame, move the smoothed value a fraction α of the way from where it was toward the new reading. At α = 1 there's no smoothing (output equals input); push α toward 0 and the output leans on its own history, so it's smoother but trails the input. The subscript i−1 means "the value from the previous frame".
Worked example: the filter
With α = 0.2, the player slams the stick from 0 to 1. The filtered value goes 0.20, 0.36, 0.49 over the first three frames and passes 90% on frame 11, about 183 ms at 60 frames a second. Run the same α = 0.2 at 144 Hz and it still passes 90% on frame 11, but that's now 76 ms: the smoothing got weaker because the frames got shorter. The compensated weight at 144 Hz is 1 − 0.860/144 = 0.089, which takes 25 frames, about 174 ms, close to the 60 Hz feel.
A smaller α smooths harder but makes the filtered signal lag the input more. Use it for drifty sticks or gyro; avoid it on anything where input lag is felt directly, like a mouselook camera. If your frame time varies, compute α each frame from a smoothing time constant τ, α = 1 − e−Δt/τ, or the smoothing strength drifts with the frame rate. A τ of about 75 ms gives α = 0.2 at 60 Hz and about 0.09 at 144 Hz, the numbers in the example. Unreal's Enhanced Input ships both steps as per-binding modifiers (an exponential response curve and a smoothing modifier)[12]. Unity's Input System Processors cover dead zones, scaling and inversion, and anything more is a custom processor[13].
The widget feeds a noisy stick signal through both steps:
07Action maps
A player wants Jump on F instead of Space, and the same game has to work with a gamepad. If gameplay code checks for the Space key directly in dozens of places, both requests mean touching all of them. So don't let gameplay name keys. Let it ask "is Jump triggered?" and answer that through an , a table from physical inputs to named actions, scoped by context (menu, on foot, in a vehicle). This is what makes rebinding, multi-device support, and accessibility remapping possible.
Worked example
The on-foot map holds three rows: scancode 26 (W) → MoveForward, scancode 44 (Space) → Jump, and gamepad button South → Jump. The player presses South on the pad; the lookup finds Jump, and gameplay sees Jump without knowing a pad was involved. The player rebinds Jump to F: the Space row becomes scancode 9 (F), and no gameplay code changes.
Steam Input states the principle bluntly: in its native mode the game receives input "in the form of 'actions'" and "has no knowledge of what actual inputs are driving said actions, simply that the actions are happening"[11]. Unreal's Enhanced Input models it as Input Actions plus Mapping Contexts you push and pop, with per-binding modifiers and triggers[12]. Unity's Input System uses Action Maps with bindings and processors[13]. The minimal data shape is a tagged physical input, so one action can bind a key or a pad button:
enum class Action { MoveForward, Jump, Fire };
struct PhysicalInput { // tagged so one action binds keyboard OR pad
enum class Source { Scancode, GamepadButton } source;
int code; // SDL_Scancode (a position) or a button
bool operator==(const PhysicalInput&) const = default;
};
// One context; swap the whole map for menu/vehicle.
std::unordered_map<PhysicalInput, Action, Hash> bindings;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Action { MoveForward, Jump, Fire }
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum PhysicalInput { // one action binds keyboard OR pad
Key(KeyCode), // physical position, survives AZERTY
Pad(Button),
}
// One context; swap the whole map for menu/vehicle.
let bindings: HashMap<PhysicalInput, Action> = HashMap::new();
08Input buffering
A fighting-game player swears they did the motion, but the special move didn't come out. The directions may have been right and simply landed a few frames too far apart. Input buffering widens timing windows so the game registers the input the player meant. It's a feel feature that adds no latency: it changes when an input is accepted, and the result still renders as soon as it would have.
- Motion windows. A fighting-game quarter-circle, rolling the stick from down to forward, counts if its directions land within an N-frame window. Community frame-data analysis puts Street Fighter 6's quarter-circle window at 11 frames, one to three frames stricter than Street Fighter V's windows, and reports that SF6 reads inputs three times per frame where SFV read once[15].
- Coyote time. A jump still fires for a few frames after you walk off a ledge. Celeste's designer documents this kind of forgiveness (coyote time, jump buffering, corner correction) as deliberate feel[14].
- Jump buffering. A jump pressed just before landing fires on the landing frame instead of being dropped[14].
Worked example
Take an 11-frame window, SF6's quarter-circle figure, about 183 ms at 60 fps. The player presses Down on frame 100, Forward on frame 106 and Punch on frame 114. The window reaches back 11 frames from the Punch, to frame 103, so the Down on frame 100 falls outside it and the move fails, even though the order was right. Widen the window to 15 frames and it reaches back to frame 99: the same presses now connect.
The widget is that check. The move fires only if the sequence lands inside the buffer window:
09Sampling & latency
A press that lands just after the frame's input check waits a whole frame before the game sees it. Polling once per frame can also miss a transition shorter than the frame entirely, like the 10 ms tap in §1. The event stream carries every transition with a timestamp, which is why fast-twitch genres lean on events (and on sub-frame sampling, like SF6's three reads per frame) rather than per-frame polling alone.
Worked example
Presses arrive at random moments, so on average a press waits half a frame for the next sample. At 60 fps that's 8.3 ms on average and 16.7 ms at worst. At 144 Hz it's 3.5 ms and 6.9 ms.
That wait is one stage of input latency, the delay from a physical press to the result on screen. The render queue and the display add their own delays, so sampling adds up to about half a frame on average (a full frame worst case) on top of the rest. High-frequency mice deliver many events per frame; accumulate them rather than taking the last (§3). Timestamps let you order inputs and age the buffer from §8, which is a timestamped ring of recent inputs.
10Pitfalls
11What's next
That completes the runtime spine: a loop, a window, and input. The next phase is resources, getting data off disk and into the engine fast. It starts with File Streaming (asynchronous reads, priority streaming and the open-world tile problem), followed by the Asset Pipeline and Compression. The renderer comes after that. The full path is on the series hub.
- Jason Gregory. Game Engine Architecture, "Human Interface Devices (HID)." gameenginebook.com. Reading and processing input in an engine: events vs polling, dead zones.
- SDL. SDL_Scancode. wiki.libsdl.org. The scancode enum, whose values are based on the USB HID usage tables.
- SDL. Best Keyboard Practices. wiki.libsdl.org. Bind movement to scancodes (WASD survives AZERTY), symbolic shortcuts to keycodes.
- winit. KeyEvent and the keyboard module. docs.rs/winit.
physical_keyvslogical_key, therepeatflag, and logical key being unaffected by Ctrl. - Microsoft. "Raw Input Overview" and "RAWMOUSE structure." learn.microsoft.com, learn.microsoft.com. Batched
GetRawInputBufferreads for 1000 Hz mice; relative vs absolute motion flags; raw deltas not subject to the Control Panel mouse speed. - winit. CursorGrabMode. docs.rs/winit.
Locked(pins the cursor for mouselook) vsConfined(keeps it in the window), and the platforms where each returnsNotSupported. - Microsoft. "Comparison of XInput and DirectInput Features." learn.microsoft.com. DirectInput combines the triggers onto one axis; XInput keeps them independent; XInput dead-zone constants.
- SDL. SDL_GamepadAxis / SDL_GetGamepadAxis. wiki.libsdl.org. The standard gamepad model with separate left/right trigger axes over any backend.
- gilrs. Crate documentation. docs.rs/gilrs. Cross-platform gamepad input in Rust; normalized axes; positional face-button names (South/East/North/West).
- Josh Sutphin. "Doing Thumbstick Dead Zones Right." joshsutphin.com. Axial vs radial vs scaled-radial dead zones and the re-scaling formula.
- Valve. Steam Input "General Concepts." partner.steamgames.com. Native mode delivers actions, not the inputs driving them; action sets group actions by context, one active per device.
- Epic Games. "Enhanced Input in Unreal Engine." dev.epicgames.com. Input Actions, Mapping Contexts, and per-binding Modifiers and Triggers. The plugin's API reference lists the modifier classes, including
UInputModifierDeadZone,UInputModifierResponseCurveExponentialandUInputModifierSmooth. - Unity Technologies. Input System: Action Bindings. docs.unity3d.com. Action Maps, bindings, deadzone/scale Processors, composite bindings.
- Maddy Thorson. "Celeste & Forgiveness." maddymakesgames.com. Coyote time, jump buffering, and corner correction as deliberate feel features.
- EventHubs, reporting Loïc "WydD" Petit's input analysis. "Breakdown of why players struggle with SF6 inputs." eventhubs.com. SF6's 11-frame quarter-circle buffer, windows one to three frames stricter than SFV's, and input read three times per frame.
- "Low-pass filter," Wikipedia (discrete-time / exponential moving average). en.wikipedia.org. The one-pole filter
y = αx + (1−α)y_prevand the smoothing-versus-lag trade.