Setting Up a Multiplayer Experience with Persistence with 6D

Rik Basu
6D.ai
Published in
11 min readNov 27, 2018

How To Make a Multiplayer Sample App with the 6D Reality Platform

AR will really affect all our day-to-day lives when it lets us communicate and share in new and engaging ways that have never been possible before.This type of communication needs real-time person to person interactions. Multiplayer experiences can move AR to a whole new level of sharing, playing and socializing.With the 6D Reality Platform, people can almost instantly join an experience from any position making AR multiplayer frictionless.

This tutorial will add on our persistence tutorial. If you have not yet completed that tutorial, you should do so before beginning this one. We will be adding multiple users to be in the same coordinate space so they can share a real-time experience together, as well as share the same persistent content together. There are many tools that can be used to manage multiplayer networking, in this case we will be using Photon Engine.

For this tutorial, we will allow users to place spheres in the world in augmented reality, and allow both users to see the spheres in the same location. We will also have persistence, meaning that any player can save the spheres created in a session, and retrieve those spheres in another session at a later date.

NOTE: We have also created a public Google doc with this tutorial for those who have shared input around wanting to expand the images.

Setup Photon

For the purposes of this tutorial, we will be using Photon Unity Networking (PUN v1) to manage the multiplayer networking of the app. The first thing you need to do is create a free account with Photon (www.photonengine.com) and copy your App ID from the Photon dashboard. You may need to click on the App ID to expand and copy it:

Once you have your AppId, you are ready to import the PUN package into Unity. Within Unity, open the Asset Store [Window>General>Asset Store] and Import PUN v1 classic:

Once the import is complete, you should have a Photon folder in your Assets folder. Within the Photon folder is a folder named Resources which contains PhotonServerSettings.asset. Selecting this asset will open up your Photon Settings. Your Photon Settings should match the pic below, except for any AppId fields where you should paste in your Photon App ID you copied earlier:

Launcher Code

The first thing we will do is make our Launcher script. This script will allow multiple users to join the same lobby so they can be in the same server together.

  1. Create an empty object [GameObject > Empty] and call it Launcher.
  2. Select the Launcher object, and add a new script as a component. [Add Component button > New Script].
  3. Name the new script Launcher. The new script Launcher.cs will automatically be saved to the Assets folder of your project.
  4. Open Launcher.cs in your favorite text editor and paste this code:
using UnityEngine;
using SixDegrees;
using System;

public class Launcher : Photon.PunBehaviour
{
public byte MaxPlayersPerRoom = 4;

public GameObject playerPrefab;

string _gameVersion = "1";

public SDKController sdkController;

public bool loaded;

public bool saving;

void Awake()
{
loaded = false;
saving = false;
sdkController.OnSaveSucceededEvent += StartGameSave;
sdkController.OnLoadSucceededEvent += StartGameLoad;
}

void StartGameSave()
{
if (!PhotonNetwork.inRoom)
{
PhotonNetwork.autoJoinLobby = false;
PhotonNetwork.automaticallySyncScene = true;
saving = true;
Connect();
}
}

void StartGameLoad()
{
if (!PhotonNetwork.inRoom)
{
PhotonNetwork.autoJoinLobby = false;
PhotonNetwork.automaticallySyncScene = true;
loaded = true;
Connect();
}
}

void OnDestroy()
{
sdkController.OnSaveSucceededEvent -= StartGameSave;
sdkController.OnLoadSucceededEvent -= StartGameLoad;
}

public void Connect()
{
if (PhotonNetwork.connected)
{
PhotonNetwork.JoinOrCreateRoom(SDPlugin.LocationID, new RoomOptions() { MaxPlayers = MaxPlayersPerRoom }, null);
}
else
{
PhotonNetwork.ConnectUsingSettings(_gameVersion);
}
}

public override void OnConnectedToMaster()
{
Debug.Log("DemoAnimator/Launcher: OnConnectedToMaster() was called by PUN");
PhotonNetwork.JoinOrCreateRoom(SDPlugin.LocationID, new RoomOptions() { MaxPlayers = MaxPlayersPerRoom }, null);
}

public override void OnDisconnectedFromPhoton()
{
Debug.LogWarning("DemoAnimator/Launcher: OnDisconnectedFromPhoton() was called by PUN");
}

public override void OnJoinedRoom()
{
Debug.Log("DemoAnimator/Launcher: OnJoinedRoom() called by PUN. Now this client is in a room.");
GameObject temp = PhotonNetwork.Instantiate(playerPrefab.name, Vector3.zero,
Quaternion.identity, 0);
if (loaded == true)
{
temp.GetComponent<GameController>().loaded = true;
}
if (saving == true)
{
temp.GetComponent<GameController>().saving = true;
}
}
}

