Go, Get Set, Ready!
Or… Execution Order in the Unity Networking HLAPI
As of June 2017 and Unity 5.6.1, the documentation for Unity Networking (formerly, and still colloquially, “UNet”) is still thin on details that matter a great deal when building multiplayer games with its HLAPI (high level API). I use the HLAPI because my networking needs are not very esoteric, but even though it simplifies many aspects of multiplayer game dev, there are several design snags that I find myself having to work around in every new project using the system. Some of my scars might give users new to Unity Networking a leg up when using it for the first time, and maybe users more familiar with the system than I can suggest alternatives to my hackish solutions. It won’t do to reiterate the manual, so I’m going to assume that anyone reading this has at least skimmed the Unity Networking HLAPI.
Ordering Operations
I’ve used Photon with Unity in the past, so I realize that getting everything to happen in sequence is not a problem unique to any one networking solution. However, the in-house system does have some distinctive characteristics when it comes to getting things straight, so to speak.
First of all, you know that scripts using the Networking system must extend NetworkBehaviour instead of MonoBehaviour, and that NetworkBehaviour has several lifecycle virtual methods you can override with your own functionality:
OnStartServer() OnStartClient() OnStartLocalPlayer()
Most commonly, these will all exist in a single script, since a lot of games run from a “host,” or one of the players who has taken responsibility to run the server and still play as one of the clients on that server. These methods are simple and are invoked at self-explanatory times—the only gotcha is that the very useful isLocalPlayer
and hasAuthority
variables can only be used in OnStartLocalPlayer
or thereafter; before that they will be false. NetId values are also 0 until OnStartServer
or OnStartClient
, so don’t try to go finding objects by that index or sending RPCs until then. The manual reminds you of most of this, though. The trickier part comes into play when you try to use MonoBehaviour’s lifecycle methods at the same time:
Awake() OnStartServer() OnStartClient() OnStartLocalPlayer() Start()
The list above is the order in which these methods will execute if a new NetworkBehaviour is instantiated and spawned by the NetworkManager on the server—for example, the main player object. This is reasonably clear, but it gets slightly more complicated when we need to start coordinating other types of objects. Note, for example, if you have regular MonoBehaviours in your scene such as managers or environment scripts, their initialization methods will execute fully before the NetworkManager automatically spawns the player:
myMonoBehaviour.Awake() myMonoBehaviour.Start() myPlayer.Awake() myPlayer.OnStartServer() myPlayer.OnStartClient() myPlayer.OnStartLocalPlayer() myPlayer.Start()
This is probably desirable and can be customized by extending the NetworkManager. Next, let’s add scene objects into the mix. Scene objects are NetworkIdentities that exist in the scene when it is loaded; they are not instantiated and spawned like the player or other network-sensitive game objects, e.g. a projectile. (A “network-sensitive” game object just means that it has a NetworkIdentity.)
Scene objects Awake
when a scene is loaded, but the NetworkManager will disable them all before they can Start
. It will re-enable these scene objects a little later, causing network-sensitive scene objects to Start
with the spawned player:
myMonoBehaviour.Awake() mySceneObject.Awake() <-- the order of the same lifecycle methods occurring simultaneously is undefined myMonoBehaviour.Start() mySceneObject.OnStartServer() mySceneObject.OnStartClient() myPlayer.Awake() myPlayer.OnStartServer() myPlayer.OnStartClient() myPlayer.OnStartLocalPlayer() mySceneObject.Start() myPlayer.Start()
Okay, so there is some slightly unexpected order there, but it kind of makes sense when you think about how the NetworkManager might have its own sequence for initialization. It can be easy to start getting tripped up at this point, though. Here’s the same sequence of events on the client after it connects:
myMonoBehaviour.Awake() mySceneObject.Awake() myMonoBehaviour.Start() hostPlayer.Awake() mySceneObject.OnStartClient() hostPlayer.OnStartClient() myPlayer.Awake() myPlayer.OnStartClient() myPlayer.OnStartLocalPlayer() mySceneObject.Start() hostPlayer.Start() myPlayer.Start()
It feels weird because Awake
is being called between a lot of other methods in the same frame. There’s a decent amount to keep straight here, but we also need to toss in the lifecycle methods on the NetworkManager. These can be overridden with your own logic as well. Here they are on the host:
netManager.OnStartHost() netManager.OnStartServer() netManager.OnServerConnect() <-- this is when the client that is part of the host connects to the server netManager.OnStartClient() <-- on the client side of the host myMonoBehaviour.Awake() mySceneObject.Awake() myMonoBehaviour.Start() netManager.OnClientConnect() <-- on the client side of the host mySceneObject.OnStartServer() mySceneObject.OnStartClient() <-- ditto netManager.OnServerSceneChanged() netManager.OnClientSceneChanged() <-- ditto netManager.OnServerReady() netManager.OnServerAddPlayer() myPlayer.Awake() myPlayer.OnStartServer() myPlayer.OnStartClient() <-- ditto myPlayer.OnStartLocalPlayer() mySceneObject.Start() myPlayer.Start()
One oddity of note here is that the MonoBehaviour initialization methods will have already executed before the scene changed methods on the NetworkManager. This method seems to indicate that the scene has not only changed but that its MonoBehaviours (not its scene object NetworkBehaviours) have fully initialized.
Because of this more complicated order of operations, you can’t necessarily rely on MonoBehaviour lifecycle methods to be a good place to perform initializations that other scripts might depend upon if they are defined in a network-sensitive class.
SyncVars
The HLAPI in Unity Networking strongly encourages developers to use SyncVars to keep the client and server states the same. The encouragement is mostly positive: SyncVars are initialized automatically for clients (so they don’t have to be specifically requested); they are efficiently managed; conveniently, they can be structs; and method hooks can be defined that will execute when the SyncVar changes on the client. This is all covered in the manual. There are a couple of gotchas, though:
First, as the manual points out, SyncVar hooks do not execute when the SyncVar is initialized for the first time on a client. That means that clients may need dedicated code to call these hooks OnStartClient
. For example, here is the first part of the order of operations for a SyncVar int on mySceneObject that starts at 0 and is set to 5 in OnStartServer
on the host:
mySceneObject.Awake() <-- Sync Var is "0" myMonoBehaviour.Awake() myMonoBehaviour.Start() mySceneObject.OnStartServer() <-- I set the SyncVar to "5" in this method mySceneObject.MySyncVarHook() <-- The hook is called mySceneObject.OnStartClient() <-- SyncVar is "5" in this method ... etc, as before.
As expected! Here is the same sequence on a pure client:
mySceneObject.Awake() <-- Sync Var is "0" myMonoBehaviour.Awake() myMonoBehaviour.Start() hostPlayer.Awake() mySceneObject.OnStartClient() <-- SyncVar is "5" in this method ...
No hook. In OnStartClient
, you can manually call MySyncVarHook
to initialize if you want, but do be aware that the hook WILL fire if the SyncVar is set on the server while the client is connected. This could result in the hook executing twice under some circumstances, so you will need to design your hook accordingly or write special instructions in OnStartClient
. To demonstrate, here is the whole sequence above. See how the client’s player hook does execute, even if the host’s does not?
mySceneObject.Awake() myMonoBehaviour.Awake() myMonoBehaviour.Start() hostPlayer.Awake() <-- a SyncVar in this method, even for the host player, is still uninitialized at 0 mySceneObject.OnStartClient() hostPlayer.OnStartClient() <-- the SyncVar is 5 in this method myPlayer.Awake() <-- the SyncVar is 0 for this player myPlayer.OnStartClient() <-- its SyncVar is now 5 myPlayer.OnStartLocalPlayer() mySceneObject.Start() hostPlayer.Start() myPlayer.Start() myPlayer.MySyncVarHook()
Oh boy. I’m not sure why the SyncVar on the client player is set to 5 before the hook executes. And if we had called MySyncVarHook
in OnStartClient
as a strategy for initializing variables, it would get called once for the hostPlayer and twice for myPlayer.
The second gotcha is visible above: SyncVar hooks execute immediately on the server / host, but there will be a delay for pure clients. See how much later the hook is called on the client than on the host? This is true even on localhost. A delay is generally to be expected in networking, though, so it should always be incorporated into the design, which is fine. One might be forgiven, however, for assuming that RPCs could at least be ordered with SyncVar calls; they can’t.
RPCs are direct invocations of a specific method on a client (typically all clients). They’re nice and straightforward in the Unity Networking HLAPI, but if you mix them with SyncVars, you might find yourself confused. Here’s what I would imagine is a fairly common scenario:
[SyncVar] public int mySyncVar = 0; ... mySyncVar = 5; Rpc_DoSomethingOnAllClients()
If Rpc_DoSomethingOnAllClients
ever cites mySyncVar
, will the value be 0 or 5? The answer is it depends! On the host, the SyncVar will be 5, since the value is set immediately, but on the client the RPC (that is called after the SyncVar is set in the script) will arrive on the client first, meaning that mySyncVar
will be 0 for the duration of that method. It will be set to 5 shortly after. Can you see how a new user of Unity Networking might find this bewildering?
The solution to this is to only conduct logic that is relevant to SyncVar changes in the SyncVar hook. Still, in a project where RPCs might be zooming around while SyncVars are also being synchronized, the potential for this to happen between scripts becomes significant even for experienced users.
SyncLists
In a SyncVar hook, the value of the SyncVar is supposedly not yet set until the developer does so explicitly in the hook (using the hook’s input parameter). This is consistent and wonderful, since you have access to the previous value of the SyncVar and the new one, too.
SyncLists don’t have hooks in the same way as SyncVars. They use a callback that the developer must assign a delegate:
mySyncList.Callback = MyCallbackMethod;
When do you assign the callback? I’m not sure where is best, exactly. If it is assigned in Awake
, the callback will not be called even if you modify the SyncList in OnStartServer
(I’m not sure why), but it will be called if you modify the list in Start
. If you assign the callback in OnStartClient
, the most likely location, you’ll also want to assign it in OnStartServer
in case you find yourself using a dedicated server (and not a host). It would be clearer if the callback could be assigned when the SyncList was declared like SyncVars and their hooks.
Unlike SyncVars, the SyncList has already been modified by the time the callback is called. As such, the SyncList callback delegate requires an index parameter in its signature. This index gives you a little more context about where a SyncList was added to, set, removed from, etc. Likewise, an Operation
enum tells you what kind of change was made to the list:
public void MyCallbackMethod( MySyncListType.Operation op, int index ) {}
The index variable is a little weird, since it’s only useful for certain operations. Unlike the SyncVar, where you have the whole previous SyncVar value and can compare it to the new one in any way you wish, it’s sometimes hard to get complete information from a SyncList callback. For example, if you were to call:
mySyncList.Remove( mySyncListElement );
This will perform much like a regular List operation, except the index that is passed into the callback is always 0. If you use the SyncList.RemoveAt
function, however, the index will be whatever index was removed. What are you going to do with that information, though? Anyway, the discrepancy is strange and undocumented as of today.
The networking documentation is still really sparse in the scripting reference. There are things I wish I had known up front when I first started working with UNet. No doubt some of these are now mentioned in the manual, perhaps including the infuriating requirement that all NetworkIdentities begin their life at the root of the scene hierarchy (why is the universe so full of pain?) and how little information is available to rooms listed by the NetworkLobbyManager
(another kettle of fish). I hope that enumerating these nitty details about execution order will help you better navigate your multiplayer games with UNet. Let my skull and skeleton rest upon these spikes as footholds for wiser adventurers to come!
Do you have any suggestions for making multiplayer games more smoothly in UNet? Topics of particular frustration or delight?