Input architecture: how do you decouple components that depend on input?

I’m fairly sure that the title could cause some confusion, so I’ll try to explain to the best of my ability.

I’m working on a game that will require a variety input contexts, and will also include multiplayer. I’m using the New Input System because I wanted to get away from the polling paradigm of the old one, but I’m running into some code structure roadblocks. Since the game is still in the prototype phase and a lot of things might change/be extended, I’m striving to keep the logic as modular and abstract as possible, within reason.

One reason for choosing the new system was that Action Maps can serve as contexts. Right now, I have three entities that are affected by input. I’m going to provide some detail to help you understand exactly what my problem is:

  • Player: an FPSController script is responsible for moving the player around and camera looking, while a PlayerInventory component acts as a ‘repository’ for the player’s items and only handles adding/removing, updating quantites etc.
  • Weapon: weapons are made from modular C# components that represent different functions (shooting, reloading etc.) and a WeaponController that is only responsible for setup and orchestration – the other components can be swapped for ones that execute different logic (i.e. RifleReload vs ShotgunReload or Hitscan vs. Projectile) using interfaces
  • UI: specific UI elements like the inventory UI are kept under a GameObject responsible for their orchestration, enabling/disabling etc. The inventory UI is only responsible for drawing the inventory and listening to events sent by the PlayerInventory script for updating the display.

Before I started the current refactoring process, and for prototyping purposes, I handled input in the simplest way possible: components that needed input to function simply created an instance of the script generated by the input asset (I’m not using the PlayerInput component), enabled the appropriate action maps and subscribed to the appropriate events. Of course, this creates a tangled mess of input code scattered everywhere and now every script with input can enable/disable action maps! I’m fine with input-dependent components listening for events within their own script, but I want the action map/context changes to be handled in a centralized manner.

I also tried the heavy-handed approach: the Player GameObject has a PlayerInputController component that creates a single instance of the generated C# input script. I create a list of private class fields for every input action, get references to the entities (UI, weapon etc.), subscribe their methods inside the PlayerInputController and call the from this location whenever an input event is fired. This creates another problem: I now need a reference to every component that needs input, so I get tight coupling.

I’ve been hitting my head on the wall with this for a while, and I can barely find anything of use online. Most tutorials/articles focus on extremely simple use cases that just cram everything in a Player script and call it a day. If you have a good approach that handles input and separates concerns as cleanly as humanly possible, I’d like to hear it. Thank you for reading.

1 Like

It feels like rather than being helpful, the new input system is prone to making people sidetracked so they end up wasting time trying to make a “perfect paradigm”, “proper hierarchy”, “the right way” and so on. As you’re not the first person looking for a “perfect way” to do things just so everything is compartmentalized “properly”.

At the moment I the reasonable option is to have PlayerInputController. Basically, because there can be a TON of actions and they bind to a resource, it makes sense to have a central point where those actions are bound in inspector. Becuase if you start binding individual actions in different components, you’ll have too many different places to keep track of, and if you change the action map, there will be trouble, as you’ll have a lot of lost bindings all over the place. I also do not think it makes sense to map actions to logical in-game actions, but instead treat them as a virtual gamepad of sorts, and decide on logical meaning in other scripts.

In last test I’ve been tinkering with the input system, I ended up with following approach.

There is an “input component” which holds an instance of input class generated by unity:

public class PlayerControl: MonoBehaviour{
    VRInputActions vrInput;//thsi thing pulls ALL data from vr devices and has dozense of actions
    KbrdSimpleInput keys;

Said class, however, can assign input actions to other objects.

    [Header("Controlled in-game objects")]
    [SerializeField] UnityEngine.InputSystem.XR.TrackedPoseDriver leftWand;
    [SerializeField] UnityEngine.InputSystem.XR.TrackedPoseDriver rightWand;
    [SerializeField] UnityEngine.InputSystem.XR.TrackedPoseDriver hmdObj;
void OnEnable(){
...
        if (vrInput == null){
            vrInput = new VRInputActions();
        }
        vrInput.Enable();
        if (leftWand){
            leftWand.positionAction = vrInput.VRControls.LPointerPos;
            leftWand.rotationAction = vrInput.VRControls.LPointerRot;
        }
        if (rightWand){
            rightWand.positionAction = vrInput.VRControls.RPointerPos;
            rightWand.rotationAction = vrInput.VRControls.RPointerRot;
        }
        if (hmdObj){
            hmdObj.positionAction = vrInput.VRControls.HMDCenterEyePos;
            hmdObj.rotationAction = vrInput.VRControls.HMDCenterEyeRot;
        }

It is also possible to create events for certain controls, and the “PlayerController” a global singleton that fires them, with other people subscribing to the input actions, but that’s basically a waste of time, as you’re introducing middlemen, while not gaining much in return, and the “coupling” is still there in some sort.

One issue with the new input system is that it is possible to end with this kind of nonsense:

        vrInput.VRControls.RAxis2d.started += ctx => updateStickRotation(ctx, stickTransfR);
        vrInput.VRControls.RAxis2d.performed += ctx => updateStickRotation(ctx, stickTransfR);
        vrInput.VRControls.RAxis2d.canceled += ctx => updateStickRotation(ctx, stickTransfR);

Because the system introduces “started/performed/cancelled” in situation where I actually needed only one function ('state changed") I need to use trible binding, which if, frankly, nonsense.

Ironically, it is also possible to end up with more compact code if you simply poll the value the old way.

1 Like

Preach. On one hand, I find the whole event-based approach cleaner. On the other, I can’t help but obsess about organizing component communication effectively. I’d normally just make it work and get it over with, however this could complicate the networking pass, so I want the core systems to be as clean as possible before adding any more features.

If I understand this correctly, I believe I agree, as this is something I wanted to talk about but couldn’t put into words. To elaborate, let’s say for example that I want the Tab key to open/close the inventory. This sounds simple, but it is actually an action comprised of multiple sub-actions: change the Action Map to UIControls, activate the UI GameObject, draw the inventory. So yes, this doesn’t exactly map to the implied action. The way I handle it is with lambda expressions

inputContext.FPSControls.OpenInventory.performed += e =>
{
      ChangeContext(UIControls);
      uiGameObject.SetActive(true);
      uiGameObject.GetComponent<InventoryUI>.Draw();
}

I use the generated input class, but instead of making an instance of it for every object which listens for input, I have one instance wrapped in a Singleton. Pattern purists will thumb their nose at that, but I really don’t care.

One thing to note is that while I too love events, in many cases polling just makes sense. Listening for changes to an axis which the player will be constantly moving around? Polling that is both simpler and more efficient. On that note…

… the docs don’t do a great job of making this clear, but the new system supports polling perfectly well. It’s a while since I’ve looked at or changed my input code, but I think you can just call ReadValue() on the action you’re interested in.

You can use them however makes sense. For me, it feels very natural to do the abstraction in the input manager (“WASD keys, left stick → move”) and then in my code just read the value from the action. I don’t want the code which controls my car to have to worry about what inputs were pressed, it just cares how hard the player currently wants to accelerate.

That’s pretty much what I said (although I could be more clear). You can use polling with readvalue.
However, I’d prefer if there was a 4th callback that would trigger when any of the other 3 fire.

Both the started, performed and canceled events have the same signature, so you can add the same listener to all of them. You can even make an extension to do that in one line!

Imagine that your handler is a lambda function.

Although extension method will work in this case, yes.

I wasn’t sure if that’s what you meant, or if you’d meant that the new system was annoying because it didn’t appear to support polling (which is a conclusion many seem to reach). Mostly I was clarifying for other readers.