Tutorial 8 - Persistent Data using Cluster Properties, and Room to Room RPCs

Summary

This tutorial introduces cluster properties and uses them to save persistent data associated with a player. Messaging between rooms will be demonstrated using cluster RPCs. This tutorial extends Tutorial 7.

Requirements

Setup

  1. Start with the 'ClusterTutorial' scene from Tutorial 7.

Scripting

Modify the Consts Script to Add More RPCs and a Property

The consts script needs two more RPC ids defined: TOKEN and SCORE. We're also going to define a NAME property id.

  1. Open and edit Consts.cs from Tutorial 7.

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;
    public const uint TOKEN = 3;
    public const uint SCORE = 4;
}

// Property IDs
public class Prop
{
    public const uint NAME = 0;
}

Modify the Server Game Room Script to Generate Scores and Authenticate Players

We're going to modify the server game room script to generate a score when it receives an RPC from a player. It will read high score data from persistent storage using cluster properties. It will broadcast new high scores to all rooms using a room-to-room cluster RPC. It will require a token to authenticate players that it receives from the lobby room via a room-to-room cluster RPC.

  1. Open 'ServerGameRoom.cs' from Tutorial 7.
  2. Define a PlayerInfo struct to hold a username and expiry time for a connecting player.
  3. Define a dictionary field mapping long tokens to PlayerInfos.
  4. Define an EXPIRY_TIME long const.
  5. Define a ksRandom field.
  6. Define an int high score field.
  7. Define an OnGetScoreProperty function.
  8. In the Initialize function, fetch the "highScore" property from persistent storage using Cluster.GetProperty() and assign the OnGetScoreProperty function as the OnComplete callback.
    • Cluster property persistent storage is a key-value store where the keys are strings and values are ksMultiTypes. Properties can be grouped as subproperties by using a "." in the property key. When fetching a property, all subproperties of the property are returned. Eg. if you create properties "A", "A.B", "A.C", and "A.D.E", when you fetch property "A", you'll retrieve properties "A", "A.B", "A.C", and "A.D.E". It is possible for a property with no value to have subproperties (in the above example, there is no property "A.D" but there is an "A.D.E"). Our script fetches two properties grouped together as subproperties of "highScore": "highScore.score" and "highScore.username".
  9. Override the Authenticate function to authenticate players using tokens and assign their username property.
  10. Log the player's name property instead of their id when they join and leave.
  11. Define an OnScore function tagged with [ksRPC(RPC.SCORE)] to generate a random score when a client sends a score RPC. If the score is a new high score, inform all rooms about the new high score using a room-to-room cluster RPC.
    • Similar to client-to-server and server-to-client RPCs, rooms can call RPCs on other rooms using cluster RPCs by calling Cluster.CallRoomRPC(). There are four overloads of this function for specifying which rooms will receive the RPC. One tags an IEnumerable of uint room ids to send the RPC to, one takes a string tag that will send the RPC to all rooms with that tag, one that takes an IEnumerable of string tags that will send the RPC to all rooms that have all the tags, and one that takes no tag or room id arguments that sends it to all rooms in the cluster (including the caller). Instead of [ksRPC(id)], cluster RPC handler functions are tagged with [ksClusterRPC(id)].
  12. Define an OnHighScore function tagged with [ksClusterRPC(RPC.SCORE)] to log new high scores that are received via room-to-room cluster RPCs.
  13. Define an AddToken function tagged with [ksClusterRPC(RPC.TOKEN)] to store a player token and username for authenticating a connecting player when a room-to-room token RPC is received from the lobby room, and reply back to the lobby with a cluster RPC.

ServerGameRoom.cs

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

public class ServerGameRoom : ksServerRoomScript
{
    // How long tokens are valid for.
    private const long EXPIRY_TIME = TimeSpan.TicksPerSecond * 10;
    private ksRandom m_random = new ksRandom();
    private int m_highScore;

    // Maps tokens to player data for connecting players. When players connect, they must provide a token in this map
    // to authenticate.
    private Dictionary<long, PlayerInfo> m_connectingPlayerData = new Dictionary<long, PlayerInfo>();

    // Information about a connecting player.
    private struct PlayerInfo
    {
        public string Username;

        // Timestamp after which the token expires. The player must connect before then.
        public long Expiry;

        public PlayerInfo(string username)
        {
            Username = username;
            Expiry = DateTime.Now.Ticks + EXPIRY_TIME;
        }
    }

