Unity Authoritative Server Part 2

3 years 1 month ago by damagefilter

NOTE: This is a copy of the original text and might lack photos and proper formatting. I'm leaving it here for reference and will eventually come around to fix these articles again!

Welcome back, people!
Today we're going to write the Netman, that's the class which is handling connection management and is timing the packets being sent, which is crucial for any network setup apart from very basic testing environments. You'll see what I mean when we get there.
At first, however, let us recap what we have done in the last article, just to make sure we have everything straight.

  • We created the Server-Side player movement logic, based on player input we get from the client
  • We created the Client-Side player movement logic, which is simply sending the server information about player input.
  • We assume our future player prefab(s) have a NetworkView attached which observes the prefabs Transform component
  • We concluded that through the NetworkView component, the movement data will be sent from the server to all clients

Now we're going to start off at that point.
For this it is very important to know when a NetworkView is sending data and when it is receiving data!
There is one easy rule to know: The one who's instantiating the NetworkView (or rather the GO it is attached to) will use it to send data, all others will receive. On that note: The one who is instantiating the GO is then the owner of this network view.
So we can also say: The owner is sending, all others are receiving.
You might have guessed by now that this means, per definition of our client/server implementation, that the server must instantiate each and every GameObject, including the player prefab. That's what the Netman will do for us by a combination of RPC calls.
If you happen to have the code with you we wrote the last time, you will notice that the client side player manager has that private owner property. That is not to be confused with the owner of an object from Unity's internal point of view. So, don't confuse that.
Let's start with the server-side of the Netman class, which I will put in src/server/Netman.js.

import System.Collections.Generic;
#pragma strict

/**
 * Server-side implementation for the generic network manager.
 * In this class are ONLY functions that are called on or as the server
 */
class Netman extends MonoBehaviour {
    public var player : GameObject; //player object to instantiate
    public static var levelName : String; //current level name

    private var playerTracker : List. = new List.();
    private var scheduledSpawns : List. = new List.();

    private var processSpawnRequests : boolean = false;
}

This is not the complete code, as you might have guessed but there are some things I want to explain before we dive into the methods.

public static var levelName
You might wonder why this is static of all things, well that is simply because it's much easier to access and the level doesn't change that much (probably). It's just for convenience so to find out the current level doesn't require an instance of Netman. If you feel better not using static, don't. It's up to you. We assume, again, that the GameObject to which the Netman is attached to has a NetworkView component and is observing the Netman script to keep this variable synchronised.

playerTracker : List
This list contains all connected NetworkPlayer structs and we will use it to spawn and destroy objects belonging to a player, depending on the situation, as a client is not allowed to destroy things on his own.

scheduledSpawns : List
This list contains newly connected but unspawned players. You might wonder why this is a list. Let me tell you why.
Imagine you have 10 players connecting, all at once (or with minmal delay) and some of them might have a slower connection and/or PC. Then you have 10 OnClientConnected calls on the server. So what? Well, before we go and spawn the client into the world we want to make sure it has actually loaded everything it needs to (the level) or else there will be trouble.
So we need to defer that process to a later occasion, and that's why we store the NetworkPlayers in a list.

processSpawnRequests : boolean
If this is not true, we will not process spawns in order to make sure there are no wild spawns. You can aswell omit that logic - it's just some extra measure of making sure the system runs stable. It should, however, run stable without it aswell.

Lets continue with the methods now that this is out of the way. We start with the method that deals with initializing the player spawn process.

//Called on the server
function OnPlayerConnected(player : NetworkPlayer) : void {
    Debug.Log("Spawning prefab for new client");
    scheduledSpawns.Add(player);
    processSpawnRequests = true;
}

This method is called exclusively on the server and it simply adds the NetworkPlayer to the spawning queue and enables processing of spawn requests.
Next function is the one that actually spawns the player instance in the network. We will utilise Network.Instantiate(...) for this.
Some people may start screaming now, why on earth am I suggesting Network.Instantiate? It's basically the same as with the undirected RPC calls. People say it's bad but no one cares to share WHY it is bad. I tell you why it is NOT bad though. Network.Instantiate is buffered, that means late-coming players will also receive this call and will therefore actually see previously spawned players. It builds around an RPC call itself, mind. If you were to do that on your own, you need to create your own network buffer, again. It's up to you but I trust Unity not to mess up that buffer and will continue to use Network.Instantiate happily.
Now that this is out of the way, here's the method:

