Input systems
Never wire gameplay to raw keys. Map physical inputs (a key, a button, a touch)
to named actions (jump, interact, move), and let gameplay read actions.
That one indirection gives you rebinding, multi-device support, and accessibility
almost for free. This skill is the engine-neutral architecture; bind it to
unity-input-system, unreal-enhanced-input, or Godot's InputMap.
When to use
- Use to design an input layer: actions, bindings, multiple devices, and a
rebinding UI with conflict detection and saved bindings.
- Use to add analog handling (deadzones, sensitivity) and game-feel features
(input buffering, coyote time).
- Use to make controls accessible (full remapping, hold-vs-toggle, sensitivity,
no required simultaneous presses).
When not to use: for an engine's concrete input package/API, use
unity-input-system, unreal-enhanced-input, or Godot's InputMap. For the
movement/jump physics the buffer feeds, see physics-tuning and the engine
movement skill. Persisting bindings to disk is save-systems.
Core workflow
- Define actions, not keys. Gameplay asks "is
jump pressed?", never "is
Space pressed?". Actions are the stable contract; bindings are data.
- Bind per device. Each action holds bindings for keyboard, gamepad, and
touch. The active device is whichever last sent input; swap UI prompts to match.
- Read the right edge. Use pressed-this-frame (edge) for discrete actions
(jump, interact) and held (level) for continuous ones (move, aim). Confusing
the two causes double-fires or missed presses.
- Filter analog input. Apply a deadzone to sticks/triggers so resting drift
reads as zero, and scale sensitivity/curve to taste.
- Buffer for feel. Remember a pressed action for a short window so a slightly
early press still fires (input buffering); allow a jump shortly after leaving a
ledge (coyote time).
- Make rebinding first-class. A UI that captures the next input, detects
conflicts, and persists bindings — and a reset-to-default. Save via
save-systems.
- Verify on every device and with rebinds: keyboard, gamepad, touch; rebind
an action mid-game and confirm gameplay and prompts follow.
Patterns
1. Actions over raw keys; edge vs held
# Gameplay reads ACTIONS. The mapping from key/button to action lives in data.
# Discrete (edge): fire once on the press frame.
if Input.is_action_just_pressed("jump"):
try_jump()
# Continuous (held): read every frame as an axis.
var move := Input.get_axis("move_left", "move_right") # -1..1
player.velocity.x = move * RUN_SPEED
# RIGHT: name actions ("jump"); rebinding/devices just change the binding data.
# WRONG: `if Input.is_key_pressed(KEY_SPACE)` — unrebindable, keyboard-only,
# and `is_key_pressed` is a held check that would re-fire jump every frame.
Engine equivalents: Godot InputMap + Input.is_action_just_pressed; Unity
Input System InputAction / action maps; Unreal Enhanced Input Input Actions +
Input Mapping Contexts.
2. Analog deadzone and sensitivity
# Raw sticks never rest at exactly zero. Apply a RADIAL deadzone (on the vector
# length), not per-axis, so diagonals aren't clipped into the axes.
func apply_deadzone(stick: Vector2, dead := 0.2, sens := 1.0) -> Vector2:
var mag := stick.length()
if mag < dead:
return Vector2.ZERO # inside deadzone -> no movement
# Rescale so motion ramps from 0 at the edge of the deadzone, not from `dead`.
var scaled := (mag - dead) / (1.0 - dead)
return stick.normalized() * pow(scaled, sens) # sens>1 = finer near center
# WRONG: clamping each axis separately — it carves a square hole and snaps to axes.
3. Input buffering + coyote time (forgiving, responsive feel)
# Buffer: a jump pressed slightly BEFORE landing still triggers on touchdown.
# Coyote: a jump pressed slightly AFTER walking off a ledge still works.
const BUFFER := 0.12 # seconds an early press stays "remembered"
const COYOTE := 0.10 # seconds after leaving ground you can still jump
var _buffer_timer := 0.0
var _coyote_timer := 0.0
func _physics_process(dt):
_buffer_timer -= dt
_coyote_timer = COYOTE if is_on_floor() else _coyote_timer - dt
if Input.is_action_just_pressed("jump"):
_buffer_timer = BUFFER # remember the press
if _buffer_timer > 0.0 and _coyote_timer > 0.0:
velocity.y = JUMP_VELOCITY
_buffer_timer = 0.0; _coyote_timer = 0.0 # consume both so it fires once
4. Rebinding with conflict detection
# Capture the next physical input, reject duplicates, then persist.
func rebind(action: String, event: InputEvent) -> bool:
for other in actions: # conflict check across actions
if other != action and binding_of(other) == event:
return false # already used -> let UI warn/swap
set_binding(action, event) # engine: erase old + add new event
save_bindings() # persist (see save-systems)
return true
# Always provide "reset to defaults", and never let the player unbind a key they
# need to reach the menu without an alternative.
Pitfalls
- Hardcoding keys in gameplay blocks rebinding, locks out gamepad/touch, and
scatters input logic. Read named actions only.
- Edge vs held confusion: using a held check for jump re-fires every frame;
using an edge check for movement drops held input. Match the check to the action.
- Per-axis deadzones clip diagonal stick input and snap movement to the axes.
Use a radial deadzone on the vector magnitude.
- No buffering/coyote time makes tight platformers feel unfair even when the
physics are correct — players "clearly pressed jump". Add small windows.
- Rebinding without conflict handling lets two actions share a key, or strands
the player by unbinding menu access. Detect conflicts; guarantee a way back.
- Not swapping prompts on device change shows "Press Space" to a gamepad
player. Track the last-used device and switch glyphs.
- Ignoring accessibility: required simultaneous presses, no remap, fixed
sensitivity, hold-only actions. Offer remap, toggle-vs-hold, and sensitivity.
- Reading input in the wrong loop: poll held state in the physics step for
consistent movement; capture discrete presses so none are missed between frames.
References
references/buffering-and-accessibility.md — buffering/coyote tuning, jump feel
(variable height, apex), device detection and prompt swapping, touch controls,
and an accessibility checklist (remap, toggle/hold, sensitivity, latency).
Related skills
unity-input-system, unreal-enhanced-input — concrete engine input APIs
(Godot uses InputMap + the Input singleton).
save-systems — persist custom key bindings and input settings.
physics-tuning — the movement the buffer/coyote windows feed into.
platformer, fps-shooter — genres whose feel depends on input handling.
1---2name: input-systems3description: Architect game input — action mapping (abstracting keys into named actions), rebinding with conflict detection and persistence, multi-device support (keyboard, gamepad, touch), analog deadzones, and feel features like input buffering and coyote time, plus accessibility. Engine-neutral. Use when the user mentions input mapping, rebind controls, gamepad support, deadzone, input buffering, coyote time, or accessible controls.4---5
6# Input systems
7
8Never wire gameplay to raw keys. Map physical inputs (a key, a button, a touch)
9to named **actions** (`jump`, `interact`, `move`), and let gameplay read actions.
10That one indirection gives you rebinding, multi-device support, and accessibility
11almost for free. This skill is the engine-neutral architecture; bind it to
12`unity-input-system`, `unreal-enhanced-input`, or Godot's `InputMap`.
13
14## When to use
15
16- Use to design an input layer: actions, bindings, multiple devices, and a
17 rebinding UI with conflict detection and saved bindings.
18- Use to add analog handling (deadzones, sensitivity) and game-feel features
19 (input buffering, coyote time).
20- Use to make controls accessible (full remapping, hold-vs-toggle, sensitivity,
21 no required simultaneous presses).
22
23**When *not* to use:** for an engine's concrete input package/API, use
24`unity-input-system`, `unreal-enhanced-input`, or Godot's InputMap. For the
25movement/jump *physics* the buffer feeds, see `physics-tuning` and the engine
26movement skill. Persisting bindings to disk is `save-systems`.
27
28## Core workflow
29
301. **Define actions, not keys.** Gameplay asks "is `jump` pressed?", never "is
31 Space pressed?". Actions are the stable contract; bindings are data.
322. **Bind per device.** Each action holds bindings for keyboard, gamepad, and
33 touch. The active device is whichever last sent input; swap UI prompts to match.
343. **Read the right edge.** Use *pressed-this-frame* (edge) for discrete actions
35 (jump, interact) and *held* (level) for continuous ones (move, aim). Confusing
36 the two causes double-fires or missed presses.
374. **Filter analog input.** Apply a deadzone to sticks/triggers so resting drift
38 reads as zero, and scale sensitivity/curve to taste.
395. **Buffer for feel.** Remember a pressed action for a short window so a slightly
40 early press still fires (input buffering); allow a jump shortly after leaving a
41 ledge (coyote time).
426. **Make rebinding first-class.** A UI that captures the next input, detects
43 conflicts, and persists bindings — and a reset-to-default. Save via
44 `save-systems`.
457. **Verify on every device** and with rebinds: keyboard, gamepad, touch; rebind
46 an action mid-game and confirm gameplay and prompts follow.
47
48## Patterns
49
50### 1. Actions over raw keys; edge vs held
51
52```gdscript
53# Gameplay reads ACTIONS. The mapping from key/button to action lives in data.
54# Discrete (edge): fire once on the press frame.
55if Input.is_action_just_pressed("jump"):
56 try_jump()
57# Continuous (held): read every frame as an axis.
58var move := Input.get_axis("move_left", "move_right") # -1..1
59player.velocity.x = move * RUN_SPEED
60# RIGHT: name actions ("jump"); rebinding/devices just change the binding data.
61# WRONG: `if Input.is_key_pressed(KEY_SPACE)` — unrebindable, keyboard-only,
62# and `is_key_pressed` is a held check that would re-fire jump every frame.
63```
64
65Engine equivalents: Godot `InputMap` + `Input.is_action_just_pressed`; Unity
66Input System `InputAction` / action maps; Unreal Enhanced Input `Input Actions` +
67`Input Mapping Contexts`.
68
69### 2. Analog deadzone and sensitivity
70
71```gdscript
72# Raw sticks never rest at exactly zero. Apply a RADIAL deadzone (on the vector
73# length), not per-axis, so diagonals aren't clipped into the axes.
74func apply_deadzone(stick: Vector2, dead := 0.2, sens := 1.0) -> Vector2:
75 var mag := stick.length()
76 if mag < dead:
77 return Vector2.ZERO # inside deadzone -> no movement
78 # Rescale so motion ramps from 0 at the edge of the deadzone, not from `dead`.
79 var scaled := (mag - dead) / (1.0 - dead)
80 return stick.normalized() * pow(scaled, sens) # sens>1 = finer near center
81# WRONG: clamping each axis separately — it carves a square hole and snaps to axes.
82```
83
84### 3. Input buffering + coyote time (forgiving, responsive feel)
85
86```gdscript
87# Buffer: a jump pressed slightly BEFORE landing still triggers on touchdown.
88# Coyote: a jump pressed slightly AFTER walking off a ledge still works.
89const BUFFER := 0.12 # seconds an early press stays "remembered"
90const COYOTE := 0.10 # seconds after leaving ground you can still jump
91var _buffer_timer := 0.0
92var _coyote_timer := 0.0
93
94func _physics_process(dt):
95 _buffer_timer -= dt
96 _coyote_timer = COYOTE if is_on_floor() else _coyote_timer - dt
97 if Input.is_action_just_pressed("jump"):
98 _buffer_timer = BUFFER # remember the press
99 if _buffer_timer > 0.0 and _coyote_timer > 0.0:
100 velocity.y = JUMP_VELOCITY
101 _buffer_timer = 0.0; _coyote_timer = 0.0 # consume both so it fires once
102```
103
104### 4. Rebinding with conflict detection
105
106```gdscript
107# Capture the next physical input, reject duplicates, then persist.
108func rebind(action: String, event: InputEvent) -> bool:
109 for other in actions: # conflict check across actions
110 if other != action and binding_of(other) == event:
111 return false # already used -> let UI warn/swap
112 set_binding(action, event) # engine: erase old + add new event
113 save_bindings() # persist (see save-systems)
114 return true
115# Always provide "reset to defaults", and never let the player unbind a key they
116# need to reach the menu without an alternative.
117```
118
119## Pitfalls
120
121- **Hardcoding keys** in gameplay blocks rebinding, locks out gamepad/touch, and
122 scatters input logic. Read named actions only.
123- **Edge vs held confusion**: using a held check for jump re-fires every frame;
124 using an edge check for movement drops held input. Match the check to the action.
125- **Per-axis deadzones** clip diagonal stick input and snap movement to the axes.
126 Use a radial deadzone on the vector magnitude.
127- **No buffering/coyote time** makes tight platformers feel unfair even when the
128 physics are correct — players "clearly pressed jump". Add small windows.
129- **Rebinding without conflict handling** lets two actions share a key, or strands
130 the player by unbinding menu access. Detect conflicts; guarantee a way back.
131- **Not swapping prompts on device change** shows "Press Space" to a gamepad
132 player. Track the last-used device and switch glyphs.
133- **Ignoring accessibility**: required simultaneous presses, no remap, fixed
134 sensitivity, hold-only actions. Offer remap, toggle-vs-hold, and sensitivity.
135- **Reading input in the wrong loop**: poll held state in the physics step for
136 consistent movement; capture discrete presses so none are missed between frames.
137
138## References
139
140- `references/buffering-and-accessibility.md` — buffering/coyote tuning, jump feel
141 (variable height, apex), device detection and prompt swapping, touch controls,
142 and an accessibility checklist (remap, toggle/hold, sensitivity, latency).
143
144## Related skills
145
146- `unity-input-system`, `unreal-enhanced-input` — concrete engine input APIs
147 (Godot uses `InputMap` + the `Input` singleton).
148- `save-systems` — persist custom key bindings and input settings.
149- `physics-tuning` — the movement the buffer/coyote windows feed into.
150- `platformer`, `fps-shooter` — genres whose feel depends on input handling.