Host or Join a Multiplayer Session on LAN

Code and workflows for hosting and/or joining a multiplayer session on LAN, and gracefully handling hosts/clients leaving

What you'll develop on this page

We will add logic to our transition between NavigateScene and MainScene that configures whether or not MainScene loads up ServerWorld and what IP address ClientWorld connects to.

We will also gracefully handle hosts or clients leaving a game.

Github branch link: https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/tree/Hosting-Joining-and-Leaving-a-Game

Hosting and joining

First, some background

We have 3 views that could take us to MainScene:

  • HostGameScreen

  • JoinGameScreen

  • ManualConnectScreen

We will update the custom Visual Elements (cVE) of HostGameScreen and ManualConnectScreen views to initially populate their values with system data. "Player Name" and "Game Name" will default to the host name of the machine running the application. We will update JoinGameScreen in the next section, "Broadcasting and Joining on LAN," when we work on broadcasting.

We will also update our LocalGamesFinder script (which we use to populate the table) with two new public variables, "Broadcast Ip Address" and "Broadcast Port." These values will be used in the next section, but we will update our ServerLaunchObject with these values now in this section to avoid doubling back to this flow diagram (which would be annoying for us and not really teach us anything).

We have a single script called "ClientServerLauncher" that handles the callbacks for these 3 views mentioned above. We will update ClientServerLauncher so that it will create the ClientLaunchObject and ServerLaunchObject that currently exist in our MainScene.

We will also update our ClientLaunchObjectData and ServerLaunchObjectData with our new broadcast, game, and player fields. ClientServerConnectionHandler will then pass this data onto our ClientServerInfo object.

We will update ClientDataComponent and ServerDataComponent to hold this additional information. We will also make GameNameComponent to be used by the client to store the game name.

To pass the game name from the server to the client we will update our load game workflow.

Finally, we will create GameOverlayUpdater to update the Game UI on the client with the new game and player information.

Now let's implement

  • Let's update LocalGamesFinder used by LocalGamesDiscovery in NavigateScene to be the "source of truth" for which IP address and port our server will broadcast UDP packets on

    • This is similar to how the game port is stored in MainScene in the ClientServerInfo GameObject

  • Add these lines to LocalGamesFinder.cs. Put them before OnEnable()

    ///The broadcast ip address and port to be used by the server across the LAN
    public string BroadcastIpAddress = "255.255.255.255";
    public ushort BroadcastPort = 8014;
  • Now let's update HostGameScreen to automatically populate data based on host name and IP address

  • To do this, we will first update the uxml so that the game's IP address is read-only

    • This way, the host is not able to configure which IP address their machine can bind on

    • Why didn't we just build it this way in the first place?!

      • We thought there would be more "oomph" to this tutorial if we point out that a machine cannot configure which IP address they can start a server on in Unity 😉

  • With the code snippet below, we are updating the HostGameScreen uxml by changing a TextField VisualElement to a Label VisualElement

  • Paste the code snippet below into HostGameScreen.uxml:

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">
    <Style src="TitleScreenUI.uss" />
    <ui:ScrollView class="screen-scroll-container">
        <HostGameScreen name="HostGameScreen" class="screen HostGameScreen">
            <ui:VisualElement name="header" class="header">
                <ui:Button text="Main Menu" display-tooltip-when-elided="True" name="back-button" class="quit-button main-menu-button" />
            </ui:VisualElement>
            <ui:VisualElement name="main-content" class="main-content" style="top: 108px; left: auto; position: absolute;">
                <ui:Label text="3D XR Asteroids" display-tooltip-when-elided="True" name="title" class="title" />
                <ui:VisualElement name="section-title-container" class="section-title-container">
                    <ui:Label text="Host a Local Game" display-tooltip-when-elided="True" name="section-title" class="section-title" style="color: rgb(160, 194, 114);" />
                </ui:VisualElement>
                <ui:VisualElement name="game-name-container" class="data-section">
                    <ui:TextField picking-mode="Ignore" value="HostNameValue" text="GameName" name="game-name" class="data-section-input" />
                    <ui:Label text="Your Game Name" display-tooltip-when-elided="True" name="game-name-label" class="data-section-label" />
                </ui:VisualElement>
                <ui:VisualElement name="game-ip-container" class="data-section">
                    <ui:Label text="127.0.0.1" display-tooltip-when-elided="True" name="game-ip" class="data-section-input" style="border-left-width: 0; border-right-width: 0; border-top-width: 0; border-bottom-width: 0;" />
                    <ui:Label text="Your Game&apos;s IP Address" display-tooltip-when-elided="True" name="game-ip-label" class="data-section-label" />
                </ui:VisualElement>
                <ui:VisualElement name="player-name-container" class="data-section">
                    <ui:TextField picking-mode="Ignore" value="PlayerNameValue" text="PlayerName" name="player-name" readonly="false" class="data-section-input" style="border-left-color: rgb(150, 191, 208); border-right-color: rgb(150, 191, 208); border-top-color: rgb(150, 191, 208); border-bottom-color: rgb(150, 191, 208);" />
                    <ui:Label text="Your Player Name" display-tooltip-when-elided="True" name="player-name-label" class="data-section-label" style="color: rgb(150, 191, 208);" />
                </ui:VisualElement>
                <ui:Button text="Host Game" display-tooltip-when-elided="True" name="launch-host-game" class="green-button" />
            </ui:VisualElement>
        </HostGameScreen>
    </ui:ScrollView>
</ui:UXML>
  • Now let's update the HostGameScreen custom VisualElement (cVE)

    • We will pull the host name data and place it in both our Game Name field and our Player Name field

    • We will pull the host IP address and place it in our IP Address label

  • Paste the code snippet below into HostGameScreen.cs (cVE):

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

public class HostGameScreen : VisualElement
{
    //We will update these fields with system data
    TextField m_GameName;
    Label m_GameIp;
    TextField m_PlayerName;

    //These are the system data variables we will be using
    String m_HostName = "";
    IPAddress m_MyIp;

    public new class UxmlFactory : UxmlFactory<HostGameScreen, UxmlTraits> { }

    public HostGameScreen()
    {
        this.RegisterCallback<GeometryChangedEvent>(OnGeometryChange);
    }

