8. Spawn Players (Write Better Netcode)


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.

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.

Spawn Players as CouchPlayers
CouchPlayers Inspector. Hidden scripts expose no Inspector fields.

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:

Even the Collider might be modularized later on.

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!

We won’t sync scale, and rotate only about Y.

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:

Hidden scripts currently don’t expose Inspector values

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:

I rather return null than risk an exception for out of bounds indexes.

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:

ShuffleAvatar will just continuously change each local player’s Avatar.

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:

SetPlayerDebugName replaces “(Clone)” with the couchPlayerIndex

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:

Okay. Wait. What? I’ll explain …

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:

Not the owner? We don’t have a TaskCompletionSource for remote spawns.

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.

Adding the remote player to the list of remote couch potatoes.

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:

Those numbers are the ClientIds. Local (owned) objects have a dotted icon.

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!

One response

  1. […] that we can spawn players, we want to have a selectable Avatar. The Avatar is the visual representation of the […]

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