How multiplayer works

The multiplayer in Northgard is based on a simple host/client architecture. The host machine, from which a game was created, is responsible for managing all gameplay related actions, like moving units, activating abilities, controlling AI and counting scores. The client machines are the ones joining the lobby, reading the state of the game from the host and sending commands to the host each time the player does something.

In the case of scripting, this means most of the scripts that affect the gameplay must be executed only by the Host, while the parts that affect the UI or feedback can be executed by everyone.

Some examples of script affecting gameplay are :

  • Changing a player’s resources,
  • Spawning/killing a unit,
  • Giving a move order to a unit,
  • Updating a player’s objectives,
  • Making a player win.

Some examples of script affecting UI or feedback :

  • Showing a top-left corner notification,
  • Showing a ping on the map,
  • Starting a dialog,
  • Using trace().

New features for multiplayer scripting in 1.4

Who is the host?

One rule to keep in mind is : what can be executed on a client can be executed on the host, but not the opposite. It is then important to separate host specific script from the rest. Thus a new isHost() : Bool function was added.

function init() {
        // Write script for the host only
    // Write script for everyone }

In the documentation, a new [OnlyHost] was added to tell you which function can be called only by the host and which field can be modified only by the host. Trying to execute a host only function during a multiplayer game will result with an exception on the client saying you don’t have the permission to call such function or modify such field.

Callback and feedback

Usually, when you call a script function as always, it is executed only on the machine running the script. Now that the host can have ownership of the gameplay script, it is important that it can communicate the results with the clients. For that, you now have the functions invoke() and invokeAll(). invoke() allows you to execute a function on a specific client, while invokeAll() will execute a function on every machine, including the host one.

Here is a simple example of a script where the host rolls a dice for each player and has them display the result.

function init() {
        for(player in state.players){
            // Create a list of arguments
            var args : Array<Dynamic> = [];
            // Push the result of the roll
            // Invoke displayResult on the player with the given argument
            invoke(p, “displayResult”, args);
// This function will only be called using invoke function displayResult(result : Int){     trace(“You rolled “ + (result + 1)); }

invoke() takes the player which will execute the given function, the name of the function and a list of arguments. Please note that the list of arguments must match the parameters of the called function.

displayResult() only ask for an Int, so we only add an Int to the argument list when calling invoke(). If it asked an Int and a String, the list must have an Int and a String in the same order.


Sending commands to the host

Now that we know how to invoke functions on the client, it is interesting to send commands to the host. The best situation to send commands is to allow the players to choose options using the objectives’ buttons.

For that, we now have the function invokeHost(), that will execute a function on the host at the demand of the client.

Since the function called by invokeHost() will always be executed on the host, you can call any [HostOnly] functions there to react to your client’s commands.

Two way communication

This shows the communication between both machines.

Network overload and infinite loop

When scripting for multiplayer, be really mindful of one thing : There is no security over when and how much you use invoke() and invokeHost(). If you invoke two functions that keep invoking each other, this will create an infinite loop that will slow down performances and might saturate your network.

Objectives in 1.4

Previously, the objectives were local to a machine. Since 1.4, they are now owned by each player and managed by the host.

Thanks to these changes, each player in a multiplayer game can have his own sets of objectives. But that also means that only the host can validate an objective and change them. This means all objective operations must be wrapped inside a if(isHost()) condition!

Also note that Northgard’s AI player cannot interact with objective buttons. It is a good idea to not give objectives to AI players. You can test if a player is an AI with the Player.isAI() function.

Now that you have invoke() and invokeHost(), you can have your host and clients communicate both ways. The following script is an application of both. We just ask each player a question, and the host keeps track of all the answers.

var answers : Array<{p:Player, a:Int}> = [];
function init() {     if (state.time == 0)         onFirstLaunch(); }
function onFirstLaunch() {    // Only the host setup the objectives     if(isHost()){        // Each players has its own set of objectives         for(p in state.players){             p.objectives.add("question", "Is Spidyy awesome?");             p.objectives.add("yes", "Yes ! A God !", null, {name:"Yes", action:"answerYes"});             p.objectives.add("no", "No. I mean, who's that?", null, {name:"No", action:"answerNo"});         }
       // The host get an additional set of objectives to see the answer of each player.         for(p in state.players){             me().objectives.add("answered" + playerId(p), + " answered...");         }     } }
// Just assign an index to a player function playerId(p : Player) : Int{    
i = 0;     for(other in state.startPlayers){         if(other == p)             return i;         i++;     }     return -1; }
// Called from client, send a message to server function answerYes(){    // Create a list of arguments     var args : Array<Dynamic> = [];    // add the client's player. Could be the player ID instead. Needed for the callback.     args.push(me());    // add the answer     args.push(1);    // send to the server     invokeHost("clientAnswer", args); }
// Called from client, send a message to server function answerNo(){     var args : Array<Dynamic> = [];     args.push(me());     args.push(0);     invokeHost("clientAnswer", args); }
// Called on the server by the client using invokeHost function clientAnswer(p:Player, a:Int){    // Save the answers     for(o in answers){         if(o.p == p)             o.a = a;     }
   // Update the client's objectives     p.objectives.setStatus("question", OStatus.Done);     p.objectives.setStatus("yes", (a == 1)? OStatus.Done : OStatus.Missed);     p.objectives.setStatus("no", (a == 0)? OStatus.Done : OStatus.Missed);
   // Update our host objective for this player     me().objectives.setStatus("answered" + playerId(p), (a == 1)? OStatus.Done : OStatus.Missed);
   // Invoke a callback function so the client gets noticed that his answer got properly received.     var args : Array<Dynamic> = [];     args.push(a);     invoke(p, "confirmation", args); }
// Called on the client by the server using invoke function confirmation(a:Int){     @async{         talk("You answered " + ((a == 1)? "Yes" : "No"));     } }