    void OnGeometryChange(GeometryChangedEvent evt)
    {
        // 
        // PROVIDE ACCESS TO THE FORM ELEMENTS THROUGH VARIABLES
        // 
        m_GameName = this.Q<TextField>("game-name");
        m_GameIp = this.Q<Label>("game-ip");
        m_PlayerName = this.Q<TextField>("player-name");

        // 
        // INITIALIZE ALL THE TEXT FIELD WITH NETWORK INFORMATION
        //        
        m_HostName = Dns.GetHostName();
        // "best tip of all time award" to MichaelBluestein
        // somehow this is the best way to get your IP address on all the internet
        foreach (var netInterface in NetworkInterface.GetAllNetworkInterfaces()) {
            if (netInterface.OperationalStatus == OperationalStatus.Up &&  
                (netInterface.NetworkInterfaceType == NetworkInterfaceType.Wireless80211 ||
                netInterface.NetworkInterfaceType == NetworkInterfaceType.Ethernet)) {
                foreach (var addrInfo in netInterface.GetIPProperties().UnicastAddresses) {
                    if (addrInfo.Address.AddressFamily == AddressFamily.InterNetwork) {

                        m_MyIp = addrInfo.Address;
                    }
                }
            }  
        }

        //Now we set our VisualElement fields
        m_GameName.value = m_HostName;
        m_GameIp.text = m_MyIp.ToString();
        m_PlayerName.value = m_HostName;

        this.UnregisterCallback<GeometryChangedEvent>(OnGeometryChange);
    }
}
  • We also need to update our ManualConnectScreen uxml to have default data of 127.0.0.1

  • Paste the code snippet below into ManualConnectScreen.uxml:

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">
    <Style src="TitleScreenUI.uss" />
    <ui:ScrollView class="screen-scroll-container">
        <ManualConnectScreen name="ManualConnectScreen" class="screen ManualConnectScreen">
            <ui:VisualElement name="header" class="header">
                <ui:Button text="Back Button" display-tooltip-when-elided="True" name="back-button" class="quit-button main-menu-button" />
            </ui:VisualElement>
            <ui:VisualElement name="main-content" class="main-content">
                <ui:Label text="3D XR Asteroids" display-tooltip-when-elided="True" name="title" class="title" />
                <ui:VisualElement name="section-title-container" class="section-title-container">
                    <ui:Label text="Manually Connect" display-tooltip-when-elided="True" name="section-title" class="section-title" />
                </ui:VisualElement>
                <ui:VisualElement name="game-ip-container" class="data-section">
                    <ui:TextField picking-mode="Ignore" value="127.0.0.1" text="127.0.0.1" name="game-ip" readonly="false" class="data-section-input" style="background-color: rgb(255, 255, 255);" />
                    <ui:Label text="Game&apos;s IP Address" display-tooltip-when-elided="True" name="game-ip-label" class="data-section-label" />
                </ui:VisualElement>
                <ui:VisualElement name="player-name-container" class="data-section">
                    <ui:TextField picking-mode="Ignore" value="PlayerNameValue" text="PlayerName" name="player-name" readonly="false" class="data-section-input" style="border-left-color: rgb(150, 191, 208); border-right-color: rgb(150, 191, 208); border-top-color: rgb(150, 191, 208); border-bottom-color: rgb(150, 191, 208);" />
                    <ui:Label text="Your Player Name" display-tooltip-when-elided="True" name="player-name-label" class="data-section-label" style="color: rgb(150, 191, 208);" />
                </ui:VisualElement>
                <ui:Button text="Join Game" display-tooltip-when-elided="True" name="launch-connect-game" class="blue-button" style="height: 120px;" />
            </ui:VisualElement>
        </ManualConnectScreen>
    </ui:ScrollView>
</ui:UXML>
  • Previously ManualConnectScreen uxml had a "Value" of "HostIPValue" even though the text read "127.0.0.1"

    • This illustrates that there can be a difference between what TextField "shows" and what "value" is saved

      • Once you update the TextField the value updates to what text is entered (automatically)

  • Next, update our ManualConnectScreen cVE

    • Here we only set our Player Name

    • We will not automatically set the IP address using any information

      • We leave the local host IP address as the default to hopefully inform our user that if this address is not updated, the client will try and connect to itself without a running server, which will not work

  • Paste the code snippet below into ManualConnectScreen.cs (cVE):

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;

public class ManualConnectScreen : VisualElement
{
    //We will update these fields with system data
    TextField m_GameIp;
    TextField m_PlayerName;

    //These are the system data variables we will be using
    string m_HostName = "";

    public new class UxmlFactory : UxmlFactory<ManualConnectScreen, UxmlTraits> { }

    public ManualConnectScreen()
    {
        this.RegisterCallback<GeometryChangedEvent>(OnGeometryChange);
    }

    void OnGeometryChange(GeometryChangedEvent evt)
    {
        // 
        // PROVIDE ACCESS TO THE FORM ELEMENTS THROUGH VARIABLES
        // 
        m_GameIp = this.Q<TextField>("game-ip");
        m_PlayerName = this.Q<TextField>("player-name");

        // 
        // INITIALIZE ALL THE TEXT FIELD WITH NETWORK INFORMATION
        // 
        m_HostName = Dns.GetHostName();

        //Now we set our VisualElement fields
        m_PlayerName.value = m_HostName;

        this.UnregisterCallback<GeometryChangedEvent>(OnGeometryChange);
    }
}
  • With NavigationScene section, hit play

  • Navigate to the Host Game view and the Manual Connect view

  • Great, our system data populates in the appropriate fields

  • Now we need to update the data we will be passing through to MainScene in these scripts:

    • ClientLaunchObjectData

    • ServerLaunchObjectData

  • First start by pasting the code snippet below into ClientLaunchObjectData.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net;

public class ClientLaunchObjectData : MonoBehaviour
{
    //This will be set by ClientServerLauncher in NavigationScene
    //It will then be pulled out in MainScene and put into ClientServerInfo
    public string PlayerName;
    public string IPAddress;
}
  • Next, paste this code snippet into ServerLaunchObjectData.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net;

