There is a common belief among game developers I have talked with that Unity is somewhat lacking when it comes to player input handling. And while I do agree, for the most part, the folks at Unity have been hard at work on rectifying this recently through the introduction of the new Input System.

However, even that comes with plenty of flaws. One of them is the way input rebinds are handled. Or more specifically, not really handled. Developers are mostly left to deal with this on their own, with just a runtime rebind solution provided and no real way to save the rebinds once the player exits the game.

Now, in the Input System preview versions of 1.1.0 and up, there is a handy method that can save the rebinds to JSON. However, preview versions of any software often come with their own set of problems, so I would much rather use the stable versions instead. But how do I handle persistent rebinds, then? Let me explain.

Setting Up the Default Bindings

The first order of business is to set up a default Input Actions Asset that holds all of the bindings I might need. In the case of The Floor is [Blank], said asset looks something like this:

Two control schemes (keyboard and gamepad), four actions, and two bindings per action.

This is an Input Actions Asset that works perfectly fine for what I need in the game and it will serve as the base for what we will do next. That being said, your game can have as many actions and bindings as you need and you can still use the same methods I am about to describe.

Rebinding at Runtime

Currently, the Input System has a very handy method that allows you to rebind any binding with as many conditions as you like. You can limit it to a specific device, rebind the entire action, or a single part of a composite binding. It’s really quite neat, to be perfectly honest. It is also non-destructive, so you can always get a reference to the default binding if you want to. It works like a charm at runtime, but it is cleared once the session is over.

So, let’s start by rebinding an action in that way. In order to do that, we can use a method, such as the one below.

public static void RemapKeyboardAction(InputAction actionToRebind, int targetBinding)
    {
        var rebindOperation = actionToRebind.PerformInteractiveRebinding(targetBinding)
            .WithControlsHavingToMatchPath("<Keyboard>")
            .WithBindingGroup("Keyboard")
            .WithCancelingThrough("<Keyboard>/escape")
            .OnCancel(operation => SuccessfulRebinding?.Invoke(null))
            .OnComplete(operation => {
                operation.Dispose();
                AddOverrideToDictionary(actionToRebind.id, actionToRebind.bindings[targetBinding].effectivePath, targetBinding);
                SaveControlOverrides();
                SuccessfulRebinding?.Invoke(actionToRebind);
            })
            .Start();
    }

This method accepts an InputAction reference and an integer for the binding index. If we take the previous screenshot as an example, we can pass in the Movement action and a binding index of 2 in order to rebind the Down part of the keyboard composite input. If we instead pass an index of 0 or 5, we will be rebinding the entire composite input (0 would be the keyboard composite and 5 – the gamepad one).

In order to call this method, I’ve created another class, pasted in full below:

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

public class InputActionDisplay : MonoBehaviour
{
    [SerializeField] private InputActionReference actionReference;
    [SerializeField] private int bindingIndex;

    private InputAction action;

    private Button rebindButton;

    private void Awake()
    {
        rebindButton = GetComponentInChildren<Button>();
        rebindButton.onClick.AddListener(RebindAction);
    }

    private void OnEnable()
    {
        action = ControlsRemapping.Controls.asset.FindAction(actionReference.action.id);

        SetButtonText();
    }

    private void SetButtonText()
    {
        rebindButton.GetComponentInChildren<TextMeshProUGUI>().text = action.GetBindingDisplayString(bindingIndex, InputBinding.DisplayStringOptions.DontUseShortDisplayNames);
    }

    private void RebindAction()
    {
        rebindButton.GetComponentInChildren<TextMeshProUGUI>().text = "...";

        ControlsRemapping.SuccessfulRebinding += OnSuccessfulRebinding;

        bool isGamepad = action.bindings[bindingIndex].path.Contains("Gamepad");

        if (isGamepad)
            ControlsRemapping.RemapGamepadAction(action, bindingIndex);
        else
            ControlsRemapping.RemapKeyboardAction(action, bindingIndex);
    }

    private void OnSuccessfulRebinding(InputAction action)
    {
        ControlsRemapping.SuccessfulRebinding -= OnSuccessfulRebinding;
        SetButtonText();
    }

    private void OnDestroy()
    {
        rebindButton.onClick.RemoveAllListeners();
    }
}

