All tutorials Mighty Professional
Build a Game Engine · The runtime spine

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.

Time~55 min LevelBeginner to mid PrereqsHelpful, not required: the Game Loop (input is sampled once per frame) and Platform & Window (the OS events come from there). StackC++ (SDL3) · Rust (gilrs / winit)
◂ Build a Game Engine Phase 3 · The runtime spine Next · File Streaming ▸
What you'll be able to do by the end

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 movement to scancodes, shortcuts to keycodes

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.

Accumulate deltas, don't overwrite

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.

Read a gamepad: poll for connect, sample state per frame
// 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].

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.

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:

eq. 1 · scaled-radial dead zone vout = v̂ · ( |v| − deadzone1 − deadzone )

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.

Two traps: the square clips diagonals, and you must re-scale

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:

The blue dot and line are the raw stick, the green line is the shaped output, and the red square or circle is the dead zone. The readout beside the stick (below it on a phone) gives both vectors and their lengths; let go and the stick orbits on its own. In Axial mode, raw (0.20, 0.20) reads output (0.00, 0.00), and (0.30, 0.10) snaps to (0.30, 0.00), straight right. Radial passes (0.20, 0.20) through untouched, but a raw length of 0.26 gives 0.26 and 0.24 gives 0.00: the output jumps at the boundary. Scaled radial turns the same (0.20, 0.20) into (0.03, 0.03), length 0.04, and ramps from 0 at the circle to 1 at the rim, which is what lets you walk slowly.

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:

eq. 2 · one-pole low-pass yi = α · xi + (1 − α) · yi−1, 0 ≤ α ≤ 1

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.

Smoothing trades latency for smoothness

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:

Top: the response curve, with raw length across and output up; the dotted diagonal is k = 1. Drag k to 2.0 and the curve sags below the diagonal: small pushes give smaller outputs, so aim is finer near the center, while full deflection still reaches the top corner. Bottom: a noisy raw signal (blue) and its filtered output (violet), scrolling. At α = 1.00 the two lines coincide. Drop α to 0.05, the minimum, and the violet line loses the fast wiggle and runs behind every swing. That trailing is the latency you trade for smoothness. α is the weight per 60 Hz step, compensated for the display's real frame time, so the smoothing looks the same on a 144 Hz screen.

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:

Physical input → action
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.

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:

The timeline scrolls left with "now" at the right edge, and the shaded box is the buffer window. Each press drops a marker. The target is a quarter-circle: Down, then Forward, then Punch. Pressing Punch judges the window that ends at that press (the "judged window" bracket): a green "MOVE! quarter-circle" if Down and Forward both landed inside it in order, a red "no Down in window" or "no Forward in window" if not. Press them too slowly and a correct sequence still fails, because buffering checks timing as well as order. The example's spacing (Forward 6 frames after Down, Punch 8 frames after that) gives "no Down in window" at 11 f and connects at 15 f. With a widget control focused, the keyboard is faster than clicking: S or ↓ for Down, D or → for Forward, J for Punch. At 60 fps, 11 frames is about 183 ms.

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

WASD moves wrong on AZERTYMovement bound to keycodes (symbols) instead of scancodes (positions).
Diagonal aim snaps / diesA per-axis square dead zone clips diagonals; use scaled-radial.
Stick jumps to a valueDead zone cut without re-scaling; output never ramps from zero.
Triggers read as one axisReading an Xbox pad through DirectInput; use XInput or SDL/gilrs.
Lost mouse motionOverwriting the per-frame delta instead of summing 1000 Hz events.
Camera feels laggyA low-pass filter on mouselook; smoothing trades latency for smoothness.
Held key auto-firesActing on OS key-repeat events for one-shot actions; check the repeat flag.
Can't rebind keysGameplay names physical keys directly instead of going through actions.

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.

  1. Jason Gregory. Game Engine Architecture, "Human Interface Devices (HID)." gameenginebook.com. Reading and processing input in an engine: events vs polling, dead zones.
  2. SDL. SDL_Scancode. wiki.libsdl.org. The scancode enum, whose values are based on the USB HID usage tables.
  3. SDL. Best Keyboard Practices. wiki.libsdl.org. Bind movement to scancodes (WASD survives AZERTY), symbolic shortcuts to keycodes.
  4. winit. KeyEvent and the keyboard module. docs.rs/winit. physical_key vs logical_key, the repeat flag, and logical key being unaffected by Ctrl.
  5. Microsoft. "Raw Input Overview" and "RAWMOUSE structure." learn.microsoft.com, learn.microsoft.com. Batched GetRawInputBuffer reads for 1000 Hz mice; relative vs absolute motion flags; raw deltas not subject to the Control Panel mouse speed.
  6. winit. CursorGrabMode. docs.rs/winit. Locked (pins the cursor for mouselook) vs Confined (keeps it in the window), and the platforms where each returns NotSupported.
  7. 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.
  8. SDL. SDL_GamepadAxis / SDL_GetGamepadAxis. wiki.libsdl.org. The standard gamepad model with separate left/right trigger axes over any backend.
  9. gilrs. Crate documentation. docs.rs/gilrs. Cross-platform gamepad input in Rust; normalized axes; positional face-button names (South/East/North/West).
  10. Josh Sutphin. "Doing Thumbstick Dead Zones Right." joshsutphin.com. Axial vs radial vs scaled-radial dead zones and the re-scaling formula.
  11. 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.
  12. 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, UInputModifierResponseCurveExponential and UInputModifierSmooth.
  13. Unity Technologies. Input System: Action Bindings. docs.unity3d.com. Action Maps, bindings, deadzone/scale Processors, composite bindings.
  14. Maddy Thorson. "Celeste & Forgiveness." maddymakesgames.com. Coyote time, jump buffering, and corner correction as deliberate feel features.
  15. 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.
  16. "Low-pass filter," Wikipedia (discrete-time / exponential moving average). en.wikipedia.org. The one-pole filter y = αx + (1−α)y_prev and the smoothing-versus-lag trade.

See also