public class ServerLaunchObjectData : MonoBehaviour
{
    //This will be set by ClientServerLauncher in NavigationScene
    //It will then be pulled out in MainScene and put into ClientServerInfo
    public string GameName;
    public string BroadcastIpAddress;
    public ushort BroadcastPort;    
}
  • Now go to Main Scene

  • Drag our ClientLaunchObject and ServerLaunchObject from MainScene into our Scripts and Prefabs folder to make them prefabs

  • Then delete them from MainScene

    • We will now be able to reference them in our ClientServerLauncher script

  • Great, now let's update ClientServerLauncher to grab data from the views and populate them in our launch objects

  • 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 will be used to grab the broadcasting port and address
    public LocalGamesFinder GameBroadcasting;
    private string m_BroadcastIpAddress;
    private ushort m_BroadcastPort;

    //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;

    //These will persist through the scene transition
    //MainScene will look for 1 or both of the objects
    //Based on what MainScene finds it will initialize as Server/Client
    public GameObject ServerLauncherObject;
    public GameObject ClientLauncherObject;

    //These pieces of data will be taken from the views
    //and put into the launch objects that persist between scenes
    public TextField m_GameName;
    public TextField m_GameIp;
    public Label m_GameIpLabel;
    public TextField m_PlayerName;


    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 => ClickedConnectGame());
    }
    
    // Start is called before the first frame update
    void Start()
    {
        //We are grabbing the broadcasting information from the discover script
        //We are going to bundle it with the server launch object so it can broadcast at that information
        m_BroadcastIpAddress = GameBroadcasting.BroadcastIpAddress;
        m_BroadcastPort = GameBroadcasting.BroadcastPort;
    }

    void ClickedHostGame()
    {
        //This gets the latest values on the screen
        //Our HostGameScreen cVE defaults these values but player name and game name can be updated
        //We set these VisualElement variables OnClick instead of OnEnable because this way
        //we don't need to make a variable for player name for every view, just 1 and set which view
        //we get it from OnClick (which is when we need it)
        m_GameName = m_HostGameScreen.Q<TextField>("game-name");
        m_GameIpLabel = m_HostGameScreen.Q<Label>("game-ip");
        m_PlayerName = m_HostGameScreen.Q<TextField>("player-name");

        //Now we grab the values from the VisualElements
        var gameName = m_GameName.value;
        var gameIp = m_GameIpLabel.text;
        var playerName = m_PlayerName.value;

        //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(gameName);
        ClientLauncher(playerName, gameIp);

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

    void ClickedJoinGame()
    {
        //This gets the latest values on the screen
        //Our JoinGameScreen cVE defaults these values but player name can be updated
        //We set these VisualElement variables OnClick instead of OnEnable because this way
        //we don't need to make a variable for player name for every view, just 1 and set which view
        //we get it from OnClick (which is when we need it)
        m_GameIpLabel = m_JoinGameScreen.Q<Label>("game-ip");
        m_PlayerName = m_JoinGameScreen.Q<TextField>("player-name");

        //Now we grab the values from the VisualElements
        var gameIp = m_GameIpLabel.text;
        var playerName = m_PlayerName.value;

        //When we click "Join Game" that means we want to be only a client
        ClientLauncher(playerName, gameIp);

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

    void ClickedConnectGame()
    {
        //This gets the latest values on the screen
        //Our ManualConnectScreen cVE defaults these values but player name and IP address be updated
        //We set these VisualElement variables OnClick instead of OnEnable because this way
        //we don't need to make a variable for player name for every view, just 1 and set which view
        //we get it from OnClick (which is when we need it)
        m_GameIp = m_ManualConnectScreen.Q<TextField>("game-ip");
        m_PlayerName = m_ManualConnectScreen.Q<TextField>("player-name");

        //Now we grab the values from the VisualElements
        var gameIp = m_GameIp.value;
        var playerName = m_PlayerName.value;

        //When we click "Join Game" that means we want to be only a client
        ClientLauncher(playerName, gameIp);

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



    public void ServerLauncher(string gameName)
    {
        //Here we create the launch GameObject and load it with necessary data
        GameObject  serverObject = Instantiate(ServerLauncherObject);
        DontDestroyOnLoad(serverObject);

        //This sets up the server object with all its necessary data
        serverObject.GetComponent<ServerLaunchObjectData>().GameName = gameName;
        serverObject.GetComponent<ServerLaunchObjectData>().BroadcastIpAddress = m_BroadcastIpAddress;
        serverObject.GetComponent<ServerLaunchObjectData>().BroadcastPort = m_BroadcastPort;

        //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(string playerName, string ipAddress)
    {
        //Here we create the launch GameObject and load it with necessary data
        GameObject  clientObject = Instantiate(ClientLauncherObject);
        DontDestroyOnLoad(clientObject);
        clientObject.GetComponent<ClientLaunchObjectData>().PlayerName = playerName;
        clientObject.GetComponent<ClientLaunchObjectData>().IPAddress = ipAddress;

        //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
    }
}
  • Now let's drag our ClientLaunchObject and ServerLaunchObject prefabs from the Scripts and Prefabs folder into the appropriate fields in our ClientServerLauncher GameObject in NavigationScene

    • As a reminder you can find these fields in Inspector when ClientServerLauncher is selected in Hierarchy

    • Lastly, let's also drag our LocalGamesDiscovery GameObject (in Hierarchy) into the Game Broadcasting field

  • Let's hit play, navigate to Host Game, click Host, and check it out

  • Now we are able to create our launch objects and our proper worlds are created 👍

  • Now let's go to Manual Connect screen and join an IP address

  • We can see from the logs that we have attempted to connect to the proper IP address

We are now able to take configurations from our NavigationScene and use them to create launch objects that are interpreted by our MainScene

  • We updated LocalGamesFinder

  • We updated our HostGameScreen uxml

  • We updated HostGameScreen and ManualConnectScreen cVEs to populate with default system data

  • We updated ClientLaunchObjectData and ServerLaunchObject data to take in more configuration data

  • We turned ClientLaunchObjectData and ServerLaunchObject into prefabs, and removed them from MainScene

  • We updated ClientServerLauncher to pull data and put them into our launch objects

Updating our Game UI

  • Now let's update ClientServerInfo to be able to take in the additional information provided by the launch objects

  • Paste the code snippet below into ClientServerInfo.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Collections;
using System;
 
public class ClientServerInfo : MonoBehaviour
{
    public bool IsServer = false;
    public bool IsClient = false;
    public string ConnectToServerIp;
    public ushort GamePort = 5001;

    public string GameName;
    public string PlayerName;

    public string BroadcastIpAddress;
    public ushort BroadcastPort;

}
  • Let's also update our ServerDataComponent to take in additional information: game name

    • We want our server to send the game name data to our client

    • A client manually connects to an IP address; they must be sent the name

  • Paste the code snippet below into ServerDataComponent.cs:

using Unity.Entities;
using Unity.Collections;


 public struct ServerDataComponent : IComponentData
{
    public FixedString64Bytes GameName;
    public ushort GamePort;
}
  • Now we will update ClientDataComponent to take in additional information: player name

    • We will make use of this in the "Scorekeeping" section when the player name needs to be sent to the server in order to keep score

  • Paste the code snippet below into ClientDataComponent.cs:

using System;
using Unity.Entities;
using Unity.Collections;

public struct ClientDataComponent : IComponentData
{
    //Must used "FixedStringN" instead of stirng in IComponentData
    //This is a DOTS requirement because IComponentData must be a struct
    public FixedString64Bytes ConnectToServerIp;
    public ushort GamePort;
    public FixedString64Bytes PlayerName;
}
  • We must also create a new component called GameNameComponent in Multiplayer Setup to store the game name on the client

    • Right-click in Multiplayer Setup > Create > C# Script > name it "GameNameComponent"

    • It may seem weird to create a component for game name because we already have that data set in ClientServerInfo when hosting a session. However, please remember that we are building both a host build and a client build, and the client will not immediately have this information if they manually connect to an IP address

    • OK, then why aren't we just putting GameName inside ClientDataComponent? Why are we making a new component? Don't we have enough of these components already?!

    • Let us explain ourselves: .GameName is a FixedString64Bytes string, which means that it faces a limitation if and when it's used in a component. The limitation of using FixedString in a component is that you cannot "tell" if the value of the FixedString has been set if it's in a component

      • FixedStringByte's default value is equal to an empty string

      • So to circumvent this limitation, we need to create an entirely separate component just to store the value of GameName. To see if its value has been set, we check for the existence of the entire component

      • This mouthful is just to explain and show you that you cannot check to see if a FixedStringByte field has been updated in a component

  • Paste the code snippet below into GameNameComponent.cs:

using Unity.Entities;
using Unity.Collections;

public struct GameNameComponent : IComponentData
{
    //Must used "FixedStringN" instead of stirng in IComponentData
    //This is a DOTS requirement because IComponentData must be a struct
    public FixedString64Bytes GameName;
}
  • Now let's update our ClientServerConnectionHandler to pass through more data from the launch objects to ClientServerInfo as well as ClientDataComponent and ServerDataComponent

  • 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
            //If it exists it creates ServerDataComponent InitializeServerComponent and
            //passes through server data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ServerLaunchObjectData>() != null)
            {
                //This sets the gameobject server data  in ClientServerInfo (mono)
                ClientServerInfo.IsServer = true;
                ClientServerInfo.GameName = launchObject.GetComponent<ServerLaunchObjectData>().GameName;
                ClientServerInfo.BroadcastIpAddress = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastIpAddress;
                ClientServerInfo.BroadcastPort = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastPort;

                //This 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
                        {
                            GameName = ClientServerInfo.GameName,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows server initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeServerComponent));
                    }
                }
            }

            // 
            //Checks for client launch object
            //If it exists it creates ClientDataComponent, InitializeServerComponent and
            // passes through client data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ClientLaunchObjectData>() != null)
            {
                //This sets the gameobject data in ClientServerInfo (mono)
                ClientServerInfo.IsClient = true;
                ClientServerInfo.ConnectToServerIp = launchObject.GetComponent<ClientLaunchObjectData>().IPAddress;                
                ClientServerInfo.PlayerName = launchObject.GetComponent<ClientLaunchObjectData>().PlayerName;

                //This 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
                        {
                            PlayerName = ClientServerInfo.PlayerName,
                            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"); 

    }
}

