Unity Master Server Framework – Part 9 – Custom Spawner

In part 8 of the series we implemented a GameServer. In this part we will implement a custom spawner, and start it from within the editor or from the command line.

We are getting close to having the following working. This video shows the following

  • A Windows command window where I launched the master server and custom spawner in one process
    • A status line at the top of the window shows the following details.
    • [Master | Users LoggedIn: 10 Matchmaking: 1]  [Games  Played: 1000  Aborted: 0 Underway: 4]
      • Master – shows it is running a master
      • LoggedIn – # authenticated MSF clients
      • Matchmaking – # clients waiting in matchmaking queue
      • Games Played – # successfully completed games
      • Games Aborted – # games the GameServer aborted
      • Games Underway – # games running with launched GameServers
  • 10 clients, each running a state machine, requesting a game, being assigned a gameserver (launched windowless), and playing a game.
    • The client has a status line showing the following
    • [Client | Played: 123  Aborted: 0 | GameStarted] Game Count Down 3
      • Clients – shows it is running a client
      • Played – # games played
      • Aborted – # games aborted – from FailedToGetGame
      • GameStarted – an example of the current state of the client
      • Game Count Down 3 – an example status message

Port Allocator

When our GameServer starts it will connect to the MasterServer using the main MasterServer port. In addition, MSF provides an AssignedPort for use by the GameServer to use with the clients. To keep things clean, we will define the following ports.

  1. Msf.Args.MasterPort – to connect to the MSF.
  2. AssignedPort – to connect to clients for game management commands.
  3. GamePort – to connect to clients for synchronization – using Unet, Bolt, Forge, etc.

The MSF SpawnBehaviour class handles assigning ports for AssignedPort but does not have a separate GamePort. Also the AssignedPort functionality is embedded in the SpawnBehaviour and cannot be reused.

I implemented a PortAllocator utility class, modeled after the AssignedPort functionality, to manage a set of available ports. We will use this in our custom spawner to manage the GamePort ports.  Create a new script called PortAllocator in Demo/Scripts.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PortAllocator
{
    private int _startingPort;
    private int _endingPort;

    public int StartingPort
    {
        get { return _startingPort; }
        private set { _startingPort = value; }
    }

    public int EndingPort
    {
        get { return _endingPort; }
        private set { _endingPort = value; }
    }

    private Queue _freePorts;
    private int _lastPortTaken = -1;

    public PortAllocator(int startingPort, int endingPort)
    {
        StartingPort = startingPort;
        EndingPort = endingPort;
        _freePorts = new Queue();
    }

    public int GetAvailablePort()
    {
        // Return a port from a list of available ports
        if (_freePorts.Count > 0)
        {
            return _freePorts.Dequeue();
        }

        if (_lastPortTaken < 0)
        {
            _lastPortTaken = StartingPort;
            return _lastPortTaken;
        }

        if (_lastPortTaken < EndingPort)
        {
            _lastPortTaken += 1;
            return _lastPortTaken;
        }

        return -1;
    }

    public void ReleasePort(int port)
    {
        _freePorts.Enqueue(port);
    }

}

Create a Custom Spawner

This design needs control over each spawner running in the framework, in the case that multiple spawners are running to support multiple servers running multiple game serves.

The built in spawner manages the AssignedPort parameter it passes on the command line to the GameServers it spawns. We need to be able to assign the GamePort as well. The easiest way to do this was to build a custom spawner by subsclassing SpawnerBehahiour and calling a custom SpawnRequestHandler.

SpawnRequestHandler gets the next free port for AssignedPort and GamePort.

var port = Msf.Server.Spawners.GetAvailablePort();
int gamePort = gamePorts.GetAvailablePort();

Sends the two ports as command line arguments to the GameServer.

string.Format("{0} {1} ", Msf.Args.Names.AssignedPort, port) +
string.Format("{0} {1} ", "-gamePort", gamePort) +

And when the process Exits, releases the ports to be reused.

Msf.Server.Spawners.ReleasePort(port);
gamePorts.ReleasePort(gamePort);

Create a new script called CustomSpawner.

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using UnityEngine;
using Barebones.Logging;
using Barebones.MasterServer;
using Barebones.Networking;

public class CustomSpawner : SpawnerBehaviour {

    public BmLogger CustomLogger = Msf.Create.Logger(typeof(CustomSpawner).Name);

    [Header("Custom Spawner")]
    public LogLevel CustomSpawnerLogLevel = LogLevel.Info;

    public int startingPort = 2600;
    public int endingPort = 2700;

    private PortAllocator gamePorts;

    protected override void Start()
    {
        base.Start();

        CustomLogger.LogLevel = CustomSpawnerLogLevel;
        Logger.LogLevel = LogLevel;

        startingPort = Msf.Args.ExtractValueInt("-gameStartPort", startingPort);
        endingPort = Msf.Args.ExtractValueInt("-gameEndPort", endingPort);

        CustomLogger.Info(string.Format("startingPort: {0}  endingPort: {1}", startingPort, endingPort));
        gamePorts = new PortAllocator(startingPort, endingPort);
    }

