Declutter your Unity Input stuff with Observer-inspired pattern

The good thing about programming is that there are many ways to solve one problem. Or bad? It’s either. We usually end up messing up our code, sometimes way too often, that we are so passionate about sharing and discussing our solutions with others to find something better and improve ourselves. That’s why design patterns and code review exist.

The problem we are tackling today is with Unity Input. More often than not we see things like this:

void Update()
{
    if (Input.GetButtonDown("Fire"))
    {
        // do some firing stuff
        // for about 20 lines
    }

    
    Vector3 movement = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
    // do some movement stuff
    // for another 5 lines
}

With the game’s complexity grows, that Update function gets cluttered quickly. A quick solution can be considered here is that we extract Update to smaller functions, such as Move, Fire, and so on. In that way, it does get cleaner, but the Update function still can be cluttered if the logic is complicated. Is there any way we can move these stuff completely out of the Update function?

Rob Nystrom’s Command Pattern is a fantastic solution to this problem. It provides a layer of indirection for your game code so that your Update function doesn’t get cluttered by a bunch of if-else statements. That is not the only solution, however, as we can make use of the Observer pattern for this problem as well.

Unity’s Input system has a concept of “actions” and “axes”, where you bind the logic to the meaning of the input action. For example, Unity encourages you to have your code reacting to the “Fire” action, rather than the left mouse click action. This way you can have either the Enter key on your keyboard or the right trigger on your gamepad controller for firing, without any needs to change the code.

The pattern I’m gonna talk about is built upon this foundation. The idea is simple – you put the if-else statements into a middle-layer object and call it Input Manager. If you want your player to react to input events, you tell the Input Manager, not query for Unity events directly in your code. The Input Manager is the “observer” who tracks Unity input events every frame and distributes that information to its “subscribers”. The Input Manager class looks somehow like this:

public enum InputActionEvent
{
    Pressed,
    Released,
    // Feel free to add more
}

public class InputManager : MonoBehaviour
{
    #region Singleton
    // A very loose Singleton definition, just for demonstration
    public static InputManager Instance
    {
        get
        {
            if (s_Instance == null)
            {
                GameObject container = new GameObject();
                container.name = "InputManager";
                s_Instance = container.AddComponent<InputManager>();
            }

            return s_Instance;
        }
    }

    private static InputManager s_Instance = null;
    #endregion

    private Dictionary<string, System.Action<float>> m_RegisteredAxes = new Dictionary<string, System.Action<float>>();
    private Dictionary<string, System.Action> m_RegisteredPressedActions = new Dictionary<string, System.Action>();
    private Dictionary<string, System.Action> m_RegisteredReleasedActions = new Dictionary<string, System.Action>();

    public void RegisterAxis(string actionName, System.Action<float> actionCallback)
    {
        if (!m_RegisteredAxes.ContainsKey(actionName))
        {
            m_RegisteredAxes.Add(actionName, actionCallback);
        }
        else
        {
            m_RegisteredAxes[actionName] += actionCallback;
        }
    }

    public void RegisterAction(string actionName, InputActionEvent actionEvent, System.Action actionCallback)
    {
        Dictionary<string, System.Action> actionMapping = null;
        switch (actionEvent)
        {
            case InputActionEvent.Pressed:
                actionMapping = m_RegisteredPressedActions;
                break;
            case InputActionEvent.Released:
                actionMapping = m_RegisteredReleasedActions;
                break;
            default:
                break;
        }

        if (!actionMapping.ContainsKey(actionName))
        {
            actionMapping.Add(actionName, actionCallback);
        }
        else
        {
            actionMapping[actionName] += actionCallback;
        }
    }

    private void Update()
    {
        foreach (var registeredAxis in m_RegisteredAxes)
        {
            registeredAxis.Value?.Invoke(Input.GetAxis(registeredAxis.Key));
        }

        foreach (var registeredAction in m_RegisteredPressedActions)
        {
            if (Input.GetButtonDown(registeredAction.Key))
                registeredAction.Value?.Invoke();
        }

        foreach (var registeredAction in m_RegisteredReleasedActions)
        {
            if (Input.GetButtonUp(registeredAction.Key))
                registeredAction.Value?.Invoke();
        }
    }
}

In the example above, the Input Manager class has some dictionaries to keep track of its subscribers, though these dictionaries are not exposed as the class’ public interface – outside usages are limited to public functions such as RegisterAction. Now your Player class is a lot simpler than it was. No Update, no disturbing if-else statements.

public class Player : MonoBehaviour
{
    [SerializeField] float moveSpeed;

    private void Start()
    {
        InputManager.Instance.RegisterAxis("Horizontal", MoveHorizontal);
        InputManager.Instance.RegisterAxis("Vertical", MoveVertical);
        InputManager.Instance.RegisterAction("Fire1", InputActionEvent.Pressed, Fire);
    }

    public void MoveHorizontal(float axisValue)
    {
        transform.position += Vector3.right * axisValue * moveSpeed * Time.deltaTime;
    }

    public void MoveVertical(float axisValue)
    {
        transform.position += Vector3.forward * axisValue * moveSpeed * Time.deltaTime;
    }

    public void Fire()
    {
        // Your firing stuff goes here
    }
}

You may argue “it’s not simpler, we have to write two classes instead of one”. Right. But keep in mind that the Input Manager is something you write once, and everything else such as menu navigation or UI interactions with drag and drop doesn’t have to write their input code in their Update function.

The code above is enough to express my idea. However, it is nowhere near a robust system for a project in production. There are a few things here that we can consider when we design the system:

Singleton or not Singleton? Singleton is easy to write and use as it is convenient, but it is notorious for creating messy spaghetti in your code. Also, managing input actions between scenes and different objects can be problematic with anything that is based on static. Consider using some sort of instance per scene, or even per object. The latter is actually what Unreal Engine uses, with its Input Component class.

Should we use System.Action directly? Managing the list of callbacks with a single C# System.Action can sometimes be troublesome. For example, if the object that contains the function is destroyed, then calling the callback in Input Manager would throw an exception and all its remaining functions aren’t get called. You can use a list of System.Action objects, or even better, wrap your callback into a custom object that you can manage at ease.

How to organize your dictionaries? In the example above, I only provide two types of input action events: pressed and released. But in real life, things can be a little bit more complicated. There are a bunch of stuff you might want to support as features, such as drag/drop or hold. It might be okay when you have two separated dictionaries like I did if you only support Pressed and Released as your input events, yet if you have more, I’d strongly recommend you not to do that. It is brain-taxing to maintain and develop if your Update function has the same code repeating several times.

That’s it. Hopefully, I explained well enough and provided a good foundation. If you have any question, let me know at the comment section!

Leave a Reply

Your email address will not be published. Required fields are marked *