Building a key rebinding menu in Godot 4
Build a key rebinding menu in Godot 4: capture the next input event, swap the InputMap binding, catch conflicts, and persist remaps as serialized events.
The player opens the controls screen, clicks the row for jump, and the button says "Press a key...". Now the game has to catch whatever they press next, exactly one event, and turn it into a new binding for that action. Then it has to notice that they just pressed the same key already bound to attack, decide what to do about it, and make the change survive a restart. A key rebinding menu in Godot 4 leans on InputMap for the live binding and gets nothing from the engine for the capture, the conflict, or the persistence. Those three parts you write yourself.
This walks through the version in my free Settings + Menus addon, which is a complete standalone template with no paid tier. The two files that matter are KeyRebindRow, one row of the controls tab, and the Settings autoload that owns the InputMap edits and the persistence. I'll build the flow in the order the events actually happen: capture, then commit, then conflict, then disk.
Catching the next event and only the next one
The row is an HBoxContainer with a label, a bind button, and a small reset button. Clicking the bind button flips a _listening flag and changes the label so the player knows the game is waiting on them.
func _start_listen() -> void:
_listening = true
_bind_btn.text = "Press a key..."The actual capture lives in _unhandled_input, not _input. That choice matters and I'll come back to it. While listening, the row watches for one of a few event types and grabs the first real press:
func _unhandled_input(event: InputEvent) -> void:
if not _listening:
return
# Esc cancels listen mode without rebinding, so keyboard users aren't
# trapped if they clicked the bind button by accident.
if event.is_action_pressed("ui_cancel"):
_listening = false
_bind_btn.text = _current_label()
_bind_btn.grab_focus()
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo:
_capture([event])
get_viewport().set_input_as_handled()
elif event is InputEventMouseButton and event.pressed:
_capture([event])
get_viewport().set_input_as_handled()
elif event is InputEventJoypadButton and event.pressed:
_capture([event])
get_viewport().set_input_as_handled()Two guards on the keyboard branch earn their place. event.pressed drops the release half of every keypress, so you bind on key-down and don't immediately re-fire on key-up. not event.echo drops the OS auto-repeat that arrives if the player holds the key for a beat, which otherwise gives you a burst of identical events and a race over which one wins. Mouse and joypad buttons don't echo, so they only check pressed.
Every branch that captures also calls get_viewport().set_input_as_handled(). Skip that and the very key you pressed to rebind jump also travels on to the rest of the game and makes the character jump, mid-menu. Marking it handled stops the propagation right there. This is the whole reason capture lives in _unhandled_input: by the time an event reaches that callback, the focused Control and the UI have already had their shot at it, so consuming it here is safe. Do the same capture in _input and you'll swallow the button click and the tab focus along with it.
Committing the binding to the InputMap
_capture hands the event list to the Settings autoload and lets it own the InputMap. The row itself never touches InputMap on write. It only reads it to redraw the label. Keeping every mutation behind one method is what makes the persistence honest later, because there's exactly one place a binding can change.
func _capture(events: Array) -> void:
_listening = false
var settings := get_node_or_null("/root/Settings")
if settings == null:
_bind_btn.text = _current_label()
return
# conflict check goes here (next section)
settings.set_keybind(action, events)
_bind_btn.text = _current_label()
rebound.emit(action)Inside the autoload, set_keybind does the InputMap swap and nothing clever. Erase the action's current events, add the new ones back. Godot has no "replace the binding" call, so replace means erase-then-add:
func set_keybind(action: String, events: Array) -> void:
if not InputMap.has_action(action):
return
if not _is_rebindable(action):
push_warning("Settings: refused to rebind '%s' - not in rebindable_actions allowlist." % action)
return
# de-dupe by serialized shape so the same key can't be added twice
var unique_events: Array = []
var seen: Dictionary = {}
for ev in events:
if not (ev is InputEvent):
continue
var sig: String = JSON.stringify(_serialize_event(ev))
if seen.has(sig):
continue
seen[sig] = true
unique_events.append(ev)
InputMap.action_erase_events(action)
for ev in unique_events:
InputMap.action_add_event(action, ev)
# persist as dicts
var binds: Dictionary = _data.get("keybinds", {}).duplicate(true)
binds[action] = unique_events.map(_serialize_event)
_data["keybinds"] = binds
save_settings()
setting_changed.emit("keybinds", binds)The _is_rebindable gate is the annoying decision I'd flag first. Every Godot project ships with ui_accept, ui_cancel, and the rest of the built-in navigation actions, and they show up in InputMap.get_actions() right next to your gameplay ones. If a stray keypress in the rebind screen reassigns ui_cancel, the player can lock themselves out of the menu they're standing in. So the autoload keeps an allowlist:
var rebindable_actions: PackedStringArray = PackedStringArray([
"move_left", "move_right", "move_up", "move_down",
"jump", "attack", "interact", "inventory", "pause",
])
func _is_rebindable(action: String) -> bool:
if action.begins_with("ui_"):
return false
for a in rebindable_actions:
if String(a) == action:
return true
# empty allowlist means "any non-ui_ action", so it works out of the box
return rebindable_actions.is_empty()Set rebindable_actions to your own action names and only those rows are editable. Leave it empty and anything that isn't ui_* is fair game. The refusal is deliberately quiet. It pushes a warning to the log and returns, rather than throwing, because it's a safety net for a case the UI shouldn't have offered in the first place.
Detecting a conflict before the rebind commits
Rebinding attack to the key that already runs jump is not an error. It's a decision, and it belongs to the game, not the addon. What the addon owes you is the fact of the clash, before the binding changes, so you still have both sides to work with. That's find_conflicting_action:
func find_conflicting_action(event: InputEvent, exclude_action: String = "") -> String:
if event == null:
return ""
for action in InputMap.get_actions():
var a := String(action)
if a == exclude_action:
continue
for existing in InputMap.action_get_events(a):
if _events_equivalent(existing, event):
return a
return ""It walks every action, skips the one being rebound (you don't want a key reported as conflicting with itself), and returns the first action already listening for an equivalent event. Empty string means clear. The row calls it before set_keybind and, in the default template, refuses the rebind and flashes the clash in the button label for a couple of seconds instead of silently double-binding:
if not events.is_empty() and events[0] is InputEvent:
var other: String = settings.find_conflicting_action(events[0], action)
if other != "":
_bind_btn.text = "%s used by %s" % [_format_event(events[0]), _humanize(other)]
get_tree().create_timer(1.8).timeout.connect(func():
if is_instance_valid(self) and is_instance_valid(_bind_btn):
_bind_btn.text = _current_label())
return
settings.set_keybind(action, events)Refuse-on-conflict is the conservative default, and it's the right one for a template. If you'd rather do the swap, where binding attack to jump's key also strips that key off jump, you have everything you need. other is the losing action, and get_keybind_events(other) gives you its current events to prune before you commit. Wire the row's rebound signal to your own resolution popup and drive both bindings from there.
Making the remap survive a restart
Live InputMap edits vanish the instant the game closes. set_keybind already wrote the serialized dicts into _data["keybinds"] and called save_settings(), which drops the whole settings dictionary to user://settings.json (or through the Save addon if you have it installed). Serialization is narrow on purpose. Keyboard, mouse button, and joypad button are the whole v1 surface:
func _serialize_event(ev) -> Dictionary:
if ev is InputEventKey:
return {"t": "key", "keycode": int(ev.physical_keycode if ev.physical_keycode != 0 else ev.keycode)}
if ev is InputEventMouseButton:
return {"t": "mb", "button_index": int(ev.button_index)}
if ev is InputEventJoypadButton:
return {"t": "jb", "button_index": int(ev.button_index)}
return {}Note the physical_keycode preference. A captured key is stored by its physical position on the board, falling back to the logical keycode only when physical is zero. Store the logical keycode instead and a player on AZERTY who binds the key where QWERTY's W sits gets a different letter back after reload, because the logical value moved with their layout while the physical slot did not. Physical keeps "the key under my finger" stable across layouts, which is what movement keys actually want.
Loading runs once at startup. apply_keybinds reads the saved dict, and for each action it erases the defaults and rebuilds from the stored events. Same erase-then-add pattern as the live path, so the code that boots your saved layout is the code that changed it in the first place:
func apply_keybinds() -> void:
var binds: Dictionary = get_value("keybinds", {})
for action in binds.keys():
var action_s := String(action)
if not InputMap.has_action(action_s):
continue
InputMap.action_erase_events(action_s)
var events_data = binds[action]
if not (events_data is Array):
continue
for ev_dict in events_data:
var ev := _deserialize_event(ev_dict)
if ev != null:
InputMap.action_add_event(action_s, ev)Reset, and why it needs a snapshot
A controls screen needs a way back to defaults, and this is the subtle part. Once you've called action_erase_events on jump, the engine no longer knows what jump used to be. There is no "default binding" lurking in the InputMap to restore from. You overwrote it. So the autoload takes its own snapshot on boot, before any saved remaps get applied, and reset replays from that copy:
func _capture_default_binds() -> void:
for action in InputMap.get_actions():
_captured_default_binds[String(action)] = InputMap.action_get_events(action)
func reset_keybind(action: String) -> void:
if not _captured_default_binds.has(action):
return
var defaults: Array = _captured_default_binds[action]
set_keybind(action, defaults)_capture_default_binds runs in _ready ahead of load_settings and apply_keybinds, so the snapshot holds the project's original Input Map, not whatever the player saved last session. Reset then routes back through set_keybind, which means the restored default gets persisted too. Reset writes to disk exactly like any other rebind, so "reset to default" is durable and doesn't quietly revert on the next launch. reset_all_keybinds just loops that over every captured action.
That's the full loop. Capture one event in _unhandled_input and consume it, swap it into the InputMap through a single gated method, ask find_conflicting_action before you commit, and persist the serialized events so the next launch rebuilds them. The pieces you're most likely to want beyond the template are chorded modifiers and a swap-on-conflict flow, and both hang off the same two functions: teach _serialize_event about modifiers, and read the losing action's events out before you overwrite them.