Navigate Between Scenes Using ClientServerBootstrap

Code and workflows to navigate between NavigationScene and MainScene using UI buttons

What will be developed on this page

We will adding logic to navigate between NavigationScene and MainScene and properly handle creating and destroying ECS worlds.

Github branch link: https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/tree/Navigating-Between-Scenes

We have 3 views that could take us to MainScene:

  • HostGameScreen

  • JoinGameScreen

  • ManualConnectScreen

Rather than have separate logic in each of their associated custom Visual Elements (cVEs), we are going to create a new script that handles all transitions from NavigationScene to MainScene, named "ClientServerLauncher." We will pull in references to each of these views and will add callbacks to the "Host Game" and "Join Game" buttons that will trigger the transition. Like most software engineering, this is an opiniated decision for where to put the "brains" for transition. We have gotten feedback that we maybe we have abstracted too much, and made it more confusing which is a fair critique. We wanted to show all the different ways elements can interact in this UI section.

Later on in the next Multiplayer section we will add additional logic to ClientServerLauncher so that we can select which IP address we connect to as a client.

In this page we will implement ClientServerBootstrap to prevent triggering Client/Server world creation until we are ready to transition to MainScene and click one of our transition buttons.

Why? Well right now in the NavigationScene DOTS NetCode bootstrap does its thing and creates server/client worlds. We want to create a build where a user can "host" (which means be both client and server) or "join" (which means they are just a client. So we cannot make the decisions whether to build a server world in the NavigationScene, that decision should be made in MainScene (once we know what decision has been made).

Bootstrap

he default bootstrap creates client server Worlds automatically at startup. It populates them with the systems defined in the attributes you have set. This is useful when you are working in the Editor, but in a standalone game, you might want to delay the World creation so you can use the same executable as both a client and server.

To do this, you can create a class that extends ClientServerBootstrap to override the default bootstrap. Implement Initialize and create the default World. To create the client and server worlds manually, call ClientServerBootstrap.CreateClientWorld(defaultWorld, "WorldName"); or ClientServerBootstrap.CreateServerWorld(defaultWorld, "WorldName");.

The following code example shows how to override the default bootstrap to prevent automatic creation of the client server worlds:

public class ExampleBootstrap : ClientServerBootstrap
{
    public override bool Initialize(string defaultWorldName)
    {
        var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.Default);
        GenerateSystemLists(systems);

        var world = new World(defaultWorldName);
        World.DefaultGameObjectInjectionWorld = world;

        DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, ExplicitDefaultWorldSystems);
        ScriptBehaviourUpdateOrder.AppendWorldToCurrentPlayerLoop(world);
        return true;
    }

}

From NetCode's Bootstrap documentation

  • Currently, because we have the NetCode package installed, NetCode automatically creates Client/Server worlds when the application starts

    • If you don't have the NetCode package installed, please go visit the previous "DOTS NetCode" section for installation instructions

  • Click play while NavigationScene is open then navigate to the DOTS Windows and see that both Server and Client worlds are created

  • We want to limit the worlds to DefaultWorld while in NavigationScene

    • This is a preemptive measure we are taking because later on, in the Multiplayer section, we won't know whether we want to join a game as just a client or as a client/server, so we want to hold off on creating these NetCode worlds

    • So we will implement NetCodeBootstrap

    • I know it seems like we have repeated ourselves like 3 times but we have gotten feedback that "World creation delay" was a big confusing so we wanted to hammer the point home!

  • Un-click Play and right-click inside the Multiplayer Setup folder, select "Create" to create a new C# script called NetCodeBootstrap

  • Paste the code snippet below into NetCodeBootstrap.cs:

using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
#if UNITY_EDITOR
using Unity.NetCode.Editor;
#endif

public class NetCodeBootstrap : ClientServerBootstrap
{
    public override bool Initialize(string defaultWorldName)
    {

        var world = new World(defaultWorldName);
        World.DefaultGameObjectInjectionWorld = world;

        var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.Default);
        GenerateSystemLists(systems);

        DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, ExplicitDefaultWorldSystems);
#if !UNITY_DOTSRUNTIME
        ScriptBehaviourUpdateOrder.AppendWorldToCurrentPlayerLoop(world);
