Tutorial 7 - Create a Lobby System Using Cluster Room Management

Summary

This tutorial demonstrates how to use a Reactor cluster to manage multiple room instances. You will create a lobby room that accepts new player connections and then moves those players to different game rooms. The lobby will also manage the starting and stopping of game rooms to accomodate those players.

Requirements

Setup

  1. Create a new scene named 'ClusterTutorial'.

Create a Lobby Room

  1. Right click in the hierarachy window and create a 'Reactor/Room' game object.
  2. Name the game object 'LobbyRoom'.
  3. Remove the ksConnect component.

Create a Game Room

  1. Right click in the hierarachy window and create a 'Reactor/Room' game object.
  2. Name the game object 'GameRoom'.
  3. Remove the ksConnect component.

It's possible to have more than one room type in the same scene. This is why when you start a room, you must specify which room type to run.

Scripting

Create a Class for Constants

The consts script defines const ids for RPCs used on both the client and the server.

  1. In 'Assets/ReactorScripts/Common', create a script named 'Consts'.

Consts.cs

// RPC IDs
public class RPC
{
    public const uint NUM_ROOMS = 0;
    public const uint JOIN_ROOM = 1;
    public const uint SHUT_DOWN = 2;
}

Create a Server Game Script

The server game room script updates the number of connected players in the room's PublicData. It adds the room tag "Game" to the room to identify this room as a game room. It shuts down the room when a shut down RPC is received from a client.

  1. Select the 'GameRoom' object.
  2. In the 'Add Component' menu, select 'Reactor->New Server Room Script'.
  3. Name the script 'ServerGameRoom'.

ServerGameRoom.cs

using KS.Reactor.Server;
using KS.Reactor;

public class ServerGameRoom : ksServerRoomScript
{
    // Called when the script is attached.
    public override void Initialize()
    {
        // Settings IsPublic to false prevents the room from appearing in the results of ksReactor.GetRooms requests
        // made by clients. We hide the game rooms because we want clients to connect to a lobby room initially.
        Room.IsPublic = false;

        // Add the "Game" tag so the this room is in the results when the lobby fetches all rooms with the "Game" tag.
        Room.PublicTags.Add("Game");

        // Add a shut down event handler.
        Room.OnShutDown += OnShutDown;
        Room.OnPlayerJoin += PlayerJoin;
        Room.OnPlayerLeave += PlayerLeave;

        // Set the number of connections in the public data.
        UpdateConnections();
    }

    // Called when the script is detached.
    public override void Detached()
    {
        Room.OnShutDown -= OnShutDown;
        Room.OnPlayerJoin -= PlayerJoin;
        Room.OnPlayerLeave -= PlayerLeave;
    }

    // Called when a player connects.
    private void PlayerJoin(ksIServerPlayer player)
    {
        ksLog.Info("Player " + player.Id + " joined.");

        // Update the number of connections if this player is not a bot.
        if (!player.IsVirtual)
        {
            UpdateConnections();
        }
    }

    // Called when a player disconnects.
    private void PlayerLeave(ksIServerPlayer player)
    {
        ksLog.Info("Player " + player.Id + " left.");

        // Update the number of connections if this player is not a bot.
        if (!player.IsVirtual)
        {
            UpdateConnections();
        }
    }

    private void UpdateConnections()
    {
        // Set the number of connections in the public data. This is available in the response to Cluster.GetRoomInfo
        // calls as ksRoomInfo.PublicData.
        Room.PublicData["connections"] = Room.ConnectedPlayerCount;
    }

    // Clients can call this RPC to shut down the game room.
    [ksRPC(RPC.SHUT_DOWN)]
    private void ShutDown()
    {
        Room.ShutDown();
    }

    // Log a message before shutting down.
    private void OnShutDown()
    {
        ksLog.Info("Shutting down...");
    }
}

Create a Server Lobby Room Script

