10. Assign Input Devices


With multiple (splitscreen) players on a single machine, each player has to have its own Input device. You may be accustomed to games where couch players have to Press [X] To Join. That kind of thing!

Press Join to X?

To that end, we need to assign a device to a specific player so that henceforth we can route all input from that device to that particular player’s object.

Surely Unity’s Input System is making that easy with PlayerInput and the aptly named PlayerInputManager for splitscreening?

                  No.

My Input System Requirements

My needs are quite simple: hook up four users with four devices.

I will only support up to four Gamepads, with the “host” player also being able to use Mouse/Keyboard (with 3 Gamepad friends). Input System does not support multiple keyboard/mice anyway.

No touch or XR input or anything like that. Not even Joysticks, as I’m not planning on making a flight sim. Can still be added later.

I need to spawn and despawn networked couch players when device pairing happens. I cannot have Input System spawn objects for me.

I want to fully take control over how the screen gets split. Imagine a four-player splitscreen but player 2 drops out – I don’t want players 3 and 4 to suddenly have to look at a different part of the screen or have everyone deal with different viewport aspect ratios.

What’s The Deal With PlayerInput?

PlayerInput and its companion PlayerInputManager is what I decided to stay away from due to these requirements. But first …

PlayerInput Does What Exactly?

PlayerInput is handling the input actions and device assignment for a single player. This thing, you know it:

PlayerInput also provides us with four different ways to react to Input events: direct or broadcast SendMessage, C# and Unity events.

The latter is the most cumbersome, least desirable. Don’t even think about using Unity Events!

SendMessage is most convenient, but you only get the performed event and no context, such as which device issued the event.

PlayerInputManager Does What Exactly?

PlayerInputManager ‘manages’ multiple PlayerInputs.

Notice how little meaning ‘Manager’ conveys? Just sayin’. 😐

PlayerInputManager allows multiple players to join/leave a session with a button press or some other means. That thing:

PlayerInputManager does too much yet isn’t configurable enough.

Awkwardly, PlayerInputManager requires specifying the player prefab which it will Instantiate for you. That’s great, except that this isn’t going to work with Netcode!

On top, it also handles splitscreen for us which seems great on first sight .. until you realize it’s rudimentary and not configurable.

Why I Won’t Use PlayerInput(Manager)

Combined these classes have 2,800 lines of code and comments. They do way more than what I need, and often more than what should be done in a single component.

They both have various TODO and FIXME dispersed throughout that are telling of their shortcomings. This just hurts:

////FIXME: PlayerInput is incomplete, confusing, over-engineered

PlayerInput also does a few things related to Camera (deprecated) and auto-switching of devices (singleplayer only). That means two main features aren’t actually useful to me, and going to be misleading every time I test with only a single player. Ugh.

During MultiPal development, I realized I could not use PlayerInputManager simply because there was no way to initialize it with an existing instance. It’s useless for Netcode.

Even the Input System manual admits that:

They are meant primarily as an easy, out-of-the-box setup that eliminates much of the need for custom scripting.

Analyzing their code told me two things:

  1. I only need a fraction of the code. Like 20% at most.
  2. It’s not all that hard to do it yourself with all options open.

Well, the latter comes with a caveat: I did have to learn more of the InputUser and InputDevice types. But that time was well spent!

Over the years I always hesitated to dig deeper into Input System. Turns out I didn’t even have to dig nearly as deep as I worried.

Introducing InputUsers

My InputUsers component assigns a InputUser to a given InputDevice when the Join/Leave action is fired – this could be the literal X button.

It also uses the auto-generated Action Map C# class and instantiates a copy for every player. The copies allow custom control mapping. I only have a vague idea how to make/save custom runtime bindings.

Spawning CouchPlayers

Let’s look at this from the outside in. I changed CouchPlayers to allow spawning and despawning of individual network players:

The Despawn RPC is new but … not worthy of showing.