#endif
        return true;
    }
}
  • Please be patient! We noticed in our testing that it takes a couple tries for Unity to pick up this new bootstrap script ⏳

  • Now hit play in NavigationScene, then navigate to DOTS Windows to take a look at the available worlds

  • Good, NetCodeBootstrap has prevented automatic creation of worlds πŸ‘

    • Only DefaultWorld is available

  • Now let's move onto creating ClientServerLauncher that will manually build (the previously automatically-created) Client and Server worlds

  • Right-click in the NavigationScene Hierarchy and create a new empty GameObject called "ClientServerLauncher"

    • Move it just below "LocalGamesDiscovery" in the scene Hierarchy

    • Add a component to the GameObject that is a new script named "ClientServerLauncher"

      • First create the file by right-clicking in the Multiplayer Setup folder and selecting Create > C# Script

πŸ”‘MAJOR KEY ALERT πŸ”‘

It is recommended by Unity to create the Client and Server worlds BEFORE navigating to a scene with converted SubScenes (as opposed to first navigating to MainScene then creating client and server worlds).

When the scene is loaded it automatically triggers loading of all SubScenes it contains into all worlds. If you manually create the worlds you need to do so before you load the scene with your content or they will not stream in any sub-scenes. It would also be possible to manually trigger SubScene streaming - but in this case it would mean you need to manually keep track of all SubScenes.

- From Tim Johansson (NetCode lead)

  • Now let's update ClientServerLauncher

  • Paste the code snippet below into ClientServerLauncher.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net;
using System.Net.Sockets;
using System.Net.NetworkInformation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine.UIElements;
using UnityEngine.SceneManagement;

public class ClientServerLauncher : MonoBehaviour
{
    //These are the variables that will get us access to the UI views 
    //This is how we can grab active UI into a script
    //If this is confusing checkout the "Making a List" page in the gitbook
    
    //This is the UI Document from the Hierarchy in NavigationScene
    public UIDocument m_TitleUIDocument;
    private VisualElement m_titleScreenManagerVE;
    //These variables we will set by querying the parent UI Document
    private HostGameScreen m_HostGameScreen;
    private JoinGameScreen m_JoinGameScreen;
    private ManualConnectScreen m_ManualConnectScreen;



    void OnEnable()
    {

        //Here we set our variables for our different views so we can then add call backs to their buttons
        m_titleScreenManagerVE = m_TitleUIDocument.rootVisualElement;
        m_HostGameScreen = m_titleScreenManagerVE.Q<HostGameScreen>("HostGameScreen");
        m_JoinGameScreen = m_titleScreenManagerVE.Q<JoinGameScreen>("JoinGameScreen");
        m_ManualConnectScreen = m_titleScreenManagerVE.Q<ManualConnectScreen>("ManualConnectScreen");

        //Host Game Screen callback
        m_HostGameScreen.Q("launch-host-game")?.RegisterCallback<ClickEvent>(ev => ClickedHostGame());
        //Join Game Screen callback
        m_JoinGameScreen.Q("launch-join-game")?.RegisterCallback<ClickEvent>(ev => ClickedJoinGame());
        //Manual Connect Screen callback
        m_ManualConnectScreen.Q("launch-connect-game")?.RegisterCallback<ClickEvent>(ev => ClickedJoinGame());
    }
    
    // Update is called once per frame
    void Update()
    {
        
    }

    void ClickedHostGame()
    {
        //When we click "Host Game" that means we want to be both a server and a client
        //So we will trigger both functions for the server and client
        ServerLauncher();
        ClientLauncher();

        //This function will trigger the MainScene
        StartGameScene();
    }

    void ClickedJoinGame()
    {
        //When we click 'Join Game" that means we want to only be a client
        //So we do not trigger ServerLauncher
        ClientLauncher();

        //This function triggers the MainScene
        StartGameScene();
    }



    public void ServerLauncher()
    {
        //CreateServerWorld is a method provided by ClientServerBootstrap for precisely this reason
        //Manual creation of worlds

        //We must grab the DefaultGameObjectInjectionWorld first as it is needed to create our ServerWorld
        var world = World.DefaultGameObjectInjectionWorld;
#if !UNITY_CLIENT || UNITY_SERVER || UNITY_EDITOR
        ClientServerBootstrap.CreateServerWorld(world, "ServerWorld");

#endif
    }