The server lobby room script queries the cluster for the list of rooms with the tag "Game" every ten seconds. It starts or stops game rooms to reach the target number of game rooms, which clients can set using an RPC to a value between 0 and 3. It gets the number of players connected to each game room from the room's 'PublicData' and only stops rooms that have no players. When clients request to join a game room, the lobby responds by sending the room information for a random game room to join.

  1. Select the 'LobbyRoom' object.
  2. In the 'Add Component' menu, select 'Reactor->New Server Room Script.
  3. Name the script 'ServerLobbyRoom'.

ServerLobbyRoom.cs

using System;
using System.Collections.Generic;
using KS.Reactor.Server;
using KS.Reactor;

public class ServerLobbyRoom : ksServerRoomScript
{
    // How often to refresh the list of game rooms.
    [ksEditable] public float RefreshInterval = 10f;

    private float m_refreshTimer;
    private List<ksRoomInfo> m_rooms = new List<ksRoomInfo>();

    // Object for locking access to m_rooms.
    private object m_roomsLock = new object();

    // The desired number of game rooms.
    private int m_targetRoomCount = 1;
    private ksRandom m_random = new ksRandom();

    // Called when the script is attached.
    public override void Initialize()
    {
        Room.OnUpdate[0] += Update;
        m_refreshTimer = 0f;
    }

    // Called when the script is detached.
    public override void Detached()
    {
        Room.OnUpdate[0] -= Update;
    }

    // Called during the update cycle.
    private void Update()
    {
        if (!Cluster.IsConnected)
        {
            // Do nothing if we are not connected to the cluster.
            return;
        }
        // Refresh the list of game rooms when the timer reaches zero.
        m_refreshTimer -= Time.Delta;
        if (m_refreshTimer <= 0)
        {
            // The parameter "Game" filters the results to only include rooms with the tag "Game", thereby excluding
            // this room from the results.
            Cluster.GetRoomInfo("Game").OnComplete += OnGetRooms;
            m_refreshTimer = RefreshInterval;
        }
    }

    // Called when the GetRoomInfo request completes.
    private void OnGetRooms(ksAsyncResult<List<ksRoomInfo>> result)
    {
        // Error handling
        if (!string.IsNullOrEmpty(result.Error))
        {
            ksLog.Error("Error getting rooms: " + result.Error);
            return;
        }
        // Completion callbacks for Cluster operations are called off the main thread, so we use a lock to make
        // accessing m_rooms thread-safe.
        lock (m_roomsLock)
        {
            m_rooms = result.Result;
            int numPlayers = Room.ConnectedPlayerCount;

            // Stop rooms that have no connected players until we reach the target room count.
            int numToStop = m_rooms.Count - m_targetRoomCount;

            // Create a list of room ids to stop.
            List<uint> roomsToStop = new List<uint>();
            foreach (ksRoomInfo roomInfo in m_rooms)
            {
                // Get the number of connected players from the public data.
                int numConnections = roomInfo.PublicData["connections"];

                // Add the number of connections to the total player count.
                numPlayers += numConnections;

                // Add the room id to the stop list if it has no players and we need to stop rooms.
                if (roomsToStop.Count < numToStop && numConnections == 0)
                {
                    ksLog.Info("Stopping room " + roomInfo.Id);
                    roomsToStop.Add(roomInfo.Id);
                }
            }
            // Log the number of rooms and players.
            ksLog.Info("Rooms: " + m_rooms.Count + ", Players: " + numPlayers);

            // Stop the rooms in the list.
            if (roomsToStop.Count > 0)
            {
                Cluster.StopRoom(roomsToStop.ToArray());
            }
            // Launch new game rooms until we reach the target number of rooms.
            for (int i = m_rooms.Count; i < m_targetRoomCount; i++)
            {
                // The first parameter is the name of the room, which can be anything.
                // The second parameter is the name of the scene to launch, and third is the room type name.
                // These must match your Unity scene name and ksRoomType game object name.
                // Call OnStartRoom when the request completes.
                Cluster.StartRoom("Game" + i, "ClusterTutorial", "GameRoom").OnComplete = OnStartRoom;
            }
        }
    }

