Skip to content

M1.3 — Input Map & Actions

What you'll learn

  • Distinguish a physical input from a named action, and define the project's action set.
  • Query actions with Input.is_action_pressed, is_action_just_pressed, and Input.get_vector.
  • Route every input through an action — for rebinding, controllers, and a smaller QA matrix.

How it applies

  • Rebinding is a baseline expectation, not a feature. Players remap keys for comfort, left-handedness, and accessibility. If your code reads KEY_W directly, rebinding means hunting every KEY_W in the codebase. If it reads the move_up action, rebinding is one edit to the action's binding — the code never changes.
  • Controller support comes almost free. An action can bind a key and a gamepad axis/button at once. Bind move_up to both W and the left-stick up, and the same Input.get_vector call reads keyboard or controller with no branching. An ARPG is a controller-friendly genre; designing against actions keeps that door open.
  • The QA matrix shrinks. Testing "does moving up work" against an action is one test that holds across every device the action is bound to. Testing raw keys means re-verifying per input source. This is the same equivalence-partitioning win as the resolution axis in M1.1: the action is the partition; one case per partition covers it.
  • Input config is project state. The action set lives in project.godot and ships to every player and teammate. A player's rebound version of it, however, is per-player state that belongs under user:// — the same project-vs-user line from M1.1, applied to input.

Concepts

Actions decouple intent from device

Godot's input system has two layers. The bottom layer is physical: key codes, mouse buttons, gamepad axes. The top layer is the InputMap — a project-level table of named actions, each bound to one or more physical inputs. move_up might be bound to W and the gamepad left-stick's upward tilt; attack to the left mouse button and the gamepad's X. Your gameplay code asks about actions ("is attack pressed?") and never about devices. Swap or add a device binding and the code is unaffected.

This is the same decoupling principle as signals (respond to what happened, not who did it): read intent, not hardware.

The action set for Emberdelve

A top-down ARPG core loop needs a small, stable action set:

Action Default binding Used for
move_up W, gamepad LS up movement (vertical −)
move_down S, gamepad LS down movement (vertical +)
move_left A, gamepad LS left movement (horizontal −)
move_right D, gamepad LS right movement (horizontal +)
attack left mouse, gamepad X melee attack
interact E, gamepad A pick up loot, use doors
inventory I / Tab, gamepad Y toggle inventory screen
pause Esc, gamepad Start pause menu

Note the screen's Y axis grows downward in 2D: move_up is the negative-Y direction. The four directional actions are deliberately separate (not one "move" action) because Input.get_vector composes them into a direction vector for you — covered below.

Names are lowercase with underscores. Verb actions are the bare verb (attack, interact); directional actions are move_<direction>. Consistency here pays off when you read input code months later.

Querying actions in code

Three calls cover almost everything:

  • Input.is_action_pressed("attack") — true every frame the action is held. Use for continuous input (holding to move).
  • Input.is_action_just_pressed("attack") — true only on the frame the action went down. Use for discrete input (swing once per press, toggle the inventory).
  • Input.is_action_just_released("attack") — true on the frame it came up. Use for charge-release mechanics.

For movement specifically, Godot gives a purpose-built helper:

var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")

get_vector returns a Vector2 pointing the way the player is steering, already normalized so diagonal movement is not faster than cardinal movement, and already accounting for analog stick magnitude on a gamepad. The argument order is negative-X, positive-X, negative-Y, positive-Y — left, right, up, down. You will use exactly this line to drive the player in M2.1.

Example

Two ways to read "the player is holding W and D". The manual way:

var x := 0.0
var y := 0.0
if Input.is_action_pressed("move_right"): x += 1.0
if Input.is_action_pressed("move_left"):  x -= 1.0
if Input.is_action_pressed("move_down"):  y += 1.0
if Input.is_action_pressed("move_up"):    y -= 1.0
var direction := Vector2(x, y).normalized()

The get_vector way:

var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")

Both produce the same normalized direction, but get_vector also handles analog-stick magnitude correctly (the manual version snaps a half-tilted stick to full speed). Prefer get_vector for movement; reserve the per-action checks for discrete verbs.

Example

is_action_pressed vs is_action_just_pressed is a frequent beginner bug. Bind attack and write the swing under is_action_pressed: holding the button swings every single frame — sixty swings a second. Under is_action_just_pressed, one click is one swing. Continuous actions (move) want is_action_pressed; discrete actions (attack, toggle inventory) want is_action_just_pressed.

