Always put this off because "key rebinding" sounds like it'll be a rabbit hole, but it ended up being way more manageable than I expected. Godot 4's InputMap singleton gives you everything at runtime: read actions, erase events, add new ones. The serialization is the actual work.
Core of what I landed on:
func remap_action(action: StringName, new_event: InputEvent) -> void:
InputMap.action_erase_events(action)
InputMap.action_add_event(action, new_event)
_save_bindings()
func _save_bindings() -> void:
var cfg := ConfigFile.new()
for action in InputMap.get_actions():
if action.begins_with("ui_"):
continue # skip built-in navigation actions
var events := InputMap.action_get_events(action)
if events.is_empty():
continue
var ev := events[0]
if ev is InputEventKey:
cfg.set_value("bindings", action, ev.keycode)
elif ev is InputEventJoypadButton:
cfg.set_value("bindings", action, "joy_%d" % ev.button_index)
cfg.save("user://input_bindings.cfg")
func _load_bindings() -> void:
var cfg := ConfigFile.new()
if cfg.load("user://input_bindings.cfg") != OK:
return
for action in cfg.get_section_keys("bindings"):
if not InputMap.has_action(action):
continue
var raw = cfg.get_value("bindings", action)
var ev: InputEvent
if raw is int:
var k := InputEventKey.new()
k.keycode = raw
ev = k
elif raw is String and raw.begins_with("joy_"):
var j := InputEventJoypadButton.new()
j.button_index = int(raw.substr(4))
ev = j
if ev:
InputMap.action_erase_events(action)
InputMap.action_add_event(action, ev)Call _load_bindings() in your main scene's _ready() before anything reads input. The ui_ prefix skip matters. You really don't want players clobbering built-in menu navigation by accident.
Known gaps: this only saves the first bound event per action, so multiple bindings (keyboard and gamepad simultaneously) aren't preserved. Also no conflict detection. If someone maps Jump and Dodge to the same key I silently allow it. A pre-check loop across all actions before confirming a remap isn't hard, just haven't wired it yet.
The "press any key to bind" capture UI also took longer than the serialization itself. There's a waiting state where you flip on input processing, grab the next valid event, and cancel out cleanly. The awkward part: Escape needs to work as both "cancel the rebind" and potentially be a bindable action. Anyone have a clean pattern for that? Right now I'm just hardcoding Escape as cancel-only, which feels like a cop-out.