    private void OnStartRoom(ksAsyncResult<ksRoomInfo> result)
    {
        if (!string.IsNullOrEmpty(result.Error))
        {
            // Error handling.
            ksLog.Error("Error starting room: " + result.Error);
        }
        else
        {
            // Log the id of the started room.
            ksLog.Info("Started room " + result.Result.Id);
        }
    }

    // Clients can call this RPC to set the target number of game rooms.
    [ksRPC(RPC.NUM_ROOMS)]
    private void OnSetNumRooms(int numRooms)
    {
        // Clamp the target number between 0 and 3.
        numRooms = Math.Min(numRooms, 3);
        numRooms = Math.Max(numRooms, 0);
        ksLog.Info("Set target room count to " + numRooms);
        m_targetRoomCount = numRooms;
    }

    // Clients call this RPC when they want to join a game room.
    [ksRPC(RPC.JOIN_ROOM)]
    private void OnRequestJoinRoom(ksIServerPlayer player)
    {
        // Use a lock so we can access m_rooms thread-safely.
        lock (m_roomsLock)
        {
            if (m_rooms.Count == 0)
            {
                // Calling the JOIN_ROOM RPC with no arguments tells the client there are no game rooms to join.
                Room.CallRPC(player, RPC.JOIN_ROOM);
            }
            else
            {
                // Choose a random game room to send the client to.
                int index = m_random.Next(m_rooms.Count);

                // Convert the ksRoomInfo to json, then to a json string and send it to the client.
                Room.CallRPC(player, RPC.JOIN_ROOM, m_rooms[index].ToJSON().Print());
            }
        }
    }
}

Create a Connect Script

The connect script has a ConnectToServer function that takes a ksRoomInfo describing the room to connect to which can be called from other scripts to connect to a different room. It reconnects to the room it is attached to (the lobby) if it disconnects from a different room (a game room).

  1. Create a Monobehaviour and name it 'Connect'.
  2. Attach it to your 'LobbyRoom' object.

Connect.cs

using System.Collections.Generic;
using UnityEngine;
using KS.Reactor;
using KS.Reactor.Client.Unity;

// Connect to a Reactor server instance.
public class Connect : MonoBehaviour
{
    // Singleton instance
    public static Connect Instance;

    // Public property used to enable connections to local or live servers.
    public bool UseLocalServer = true;

    // Current room.
    private ksRoom m_room;

    // Run when the game starts.
    void Start()
    {
        Instance = this;
        ConnectToServer();
    }

    public void ConnectToServer()
    {
        // If we are using a local server, then fetch room information from the ksRoomType component. Otherwise use the
        // ksReactor service to request a list of live servers.
        if (UseLocalServer)
        {
            ConnectToServer(GetComponent<ksRoomType>().GetRoomInfo());
        }
        else
        {
            ksReactor.GetServers(OnGetServers);
        }
    }

    // Connect to a server whose location is described in a ksRoomInfo object.
    public void ConnectToServer(ksRoomInfo roomInfo)
    {
        // If we are connected to another room, disconnect from it.
        if (m_room != null)
        {
            m_room.Disconnect();
        }

        ksRoom room = new ksRoom(roomInfo);
        m_room = room;

        // Register an event handler that will be called when a connection attempt completes.
        room.OnConnect += HandleConnect;

        // Register an event handler that will be called when a connected room disconnects.
        room.OnDisconnect += (ksRoom.ConnectStatus status) =>
        {
            ksLog.Info("Disconnected from " + room + " (" + status + ")");
            room.CleanUp();

            // Destroy the room game object.
            Destroy(room.GameObject);

            // Check m_room == room to make sure we aren't connected or connecting to a different room from the one we
            // just disconnected from.
            if (m_room == room)
            {
                m_room = null;

                // If the room type is different from the game object name, the room we disconnected from was not the
                // the room this script is on. Since this script is on the lobby, that means we disconnected from a game room.
                if (room.Info.Type != gameObject.name)
                {
                    // Reconnect to the lobby when we disconnect from a game room.
                    ConnectToServer();
                }
            }
        };

        room.Connect();
    }