This script is attached to a GameObject in the Editor that has two children – one simple TextMeshProUGUI label and one Button. The action reference and binding index are exposed to the inspector and set manually, but in your case, you might want to look into automating those if you have too many actions and inputs.

As you can see, the default input action reference is only used to find an action in a static GameControls instance in another class. Said GameControls instance is what we subscribe to in the player controller.

This, in itself, is enough to rebind an input at runtime and have it stay rebound until the session is over (hence the static object). However, how do we save the rebinds for future use?

Introducing the Rebind Overrides Dictionary

In my case, I opted to store all rebind overrides in a dictionary on disk that I can later load when the player restarts the game. This looks something like this:

public static Dictionary<string, string> OverridesDictionary = new Dictionary<string, string>();

private static void AddOverrideToDictionary(Guid actionId, string path, int bindingIndex)
    {
        string key = string.Format("{0} : {1}", actionId.ToString(), bindingIndex);

        if (OverridesDictionary.ContainsKey(key))
        {
            OverridesDictionary[key] = path;
        }
        else
        {
            OverridesDictionary.Add(key, path);
        }
    }

    private static void SaveControlOverrides()
    {
        FileStream file = new FileStream(Application.persistentDataPath + "/controlsOverrides.dat", FileMode.OpenOrCreate);
        BinaryFormatter bf = new BinaryFormatter();
        bf.Serialize(file, OverridesDictionary);
        file.Close();
    }

Now, dictionaries have a key and a value. Therefore, I need to figure out how to store three values in a collection that can hold two per entry. Therefore, I combined the action ID and the binding index as the key for the dictionary and stored the new input path as the value.

At first I though I could use the binding GUID directly, but this proved to be a problem, because I couldn’t find a way to get a reference to the binding, based on its GUID when loading the overrides.

Speaking of loading, here is how I do that part:

private static void LoadControlOverrides()
    {
        if (!File.Exists(Application.persistentDataPath + "/controlsOverrides.dat"))
        {
            return;
        }

        FileStream file = new FileStream(Application.persistentDataPath + "/controlsOverrides.dat", FileMode.OpenOrCreate);
        BinaryFormatter bf = new BinaryFormatter();
        OverridesDictionary = bf.Deserialize(file) as Dictionary<string, string>;
        file.Close();

        foreach (var item in OverridesDictionary)
        {
            string[] split = item.Key.Split(new string[] { " : " }, StringSplitOptions.None);
            Guid id = Guid.Parse(split[0]);
            int index = int.Parse(split[1]);
            Controls.asset.FindAction(id).ApplyBindingOverride(index, item.Value);
        }
    }

Obviously, I already know how the data will be formatted. So, splitting and parsing the dictionary key is just a matter of reversing the process that combined them in the first place. Then, I get a reference to the action in the static Controls instance and use the ApplyBindingOverride method to apply the override.

Conclusion

The new Input System is both much better and infinitely worse than the old one. And, honestly, if you can do what you need with the old Input API, I would advise sticking to it. It is far easier to work with, despite being much less evolved.

That being said, once you get your head around the various intricacies of the Input System, it can do some really powerful stuff. It just needs some more love from Unity to reach its full potential.

Now, since I am feeling generous, I will paste the entire code of the two classes we looked at below, so feel free to copy, modify, and use them in your own projects. Or maybe even to make some better tutorials on the subject.

The Code

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

public class InputActionDisplay : MonoBehaviour
{
    [SerializeField] private InputActionReference actionReference;
    [SerializeField] private int bindingIndex;

    private InputAction action;

    private Button rebindButton;

    private void Awake()
    {
        rebindButton = GetComponentInChildren<Button>();
        rebindButton.onClick.AddListener(RebindAction);
    }

    private void OnEnable()
    {
        action = ControlsRemapping.Controls.asset.FindAction(actionReference.action.id);

        SetButtonText();
    }

    private void SetButtonText()
    {
        rebindButton.GetComponentInChildren<TextMeshProUGUI>().text = action.GetBindingDisplayString(bindingIndex, InputBinding.DisplayStringOptions.DontUseShortDisplayNames);
    }