    public void ClientLauncher()
    {
        //First we grab the DefaultGameObjectInjectionWorld because it is needed to create ClientWorld
        var world = World.DefaultGameObjectInjectionWorld;

        //We have to account for the fact that we may be in the Editor and using ThinClients
        //We initially start with 1 client world which will not change if not in the editor
        int numClientWorlds = 1;
        int totalNumClients = numClientWorlds;

        //If in the editor we grab the amount of ThinClients from ClientServerBootstrap class (it is a static variable)
        //We add that to the total amount of worlds we must create
#if UNITY_EDITOR
        int numThinClients = ClientServerBootstrap.RequestedNumThinClients;
        totalNumClients += numThinClients;
#endif
        //We create the necessary number of worlds and append the number to the end
        for (int i = 0; i < numClientWorlds; ++i)
        {
            ClientServerBootstrap.CreateClientWorld(world, "ClientWorld" + i);
        }
#if UNITY_EDITOR
        for (int i = numClientWorlds; i < totalNumClients; ++i)
        {
            var clientWorld = ClientServerBootstrap.CreateClientWorld(world, "ClientWorld" + i);
            clientWorld.EntityManager.CreateEntity(typeof(ThinClientComponent));
        }
#endif
    }

    void StartGameScene()
    {
        //Here we trigger MainScene
#if UNITY_EDITOR
        if(Application.isPlaying)
#endif
            SceneManager.LoadSceneAsync("MainScene");
#if UNITY_EDITOR
        else
            Debug.Log("Loading: " + "MainScene");
#endif
    }
}
  • You'll notice that there are 3 key functions in ClientServerLauncher:

    • ServerLauncher (creates server world)

    • ClientLauncher (creates client world(s) if Thin Clients exist)

    • StartGameScene (triggers loading MainScene)

  • Depending on whether we click "Host Game" or "Join Game" we trigger 2 or 3 of these functions

    • We always trigger StartGameScene and ClientLauncher

    • We launch ServerLauncher only if we are a host

  • Select ClientServerLauncher in the Hierarchy, click Add Component in Inspector and add Client Server Launcher

  • Drag TitleScreenUI GameObject from the Hierarchy onto the "Title UI Document" field under the Client Server Launcher component in Inspector while ClientServerLauncher is still selected in Hierarchy

  • We now need to add our scenes to our build settings so we can navigate to them

    • Navigate to MainScene, go to "Build Settings..." and click "Add Open Scenes"

    • Return to NavigationScene, go to "Build Settings..." and click "Add Open Scenes"

  • Go to Multiplayer > PlayMode Tools > set the Num Thin Clients equal to 1

  • Hit play, and then click Host Game

We are now able to transition to MainScene with the proper amount of NetCode worlds

  • We created NetCodeBootstrap

  • We created ClientServerLauncher GameObject

    • Added a new script ClientServerLauncher

  • We updated ClientServerLauncher to trigger creation of client/server worlds and trigger a scene

  • We added our scenes to our build settings

  • We dragged TitleUI to our ClientServerLauncher's "Title UI Document" field

Similar to how we created client/server worlds to transition to MainScene, we now need to destroy those client/server worlds when we return to NavigationScene. We will also delete all entities created so we can start again in NavigationScene with a "blank slate".

  • Let's update our ClientServerConnectionHandler to be able to take over these new "clean-up" duties

    • We think this is a good place to put "clean-up" functionality because it's part of handling the connection between servers and clients

  • Paste the code snippet below into ClientServerConnectionHandler.cs:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine.UIElements;
using UnityEngine.SceneManagement;

public class ClientServerConnectionHandler : MonoBehaviour
{
    //this is the store of server/client info
    public ClientServerInfo ClientServerInfo;

    // these are the launch objects from Navigation scene that tells what to set up
    private GameObject[] launchObjects;

    //these will gets access to the UI views 
    public UIDocument m_GameUIDocument;
    private VisualElement m_GameManagerUIVE;