    // Called when the script is attached.
    public override void Initialize()
    {
        // Setting 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 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();

        // Check if we are connected to the cluster.
        if (Cluster.IsConnected)
        {
            // Get the "highScore" property from persistent storage asynchronously. Call OnGetScoreProperty when the
            // request completes.
            Cluster.GetProperty("highScore").OnComplete = OnGetScoreProperty;
        }
    }

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

    // Authenticates a player. Return non-zero to fail authentication.
    public override uint Authenticate(ksIServerPlayer player, ksMultiType[] args)
    {
        // Fail authentication if there isn't one argument.
        if (args.Length != 1)
        {
            return 1;
        }
        // The argument is a token we use to authenticate and identify the player.
        long token = args[0];
        PlayerInfo info;

        // Retrieve the player information from the connecting player data map using the token.
        if (!m_connectingPlayerData.TryGetValue(token, out info))
        {
            // Invalid token. Fail authentication.
            return 1;
        }
        m_connectingPlayerData.Remove(token);
        if (info.Expiry < DateTime.Now.Ticks)
        {
            // Token expired. Fail authentication.
            return 1;
        }
        // Set the player's name property.
        player.Properties[Prop.NAME] = info.Username;
        return 0;
    }

    // Called when a player connects.
    private void PlayerJoin(ksIServerPlayer player)
    {
        ksLog.Info(player.Properties[Prop.NAME] + " 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.Properties[Prop.NAME] + " 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...");
    }

    // Called asynchronously when our request to retrieve the "highScore" property from persistent storage completes.
    private void OnGetScoreProperty(ksAsyncResult<Dictionary<string, ksMultiType>> result)
    {
        // Error handling.
        if (!string.IsNullOrEmpty(result.Error))
        {
            ksLog.Error("Error getting highScore property: " + result.Error);
            return;
        }
        // Get the subproperties "highScore.score" and "highScore.username".
        ksMultiType value;
        if (result.Result.TryGetValue("highScore.score", out value))
        {
            m_highScore = value;
        }
        if (result.Result.TryGetValue("highScore.username", out value))
        {
            ksLog.Info("High score: " + m_highScore + " by " + value);
        }
    }

    // Called when a player sends a score RPC.
    [ksRPC(RPC.SCORE)]
    private void OnScore(ksIServerPlayer player)
    {
        // Assign the player a random score.
        int score = Math.Min(m_random.Next(10000), m_random.Next(10000));
        ksLog.Info(player.Properties[Prop.NAME] + " scored " + score);

        // If it's higher than the high score, send a cluster RPC to inform all rooms about the new high score.
        if (score > m_highScore)
        {
            m_highScore = score;
            Cluster.CallRoomRPC(RPC.SCORE, score, player.Properties[Prop.NAME]);
        }
    }

    // Called when we receive a room-to-room cluster RPC informing us of a new high score.
    // The first parameter is the id of the room that sent the RPC.
    [ksClusterRPC(RPC.SCORE)]
    private void OnHighScore(uint roomId, int score, string name)
    {
        m_highScore = score;
        ksLog.Info("New high score: " + score + " by " + name);
    }

    // Called when the lobby sends us a room-to-room cluster RPC to tell us to expect a new player to connect with the
    // given token as authentication.
    [ksClusterRPC(RPC.TOKEN)]
    private void AddToken(uint roomId, long token, string userName)
    {
        // Store the player's username in a connecting player data map under the token.
        m_connectingPlayerData[token] = new PlayerInfo(userName);
        // Send the token in a room-to-room cluster RPC back to the lobby to acknowledge we received the token.
        Cluster.CallRoomRPC(new uint[] { roomId }, RPC.TOKEN, token);
    }
}

Modify the Server Lobby Room Script to Assign Players Data and Handle High Scores

We're going to modify the server lobby room script to set a player's username property when they authenticate. It will generate random tokens that players will use to authenticate when they request to join a game room. It will send the token and the player's username to the game room using a room-to-room cluster RPC, and wait for a response before sending the player to the game room. It will read and write high score data to persistent storage using cluster properties.