    private void RebindAction()
    {
        rebindButton.GetComponentInChildren<TextMeshProUGUI>().text = "...";

        ControlsRemapping.SuccessfulRebinding += OnSuccessfulRebinding;

        bool isGamepad = action.bindings[bindingIndex].path.Contains("Gamepad");

        if (isGamepad)
            ControlsRemapping.RemapGamepadAction(action, bindingIndex);
        else
            ControlsRemapping.RemapKeyboardAction(action, bindingIndex);
    }

    private void OnSuccessfulRebinding(InputAction action)
    {
        ControlsRemapping.SuccessfulRebinding -= OnSuccessfulRebinding;
        SetButtonText();
    }

    private void OnDestroy()
    {
        rebindButton.onClick.RemoveAllListeners();
    }
}
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class ControlsRemapping : MonoBehaviour
{
    public static GameControls Controls;
    public static Action<InputAction> SuccessfulRebinding;

    public static Dictionary<string, string> OverridesDictionary = new Dictionary<string, string>();

    private void Awake()
    {
        if (Controls != null)
        {
            Destroy(this);
            return;
        }

        Controls = new GameControls();

        if (File.Exists(Application.persistentDataPath + "/controlsOverrides.dat"))
        {
            LoadControlOverrides();
        }
    }

    public static void RemapKeyboardAction(InputAction actionToRebind, int targetBinding)
    {
        var rebindOperation = actionToRebind.PerformInteractiveRebinding(targetBinding)
            .WithControlsHavingToMatchPath("<Keyboard>")
            .WithBindingGroup("Keyboard")
            .WithCancelingThrough("<Keyboard>/escape")
            .OnCancel(operation => SuccessfulRebinding?.Invoke(null))
            .OnComplete(operation => {
                operation.Dispose();
                AddOverrideToDictionary(actionToRebind.id, actionToRebind.bindings[targetBinding].effectivePath, targetBinding);
                SaveControlOverrides();
                SuccessfulRebinding?.Invoke(actionToRebind);
            })
            .Start();
    }

    public static void RemapGamepadAction(InputAction actionToRebind, int targetBinding)
    {
        var rebindOperation = actionToRebind.PerformInteractiveRebinding(targetBinding)
            .WithControlsHavingToMatchPath("<Gamepad>")
            .WithBindingGroup("Gamepad")
            .WithCancelingThrough("<Keyboard>/escape")
            .OnCancel(operation => SuccessfulRebinding?.Invoke(null))
            .OnComplete(operation => {
                operation.Dispose();
                AddOverrideToDictionary(actionToRebind.id, actionToRebind.bindings[targetBinding].effectivePath, targetBinding);
                SaveControlOverrides();
                SuccessfulRebinding?.Invoke(actionToRebind);
            })
            .Start();
    }

    private static void AddOverrideToDictionary(Guid actionId, string path, int bindingIndex)
    {
        string key = string.Format("{0} : {1}", actionId.ToString(), bindingIndex);

        if (OverridesDictionary.ContainsKey(key))
        {
            OverridesDictionary[key] = path;
        }
        else
        {
            OverridesDictionary.Add(key, path);
        }
    }

    private static void SaveControlOverrides()
    {
        FileStream file = new FileStream(Application.persistentDataPath + "/controlsOverrides.dat", FileMode.OpenOrCreate);
        BinaryFormatter bf = new BinaryFormatter();
        bf.Serialize(file, OverridesDictionary);
        file.Close();
    }

    private static void LoadControlOverrides()
    {
        if (!File.Exists(Application.persistentDataPath + "/controlsOverrides.dat"))
        {
            return;
        }

        FileStream file = new FileStream(Application.persistentDataPath + "/controlsOverrides.dat", FileMode.OpenOrCreate);
        BinaryFormatter bf = new BinaryFormatter();
        OverridesDictionary = bf.Deserialize(file) as Dictionary<string, string>;
        file.Close();

        foreach (var item in OverridesDictionary)
        {
            string[] split = item.Key.Split(new string[] { " : " }, StringSplitOptions.None);
            Guid id = Guid.Parse(split[0]);
            int index = int.Parse(split[1]);
            Controls.asset.FindAction(id).ApplyBindingOverride(index, item.Value);
        }
    }
}

Damyan Momchev

Game Designer at Snapshot Games at day, an indie developer at night. Happy husband, proud dad, and avid gamer at all times.