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!
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.
Table of Contents
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 PlayerInput
s.
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:
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:
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:
- I only need a fraction of the code. Like 20% at most.
- 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:
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:
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:
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]
):
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
:
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:
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:
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?
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!
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! 🧐
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