Both methods are hooked up to device pairing events:

And these events are registered and unregistered when our CouchPlayers instance (aka the “default player prefab” in NetworkManager) spawns and despawns:

And that’s where InputUsers comes into play. I get it via the trusted Components Registry object. And this also tells you that InputUsers exists outside network sessions:

Input ain’t networked. Of course!

Note that pairing is currently enabled for the whole duration of the network session. That’s only for testing, and will later be restricted to the pregame Lobby. Though it could also be a feature.

Generated Input Action Class

I make use of the Input Action asset’s auto generation feature:

Currently generates 1,600 lines of code.

This provides neat interfaces for each action map, such as this one:

The interface strictly matches the Input Action map in the GUI:

The positive benefit here is that you get an actual compile error if you rename, add or remove an Input Action. Because the classes implementing the interface may have a missing implementation.

Why is a compile error good?

It’s definitely better than if your (recently renamed) Input Action method simply won’t run anymore. A newly added Input Action will also be an error until you implement it, thus reminding you.

InputUsers In Detail

InputUsers is only interested in implementing the IPairingActions in order to relay the device pairing events:

It needs to do a few more things though but it’s pretty complete already at about 160 lines total. Less than 10% of PlayerInput!

InputUsers keeps track of each user and per-user actions:

It creates four users without paired devices, then pairs all unpaired devices to the HostUser (short for m_Users[0]):

HostUser is the one hosts the couch. Not to be confused with network Host.

The HostUser? That’s the couch player who invited friends over to play via splitscreen – it’s not necessarily the network session host.

Why do I pair all devices to the HostUser? Well, the Host player ought to be able to freely choose the device to play with at any time. The Host may also be playing alone, in singleplayer.

The GeneratedInputActions class is instantiated per user too. This is done so that we can later have custom control mappings per user. Maybe someone would like to remap the X button action?

InputUsers sets itself as the callback for the Pairing action map. Means we have to implement the OnJoin and OnLeave callbacks as defined by GeneratedInputActions.IPairingActions:

Join and Leave are mapped to the south and east buttons for testing.

If I ever find the need, the InputAction.CallbackContext gives access to time, phase, control, device, etc. for the input. Here, I pass in the control’s device which the join/leave action originated from.

Pairing A Device

The actual pairing and unpairing took me a bit of effort to put the fragmented pieces of PlayerInput back together. I’ll post it in full:

Assign Input Devices

First line: I don’t want to pair with anything but Gamepad devices. Keyboard and Mouse are already and always paired to the Host user. Just don’t unplug them after the game launched, okay? 🤭

By the way, part of PlayerInput‘s complexity comes from checking whether a device supports the mapped input actions. That’s of no concern here thanks to narrowing down the requirement to Gamepads and Mouse/Keyboard.

The first block tries to find if the device is already paired to a user:

Note: InputUser is a struct returned as a Nullable<InputUser>

We will most likely find a paired user – the host. When someone else wants to join the game, I unpair the device (grab it) from the host to make it available for pairing.

Note that InputUser is a struct. FindUserPairedToDevice returns it as InputUser? though – a nullable value type. That’s why we have to check it for null and we need to use deviceUser.Value to get the actual struct.

With the device user now likely being null – the only exception being an already joined user who tries to join again on the same device – we pair to the next unpaired user:

If we get a valid userIndex we pair the device, associate actions, and assign the struct back to our m_Users array. I don’t quite understand why InputUser is a struct but okay, that’s what it is.

At the end of the pairing process the OnDevicePaired event is invoked with the user and device. I currently only need the user.index though – it is equivalent to the Player’s index in CouchPlayers.

Getting A User Index

A quick check-up on GetUnpairedUserIndex reveals that it looks for one of our InputUser instances which has neither paired nor lost devices.

Lost?

Yes. Lost! The ending had me pull the plug.