@RPC
function requestSpawn(requester : NetworkPlayer) {
    //Called from client to the server to request a new entity
    if (Network.isClient) {
        Debug.LogError("Client tried to spawn itself! Revise logic!");
        return; //Get lost! This is server business
    }
    if (!processSpawnRequests) {
        return; //silently ignore this
    }
    //Process all scheduled players
    for (var spawn : NetworkPlayer in scheduledSpawns) {
        Debug.Log("Checking player " + spawn.guid);
        if (spawn == requester) { //That is the one, lets make him an entity!
            var num : int = parseInt(spawn + "");
            var handle : GameObject =  Network.Instantiate(
                                                player, 
                                                transform.position, 
                                                Quaternion.identity, 
                                                NetworkGroup.PLAYER);
            var sc = handle.GetComponent(C_PlayerManager);
            if (!sc) {
                Debug.LogError("The prefab has no C_PlayerManager attached!");
            }
            playerTracker.Add(sc);
            //Get the network view of the player and add its owner
            var netView : NetworkView = handle.GetComponent(NetworkView);
            netView.RPC("setOwner", RPCMode.AllBuffered, spawn);
        }
    }
    scheduledSpawns.Remove(requester); //Remove the guy from the list now
    if (scheduledSpawns.Count == 0) {
        Debug.Log("spawns is empty! stopping spawn request processing");
        //If we have no more scheduled spawns, stop trying to process spawn requests
        processSpawnRequests = false;
    }
}

What we do here is simple. requestSpawn() expects the NetworkPlayer in question as parameter and then iterates over the NetworkPlayer list of people that still need to spawn. If it finds the corresponding NetworkPlayer it will spawn it by calling Network.Instantiate and then send an RPC call to the newly added object, setting the player which is supposed to send data with it. The C_PlayerManager script will also be stored for tracking purposes (as discusses earlier).
You might have noticed, the NetworkGroup.PLAYER, in Network.Instantiate does not exist. That is because it is a custom enum of numbers. You may make one too. Mine has

  • DEFAULT = 0
  • PLAYER = 1
  • SERVER = 2

This is specifically to deal with Unity's network group mechanic. You can group every networked object by numbers for batch processing or for easy chat implementations. I simply masked those numbers with an enum for easy reading and code maintenance.
After the loop is through, the requesting player will be removed from the list of scheduled spawns and if the list is empty after this operation, we stop processing the spawns, until there is a new connection.

One more thing for the Netman on the server side now, the function to clean up spawned objects when a player disconnects.

function OnPlayerDisconnected(player : NetworkPlayer) : void {
    Debug.Log("Player " + player.guid + " disconnected.");
    var found : C_PlayerManager = null;
    for (var man : C_PlayerManager in playerTracker) {
        if (man.getOwner() == player) {
            Network.RemoveRPCs(man.gameObject.networkView.viewID);
            Network.Destroy(man.gameObject);
        }
    }
    if (found) {
        playerTracker.Remove(found);
    }
}

So this takes the NetworkPlayer and iterates over the tracked players list, checking against the script owners. If the right one is found it is removed with Network.Destroy() and all buffered RPC calls for this player are removed from the buffer so it doesn't spawn for players connecting after this event. (Because that would be stupid)

Lets talk client-side!
So what do we want the client to do? It should load the level that is currently loaded on the server and then request a spawn via the RPC call we wrote earlier on the server-side Netman.
So let's create src/client/C_Netman.js and it should contain something like this:

#pragma strict

class C_Netman extends MonoBehaviour {
    function OnConnectedToServer() {
        Debug.Log("Disabling message queue!");
        Network.isMessageQueueRunning = false;
        Application.LoadLevel(Netman.levelName);
    }
    
    function OnLevelWasLoaded(level : int) {
        if (level != 0 && Network.isClient) { //0 is my menu scene so ignore that.
            Network.isMessageQueueRunning = true;
            Debug.Log("Level was loaded, requesting spawn");
            Debug.Log("Re-enabling message queue!");
            //Request a player instance form the server
            networkView.RPC("requestSpawn", RPCMode.Server, Network.player);
        }
    }
}

That looks lightweight, does it not?

OnConnectedToServer()
Here it is VERY important to disable the message queue for this client as it's not in the correct scene yet. It's probably still in the scene where your menu is. So, load the level. Thanks to the NetworkView in Netman, the levelName variable is representing the current levels name. Let me explain what the message queue is. This is actually the queue in Unitys network layer, that contains all messages (or packets) that need to be sent over the network. When we're not in the level that is requried by the server, many of these messages will not be received, throwing errors over errors. So we want to stop these messages from being sent for the moment.

OnLevelWasLoaded()
Once the level has finished loading on the client we re-enable the message queue for the client and request a spawn on the server. As soon as we re-enable the message queue the server will send all the buffered RPC calls to the client so the other players will show up and the newly connected player is up-to-date with the current state on the server.
Once the requestSpawn call is through the player will get its object to play with and whoop. There you go.

And this is it for today. Remember, this is not intended to be a walk-through but a piece of content to help you understand things and propose a concept of authoritative servers. I encourage you to deviate from this and create your own concept with thie information provided.
I hope you find this useful.
Next time, I'll talk about a basic prediction method on the local player and a new class that handles remote object prediction to conquer the lag you're probably experiencing right now.
Feel free to experiment a little bit already.
Here are some hints:

  • We need to buffer server states for delayed playblack
  • We need to smooth out movement on the local player
  • The client-side C_PlayerManager needs to get some movement logic to think ahead but still be dumb enough