Spawn players? Add the player prefab to NetworkManager
‘s Default Player Prefab, done. Nope!
We’re literally going to spawn multi-players per client since we will have up to four splitscreen “couch players” all going online together.
We will use the CouchPlayers prefab as the player prefab. It will handle spawning the actual controllable player prefabs.
Table of Contents
The CouchPlayers Prefab
The CouchPlayers prefab is assigned to NetworkManager:
The CouchPlayers prefab itself contains a NetworkObject
with non-default settings, the CouchPlayers
script.
And the pattern I will follow for similar use cases: two separate components (CouchPlayersClient
, CouchPlayersServer
) for the client-side and server-side methods and RPCs.
That purple pill is just for debugging to see there’s something there.
NetworkObject Settings
Since CouchPlayers is a purely logic object, I disabled Synchronize Transform since it’ll never move. At least this saves the initial transform sync on spawn. Otherwise and generally, if an object doesn’t move, it doesn’t send any data.
The other settings I changed probably won’t have any effect anyway since we aren’t parenting this object, nor are we changing scenes. I enabled Always Replicate As Root, and disabled Scene Migration Synchronization and Auto Object Parent Sync.
Why? That was just me being pedantic.
The ModularPlayer Prefab
You saw it above in the CouchPlayersServer script: the ModularPlayer prefab is what we’re going to spawn as the actual, controllable, visualized network player.
Modular means this player prefab is practically empty:
The idea is that we’ll just spawn in what we need for any given type of player. This mainly includes the visualization (Avatar), the character controller, and the Camera – I want to support First Person, Third Person and Top-down perspectives.
NetworkTransform Settings
It’s super important to make the right changes to NetworkTransform
settings!
NGO ships with defaults which avoid any possible quality issue in all circumstances – however this is causing the traffic per NetworkTransform to be 2-4 times higher as necessary!
First, the Axis to Synchronize checkboxes: we won’t scale players and only rotate them around Y. Knowing that each value is 4 bytes we’ve already cut down the traffic by 20 bytes per transform update. Per player! That’ll spare us up to 9 KiB/s in a 16-player match.
But it gets better: I ticked Use Unreliable Deltas and Use Half Float Precision. This cuts down the remaining traffic in half with typically no noticable side-effects – provided your players don’t move at absurdly high speeds.
You do have to manually call the NetworkTransform Teleport
method to move players over large distances. Best practice anyway.
Half float means the float precision errors that start plaguing us at about 5-10 thousand units distance from origin will move a lot closer! But with delta updates enabled this doesn’t matter since we’re only sending the change from the previous position, a much smaller value range.
Note that I also unchecked Interpolate. When I want to develop network behaviour I need to see what’s actually happening. I can’t have the players rubber-banding about, lagging behind. I’d rather increase the tick rate than enable Interpolate to combat judder.
Lastly, a new NGO feature: we can set Authority Mode to Owner which means this NetworkTransform
is a client-authoritative NetworkTransform
without having to create a subclass.
Player Components
The ModularPlayer prefab root also has these scripts next to the NetworkObject
and NetworkTransform
:
Again, I’m following the same principle as with the CouchPlayers prefab. There’s one central script (Player
, CouchPlayers
) which exposes the “public” API – for outside objects.
The other scripts separate core components and are exposed through the Player
script:
PlayerAvatar
will handle replacing the Avatar object.PlayerVars
contains all NetworkVariable and RPCs.PlayerServer
contains server-side logic and RPCs.PlayerClient
contains the client-side logic and RPCs.
These classes and their methods are generally exposed as internal
at most because I want all outside calls to go through the Player script. This simplifies component management, and makes it easier to make changes to the underlying structure of the object.
I’ll explain the Avatar selection process later.
CouchPlayers Script
The CouchPlayers
script will take responsibility for these tasks:
- Give access to a client’s 0-4 player instances.
- Spawn and Despawn local player instances.
- Add/Remove remote player instances.
- Orchestrate RPC calls to / from server.
The script will later allow local couch players to join/leave a session on the fly by pressing a join button or via GUI.
Since CouchPlayers
is our official Player Prefab this provides us with a shortcut to get any local couch player, for example:
var playerObj = NetworkManager.LocalClient.PlayerObject;
var localPlayers = playerObj.GetComponent<CouchPlayers>();
var player3 = localPlayers[3];
The CouchPlayers
script has an indexer for convenient access:
Why four couch players you might ask? Well, a four-way splitscreen is as good as it gets. Any more splits in your screen and it’s .. ewww.
Sure, you could have a top-down view with every player on screen. Typically you only have four gamepads max though. Remember, this is couch play! One does not simply couch with a mouse.
Awaitable Spawn RPCs
I’ve come to like the awaitable Unity Services APIs. So I wanted to experiment with awaitable RPCs. Spawning is the ideal opportunity as I need to wait for the ClientRPC with the newly spawned object.
First, I will spawn test players as soon as CouchPlayers
spawns:
Ignore the hacky nature of this test setup. Just take note of the await in the m_ClientSide.Spawn
calls and the fact that this Spawn
method returns a valid player object that we can modify thereafter:
Immediately neat, right? Or: neatly immediate!
Well, this has one downside: it only works for client-side initiated spawn. We will have to handle receiving a spawn notification from a remote player differently. Still, it’s neat, right? 🥲
Implementing Awaitable RPCs
The m_ClientSide
above is the CouchPlayersClient
instance which handles the await-ableing of the Spawn and DidSpawn RPCs.
To that end, we’ll need a so-called TaskCompletionSource:
The Spawn
method returns a Task<Player>
. Task
wraps the return value and makes it awaitable. The await
unwraps the Task automatically for us so it’s valid to write this:
Player spawnedPlayer = await Spawn(0, 0);
A TaskCompletionSource<Player>
is instantiated and set to an array before we return from the Spawn
method. This array:
.. has the same size as our players array. We need one TaskCompletionSource
per player in case multiple couch players attempt to spawn simultaneously.
This means while the SpawnPlayerServerRpc
is on its way to the server, we have a TaskCompletionSource
that we hold on to because we’ll need it to literally complete the task.
When we receive the DidSpawnPlayerClientRpc
we can then call SetResult
on the player’s TaskCompletionSource
:
SetResult
will return the Player instance to the awaited Spawn
, thus we can set the TaskCompletionSource
to null right after.
For safety, I throw an exception first thing in Spawn
if there ever were a second spawn issued for the same couch player.
The Gotcha
As I mentioned before, we need a separate code path for players that weren’t spawned by “us” but are remote player spawns. Here’s the full client Rpc for reference:
From the top: the NetworkObjectReference
is a wrapper for a ulong NetworkObjectId
. I’m afraid I have to mention this because I just see too much code where devs are actually using the latter and then look for the object in the SpawnManager
.
It’s so much easier with TryGet()
.
Using the NetworkObject
of the player I get the Player
script which is either returned by the TaskCompletionSource
, or sent to the CouchPlayers
script via the AddRemotePlayer
method.
The IsOwner
flag tells us whether we’re working with the local CouchPlayers
instance (we own that), or a remote one. Remote ones are getting their m_Players array filled as above so we have all players on all machines in the same place, just on different objects.
Keep in mind: this CouchPlayers
instance was spawned as the player prefab of a remote client, it’s not the same as the local CouchPlayers
instance. Yeah, this gets me too, sometimes.
Best to look at the hierarchy, you’ll see two CouchPlayers
instances: the local (0) and remote (1) ones in a two-player match:
And of course both clients have four couch players with couch indexes 0 to 3; and their names appropriately updated. Still need to uniquify the CouchPlayers
‘ names though.
Both CouchPlayers
player arrays contain their respective four players on both machines – perfectly synchronized without specifically needing to synchronize a players list or something.
Server Spawn RPC
The observant reader has noticed that I haven’t shown the server-side SpawnPlayerServerRpc
in the CouchPlayerServer
script:
Nothing out of the ordinary. Instantiate
the (one and only) player prefab. Get its NetworkObject
and SpawnWithOwnership
. No, not SpawnAsPlayer
! We already have a CouchPlayers
player object on every client.
Our players are going to control non-player player objects. Exciting!
But really nothing out of the ordinary – what makes a player object special is only its auto-spawn behavior and that you can get its reference easily via NetworkManager
.
Then I’ll set the player’s AvatarIndex
– a NetworkVariable
hidden behind a property – and call DidSpawnPlayerClientRpc
.
Why DeferLocal = true?
You noticed?
[Rpc(SendTo.Server, DeferLocal = true)]
I always apply this flag to RPCs because most development happens in host-client sessions. DeferLocal
prevents RPCs from executing instantly on the host.
Instant RPC execution can lead to unexpected behaviour on the host. Specifically, it could cause a deadlock when you send a ServerRpc that sends a ClientRpc that sends the same ServerRpc. Means: force quit the editor, lose all unsaved changes!
But not having any delay on the host side also prevents you from seeing any issues only a non-host would notice, such as a short flicker when switching visuals.
Certainly, when you play singleplayer in Host-Alone mode you wouldn’t want that delay?
Well, no. But no.
The players will hardly ever notice since the online play has several frames of lag anyway. If the game feels right in online play, it’ll feel and play generally even more responsive with just a single-frame delay (< 0.017s) for RPC driven tasks in singleplayer.
It might be a factor if you are making a singleplayer speedrun / racing game where winning comes down to mere milliseconds. But even then everyone would have the same (miniscule) handicap.
Next …
Read on with 9. Select Player Avatar …
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