Why do we duplicate data in ServerDataComponent/ClientDataComponent and ClientServerInfo? That seems super redundant...

Sometimes we want to use data in ECS, and because of that we save data in components so our systems can easily access it. At other times we want to use the data in a MonoBehaviour, so we save it in a GameObject to make it easily accessible to scripts.

This is not a rock-solid approach because it can be easy to "forget" to update data in one of the places we store it and not the other. The approach we take in this gitbook is to first always save any updates to ClientServerInfo and only push data into ECS from thereafter, as you might notice in ClientServerConnectionHandler.

  • Now let's hit play and join through Host Game and Manual Connect and check out the updates to ClientServerInfo

    • Play around! For example, go ahead and change up the input fields on the view to see the updates in ClientServerInfo (see gif below for ideas)

  • Now let's update our SendClientGameRpc to include the game name

  • Paste the code snippet below into SendClientGameRpc:

using AOT;
using Unity.Burst;
using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Entities;
using Unity.Collections;
using System.Collections;
using System;

public struct SendClientGameRpc : IRpcCommand
{
    public int levelWidth;
    public int levelHeight;
    public int levelDepth;
    public float playerForce;
    public float bulletVelocity;
    public FixedString64Bytes gameName;
}
  • We need to include the updated information in ServerSendGameSystem

  • Paste the code snippet below into ServerSendGameSystem.cs:

using Unity.Entities;
using Unity.Jobs;
using Unity.Collections;
using Unity.NetCode;
using UnityEngine;

//This component is only used by this system so we define it in this file
public struct SentClientGameRpcTag : IComponentData
{
}