    void OnEnable()
    {
        // This will put callback on "Quit Game" button
        // This triggers the clean up function (ClickedQuitGame)
        m_GameManagerUIVE = m_GameUIDocument.rootVisualElement;
        m_GameManagerUIVE.Q("quit-game")?.RegisterCallback<ClickEvent>(ev => ClickedQuitGame());
    }

    void Awake()
    {
        launchObjects = GameObject.FindGameObjectsWithTag("LaunchObject");
        foreach(GameObject launchObject in launchObjects)
        {
            ///  
            // checks for server launch object
            // does set up for the server for listening to connections and player scores
            //
            if(launchObject.GetComponent<ServerLaunchObjectData>() != null)
            {
                //sets the gameobject server data (mono)
                ClientServerInfo.IsServer = true;
                
                //sets the component server data in server world(dots)
                //ClientServerConnectionControl (server) will run in server world
                //it will pick up this component and use it to listen on the port
                foreach (var world in World.All)
                {
                    //we cycle through all the worlds, and if the world has ServerSimulationSystemGroup
                    //we move forward (because that is the server world)
                    if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
                    {
                        var ServerDataEntity = world.EntityManager.CreateEntity();
                        world.EntityManager.AddComponentData(ServerDataEntity, new ServerDataComponent
                        {
                            GamePort = ClientServerInfo.GamePort
                        });
                        //create component that allows server initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeServerComponent));
                    }
                }
            }

            // 
            // checks for client launch object
            //  does set up for client for dots and mono
            // 
            if(launchObject.GetComponent<ClientLaunchObjectData>() != null)
            {
                //sets the gameobject data in ClientServerInfo (mono)
                //sets the gameobject data in ClientServerInfo (mono)
                ClientServerInfo.IsClient = true;
                ClientServerInfo.ConnectToServerIp = launchObject.GetComponent<ClientLaunchObjectData>().IPAddress;                

                //sets the component client data in server world(dots)
                //ClientServerConnectionControl (client) will run in client world
                //it will pick up this component and use it connect to IP and port
                foreach (var world in World.All)
                {
                    //we cycle through all the worlds, and if the world has ClientSimulationSystemGroup
                    //we move forward (because that is the client world)
                    if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
                    {
                        var ClientDataEntity = world.EntityManager.CreateEntity();
                        world.EntityManager.AddComponentData(ClientDataEntity, new ClientDataComponent
                        {
                            ConnectToServerIp = ClientServerInfo.ConnectToServerIp,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //create component that allows client initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeClientComponent));
                    }
                }
            }
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
   //This function will navigate us to NavigationScene
    void ClickedQuitGame()
    {

#if UNITY_EDITOR
        if(Application.isPlaying)
#endif
            SceneManager.LoadSceneAsync("NavigationScene");
#if UNITY_EDITOR
        else
            Debug.Log("Loading: " + "NavigationScene");
#endif
    }

    //When the OnDestroy method is called (because of our transition to NavigationScene) we
    //must delete all our entities and our created worlds to go back to a blank state
    //This way we can move back and forth between scenes and "start from scratch" each time
    void OnDestroy()
    {
        //This query deletes all entities
        World.DefaultGameObjectInjectionWorld.EntityManager.DestroyEntity(World.DefaultGameObjectInjectionWorld.EntityManager.UniversalQuery);
        //This query deletes all worlds
        World.DisposeAllWorlds();

        //We return to our initial world that we started with, defaultWorld
        var bootstrap = new NetCodeBootstrap();
        bootstrap.Initialize("defaultWorld"); 

    }
}
  • Within MainScene, click on ClientServerConnectionHandler in the Hierarchy and drag the GameUI GameObject (also in Hierarchy) onto the "Game UI Document" field under Client Server Connection Handler component in Inspector, save, and navigate back to NavigationScene

  • Hit play, host a game, play around, quit, and host again

  • Note that hitting play in MainScene will no longer trigger starting the gameplay

    • This is because client/server worlds are created in NavigationScene

  • Note that hitting "Join Game" will not trigger gameplay because we are not connected to a server

    • This will be updated in the Multiplayer section

We are now able to navigate back to NavigationScene by hitting the "Quit Game" button in MainScene

  • We updated ClientServerConnectionHandler

Github branch link:β€Œ

git clone https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/ git checkout 'Navigating-Between-Scenes'β€Œ

Last updated