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;
}
}
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.
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
Navigating from MainScene to NavigationScene
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