Deterministic Gameobjects Creation Over Network. P2P Photon, Unity3d.

One of my latest additions to Secret Neighbor was moving from server client authority for game objects spawns to deterministic spawn of networked objects across all clients. Turns out, it's not that hard that hard.

First of all, why do we even decide to go this route? There are two main reasons:

  • During level load, master client spawned many game objects over network for others. So, if master client hangs or disconnects in the process of level loading, we can't be sure that all objects are spawned correctly. Therefore we were forced to disconnect everyone from game session.

  • We can save network traffic by spawning objects locally. Previously, in order to spawn scene object, master client needed to send net message to all players with data which object at which position needs to be spawned. With local object instantiation that is obsolete.

In general, we need two things for making deterministic object spawn real:

  • seed based pseudo-random numbers generation algorithm.

  • a mechanism to spawn game object locally with the same id across all clients. It means spawning objects in the same order on remote clients.

That's basically it.

Pseudo-random numbers generation

Though, it's pretty easy to write your very own linear pseudo-random number generator, we decided to stay with built-in C# System.Random. It's not recommended to use it if your users might have different .NET / Mono run-times. Unity ships it's own though.

First, I am not a fan of abstracting everything out and don't consider following code as good one, but I abstracted this class in case if we need to change algorithm implementation in future. Working within a team on middle size / big project implies that you often do such stuff. At least in our team. I should write separate post with my opinions on this one!

public class HoloRandom  {

    private readonly System.Random _instance;

    public HoloRandom(int seed) {
      _instance = new System.Random(seed);
    }

    public int Next() {
      var randNum = _instance.Next();
      return randNum;
    }

    public void NextBytes(byte[] buffer) {
      _instance.NextBytes(buffer);
    }

    public double NextDouble() {
      return _instance.NextDouble();
    }

    public int Next(int minValue, int maxValue) {
      return _instance.Next(minValue, maxValue);
    }
  }

Spawners

We need a way to allocate the same id for networked object for all clients. Projecting this onto Photon, this would be PhotonViewId. Though we implemented our own networked library which allows us to use any networking backend having implemented only provider class for it. To be short: our networking library (Holonet further), manages objects and id's by it's own algorithms taking this responsibility out of Photon. We take objects management, synchronization and serialization out of Photon hands and put it all to ours.

In general, we need a mechanism which spawns scene object locally and assigns corresponding id to it. It looks like this in HoloNet:

public HoloNetObject SpawnSceneObjectLocal(GameObject go, Vector3 position, Quaternion rotation) {
      var id = AllocateSceneObjectIdLocal();
      var result = Object.Instantiate(go, position, rotation).GetComponent<HoloNetObject>();
     result.transform.position = position;
      result.transform.rotation = rotation;
      result.oid = id;
      RegisterSceneObject(result);
      return result;
    }

Think of HoloNetObject the way you think about PhotonView component. It gets attached to gameobject and works as routing component for net messages delivery service.
AllocateSceneObjectIdLocal() does what is says: allocates next game object id having knowledge about all allocated ids in the system and returns it.

Now what system needs is actual spawner method, which uses our random number generator for creating game object. In C# code it would look like this:

public HoloNetObject SpawnObject(GameObject go, Vector3 pos, Quaternion rot, Holorandom rand) {
   return HoloNet.SpawnObjectLocal(go, RandomizePosition(rand), RandomizeRotation(rand), rand);
}

Randomize position and rotation can be written in many ways depending on your code base context. Thought, might look like this in it's simplest form:

public Vector3 RandomizePosition(rand) {
   return new Vector3(rand.Next(minXVal, maxXVal), rand.Next(minYval, maxYVal), rand.Next(minZval, maxZval));
}

Spawning operations order

At this point, everything which needed to be done is calling spawners in the same order on all clients during level load. That was the hardest task in Secret Neighbor codebase, as often created net objects spawned other net objects on their initialization calls. Taking into account that you can't guarantee same init order of net objects on all clients without reordering them - you can't spawn anything on net object init for the task of determinism.

Besides that, what needs to be done is calling all spawning operations in the same order across all clients.

For instance, we have separate level loading routines which always get called in the same order:

 LoadManager.instance.RegisterEnumerator(PrepareLoading());
 LoadManager.instance.RegisterEnumerator(LoadLevel());
 LoadManager.instance.RegisterEnumerator(InitLevel());
 LoadManager.instance.RegisterEnumerator(GenerateLevel());
 LoadManager.instance.RegisterEnumerator(InitSceneObjects());
 LoadManager.instance.RegisterEnumerator(SimulatePhysics(10f));
 LoadManager.instance.RegisterEnumerator(WaitForOtherPlayers());

Briefly, each of this routines prepares part of the gameplay session for the user. Object spawn happens in GenerateLevel(). Then spawned objects get initialized by calling InitSceneObjects() method.