    protected override void OnConnectedToMaster()
    {
        base.OnConnectedToMaster();

    }

    void CustomSpawnRequestHandler(SpawnRequestPacket packet, IIncommingMessage message)
    {
        SpawnerController controller = Msf.Server.Spawners.GetController(packet.SpawnerId);

        if (string.IsNullOrEmpty(Msf.Args.MasterIp))
            controller.DefaultSpawnerSettings.MasterIp = "127.0.0.1";

        MyCustomSpawnRequestHandler(packet, message);
    }

    public void MyCustomSpawnRequestHandler(SpawnRequestPacket packet, IIncommingMessage message)
    {
        CustomLogger.Debug("Custom spawn handler started handling a request to spawn process");

        var controller = Msf.Server.Spawners.GetController(packet.SpawnerId);

        if (controller == null)
        {
            message.Respond("Failed to spawn a process. Spawner controller not found", ResponseStatus.Failed);
            return;
        }

        var port = Msf.Server.Spawners.GetAvailablePort();
        int gamePort = gamePorts.GetAvailablePort();

        // Check if we're overriding an IP to master server
        var masterIp = string.IsNullOrEmpty(controller.DefaultSpawnerSettings.MasterIp) ?
            controller.Connection.ConnectionIp : controller.DefaultSpawnerSettings.MasterIp;

        // Check if we're overriding a port to master server
        var masterPort = controller.DefaultSpawnerSettings.MasterPort 
            {
                try
                {
                    CustomLogger.Debug("New thread started");

                    using (var process = System.Diagnostics.Process.Start(startProcessInfo))
                    {
                        CustomLogger.Debug("Process started. Spawn Id: " + packet.SpawnId + ", pid: " + process.Id);
                        processStarted = true;

                        var processId = process.Id;

                        // Notify server that we've successfully handled the request
                        BTimer.ExecuteOnMainThread(() =>
                        {
                            message.Respond(ResponseStatus.Success);
                            controller.NotifyProcessStarted(packet.SpawnId, processId, startProcessInfo.Arguments);
                        });

                        process.WaitForExit();
                    }
                }
                catch (Exception e)
                {
                    if (!processStarted)
                        BTimer.ExecuteOnMainThread(() => { message.Respond(ResponseStatus.Failed); });

                    CustomLogger.Error("An exception was thrown while starting a process. Make sure that you have set a correct build path. " +
                                 "We've tried to start a process at: '" + path + "'. You can change it at 'SpawnerBehaviour' component");
                    CustomLogger.Error(e);
                }
                finally
                {
                    BTimer.ExecuteOnMainThread(() =>
                    {
                        // Release the port number
                        Msf.Server.Spawners.ReleasePort(port);
                        gamePorts.ReleasePort(gamePort);

                        CustomLogger.Debug("Notifying about killed process with spawn id: " + packet.SpawnerId);
                        controller.NotifyProcessKilled(packet.SpawnId);
                    });
                }

            }).Start();
        }
        catch (Exception e)
        {
            message.Respond(e.Message, ResponseStatus.Error);
            Logs.Error(e);
        }
    }

    protected override void HandleSpawnRequest(SpawnRequestPacket packet, IIncommingMessage message)
    {
        CustomSpawnRequestHandler(packet, message);
    }
}

We are going to add a new custom opcode called gameServerMatchCompletion that will support the GameServer sending the CustomMatchmaker the results of the game.

Replace CustomOpCodes.cs with the following.

public enum CustomOpCodes : short
{
    requestStartGame = 1,
    gameServerMatchDetails,
    gameServerMatchCompletion
}

Update StartComponents

We now need to update StartComponents.cs to start a Spawner from the command line.

First we will add a Spawner GameObject.

public GameObject Spawner;

Next in the Start() method we will change the IF statement for inEditor to start both the MasterServer as well as a Spawner.

if (inEditor)
{
    StartMasterServer();
    StartSpawner();
    anythingStarted = true;
    return;
}

And check if the StartSpawner MSF command line switch is provided.

if (Msf.Args.IsProvided(Msf.Args.Names.StartSpawner))
{
    StartSpawner();
    anythingStarted = true;
}

And lastly we will start a Spawner.

private void StartSpawner()
{
    guiConsole.Show = true;
    Msf.Connection.Connect(Msf.Args.MasterIp, Msf.Args.MasterPort);
    Spawner.gameObject.SetActive(true);
}

Now perform the following steps.

  1. In the Hierarchy window, select the Components GameObject.
  2. Create an Empty GameObject as a child of Components GameObject. Rename it to Spawner.
  3. Add our CustomSpawner script to the Spawner GameObject.
  4. Make sure the field “Default Spawn in Batchmode” is unchecked.
  5. Set the “Default Exe Path” and “Exe Path From Editor” to the full path of the Matchmaking.exe.
  6. Select the Components GameObject and drag the Spawner GameObject onto the “Spawner” field.
  7. Save the scene.

If you run the application in the editor you will see in the Console window that both the MasterServer and the Spawner has been started.

A zip file of the Unity project from part 9 can be downloaded here.
In part 10 we will spawn our GameServers using our CustomSpawner.

Leave a comment