//This system should only be run by the server (because the server sends the game settings)
//By sepcifying to update in group ServerSimulationSystemGroup it also specifies that it must
//be run by the server
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
[UpdateBefore(typeof(RpcSystem))]
public partial class ServerSendGameSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_Barrier;

    protected override void OnCreate()
    {
        m_Barrier = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();
        RequireSingletonForUpdate<GameSettingsComponent>();
        RequireSingletonForUpdate<ServerDataComponent>();
    }

    protected override void OnUpdate()
    {
        var commandBuffer = m_Barrier.CreateCommandBuffer();

        var serverData = GetSingleton<GameSettingsComponent>();
        var gameNameData = GetSingleton<ServerDataComponent>();

        Entities
        .WithNone<SentClientGameRpcTag>()
        .ForEach((Entity entity, in NetworkIdComponent netId) =>
        {
            commandBuffer.AddComponent(entity, new SentClientGameRpcTag());
            var req = commandBuffer.CreateEntity();
            commandBuffer.AddComponent(req, new SendClientGameRpc
            {
                levelWidth = serverData.levelWidth,
                levelHeight = serverData.levelHeight,
                levelDepth = serverData.levelDepth,
                playerForce = serverData.playerForce,
                bulletVelocity = serverData.bulletVelocity,
                gameName = gameNameData.GameName
            });

            commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent {TargetConnection = entity});
        }).Schedule();

        m_Barrier.AddJobHandleForProducer(Dependency);
    }
}
  • Now let's update the ClientLoadGameSystem to update ClientDataComponent with the game name. This will update our Game UI with the game name

    • Paste the code snippet below into ClientLoadGameSystem.cs:

using Unity.Entities;
using Unity.NetCode;
using UnityEngine;

//This will only run on the client because it updates in ClientSimulationSystemGroup (which the server does not have)
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
[UpdateBefore(typeof(RpcSystem))]
public partial class ClientLoadGameSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    protected override void OnCreate()
    {
        //We will be using the BeginSimECB
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //Requiring the ReceiveRpcCommandRequestComponent ensures that update is only run when an NCE exists
        RequireForUpdate(GetEntityQuery(ComponentType.ReadOnly<SendClientGameRpc>(), ComponentType.ReadOnly<ReceiveRpcCommandRequestComponent>()));   
        //This is just here to make sure the Sub Scene is streamed in before the client sets up the level data
        RequireSingletonForUpdate<GameSettingsComponent>();
        RequireSingletonForUpdate<ClientDataComponent>();
    }

    protected override void OnUpdate()
    {

        //We must declare our local variables before using them within a job (.ForEach)
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        var rpcFromEntity = GetBufferFromEntity<OutgoingRpcDataStreamBufferComponent>();
        var gameSettingsEntity = GetSingletonEntity<GameSettingsComponent>();
        var getGameSettingsComponentData = GetComponentDataFromEntity<GameSettingsComponent>();

        Entities
        .ForEach((Entity entity, in SendClientGameRpc request, in ReceiveRpcCommandRequestComponent requestSource) =>
        {
            //This destroys the incoming RPC so the code is only run once
            commandBuffer.DestroyEntity(entity);

            //Check for disconnects before moving forward
            if (!rpcFromEntity.HasComponent(requestSource.SourceConnection))
                return;

            //Set the game size (unnecessary right now but we are including it to show how it is done)
            getGameSettingsComponentData[gameSettingsEntity] = new GameSettingsComponent
            {
                levelWidth = request.levelWidth,
                levelHeight = request.levelHeight,
                levelDepth = request.levelDepth,
                playerForce = request.playerForce,
                bulletVelocity = request.bulletVelocity
            };


            //Here we create a new singleton entity for GameNameComponent
            //We could add this component to the singleton entity that has the GameSettingsComponent
            //but we will keep them separate in case we want to change workflows in the future and don't
            //want these components to be dependent on the same entity
            var gameNameEntity= commandBuffer.CreateEntity();
            commandBuffer.AddComponent(gameNameEntity, new GameNameComponent {
                GameName = request.gameName
            });

            //These update the NCE with NetworkStreamInGame (required to start receiving snapshots) and
            //PlayerSpawningStateComponent, which we will use when we spawn players
            commandBuffer.AddComponent(requestSource.SourceConnection, new PlayerSpawningStateComponent());
            commandBuffer.AddComponent(requestSource.SourceConnection, default(NetworkStreamInGame));
            
            //This tells the server "I loaded the level"
            //First we create an entity called levelReq that will have 2 necessary components
            //Next we add the RPC we want to send (SendServerGameLoadedRpc) and then we add
            //SendRpcCommandRequestComponent with our TargetConnection being the NCE with the server (which will send it to the server)
            var levelReq = commandBuffer.CreateEntity();
            commandBuffer.AddComponent(levelReq, new SendServerGameLoadedRpc());
            commandBuffer.AddComponent(levelReq, new SendRpcCommandRequestComponent {TargetConnection = requestSource.SourceConnection});

        }).Schedule();

        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • Right-click in the Assets/UI folder and Create a new C# Script named GameOverlayUpdater

  • GameOverlapUpdater will be responsible for updating our GameUI overlay and pulling the ClientDataComponent and setting ClientServerInfo GameName

    • It will update the game name and player name shown in the game UI

    • It will also be responsible for updating player scores

  • Paste the code snippet below into the newly created GameOverlayUpdater.cs:

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

public class GameOverlayUpdater : MonoBehaviour
{
    //This is how we will grab access to the UI elements we need to update
    public UIDocument m_GameUIDocument;
    private VisualElement m_GameManagerUIVE;
    private Label m_GameName;
    private Label m_GameIp;
    private Label m_PlayerName;
    private Label m_CurrentScoreText;
    private Label m_HighScoreText;
    private Label m_HighestScoreText;
    
    //We will need ClientServerInfo to update our VisualElements with appropriate valuess
    public ClientServerInfo ClientServerInfo;
    private ClientSimulationSystemGroup m_ClientWorldSimulationSystemGroup;

    //Will check for GameNameComponent
    private EntityQuery m_GameNameComponentQuery;
    private bool gameNameIsSet = false;

    void OnEnable()
    {

        //We set the labels that we will need to update
        m_GameManagerUIVE = m_GameUIDocument.rootVisualElement;
        m_GameName = m_GameManagerUIVE.Q<Label>("game-name");
        m_GameIp = m_GameManagerUIVE.Q<Label>("game-ip");
        m_PlayerName = m_GameManagerUIVE.Q<Label>("player-name");

        //Scores will be updated in a future section
        m_CurrentScoreText = m_GameManagerUIVE.Q<Label>("current-score");
        m_HighScoreText = m_GameManagerUIVE.Q<Label>("high-score");
        m_HighestScoreText = m_GameManagerUIVE.Q<Label>("highest-score");
    }

    // Start is called before the first frame update
    void Start()
    {
        //We set the initial client data we already have as part of ClientDataComponent
        m_GameIp.text = ClientServerInfo.ConnectToServerIp;
        m_PlayerName.text = ClientServerInfo.PlayerName;
        
        //If it is not the client, stop running this script (unnecessary)
        if (!ClientServerInfo.IsClient)
        {
            this.enabled = false;         
        }
        
        //Now we search for the client world and the client simulation system group
        //so we can communicated with ECS in this MonoBehaviour
        foreach (var world in World.All)
        {
            if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
            {
                m_ClientWorldSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
                m_GameNameComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<GameNameComponent>());
            }
        }
    }


    // Update is called once per frame
    void Update()
    {
        //We do not need to continue if we do not have a GameNameComponent yet
        if(m_GameNameComponentQuery.IsEmptyIgnoreFilter)
            return;

        //If we have a GameNameComponent we need to update ClientServerInfo and then our UI
        //We only need to do this once so we have a boolean flag to prevent this from being ran more than once
        if(!gameNameIsSet)
        {
                ClientServerInfo.GameName = m_ClientWorldSimulationSystemGroup.GetSingleton<GameNameComponent>().GameName.ToString();
                m_GameName.text = ClientServerInfo.GameName;
                gameNameIsSet = true;
        }
    }
}
  • We already covered most of the techniques used here (grabbing UI Document, querying for Visual Elements, setting them to values) in the "Creating a ListView" page in the "UI Builder and UI Toolkit" section of the gitbook

    • So, if what we are doing here in this section is blowing your mind and you don't feel comfortable moving forward, we implore you to visit our earlier section, "UI Builder and UI Toolkit," where we take you step-by-step through UI Builder and UI Toolkit

  • Let's add GameOverlayUpdater as a component to the GameUI GameObject in MainScene

    • Click Add Component in Inspector while GameUI is selected in Hierarchy and add GameOverlayUpdater

  • Now let's drag the GameUI GameObject and the ClientServerInfo GameObjects into the appropriate fields on the component and save the scene, then return to NavigationScene

  • Let's hit play, join a game, and see how our game UI gets updated