A lost device is one whose battery ran out or it got unplugged. Those get moved to the lostDevices list but remains paired to the user!

Checking for both paired and lost devices.

Notice that if we find that all devices are already paired, we can’t let a fifth player join. Thus -1 is returned. Hence the userIndex >= 0 condition above, just before starting the pairing procedure.

Oh, and we won’t ever get the Host index because the Host always has at least the Keyboard/Mouse paired.

Unless .. the Host somehow manages to start the game without either being connected, or unplugs them at runtime. Sorry, but I just won’t complicate the code to deal with such a rare situation.

Don’t Cross The Streams!

So, this isn’t perfect but it’s pretty damn close and works reliably!

I can pair and unpair users and a new user will always fill the first available slot.

This provides consistency to allow players to arrange themselves on the splitscreen areas of their choice merely by the order they join the game.

Safety tip: couch players hate crossing eye streams! 🧐

Don’t cross the splitscreen streams. It would be bad!

Unpairing A Device

Unpairing works quite similar to pairing. Again we will only unpair Gamepads in case Keyboard/Mouse input is mapped to Join/Leave.

We will find the paired user again, except this time we expect to find a matching user and just to be sure, I double-check that it’s not accidentally the Host player.

I pass null to AssociateActionsWithUser since the docs mention that this unsets the action map. Not sure if required.

Lastly, the host gets the unpaired device back in case the last pal left and hands over the gamepad:

“Surely you can fight the boss without us?!”

Enable Pairing Mode

To only allow device pairing during a specific time, typically in the pregame Lobby screen, the PairingEnabled property exists.

This enables or disables the corresponding Pairing Input Map:

In fact, it calls Enable() or Disable() on the Pairing action map for all users. There’s no need to individually toggle pairing.

The OnJoin and OnLeave events will only be invoked if the particular action map is enabled.

By the way, the Pairing Input Map defines two gamepad-only buttons to join or leave a game session as the 2nd, 3rd or 4th player:

Notice that I didn’t even bind the Mouse/Keyboard to pairing since I assume them to be always paired.

Register New Devices

One last but not unimportant detail: what if a Gamepad gets connected or turned on at runtime? Easy:

The OnDeviceChange is raised whenever a device, uh, changes.

There are many kinds of changes but I’m really only interested in added devices. If a device is removed, well, it just stays in the list of paired devices – dormant. Actually, it’ll be in lost devices.

If you think about this, there are plenty of complications that could theoretically occur. Hence the typically short-lived, pregame “join up” screen. That’s quick to restart, the game session .. not so much.

The PairUnpairedDevicesWithHostUser method is the same code you saw earlier in CreateInputUsers. I only extracted it as a method of its own now that it’s being used in two places:

All good IDEs have an Extract method refactoring – you should not have to do any typing to extract a block of code as a new method:

Lean heavily into refactoring functionality. Huge timesavers!

You’re Deadzoned!

Since Mouse/Keyboard are always assigned to the Host player as well as any unpaired, connected devices, one needs to consider what happens to the Host player if the device is noisy, or worn.

Imagine Host is playing on Mouse/Keyboard but a Gamepad is also connected. The Host slowly drifts to one side all the time.

Why is that?

My gamepads are so worn, the thumbsticks don’t center correctly anymore. With Input System’s default 0.125 (12.5%) deadzone setting I cannot play, I need to double it. If this happens in a game but there is no deadzone setting, I have to make a cute kitten cry!

Speaking of which .. what if someone grabs a gamepad, turns it on, and begins messing with the Host’s input? I don’t care. This issue is best resolved with an appropriately executed melee attack. 😠

Next …

Read on with 11. (Responding to Input Action Events) (tbd)

Return to the Write Better Netcode Overview

Source Code on GitHub (GPL3 License)

Join my Patreon – it’s free! Get the latest updates by email.

Leave a comment below if you have any questions or feedback!

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.

WordPress Cookie Notice by Real Cookie Banner