  1. Open 'ServerLobbyRoom.cs' from Tutorial 7.
  2. Define a RoomTransfer struct to hold a player, and 'ksRoomInfo' to send the player to, and an expiry time.
  3. Define a dictionary field mapping long tokens to RoomTransfers.
  4. Define an EXPIRY_TIME long const.
  5. Define an int high score field.
  6. Define an OnGetScoreProperty function.
  7. In the Initialize function, fetch the "highScore.score" property from persistent storage using Cluster.GetProperty() and assign the OnGetScoreProperty function as the OnComplete callback.
  8. Override the Authenticate function to assign the player's username property to the string they provided.
  9. Remove expired room transfers from the map of tokens to room transfers in the Update function.
  10. Define a GenerateToken function.
  11. Modify the OnRequestJoinRoom function to generate a token and send it to the game room along with the player's username instead of sending the player to the game room right away.
  12. Define an OnAcknowledgeToken function tagged with [ksClusterRPC(RPC.TOKEN)] to send the player from the corresponding room transfer to a game room when the game room acknowledges a token.
  13. Define an OnHighScore function tagged with [ksClusterRPC(RPC.SCORE)] to log new high scores that are received via room-to-room cluster RPCs and write them to persistent storage.
    • When you write a cluster property, you can choose to write it to persistent storage by passing ksClusterProperty.WriteCache.STORE as the last parameter, or to the temporary cache by passing ksClusterProperty.WriteCache.CACHE. Properties that are not written to persistent storage are transient and will be lost when the cluster shuts down. Properties in persistent storage are accessible across cluster instances and across server regions. By default properties are written to persistent storage.

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();

    // How long to wait for a room to acknowledge a token RPC.
    private const long EXPIRY_TIME = TimeSpan.TicksPerSecond * 10;
    private long m_highScore;

    // Maps tokens to room transfer data (for transferring players to game rooms).
    // The tokens are used to authenticate the players when they connect to the game room.
    private Dictionary<long, RoomTransfer> m_roomTransfers = new Dictionary<long, RoomTransfer>();

    // Information for transfering a player to a different room.
    struct RoomTransfer
    {
        // Player to transfer
        public ksIServerPlayer Player;

        // Room info for the room to transfer to.
        public ksRoomInfo RoomInfo;

        // Timestamp after which the transfer expires. The room we are transfering to must acknowledge the player's
        // token before this time.
        public long Expiry;

        public RoomTransfer(ksIServerPlayer player, ksRoomInfo roomInfo)
        {
            Player = player;
            RoomInfo = roomInfo;
            Expiry = DateTime.Now.Ticks + EXPIRY_TIME;
        }
    }

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

        // Check if we are connected to the cluster.
        if (Cluster.IsConnected)
        {
            // Get the "highScore.Score" property from persistent storage asynchronously. Call OnGetScoreProperty when
            // the request completes.
            Cluster.GetProperty("highScore.Score").OnComplete = OnGetScoreProperty;
        }
    }

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

    // Authenticates a player. Return non-zero to fail authentication.
    public override uint Authenticate(ksIServerPlayer player, ksMultiType[] args)
    {
        // args contains the arguments we passed to ksRoom.Connect().
        if (args.Length != 1)
        {
            // Returning a non-zero value means authentication failed.
            return 1;
        }
        // Set the name to the value provided by the client. You can do your own validation here.
        player.Properties[Prop.NAME] = args[0];
        return 0;
    }

    // Called during the update cycle.
    private void Update()
    {
        if (!Cluster.IsConnected)
        {
            // Do nothing if we are not connected to the cluster.
            return;
        }

        // Remove room transfer data for unacknowledged expired tokens.
        List<long> toRemove = new List<long>();
        long now = DateTime.Now.Ticks;
        foreach (KeyValuePair<long, RoomTransfer> pair in m_roomTransfers)
        {
            if (now >= pair.Value.Expiry)
            {
                toRemove.Add(pair.Key);
                ksLog.Warning("Did not receive token acknowledgement from room " + pair.Value.RoomInfo.Id);
            }
        }
        foreach (long token in toRemove)
        {
            m_roomTransfers.Remove(token);
        }

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

    // Generate a unique random token.
    private long GenerateToken()
    {
        long token;
        do
        {
            token = m_random.Next();
            token <<= 32;
            token += m_random.Next();
        }
        while (m_roomTransfers.ContainsKey(token) && token != 0);
        return token;
    }

    // Called asynchronously when our request to retrieve the "highScore.score" property from persistent storage
    // completes.
    private void OnGetScoreProperty(ksAsyncResult<Dictionary<string, ksMultiType>> result)
    {
        // Error handling.
        if (!string.IsNullOrEmpty(result.Error))
        {
            ksLog.Error("Error getting highScore.score property: " + result.Error);
            return;
        }
        // Get the property. Since properties can have any number of sub-properties and all sub-properties are returned
        // when fetching a property, the result is a dictionary containing all returned properties.
        ksMultiType value;
        if (result.Result.TryGetValue("highScore.score", out value))
        {
            m_highScore = value;
        }
    }

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

                // Generate a random token the client will use to authenticate with the game room.
                long token = GenerateToken();

                // Use a room-to-room cluster RPC to send the player's token and username to the game room.
                Cluster.CallRoomRPC(new uint[] { m_rooms[index].Id }, RPC.TOKEN, token, player.Properties[Prop.NAME]);

                // Store the player and room info in the room transfers map under the token. When the game room
                // acknowledges the token, we will retrive this data from the room transfer map and tell the client
                // to connect to the game room.
                m_roomTransfers[token] = new RoomTransfer(player, m_rooms[index]);
            }
        }
    }

    // Called when a game room sends us a room-to-room cluster RPC to acknowledge a token we sent them that a client
    // will use to authenticate. The first parameter is the id of the room that sent the RPC.
    [ksClusterRPC(RPC.TOKEN)]
    private void OnAknowledgeToken(uint roomId, long token)
    {
        RoomTransfer transfer;
        if (!m_roomTransfers.TryGetValue(token, out transfer))
        {
            ksLog.Warning("No room transfer for acknowledged token from room " + roomId);
            return;
        }
        m_roomTransfers.Remove(token);
        if (transfer.Player.Connected)
        {
            // Convert the ksRoomInfo to json, then to a json string and send it to the client, along with the token
            // they must use to authenticate.
            Room.CallRPC(transfer.Player, RPC.JOIN_ROOM, transfer.RoomInfo.ToJSON().Print(), token);
        }
    }

    // Called when we receive a room-to-room cluster RPC informing us of a new high score.
    [ksClusterRPC(RPC.SCORE)]
    private void OnHighScore(uint roomId, int score, string name)
    {
        // Check that this score is higher than the current high score.
        if (score > m_highScore)
        {
            m_highScore = score;
            ksLog.Info("New high score: " + score + " by " + name);

            // Store the high score and username of the player who got the score in persistent storage.
            // A dictionary can be used to set multiple properties at a time.
            Dictionary<string, ksMultiType> scoreProperties = new Dictionary<string, ksMultiType>();
            scoreProperties["highScore.score"] = score;
            scoreProperties["highScore.username"] = name;
            Cluster.SetProperty(scoreProperties);
        }
    }
}