We now have our Game UI updated with player and game information

  • We updated ClientServerInfo to take in additional data

  • We updated ServerDataComponent and ClientDataComponent to take in additional data

  • We created GameNameComponent

  • We updated ClientServerConnectionHandler to pass through more data from the launch objects

  • We updated SendClientGameRpc to include the game name

  • Updated ServerSendGameSystem to add the game name to the RPC

  • We updated ClientLoadGameSystem to create GameNameComponent when receiving the RPC

  • We created GameOverLayUpdater to update the Game UI

Updating build configurations

Now that we have 2 scenes, we need to update our build configurations so that we can test how our game responds to clients/hosts leaving games.

  • Select BaseBuildConfiguration in the Assets/BuildSettings folder in your Project

  • Go the Inspector. Under "Scene List" click the drop down icon next to "Scene Infos" and click "+ Add Element"

  • Update Element 0 to be NavigationScene (drag "NavigationScene" from your Scenes folder into the Scene field)

  • Update Element 1 to be MainScene (drag "MainScene" from your Scenes folder into the Scene field)

  • Uncheck "Build Current Scene" at the top of Scene List if it isn't already unchecked

  • Uncheck "Auto Load" for Element 0 if it isn't already unchecked

  • Click "Apply" at the bottom

  • Go to File > Build Settings and click the "Player Settings..." button in the bottom left corner

  • When Player is selected on the left, go to the Resolution and Presentation section and set FullScreen Mode to "Windowed"

    • This will make it easier for testing

    • If a scroll bar appears in your window increase the Default Screen Height to 1000px

      • This sometimes happens on retina screens

    • Also make the screen resizeable

  • In your Project folder, go to the BuildSettings folder, click on the file with your development platform name (i.e. macOS) then hit "Build and Run" in Inspector

  • Open your Unity editor, hit player, and host a game

  • In your running build manually join the game

  • In your running build quit the game

  • Notice that our player has not disappeared from the game when the client disconnected

    • A floating corpse!

We have updated our build configurations

  • We updated BaseBuildConfiguration

  • We updated Player Settings

Leaving a game

These are two ways to handle leaving a game:

  1. Hitting the "Quit Game" button at the top of the game UI

  2. Timing out (through either quitting the app or losing network connectivity)

When a client or server times out, NetCode automatically adds a "NetworkStreamDisconnected" component to the NCE "on the other side" that is still in the game. So if a client times out, the server gets it on their NCE, if a server times out, the client gets it on their NCE.

Network connection

The network connection uses the Unity Transport package and stores each connection as an entity. Each connection entity has a NetworkStreamConnection component with the Transport handle for the connection. The connection also has a NetworkStreamDisconnected component for one frame, after it disconnects and before the entity is destroyed.

To request disconnect, add a NetworkStreamRequestDisconnect component to the entity. Direct disconnection through the driver is not supported. Your game can mark a connection as being in-game, with the NetworkStreamInGame component. Your game must do this; it is never done automatically.

From NetCode's Network connection documentation

NetCode provides NetworkStreamDisconnect automatically if a client/server times out. We can also trigger a NetworkStreamDisconnect automatically by adding a NetworkStreamRequestDisconnect tag. If the client or host clicks the "Quit Game" button on-screen, we can add NetworkStreamRequestDisconnect to inform everyone of the departure. The host must tell all clients. The client only needs to tell the server.

If the client notices a server disconnect, they'll be taken back to the NavigationScene as if they clicked the "Quit Game" button.

We are going to "clean" up any disconnected players on the server if a client leaves by deleting their player entity. We will do this by checking for a "NetworkStreamDisconnected" component on any NCEs.

Leaving as a client

