Quick answer: InputMap changes at runtime live only in memory. The engine never writes them back to project.godot, so a restart reloads the original defaults. Persist every rebind to user://input.cfg via ConfigFile, replay the saved events on boot from an autoload, and version the config so schema changes can migrate gracefully.
Your rebinding menu works great. The player swaps Jump from Space to the A button, presses save, exits. They relaunch, and every binding is back to the defaults — worse, in some cases the action appears to have no events at all, giving “input exhausted” warnings in the log. The cause is simple: you never persisted the change. Godot treats the InputMap as a runtime-mutable copy of the project settings, not a saved state.
How InputMap Works
At boot, Godot reads the [input] section of project.godot and populates the InputMap singleton with actions and their default event arrays. Any InputMap.action_add_event or InputMap.action_erase_event call modifies that in-memory copy. Nothing touches the project file. Nothing writes to user storage. On exit, the modified map is simply discarded.
The “action exhausted” behavior you may have seen happens when you accidentally erase all events for an action without re-adding one. The action still exists, but Input.is_action_pressed always returns false. Players report it as “my jump key stopped working.”
Step 1: Save Bindings to ConfigFile
Create a controls autoload (Project → Project Settings → Autoload). On every rebind, write all user-rebindable actions to user://input.cfg:
# controls.gd (autoload)
extends Node
const REBINDABLE = ["jump", "attack", "dash", "interact"]
const CONFIG_PATH = "user://input.cfg"
const SCHEMA_VERSION = 2
func save_bindings():
var cfg = ConfigFile.new()
cfg.set_value("meta", "version", SCHEMA_VERSION)
for action in REBINDABLE:
var events = InputMap.action_get_events(action)
cfg.set_value("bindings", action, events)
cfg.save(CONFIG_PATH)
Because InputEvent derivatives are Resource objects, ConfigFile serializes them natively. No hand-rolled encoding required.
Step 2: Load on Boot
In the autoload’s _ready, replay the saved events. Clear the default events for each action first to avoid double bindings:
func _ready():
var cfg = ConfigFile.new()
if cfg.load(CONFIG_PATH) != OK:
return # fresh install, keep defaults
var version = cfg.get_value("meta", "version", 1)
if version < SCHEMA_VERSION:
migrate(cfg, version)
for action in REBINDABLE:
if not InputMap.has_action(action):
continue
var events = cfg.get_value("bindings", action, [])
if events.is_empty():
continue # keep defaults if nothing saved
InputMap.action_erase_events(action)
for ev in events:
InputMap.action_add_event(action, ev)
Note the is_empty() guard. If the user saved an empty list (by accident or by clearing the binding without adding a new one), fall back to defaults rather than leaving the action unusable. This is the “exhausted” bug fix.
Step 3: Handle the Rebind UI Properly
The most common bug inside a rebind UI is erasing the wrong event type or all events. If the player is rebinding the keyboard key, you should only erase the existing keyboard event, leaving the gamepad and mouse bindings alone.
func rebind_keyboard(action: String, new_event: InputEventKey):
var existing = InputMap.action_get_events(action)
for ev in existing:
if ev is InputEventKey:
InputMap.action_erase_event(action, ev)
InputMap.action_add_event(action, new_event)
save_bindings()
Do the same pattern for InputEventJoypadButton, InputEventJoypadMotion, and InputEventMouseButton. This way a player rebinding the keyboard Jump does not accidentally lose their gamepad binding.
Step 4: Schema Migration
When you add a new rebindable action in an update, players who already have an input.cfg will not see the new action in their saved list. Use a SCHEMA_VERSION constant and a migration function:
func migrate(cfg: ConfigFile, from_version: int):
if from_version < 2:
# v2 added "dash" action; leave defaults by omitting from cfg
if not cfg.has_section_key("bindings", "dash"):
print("Added default bindings for new action: dash")
cfg.set_value("meta", "version", SCHEMA_VERSION)
cfg.save(CONFIG_PATH)
Never silently wipe a player’s custom bindings on a version bump. If you must break compatibility, detect the old version, back up the file to input.cfg.v1.bak, then reset to defaults. A player who has spent time rebinding is furious to lose that setup on an update.
Step 5: Detect Conflicts Before Saving
A rebind UI should reject (or confirm) when the new event already belongs to another action. Otherwise, both actions fire on the same input and the player experiences “input exhaustion” — pressing the key triggers nothing because two actions cancel each other in logic.
func has_conflict(action: String, new_event: InputEvent) -> String:
for other in REBINDABLE:
if other == action: continue
for ev in InputMap.action_get_events(other):
if ev.is_match(new_event):
return other
return ""
“InputMap is a singleton, not a saved state. Every UI change is temporary until you write it to disk.”
Verifying the Fix
Rebind every action, quit, relaunch, and confirm the UI shows the custom bindings. Delete user://input.cfg (or bump the schema) and verify defaults are restored. Add a new action in the project settings, bump the schema, and verify that returning players see the new action with defaults while keeping their old customizations.
Related Issues
If controller input stops working after plugging in a second device, see Joypad Device IDs Shift. For UI focus bugs after rebind, read UI Focus Lost After Popup.
ConfigFile save on rebind + autoload load on boot + schema versioning = rebinds survive restarts forever.