Let’s go over what this script is doing in a little detail:

  1. There’s a reference to sdkController. This is needed because for a user to create a lobby, we need to save a 6D map first. This gives us the locationID which we use as the name to the lobby/room.
  2. Following up, for other users to join that lobby, they need to load that map, and successfully relocalize. This will give them that same locationID that we use to determine which lobby/room to join.
  3. You’ll see there are two callbacks for sdkController.save, and sdkController.load. This is how we know if we should create the lobby or join the lobby.
  4. You’ll also see the last function OnJoinedRoom has a reference to the GameController script, as well as a reference to playerPrefab.
  5. The playerPrefab is a prefab that represents every player in the experience. We’ll have to make one next.

Now we need to change a few things around to make sure that Photon has access to the assets in our scene.

Setting up the Resources Directory

Whenever we call the function PhotonNetwork.Instantiate() (like in the Launcher script), the call looks for prefabs in the Resources directory. Therefore, we need to create that directory and place a couple of prefabs into it. Go ahead and make a new folder under Assets and call it Resources.

Now, let’s make the PlayerCamera prefab. Start by creating a standard cube in the hierarchy [GameObject > 3DObject > Cube]. Set the scale to (0.1, 0.1, 0.2) and name it PlayerCamera. Drag this new PlayerCamera object from the hierarchy to the Resources folder, then delete the copy of the object in the hierarchy.

We also need to move and slightly modify our Sphere prefab that was created in the previous tutorial. Move the Sphere prefab from Assets/6D SDK/Prefabs to Assets/Resources. Once it is moved, select it to bring up its properties. Click on Add Component > Photon Networking > Photon View. This ensures that spheres can be spawned once a room ID is established, and that both players will see them.

So your project should look something like this:

Create the GameControllerPhoton Script

At the bottom of the window you may notice an error in GameController; that there is no definition for loaded or saved.

We aren’t going to use GameController anymore, so let’s delete the object in the hierarchy (no need to delete the script). However, we will create a new script called GameControllerPhoton that will be used by our PlayerCamera prefab.

  1. Right click on the Assets folder and select Create > C# Script. Name the script GameControllerPhoton.
  2. The new script GameControllerPhoton.cs will automatically be saved to the Assets folder of your project. Open GameControllerPhoton.cs in your favorite text editor and paste this code:
using UnityEngine;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using SixDegrees;