When a client leaves, it must tell the server "I am leaving, goodbye!" before it goes. (Irish exiting is a great method of leaving parties, but it's not great for keeping a clean server game 👋)

  • First, we will add NetworkStreamRequestDisconnect to our client NCE before we leave. This will happen in ClientServerConnectionHandler

    • This will allow the server to follow a clean-up workflow

  • We will also update ClientServerConnectionHandler to delete our launch objects

  • 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;

    //We will use these variables for hitting Quit Game
    private ClientSimulationSystemGroup m_ClientSimulationSystemGroup;
    private World m_ClientWorld;
    private EntityQuery m_NetworkIdComponentQuery;

    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
            //If it exists it creates ServerDataComponent InitializeServerComponent and
            //passes through server data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ServerLaunchObjectData>() != null)
            {
                //This sets the gameobject server data  in ClientServerInfo (mono)
                ClientServerInfo.IsServer = true;
                ClientServerInfo.GameName = launchObject.GetComponent<ServerLaunchObjectData>().GameName;
                ClientServerInfo.BroadcastIpAddress = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastIpAddress;
                ClientServerInfo.BroadcastPort = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastPort;

                //This 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
                        {
                            GameName = ClientServerInfo.GameName,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows server initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeServerComponent));

                    }
                }
            }

            // 
            //Checks for client launch object
            //If it exists it creates ClientDataComponent, InitializeServerComponent and
            // passes through client data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ClientLaunchObjectData>() != null)
            {
                //This sets the gameobject data in ClientServerInfo (mono)
                ClientServerInfo.IsClient = true;
                ClientServerInfo.ConnectToServerIp = launchObject.GetComponent<ClientLaunchObjectData>().IPAddress;                
                ClientServerInfo.PlayerName = launchObject.GetComponent<ClientLaunchObjectData>().PlayerName;

                //This 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
                        {
                            PlayerName = ClientServerInfo.PlayerName,
                            ConnectToServerIp = ClientServerInfo.ConnectToServerIp,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows client initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeClientComponent));

                        //We will now set the variables we need to clean up during QuitGame()
                        m_ClientWorld = world;
                        m_ClientSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
                        m_NetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());

                    }
                }
            }
        }
    }

    // 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 we were able to create an NCE we must add a request disconnect
        if (!m_NetworkIdComponentQuery.IsEmptyIgnoreFilter)
        {
            var clientNCE = m_ClientSimulationSystemGroup.GetSingletonEntity<NetworkIdComponent>();
            m_ClientWorld.EntityManager.AddComponentData(clientNCE, new NetworkStreamRequestDisconnect());

        }

#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()
    {
        for (var i = 0; i < launchObjects.Length; i++)
        {
            Destroy(launchObjects[i]);
        }

        //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"); 

    }
}
  • Now we need to create DisconnectSystem in the Server/Systems folder to handle the clean-up of any player entities from disconnected clients

    • Right-click in the Server/Systems folder > Create > C# Script

  • Paste the code snippet below into DisconnectSystem.cs:

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.NetCode;
using UnityEngine;
using Unity.Physics;
using Unity.Physics.Stateful;

[UpdateInWorld(TargetWorld.Server)]
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
public partial class DisconnectSystem : SystemBase
{
    //We are going to want to playback adding our "DestroyTag" omponent in EndFixedStepSimEcb
    //similar to adding destroy tags from collisions with bullets
    private EndFixedStepSimulationEntityCommandBufferSystem m_CommandBufferSystem;
    
    //We will need a query of all entities with NetworkStreamDisconnected components
    private EntityQuery m_DisconnectedNCEQuery;

    protected override void OnCreate()
    {
        //We set our variables
        m_CommandBufferSystem = World.GetOrCreateSystem<EndFixedStepSimulationEntityCommandBufferSystem>();
        m_DisconnectedNCEQuery = GetEntityQuery(ComponentType.ReadWrite<NetworkStreamDisconnected>());

        //We only need to run this if there are disconnected NCEs
        RequireForUpdate(m_DisconnectedNCEQuery);
    }

    protected override void OnUpdate()
    {

        //We need a command buffer because we are making a structural change (adding a DestroyTag)
        var commandBuffer = m_CommandBufferSystem.CreateCommandBuffer();    

        //There is a dependency on our "ToEntityArrayAsync" because
        //we are depending on this to get done for us to run our .ForEach()
        JobHandle disconnectNCEsDep;

        //We query for all entities that have a NetworkStreamDisconnected component and save it in a native array
        var disconnectedNCEsNative = m_DisconnectedNCEQuery.ToEntityArrayAsync(Allocator.TempJob, out disconnectNCEsDep);
        //We will need to pull the NetworkIdComponent from these entities within our .ForEach so we
        //declare the local variable now
        var getNetworkIdComponentData = GetComponentDataFromEntity<NetworkIdComponent>();

        //We are going to save the JobHandle required from this .ForEach as "cleanPlayersJob"
        //We pass through our native array as read only, and ask .ForEach to dispose of our array on completion
        var cleanPlayersJob = Entities
        .WithReadOnly(disconnectedNCEsNative)
        .WithDisposeOnCompletion(disconnectedNCEsNative)
        .WithAll<PlayerTag>()
        .ForEach((Entity entity, in GhostOwnerComponent ghostOwner) => {

            //We navigate through our disconnected NCE's and see if any player entities match
            for (int i = 0; i < disconnectedNCEsNative.Length; i++)
            {
                if (getNetworkIdComponentData[disconnectedNCEsNative[0]].Value == ghostOwner.NetworkId)
                {
                    //If they do match we add a DestroyTag to delete
                    commandBuffer.AddComponent<DestroyTag>(entity);
                }             
            }
        }).Schedule(JobHandle.CombineDependencies(Dependency, disconnectNCEsDep));

        //We set our Dependency of this sytem to cleanPlayersJob
        Dependency = cleanPlayersJob;

        //And we add our dependency to our command buffer
        m_CommandBufferSystem.AddJobHandleForProducer(Dependency);
    }
}
  • Find your development platform of-choice in the Assets/BuildSettings folder again, then click Build and Run in Inspector

  • Now let's host a game in our editor and join the game in the build

  • Create a player then quit the game on the running build

  • Great- now our session can handle clients disconnecting

  • Now host a game in the build and join it in the editor

  • Quit the game on the running build

  • We are "stuck" in the game. Pretty lame

  • Let's fix that

Leaving as a host

If the server disconnects, we want our clients to return to NavigationScene. The clients must be "informed" that the server disconnects similar to how that server was "informed" when a client disconnected.

In ClientServerConnectionHandler, we will put in a check on the client to see if its NCE has a "NetworkStreamDisconnected" tag on it.