    // Handle the response to a ksReactor.GetServers() request.
    private void OnGetServers(List<ksRoomInfo> roomList, string error)
    {
        // Write errors to the logs.
        if (error != null)
        {
            ksLog.Error("Error fetching servers. " + error);
            return;
        }

        // Connect to the first room in the list of rooms returned.
        if (roomList != null && roomList.Count > 0)
        {
            ConnectToServer(roomList[0]);
        }
        else
        {
            ksLog.Warning("No servers found.");
        }
    }

    // Handle a ksRoom connect event.
    private void HandleConnect(ksRoom.ConnectStatus status, uint customStatus)
    {
        if (status == ksRoom.ConnectStatus.SUCCESS)
        {
            ksLog.Info("Connected to " + m_room);
        }
        else
        {
            ksLog.Error("Unable to connect to " + m_room + " (" + status + ", " + customStatus + ")");
        }
    }
}

Create a Client Lobby Room Script

The client lobby room script sends an RPC to the server when 0 - 3 is pressed to set the desired number of game rooms to the pressed number. It sends a different RPC to request a game room to join when J is pressed. It connects to the game room the server sends when it responds with an RPC.

  1. Select the 'LobbyRoom' object.
  2. In the 'Add Component' menu, select 'Reactor->New Client Room Script'.
  3. Name the script 'ClientLobbyRoom'.

ClientLobbyRoom.cs

using UnityEngine;
using KS.Reactor.Client.Unity;
using KS.Reactor;

public class ClientLobbyRoom : ksRoomScript
{
    // Called every frame.
    private void Update()
    {
        // When keys 0 - 3 are pressed, call an RPC to set the desired number of game rooms to that number.
        if (Input.GetKeyDown(KeyCode.Alpha0) || Input.GetKey(KeyCode.Keypad0))
        {
            Room.CallRPC(RPC.NUM_ROOMS, 0);
        }
        else if (Input.GetKeyDown(KeyCode.Alpha1) || Input.GetKey(KeyCode.Keypad1))
        {
            Room.CallRPC(RPC.NUM_ROOMS, 1);
        }
        else if (Input.GetKeyDown(KeyCode.Alpha2) || Input.GetKey(KeyCode.Keypad2))
        {
            Room.CallRPC(RPC.NUM_ROOMS, 2);
        }
        else if (Input.GetKeyDown(KeyCode.Alpha3) || Input.GetKey(KeyCode.Keypad3))
        {
            Room.CallRPC(RPC.NUM_ROOMS, 3);
        }

        // When J is pressed, call an RPC to request a game room to join.
        if (Input.GetKeyDown(KeyCode.J))
        {
            Room.CallRPC(RPC.JOIN_ROOM);
        }
    }

    // The server calls this RPC to tell the client to join a game room. ksMultiType[] as the parameters can be used to
    // handler variable number of arguments and argument types.
    [ksRPC(RPC.JOIN_ROOM)]
    private void JoinRoom(ksMultiType[] args)
    {
        if (args.Length == 0)
        {
            // If there are no game rooms, the server responds with no arguments.
            ksLog.Warning("No rooms to join.");
        }
        else if (Connect.Instance != null)
        {
            // The argument is a JSON string. Parse it to JSON and convert it to a ksRoomInfo to join.
            Connect.Instance.ConnectToServer(ksRoomInfo.FromJSON(ksJSON.Parse(args[0])));
        }
    }
}

Create a Client Game Room Script

The client game room script disconnects from the game room and reconnects to the lobby when L is pressed. It sends an RPC to shut down the server when S is pressed, which will cause the connect script to reconnect to the lobby when the room shuts down.

  1. Select the 'GameRoom' object.
  2. In the 'Add Component' menu, select 'Reactor/New Client Room Script'.
  3. Name the script 'ClientGameRoom'.

ClientGameRoom.cs

using UnityEngine;
using KS.Reactor.Client.Unity;