public class GameControllerPhoton : Photon.MonoBehaviour
{
#if UNITY_IOS
[DllImport("__Internal")]
public static extern void GetAPIKey(StringBuilder apiKey, int bufferSize);
#else
public static void GetAPIKey(StringBuilder apiKey, int bufferSize) { }
#endif
private static GameControllerPhoton _LocalPlayerInstance = null;

private static List<GameControllerPhoton> controllers = new List<GameControllerPhoton>();

public static GameControllerPhoton LocalPlayerInstance
{
get
{
return _LocalPlayerInstance;
}
}

private Camera ARCamera;

public GameObject ballReference;

private GameObject ball;

private FileControl fileControl;

private SDKController sdkController;

public List<GameObject> balls;

private static string apiKey = "";

private string filename;

public bool loaded;

public bool saving;

void Start()
{
ARCamera = GameObject.FindObjectOfType<SDCamera>().GetComponent<Camera>();
fileControl = GameObject.FindObjectOfType<FileControl>();
sdkController = GameObject.FindObjectOfType<SDKController>();
if (photonView.isMine)
{
_LocalPlayerInstance = this;
sdkController.OnSaveSucceededEvent += SaveCSV;
sdkController.OnLoadSucceededEvent += RetrieveFile;
if (saving == true)
{
SaveCSV();
}
if (loaded == true)
{
RetrieveFile();
}
}
controllers.Add(this);
}

void Update()
{
if (Input.touchCount > 0)
{
LaunchBall();
}
}

void LaunchBall()
{
Touch touch = Input.GetTouch(0);
if (EventSystem.current.currentSelectedGameObject == null)
{
if (touch.phase == TouchPhase.Began)
{
if (photonView.isMine)
{
Vector3 position = new Vector3(Input.mousePosition.x, Input.mousePosition.y, .5f);
position = ARCamera.ScreenToWorldPoint(position);
SpawnBall(position);
}
}
}
}

[PunRPC]
void SpawnBall(Vector3 position)
{
ball = (GameObject)PhotonNetwork.Instantiate(ballReference.name, position, Quaternion.identity, 0);
balls.Add(ball);
PhotonView photonView = PhotonView.Get(this);
if (photonView.isMine)
photonView.RPC("SpawnBall", PhotonTargets.OthersBuffered, position);
}

void OnDestroy()
{
controllers.Remove(this);
if (photonView.isMine)
{
sdkController.OnSaveSucceededEvent -= SaveCSV;
sdkController.OnLoadSucceededEvent -= RetrieveFile;
}
}

private void GetFilename()
{
if (string.IsNullOrEmpty(apiKey))
{
StringBuilder sb = new StringBuilder(32);
GetAPIKey(sb, 32);
apiKey = sb.ToString();
}

if (string.IsNullOrEmpty(apiKey))
{
Debug.Log("API Key cannot be found");
filename = "";
}

if (string.IsNullOrEmpty(SDPlugin.LocationID))
{
Debug.Log("Location ID is missing");
filename = "";
}

filename = apiKey + "-" + SDPlugin.LocationID;
}

public void SaveCSV()
{
GetFilename();
if (string.IsNullOrEmpty(filename))
{
Debug.Log("Error evaluating the filename, will not save content CSV");
return;
}
string filePath = GetPath();
StreamWriter writer = new StreamWriter(filePath);
writer.WriteLine(controllers.Count);
for (int j = 0; j < controllers.Count; j++)
{
writer.WriteLine(controllers[j].balls.Count);
for (int i = 0; i < balls.Count; i++)
{
writer.WriteLine(controllers[j].balls[i].transform.position.x + "," + controllers[j].balls[i].transform.position.y + "," + controllers[j].balls[i].transform.position.z);
}
}
writer.Flush();
writer.Close();
StartCoroutine(fileControl.UploadFileCoroutine(filename));
}

public void ReadTextFile(string csv)
{
StringReader reader = new StringReader(csv);
string line = reader.ReadLine();
int controllersCount = int.Parse(line);
for (int k = 0; k < controllersCount; k++)
{
line = reader.ReadLine();
int ballCount = int.Parse(line);
for (int i = 0; i < ballCount; i++)
{
line = reader.ReadLine();
string[] parts = line.Split(',');
Vector3 ballPosition = new Vector3();
ballPosition.x = float.Parse(parts[0]);
ballPosition.y = float.Parse(parts[1]);
ballPosition.z = float.Parse(parts[2]);
SpawnBall(ballPosition);
}
}
reader.Close();
}

public string GetPath()
{
return Application.persistentDataPath + "/" + SDPlugin.LocationID + ".csv";
}

public void RetrieveFile()
{
GetFilename();
if (string.IsNullOrEmpty(filename))
{
Debug.Log("Error evaluating the filename, will not load content CSV");
return;
}
StartCoroutine(fileControl.GetTextCoroutine(filename));
}
}

Editing Launcher

Now that we have created a new GameController, we need to revisit the Launcher script and make a couple changes. Find the following section in the Launcher script:

public override void OnJoinedRoom()
{
Debug.Log("DemoAnimator/Launcher: OnJoinedRoom() called by PUN. Now this client is in a room.");
GameObject temp = PhotonNetwork.Instantiate(playerPrefab.name, Vector3.zero,
Quaternion.identity, 0);
if (loaded == true)
{
temp.GetComponent<GameController>().loaded = true;
}
if (saving == true)
{
temp.GetComponent<GameController>().saving = true;
}
}
}

In this section, change all instances of GameController to GameControllerPhoton.

Create the PlayerCameraPositionPhoton Script