Also in ClientServerConnectionHandler, we will add to the ClickedQuitGame() method so that if the host clicks a button, tell all the clients that we disconnected by adding the "NetworkStreamRequestDisconnect" tag.

  • Paste the code snippet below into ClientServerConnectionHandler.cs and save:

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

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;

    //We will use these variables for hitting Quit Game on client or if server disconnects
    private ClientSimulationSystemGroup m_ClientSimulationSystemGroup;
    private World m_ClientWorld;
    private EntityQuery m_ClientNetworkIdComponentQuery;
    private EntityQuery m_ClientDisconnectedNCEQuery;

    //We will use these variables for hitting Quit Game on server
    private World m_ServerWorld;
    private EntityQuery m_ServerNetworkIdComponentQuery;

    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
            //If it exists it creates ServerDataComponent InitializeServerComponent and
            //passes through server data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ServerLaunchObjectData>() != null)
            {
                //This sets the gameobject server data  in ClientServerInfo (mono)
                ClientServerInfo.IsServer = true;
                ClientServerInfo.GameName = launchObject.GetComponent<ServerLaunchObjectData>().GameName;
                ClientServerInfo.BroadcastIpAddress = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastIpAddress;
                ClientServerInfo.BroadcastPort = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastPort;

                //This 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
                        {
                            GameName = ClientServerInfo.GameName,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows server initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeServerComponent));

                        //For handling server disconnecting by hitting the quit button
                        m_ServerWorld = world;
                        m_ServerNetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());

                    }
                }
            }

            // 
            //Checks for client launch object
            //If it exists it creates ClientDataComponent, InitializeServerComponent and
            // passes through client data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ClientLaunchObjectData>() != null)
            {
                //This sets the gameobject data in ClientServerInfo (mono)
                ClientServerInfo.IsClient = true;
                ClientServerInfo.ConnectToServerIp = launchObject.GetComponent<ClientLaunchObjectData>().IPAddress;                
                ClientServerInfo.PlayerName = launchObject.GetComponent<ClientLaunchObjectData>().PlayerName;

                //This 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
                        {
                            PlayerName = ClientServerInfo.PlayerName,
                            ConnectToServerIp = ClientServerInfo.ConnectToServerIp,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows client initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeClientComponent));

                        //We will now set the variables we need to clean up during QuitGame()
                        m_ClientWorld = world;
                        m_ClientSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
                        m_ClientNetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
                        //This variable is used to check if the server disconnected
                        m_ClientDisconnectedNCEQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDisconnected>());

                    }
                }
            }
        }
    }

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

    // Update is called once per frame
    void Update()
    {
        //The client checks if the NCE has a NetworkStreamDisconnected component
        //If it does we act like they quit the game manually
        if(m_ClientDisconnectedNCEQuery.IsEmptyIgnoreFilter)
            return;
        else
            ClickedQuitGame();
    }

   //This function will navigate us to NavigationScene and connected with the clients/server about leaving
    void ClickedQuitGame()
    {
        //As a client if we were able to create an NCE we must add a request disconnect
        if (!m_ClientNetworkIdComponentQuery.IsEmptyIgnoreFilter)
        {
            var clientNCE = m_ClientSimulationSystemGroup.GetSingletonEntity<NetworkIdComponent>();
            m_ClientWorld.EntityManager.AddComponentData(clientNCE, new NetworkStreamRequestDisconnect());

        }

        //As a server if we were able to create an NCE we must add a request disconnect to all NCEs
        //We must to see if this was a host build
        if (m_ServerWorld != null)
        {
            //First we grab the array of NCEs
            var nceArray = m_ServerNetworkIdComponentQuery.ToEntityArray(Allocator.TempJob);
            for (int i = 0; i < nceArray.Length; i++)
            {
                //Then we add our NetworkStreamDisconnect component to tell the clients we are leaving
                m_ServerWorld.EntityManager.AddComponentData(nceArray[i], new NetworkStreamRequestDisconnect());
            }
            //Then we dispose of our array
            nceArray.Dispose();
        }

#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()
    {
        for (var i = 0; i < launchObjects.Length; i++)
        {
            Destroy(launchObjects[i]);
        }

        //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"); 

    }
}
  • Now again: go back to BuildSettings folder, choose your development platform, then click Build and Run in Inspector

  • Now Host a Game on the build (the one in the "Sample" window) and the Join the game in the Editor (in Unity)

  • Click "Quit Game" in the build version

If you are running with a non-zero amount of Thin Clients (in PlayMode Tools) you will notice some harmless errors about sending RPCs. This is because we are not gracefully handling Thin Clients when hosts disconnect mid-game. The Thin Client is inputting "spacebar" as the entities/systems are being destroyed causing RPCs to be sent but with no connections.

We are now able to handle clients or hosts leaving the game

  • We updated ClientServerConnectionHandler

  • We created DisconnectSystem to be run by the server

Hitting the "Quit" button on the main menu

Although it may be hard to believe, people might want to quit playing this super basic game 😱so let's add functionality to the "Quit" button at the top of the Title Screen.

  • Select the TitleScreenUI GameObject in NavigationScene

  • Add a new script as a component called "QuitButtonHandler"

  • Move the new script to Assets/UI

  • Paste the code snippet below into QuitButtonHandler.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public class QuitButtonHandler : MonoBehaviour
{
    //This is the UI Document from the Hierarchy in NavigationScene
    public UIDocument m_TitleUIDocument;
    private VisualElement m_titleScreenManagerVE;
    //Button we will set by querying the parent UI Document
    private Button m_QuitButton;

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

    }

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

    // Update is called once per frame
    void Update()
    {
        
    }

    void ClickedQuit()
    {
     // save any game data here
#if UNITY_EDITOR
         // Application.Quit() does not work in the editor so
         // UnityEditor.EditorApplication.isPlaying need to be set to false to end the game
         UnityEditor.EditorApplication.isPlaying = false;
#else
         Application.Quit();
#endif
    }
}
  • Now let's drag the TitleScreenUI GameObject from the Hierarchy into the appropriate field in the QuitButtonHandler component

  • Let's navigate back to BuildSettings, choose the configuration of your development platform, and Build and Run our game

  • Hit the "Quit" button at the top right

We are now able to quit the application

  • We created QuitButtonHandler and added it to TitleScreenUI

Github branch link:

git clone https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/ git checkout 'Hosting-Joining-and-Leaving-a-Game'

Last updated