Where the bindings live, and runtime rebinding

The action set is stored in project.godot under [input]. Editing it in the Input Map tab writes there. At runtime you can change bindings in code (InputMap.action_erase_events / action_add_event) to implement a rebinding menu — but the result of a player's rebinding must be saved under user://, not back into project.godot (which is read-only in an exported build). This book does not build a rebinding UI, but designing every input against actions is what makes adding one later a contained task rather than a rewrite.

Walkthrough

  1. Open Project → Project Settings → Input Map (a top-level tab in the dialog).
  2. In the Add New Action field, type move_up, press Add. Repeat for move_down, move_left, move_right, attack, interact, inventory, pause.
  3. For each action, click the + on its row to add an event:
  4. move_up: press +Key → press the W key when prompted. Add a second event → Joypad Axis → choose the left stick's up direction if you have a controller to test with.
  5. Do the same for move_down/S, move_left/A, move_right/D.
  6. attack: +Mouse Button → Left Button. (Optionally add gamepad X.)
  7. interact: +KeyE.
  8. inventory: +KeyI (optionally also Tab).
  9. pause: +KeyEscape.
  10. Close the dialog. The actions are written to project.godot.
  11. There is nothing to run yet. In M2.1 you will read move_* with get_vector to move the player, and in M3.3 you will read attack with is_action_just_pressed.

Optional sanity check

Reopen Project Settings → Input Map and confirm all eight actions are present with their bindings. If a directional action is missing its key, get_vector will silently return a vector that ignores that direction in M2 — a quiet bug that is easier to catch now than to diagnose as "the player won't walk left" later.

Self-check quiz

Q1 — What is the correct argument order for Input.get_vector(...) to drive top-down movement?

A. ("move_up", "move_down", "move_left", "move_right") B. ("move_left", "move_right", "move_up", "move_down") C. ("move_right", "move_left", "move_down", "move_up") D. Order does not matter; get_vector sorts them.

Reveal answer

B — negative-X, positive-X, negative-Y, positive-Y (left, right, up, down). get_vector builds the X component from the first two and the Y from the last two, subtracting the first of each pair from the second. Order absolutely matters (D is wrong); A swaps the axes (you'd get vertical input on the X axis); C inverts both pairs (left/right and up/down would be reversed).

Q2 — You want the player to swing once per click, but holding the button currently swings every frame. Which call fixes it?

A. Replace is_action_pressed("attack") with is_action_just_pressed("attack"). B. Replace is_action_pressed("attack") with is_action_just_released("attack"). C. Lower the physics tick rate so frames are rarer. D. Unbind the mouse and bind only a key.

Reveal answer

A. is_action_pressed is true every held frame (continuous); is_action_just_pressed is true only on the down-frame (discrete) — one swing per click. B would swing on release, which is a different feel and still one-per-click but usually not what's wanted. C is a hack that changes simulation timing, not the per-press logic. D doesn't address the held-frame problem at all.

Q3 — Why bind move_up to a named action instead of reading KEY_W directly in the player script?

A. KEY_W is slower to evaluate than an action lookup. B. Actions let one intent bind multiple devices (key + gamepad) and be rebound without touching gameplay code; raw keys scatter device knowledge through the codebase. C. Godot forbids reading key codes directly. D. Actions automatically save the player's rebinds to disk.

Reveal answer

B. The action is the decoupling seam: one move_up covers keyboard and controller and can be rebound in one place, while KEY_W hard-codes one device into every site that reads it. A is not a meaningful performance difference. C is false — you can read key codes (via InputEvent), you just shouldn't for gameplay. D overstates it: actions don't persist rebinds for you; you save the rebound config to user:// yourself.

Integration question

Q4 — open

M1.1 distinguished project settings (shipped) from user settings (per-player). The InputMap is a project setting. Yet a player's rebound controls are per-player. Reconcile these: where does the default action set live, where must a player's customized bindings live, and what property of the action layer makes supporting both straightforward?

Reveal expected answer

The default action set lives in project.godot under [input] — it ships with the game and is the same for everyone, exactly like the resolution and stretch settings from M1.1. A player's customized bindings are per-player runtime state and must be written under user://, because res:///project.godot is read-only in an exported build and shared across players. The property that makes supporting both easy is that gameplay code references actions, not devices: the default bindings and the player's overrides are both just sets of events attached to the same action names, so swapping in a player's saved bindings at startup (via InputMap) changes behavior without changing a single gameplay call site.