We also need to add a bit of code to manage the camera position of each player. Right click on the Assets folder, and select Create>C# Script to create a new script. Name the new script PlayerCameraPositionPhoton. Open the script in your preferred editor and place this code into it:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SixDegrees;
using Photon;

public class PlayerCameraPositionPhoton : PunBehaviour
{
private GameObject mSDCamera;

void Start()
{

SDCamera sdCamera = FindObjectOfType<SDCamera>();
if (!sdCamera)
{
Debug.LogWarning("No SDCamera found!");
return;
}

mSDCamera = sdCamera.gameObject;
}

void Update()
{
if (mSDCamera)
{
transform.SetPositionAndRotation(mSDCamera.transform.position, mSDCamera.transform.rotation);
}
}
}

We have now created most of the code needed for the app, but we need to add a few components and references to the objects we have. Let’s start by heading to our Assets > Resources folder and select the PlayerCamera prefab we made earlier. We should attach a few components to it:

  1. PlayerCameraPositionPhoton, the script we just created. This will put the camera prefab directly on each camera of each phone. This will allow each player to see the other persons camera, and will also give a sense of how accurate the relocalization was. To add the script to the prefab, simply drag it from its folder in the project and drop it into the Inspector properties for the PlayerCamera prefab.
  2. GameControllerPhoton script. Now instead of having a single GameController object in the scene, we are going to spawn them using our Launcher game object. Drag this script from its folder in the project and drop it into the Inspector properties for PlayerCamera.
    In the Ball Reference field, make sure to insert the Sphere prefab.
  3. PhotonView component. This component is provided by Photon. To add it, click on the Add Component button, then select Photon Networking > Photon View.
  4. Photon Transform View component. Similar to the Photon View component, this is also provided by Photon. To add it, click on the Add Component button, then select Photon Networking > Photon Transform View.
  5. Once Photon Transform View is created, we want to drag it into the Observed Components field of the Photon View component. You will also want to check the boxes for Synchronize Position and Synchronize Rotation with the values shown in the picture below:

Finally, we need to make a few changes to our Launcher object. Select the Launcher object in the hierarchy and change its properties to match the picture below:

Almost there! We have a couple of small changes to make to the FileControl script, then we will be ready to go.

Change:

GameController.ReadTextFile(csv);

To:

GameControllerPhoton.LocalPlayerInstance.ReadTextFile(csv);

Also, Change:

string localFileName = GameController.GetPath();

To:

string localFileName = GameControllerPhoton.LocalPlayerInstance.GetPath();

Before we build and test, there are a few recommendations to flag:

  • This is a bare bones tutorial. There is no UI to let the user know if saving or relocalization was successful. It is recommended that at least one device stays connected to your computer so that you can monitor the console in Xcode.
  • Both devices that are used for your test should be connected to the same wifi network.
  • Keep in mind that you will not be able to spawn spheres until a player saves a map (and thus creates a room ID in photon).

Okay, it is finally time to build and run this project. If you need a refresher on building a 6D app, a walkthrough can be found here.

Below is the flow of how to use the application:

  1. Player 1 starts the app, scans the area, and clicks on the ‘Save’ button to save the map. This skips creating a lobby, and automatically creates a room in Photon with the locationID as the room name.
  2. Player 2 starts the app, clicks on the ‘Load’ button, and scans the area to relocalize. This automatically joins the room that player 1 made since they both share the same locationID.
  3. Once relocalization is successful, both players should be able to see each other’s PlayerCamera prefab attached to each phone. Players should see how the prefab syncs to each phone’s movement.
  4. Now that the room is created, either player should be able to place spheres throughout the world, and both players can see where they are placed since they share the same coordinate space.
  5. Either player 1 or player 2 can save again, and this will save any new spheres that have been created since the last save.
  6. The spheres should be persistent content. To verify, close the app on both devices. Open the app again on a device, and click the ‘Load’ button to load the content from previous sessions. Now the player should be able to see the spheres placed in the previous session, and can add more spheres if they want.

Congrats, you have completed the basic 6D Multiplayer tutorial app. Hopefully this will be a helpful jumpstart as you begin to build multi-user experiences with the 6D Reality Platform.

If you have questions about relocalization, persistence, or other 6D specific features, make sure to check out our developer slack and developer dashboard. Also, you can find more information about Photon Unity Networking here.

Happy building!

--

--