Modify the Connect Script to Send a Username or Token

We're going to make the Connect script send either a username or a token when connecting.

  1. Open 'Connect.cs' from Tutorial 7.
  2. Modify the ConnectToServer(ksRoomInfo) function to take an optional token parameter.

Connect.cs

    ...
    // Connect to a server whose location is described in a ksRoomInfo object.
    public void ConnectToServer(ksRoomInfo roomInfo, long token = 0)
    {
        // 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.DisconnectError 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();
                }
            }
        };

        // If token is 0, connect with a username.
        if (token == 0)
        {
            // Connect with username "MyName". You can change this to anything.
            room.Connect("MyName");
        }
        else
        {
            // Connect with a token.
            room.Connect(token);
        }
    }
    ...

Modify the Client Lobby Room Script To Use the Token

The client lobby room script passes the token from the join room RPC to the connect script.

  1. Open 'ClientLobbyRoom.cs' from Tutorial 7.
  2. Modify the JoinRoom function to pass the token from the second argument to the connect script.

ClientLobbyRoom.cs

...
    // The server calls this RPC to tell the client to join a game room. ksMultiType[] as the parameters can be used to
    // handle 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)
        {
            // arg[0] is a JSON string. Parse it to JSON and convert it to a ksRoomInfo to join.
            // arg[1] is our token for authenticating with the room.
            Connect.Instance.ConnectToServer(ksRoomInfo.FromJSON(ksJSON.Parse(args[0])), args[1]);
        }
    }
...

Modify the Client Game Room Script to Request a Score When Space is Pressed

We're going to make the client game room script call the score RPC on the server when space is pressed.

  1. Open 'ClientGameRoom.cs' from Tutorial 7.
  2. Call the SCORE RPC when space is pressed in the Update function.

ClientGameRoom.cs

    ...
    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);
        }
        else if (Input.GetKeyDown(KeyCode.Space))
        {
            Room.CallRPC(RPC.SCORE);
        }
    }
    ...

Testing

  1. Start a local cluster running the 'ClusterTutorial' scene and 'LobbyRoom' room type.
  2. Check the 'Use Local Server' checkbox in the inspector for the 'Connect' script.
  3. Enter play mode.
  4. Press J to enter a game room.
    • You may see a message saying there are no rooms to join if the lobby hasn't started a game room yet. Wait a bit and try again.

When you press space while in a game room, the server will log a random score for you. If it's a new high score, the high score and your name will appear in the logs for all running rooms. When you start a new game room, the game room's logs will log the current high score and the username of the player who set the score. Note that the local cluster does not have persistent storage, so if you stop and restart the local cluster, the high score data will be gone.