Creating a single player version of a multiplayer game in Unity

I struggled to find any information about this online, so I’ll write a quick post about how I’m solving this with the prototype for Contension in hopes that it will help someone out there at some point.

The prototype has a ContensionGame object which derives from NetworkManager, which, if you’re not familiar with UNET, is basically the thing that coordinates the network traffic of the application, kind of a very abstract client/server class.

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

public class MultiplayerGame : ContensionGame // ContensionGame is a NetworkManager 
{
    public List<uint> _readySignals;
	
    public void Launch() 
    {
        StartHost();
    }
	
    public void Connect(string ipAddress) 
    {
        networkAddress = ipAddress; 
        StartClient();
        Debug.Log("connected");
    }

    public void AddReady(uint id) 
    {
        if(!_readySignals.Contains(id)) 
        {
            _readySignals.Add(id);
            if(_readySignals.Count > 1) 
            {
                ServerChangeScene(this.onlineScene); 
            }
        }
    }

    void Awake() 
    {
        DontDestroyOnLoad(this);
        _readySignals = new List<uint>();
    }
}

Simple enough – in a normal multiplayer game, we wait for all the players to connect (tracked with _readySignals), and once we have two or more we go to the “main” scene. This isn’t exactly how you’d do things with a full game; for one thing, you’d have more complex scene loading, and for another you’d probably have more robust reconnection logic, but it gets the job done for prototyping.

The real work of starting a multiplayer level, however, is done in the Player GameObject, primarily by the TeamSpawner script component. This object actually spawns our units in the appropriate areas on the map.

Network code can be hard to think about, but in Contension I’m using an authoritative server, which just means that the client won’t actually be doing a whole lot in terms of judging when and how units move or come into conflict. The premise of the game doesn’t work super well if you allow clients to make those judgements, though I’ll probably have to revisit that down the road.

The basic things you need to know to understand this are:

  1. SyncVars are automagically managed data that get replicated across the network
  2. OnXYZ functions are called “Message” functions, and they’re usually only called by Unity based on events internal to the game engine, such as when a server starts or a client connects to the server
  3. Command functions are called from the client to the server.
  4. ClientRpc functions are called from the server to the client
  5. NetworkServer.Spawn creates an object in the game world for all players.
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Collections.Generic;

[RequireComponent(typeof(NetworkIdentity))]
public class TeamSpawner : NetworkBehaviour 
{
    public GameObject ContenderPrefab;
    
    [SyncVar]string _teamTag;

    List<Contender.Description> _contenderDescriptions;
    bool _spawned;

    void Start() 
    {
        DontDestroyOnLoad(this);
    }

    public override void OnStartServer ()
    {
        if(MoreThanOnePlayerWithMyTag()) 
        {
            _teamTag = "Team2";
        }
        if(isServer) { _tagged = true; }
    }

    public override void OnStartClient() 
    {
        _teamTag = tag;
    }

    public override void OnStartLocalPlayer ()
    {
        if(!isServer) 
        {
            CmdSendTag();
        }
        base.OnStartLocalPlayer ();
    }

    [Command] 
    public void CmdSendTag() 
    {
        RpcSetTag(this.tag);
    }

    [ClientRpc]
    public void RpcSetTag(string newTag) 
    {
        tag = newTag;
        _tagged = true;
    }

    internal void SubmitTeam (IEnumerable<TeamSetup.DescriptionWrapper> team)
    {
        ClearTeam();
        foreach(TeamSetup.DescriptionWrapper description in team) 
        {
            AddDescription(description.Role, description.Commitment, description.Speed);
        }
        CmdSignalReady();
    }

    [Command]
    void CmdSignalReady() 
    {
        GetComponent<ReadySignal>().Send();
    }

    private void AddDescription(Contender.Roles role, Contender.Commitments commitment, Contender.Speeds speed) 
    { 
        CmdAddDescription(role, commitment, speed);
    }

    [Command]
    void CmdAddDescription(Contender.Roles role, Contender.Commitments commitment, Contender.Speeds speed) 
    {
        ContenderDescriptions.Add(new Contender.Description(role, commitment, speed));
    }

    void OnLevelWasLoaded()
    {
        _spawned = false;
    }

    void Update () 
    {
        if(isLocalPlayer && _tagged && !_spawned && _contenderDescriptions != null) 
        {
            TeamSpawnArea[] spawnAreas = FindObjectsOfType<TeamSpawnArea>();
            foreach(TeamSpawnArea area in spawnAreas) 
            {
                if(area.tag == this.tag) 
                {
                    // Simple local perspective hack - the camera is rotated 180 if the player spawns in the
                    // top of the map instead of the bottom
                    transform.position = area.Center;
                    if(transform.position.y > 0 && GetComponent<AiPlayer>() == null) 
                    {
                        Camera.main.transform.Rotate (new Vector3(0,0,180));
                    }

                    if(isServer) 
                    {
                        SpawnTeam (tag);
                    }
                    else 
                    {
                        CmdSpawnTeam(tag);
                    }
                    _spawned = true;
                }
            }
        }
    }