public class ClientGameRoom : ksRoomScript
{
    // Called every frame.
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.L) && Connect.Instance != null)
        {
            // When L is pressed, return to the lobby.
            Connect.Instance.ConnectToServer();
        }
        else if (Input.GetKeyDown(KeyCode.S))
        {
            // When S is pressed, call an RPC to shut down the game room and return to the lobby once the room is shut
            // down.
            Room.CallRPC(RPC.SHUT_DOWN);
        }
    }
}

Testing

Testing will be a bit different than for earlier tutorials, since we need to launch a cluster with the room to use cluster features.

Local Cluster

  1. Build your scene config (CTRL + F2).
  2. Open the 'Menu Bar->Reactor' menu and select 'Launch Local Cluster' to open the local cluster window.
  3. Set the launch scene to 'ClusterTutorial' and the launch room to 'LobbyRoom'.
  4. Click 'Launch Local Cluster'.
    • This will launch the local cluster along with a 'LobbyRoom' running the 'ClusterTutorial' scene.
    • The 'Server Logs' window will show the cluster logs. If you select the drop down for the log file, you will see three logs with a '*' after their name indicating a running server. One is for the cluster and one is the 'LobbyRoom', and one is a 'GameRoom' the 'LobbyRoom' started. Use this to switch which room's logs you are looking at.
  5. Enter play mode to connect to the lobby.
    • The server logs for the lobby will print the number of game rooms and the number of connected players across all rooms (games and lobby).
    • While connected to the lobby, you can press the number keys 0 - 3 to set the desired number of game rooms to that number. The next time the lobby refreshes the room list after you change the desired number of game rooms, the lobby will log that it is starting or stopping rooms.
    • When you press J from in the lobby, you will leave the lobby and join a random game room, as long as at least one game room is running. The Unity logs will show a message when you connect to a room showing the scene name and room type.
    • When you press L from within a game room, you will leave the game room and return to the lobby.
    • When you press S from within a game room, you will shut down the game room and return to the lobby. The lobby will start a new game room to replace the one that you shut down.
  6. When you are done testing, stop the cluster by opening the local cluster window and clicking 'Stop Local Cluster', or by closing the cluster process window.

Online Cluster

  1. Open the **'Menu Bar->Reactor->Publishing'.
  2. Login using your KinematicSoup account.
  3. Enter an image name and version in the form and click the 'Publish' button.
  4. Clusters can only be launched via the web console. Open https://console.kinematicsoup.com and login to your KinematicSoup account.
  5. Select your project from the 'Projects' or 'Shared Projects' panel.
  6. Select the 'Images' tab.
  7. Click 'Launch' next to the image you just published.
  8. Configure the launch options for the Cluster.
    • Name is the name you want to give the cluster (or server if launching without a cluster).
    • Location is used to select the geographic region where the server will be hosted.
    • Provider is used to select which cloud provider will host the server.
    • Launch Cluster determines if we are launching a cluster or a stand-alone server. Make sure this is checked.
    • Instance Count is the number of rooms to launch with the cluster. This option is only available if 'Launch Cluster' is checked.
    • Cluster allows you to launch a room and add it to a running cluster. This option is only available if 'Launch Cluster' is unchecked.
    • Scene contains a list of Unity scenes available to the image. Make sure 'ClusterTutorial' is selected.
    • Room contains a list of Rooms available in a scene. Make sure 'LobbyRoom' is selected.
    • Is Room Public will allow clients to find this server in a list of public instances.
    • Keep Room Alive will restart servers if they stop unexpectedly. It is recommended that you test servers for common errors before enabling this option.
    • Tags allows you to specify a list of room tags for the server seperated by a ';'.
  9. Click 'Launch' to launch the cluster.
  10. To connect to the online cluster, uncheck 'Use Local Server' in the inspector for the 'Connect' script and enter play mode.
  11. Click the 'Sessions' panel in the web console. You will see a list of running stand-alone servers and clusters. Clusters can be expanded to view rooms running in those clusters. You can view logs for the running rooms by clicking the icon next to the room in the 'Log' column.
  12. When you are done testing, stop the cluster by clicking the 'X' in the 'Stop' column next to the cluster in the 'Sessions' panel.