    [Command]
    public void CmdSpawnTeam (string tag) 
    {
        SpawnTeam(tag);
    }

    private void SpawnTeam(string tag) 
    {
        TeamSpawnArea[] spawnAreas = FindObjectsOfType<TeamSpawnArea>();
        TeamSpawnArea teamArea = spawnAreas[0];
        foreach(TeamSpawnArea area in spawnAreas) 
        {
            if(area.tag == tag) 
            {
                teamArea = area;
                break;
            }
        }
        foreach(Contender.Description description in _contenderDescriptions) 
        {
            Vector2 SpawnLocation = PickSpawnPoint(teamArea);
            GameObject obj = (GameObject)Instantiate(ContenderPrefab, SpawnLocation, Quaternion.identity);
            
            Contender contender = obj.GetComponent<Contender>();
            contender.Initialize(tag, netId.Value, description);
            NetworkServer.Spawn(obj);
        }
    }
}

One of the basic problems with UNET, however, is it doesn’t natively support different player prefabs (read: types) for different players. This means that you can’t just set the player type and forget about it if you want to reuse the multiplayer code for your single player game. In a larger studio that might not be a concern, but I’m doing this on my own right now and that means I need to try to restrict how many things I have to worry about.

My solution to this (again, this is prototype code!) is pretty quick and dirty. Basically I’ve set the “main” playerPrefab to be my AI player class, and then added the human player as a spawnable prefab. As soon as the game starts, the AI player connects, which causes the game to spawn a second client with a hardcoded team.

Sooo dirty. But it works!

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

public class SinglePlayerGame : ContensionGame
{
    bool _playerAdded;

    // Use this for initialization
    void Start () 
    {
        StartHost();
    }

    public override void OnServerAddPlayer(NetworkConnection conn, short playerControllerId)
    {
        GameObject Player;
        if(playerControllerId == 0)
        {
            Player = (GameObject)GameObject.Instantiate(playerPrefab, Vector2.zero, Quaternion.identity);;
        }
        else
        {
            Player = (GameObject)GameObject.Instantiate(spawnPrefabs[0], Vector2.zero, Quaternion.identity);
        }
         
        NetworkServer.AddPlayerForConnection(conn, Player, playerControllerId);
        if(playerControllerId != 0)
        {
            TeamSpawner PlayerTeam = Player.GetComponent<TeamSpawner>();
            List<TeamSetup.DescriptionWrapper> Units = new List<TeamSetup.DescriptionWrapper>();
            Units.Add(
                new TeamSetup.DescriptionWrapper(
                    new Contender.Description(Contender.Roles.ManyOnOne, Contender.Commitments.Balanced, Contender.Speeds.Average)));
            Units.Add(
                new TeamSetup.DescriptionWrapper(
                    new Contender.Description(Contender.Roles.ManyOnOne, Contender.Commitments.Balanced, Contender.Speeds.Average)));
            Units.Add(
                new TeamSetup.DescriptionWrapper(
                    new Contender.Description(Contender.Roles.OneOnMany, Contender.Commitments.Balanced, Contender.Speeds.Average)));
            Units.Add(
                new TeamSetup.DescriptionWrapper(
                    new Contender.Description(Contender.Roles.OneOnMany, Contender.Commitments.Balanced, Contender.Speeds.Average)));
            Units.Add(
                new TeamSetup.DescriptionWrapper(
                    new Contender.Description(Contender.Roles.OneOnOne, Contender.Commitments.Balanced, Contender.Speeds.Average)));
            Units.Add(
                new TeamSetup.DescriptionWrapper(
                    new Contender.Description(Contender.Roles.OneOnOne, Contender.Commitments.Balanced, Contender.Speeds.Average)));
            
            PlayerTeam.SubmitTeam(Units);
        }
    }

    // Update is called once per frame
    void Update () 
    {
        if(!_playerAdded && ClientScene.ready)
        {
            _playerAdded = true;
            ClientScene.AddPlayer(2);
        }
    }
}

For two AI players (for example, when building an AI demo or training simulator), you can do a similar thing but simply spawn a second AI player prefab instead of the human player.

I’ve also realized while writing this article that I can do a better team tagging solution based on the map’s available spawn areas. Which is neat!