Cluster Sample

Summary

This sample creates a lobby which manages game instances. Players connect to the lobby and are sent to an available game instance. The game selects a group of winners and then spawn a new game instance for those players. Other players are sent back to the lobby.

Requirements

Define consts

In Kinematicsoup/Reactor/Common, create a Consts.cs file to hold const values we will use on the client and server. The consts are ids for properties and RPCs. Add the following to your script: Consts.cs

public class Prop
{
    public const uint NAME = 0;
}

public class RPC
{
    public const uint TOKEN = 0;
    public const uint GAME_OVER = 1;
    public const uint MOVE_TO_ROOM = 2;
    public const uint RETURN_TO_LOBBY = 3;
    public const uint PLAYER_DATA = 4;
    public const uint ACKNOWLEDGE_PLAYER_DATA = 5;
}

Create the lobby scene

Create the game scene

Create a room launcher script

sRoomLauncher.cs

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

public class sRoomLauncher : ksServerRoomScript
{
    private ksAsyncResult<ksRoomInfo> m_task;
    private List<ksIServerPlayer> m_players = new List<ksIServerPlayer>();

    public bool IsLaunchingRoom
    {
        get { return m_task != null; }
    }

    public override void Initialize()
    {
        Room.OnUpdate[0] += Update;
    }

    public override void Detached()
    {
        Room.OnUpdate[0] -= Update;
    }

    public override void Update()
    {
        // Check if the launch room task is complete
        if (!Cluster.IsConnected || m_task == null || !m_task.IsCompleted)
        {
            return;
        }
        if (!string.IsNullOrEmpty(m_task.Error))
        {
            ksLog.Error(this, "Error starting game: " + m_task.Error);
        }
        else
        {
            SendPlayers(m_task.Result);
        }
        m_task = null;
    }

    // Launches a room and sends players in the given player list to that room once it is ready
    public void LaunchRoom(IEnumerable<ksIServerPlayer> players, string name, string scene, string roomType)
    {
        if (Cluster.IsConnected && !IsLaunchingRoom)
        {
            m_players.Clear();
            m_players.AddRange(players);
            ksLog.Info(this, "Starting room " + scene);
            m_task = Cluster.StartRoom(name, scene, roomType);
        }
    }

    // Sends players to the given room
    private void SendPlayers(ksRoomInfo roomInfo)
    {
        // Convert room info to json and send it to players
        Room.CallRPC(m_players, RPC.MOVE_TO_ROOM, roomInfo.ToJSON().Print());
    }
}

Create a lobby script

sLobby.cs

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

public class sLobby : ksServerRoomScript
{
    private sRoomLauncher m_roomLauncher;
    private float m_timer = 0f;

    public override void Initialize()
    {
        Room.OnUpdate[0] += Update;
        m_roomLauncher = Scripts.Get<sRoomLauncher>();
    }

    public override void Detached()
    {
        Room.OnUpdate[0] -= Update;
    }

    public override void Update()
    {
        // Do nothing if we aren't connected to the cluster, we are already launching a room, or no players are
        // connected.
        if (!Cluster.IsConnected || m_roomLauncher.IsLaunchingRoom || Room.ConnectedPlayerCount == 0)
        {
            return;
        }
        // Start a game room when there are 10 or more players and at least 2 seconds have passed since the last game,
        // or 30 seconds have passed.
        if (m_timer < 2f)
        {
            m_timer += Time.Delta;
            return;
        }
        m_timer += Time.Delta;
        if (Room.Players.Count >= 10 || m_timer >= 30f)
        {
            // "Game1" is the name of the room (can be anything you want).
            // "Game" is the name of the scene to launch.
            // "Game Room" is the name of the room type game object in the scene to launch.
            m_roomLauncher.LaunchRoom(Room.Players, "Game1", "Game", "Game Room");
            m_timer = 0f;
        }
    }
}

Create a connect script

Connect.cs

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

public class Connect : MonoBehaviour
{
    public bool UseLiveServer = false;
    private ksRoom m_room;

    public static ksRoomInfo RoomInfo;

    void Start()
    {
        // If RoomInfo is set, connect to that room.
        if (RoomInfo != null)
        {
            ConnectToServer(RoomInfo);
            RoomInfo = null;
        }
        else if (UseLiveServer)
        {
            ksReactor.GetServers(OnGetServers);
        }
        else
        {
            ConnectToServer(GetComponent<ksRoomType>().GetRoomInfo());
        }
    }

    private void OnGetServers(List<ksRoomInfo> roomList, string error)
    {
        if (error != null)
        {
            ksLog.Error("Error fetching servers. " + error);
            return;
        }

        if (roomList == null || roomList.Count == 0)
        {
            ksLog.Warning("No servers found.");
            return;
        }

        ConnectToServer(roomList[0]);
    }

    private void ConnectToServer(ksRoomInfo roomInfo)
    {
        m_room = new ksRoom(roomInfo);
        m_room.OnConnect += OnConnect;
        m_room.OnDisconnect += OnDisconnect;
        m_room.Connect();
    }

    private void OnConnect(ksBaseRoom.ConnectError status, ksAuthenticationResult result)
    {
        ksLog.Info("Connect status " + status);
    }

    private void OnDisconnect(ksBaseRoom.DisconnectError status)
    {
        ksLog.Info("Disconnect status " + status);
    }
}

Create a client room script

cRoom.cs

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

public class cRoom : ksRoomScript
{
    [ksRPC(RPC.MOVE_TO_ROOM)]
    private void OnMoveToRoom(string jsonStr)
    {
        // Disconnect from the current room
        ksReactor.Service.LeaveRoom(Room);
        // Parse new room info from json
        Connect.RoomInfo = ksRoomInfo.FromJSON(ksJSON.Parse(jsonStr));
        // Load the scene for the new room
        SceneManager.LoadScene(Connect.RoomInfo.Scene);
    }
}

Add scripts to the game room

Run and connect to the local cluster

Create a game room script

sGameRoom.cs

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

public class sGameRoom : ksServerRoomScript
{
    private ksRandom m_rand = new ksRandom();
    private sRoomLauncher m_roomLauncher;
    private float m_gameTimer = 0f;
    private float m_shutdownTimer = 0f;
    private bool m_gameOver = false;

    public override void Initialize()
    {
        // Setting IsPublic to false prevents this room from appearing in the server list when clients call
        // ksReactor.GetServers.
        Room.IsPublic = false;
        Room.OnUpdate[0] += Update;
        m_roomLauncher = Scripts.Get<sRoomLauncher>();
    }

    public override void Detached()
    {
        Room.OnUpdate[0] -= Update;
    }

    private void Update()
    {
        if (m_gameOver)
        {
            if (!m_roomLauncher.IsLaunchingRoom)
            {
                // Stop the room one second after the game is over and the next game (if there is one) is started.
                m_shutdownTimer += Time.Delta;
                if (m_shutdownTimer >= 1f)
                {
                    Room.ShutDown();
                }
            }
            return;
        }

        if (Room.ConnectedPlayerCount <= 0)
        {
            // Stop the room if no players are connected for 60s.
            m_shutdownTimer += Time.Delta;
            if (m_shutdownTimer >= 60f)
            {
                Room.ShutDown();
            }
            return;
        }
        m_shutdownTimer = 0f;

        // Choose random winners after 10 seconds.
        if (m_gameTimer < 10f)
        {
            m_gameTimer += Time.Delta;
            if (m_gameTimer >= 10f)
            {
                List<ksIServerPlayer> winners = new List<ksIServerPlayer>();
                List<ksIServerPlayer> losers = new List<ksIServerPlayer>();
                // Each player has a 50-50 chance of being a winner of loser.
                foreach (ksIServerPlayer player in Room.Players)
                {
                    if (m_rand.Next(2) == 0)
                    {
                        winners.Add(player);
                    }
                    else
                    {
                        losers.Add(player);
                    }
                }
                FinishGame(winners, losers);
            }
        }
    }

    private void FinishGame(List<ksIServerPlayer> winners, List<ksIServerPlayer> losers)
    {
        m_gameOver = true;
        m_shutdownTimer = 0f;
        // Tell the winners they won.
        Room.CallRPC(winners, RPC.GAME_OVER, true);
        // Tell the losers they lost.
        Room.CallRPC(losers, RPC.GAME_OVER, false);

        if (winners.Count <= 0)
        {
            // Send all players back to the lobby.
            Room.CallRPC(RPC.RETURN_TO_LOBBY);
        }
        else
        {
            // Send losers back to the lobby.
            Room.CallRPC(losers, RPC.RETURN_TO_LOBBY);
            // Start the next game and send the winners to it.
            m_roomLauncher.LaunchRoom(winners, "Game2", "Game", "Game Room");
        }
    }
}

Handle game over and return to lobby RPCs in the client room script

cRoom.cs

public class cRoom : ksRoomScript
{
    ...
    [ksRPC(RPC.GAME_OVER)]
    private void OnGameOver(bool isWinner)
    {
        if (isWinner)
        {
            ksLog.Info(this, "You Win!");
        }
        else
        {
            ksLog.Info(this, "You Lose!");
        }
    }

    [ksRPC(RPC.RETURN_TO_LOBBY)]
    private void OnReturnToLobby()
    {
        ksReactor.Service.LeaveRoom(Room);
        SceneManager.LoadScene("Lobby");
    }
}

Run and connect to the local cluster

Create a player script

sPlayer.cs

public class sPlayer : ksServerPlayerScript
{
    public long Token;
}

Authenticate players and add bots in the lobby script

sLobby.cs

public class sLobby : ksServerRoomScript
{
    ...
    private ksRandom m_rand = new ksRandom();
    private HashSet<long> m_tokens = new HashSet<long>();

    public override void Initialize()
    {
        ...
        Room.OnAuthenticate += Authenticate;
        Room.OnPlayerJoin += OnPlayerJoin;
        Room.OnPlayerLeave += OnPlayerLeave;
    }

    public override void Detached()
    {
        ...
        Room.OnAuthenticate -= Authenticate;
        Room.OnPlayerJoin -= OnPlayerJoin;
        Room.OnPlayerLeave -= OnPlayerLeave;
    }

    private async Task<ksAuthenticationResult> Authenticate(ksIServerPlayer player, ksMultiType[] args, 
        CancellationToken cancellationToken)
    {
        if (args.Length != 1)
        {
            // Returning a non-zero result means authentication failed.
            return new ksAuthenticationResult(1);
        }
        // Set the name to the value provided by the client. You can do your own validation here.
        player.Properties[Prop.NAME] = args[0];
        // We await a completed Task to prevent a compiler warning that the async method never awaits anything and
        // will be completed synchronously.
        return await Task.FromResult(new ksAuthenticationResult(0));
    }

    private void Update()
    {
        if (!Cluster.IsConnected || m_roomLauncher.IsLaunchingRoom || Room.ConnectedPlayerCount == 0)
        {
            return;
        }
        if (Room.Time.Frame % (60 * 2) == 0)
        {
            string name = "bot" + m_rand.Next(1000);
            // Create a virtual player. The parameter 'name' is passed to the Authenticate function.
            Room.CreateVirtualPlayer(name);
        }
        ...
    }

    private void OnPlayerJoin(ksIServerPlayer player)
    {
        ksLog.Info(this, player.Properties[Prop.NAME] + " joined");
        if (!player.IsVirtual)
        {
            // Generate a unique token for this player.
            long token = GenerateToken();
            player.Scripts.Get<sPlayer>().Token = token;
            // Send the token to the player.
            Room.CallRPC(player, RPC.TOKEN, token);
        }
    }

    private void OnPlayerLeave(ksIServerPlayer player)
    {
        ksLog.Info(this, player.Properties[Prop.NAME] + " left");
        if (!player.IsVirtual)
        {
            // Remove the token so it can be reused.
            m_tokens.Remove(player.Scripts.Get<sPlayer>().Token);
        }
    }

    private long GenerateToken()
    {
        long token = 0;
        do
        {
            unchecked
            {
                token = (uint)m_rand.Next();
                token <<= 32;
                token += (uint)m_rand.Next();
            }
        } while (!m_tokens.Add(token) && token != 0);
        return token;
    }
}

Send a username or token when connecting

Connect.cs

public class Connect : MonoBehaviour
{
    ...
    public static long Token;

    void Start()
    {
        // If RoomInfo is set, connect to that room.
        if (RoomInfo != null)
        {
            ConnectToServer(RoomInfo, Token);
            RoomInfo = null;
        }
        ...
    }
    ...
    private void ConnectToServer(ksRoomInfo roomInfo, long token = 0)
    {
        m_room = new ksRoom(roomInfo);
        m_room.OnConnect += OnConnect;
        m_room.OnDisconnect += OnDisconnect;
        // The parameters passed to Connect will be passed to the Authenticate function on the server.
        if (token != 0)
        {
            // If token is non-zero, we are connecting to a game room and send our token to authenticate and identify
            // us.
            m_room.Connect(token);
        }
        else
        {
            // We are connecting to a lobby. "MyName" is our username. You can change this to anything.
            m_room.Connect("MyName");
        }
    }
}

Set the token in the client room script

cRoom.cs

public class cRoom : ksRoomScript
{
    [ksRPC(RPC.TOKEN)]
    private void OnSetToken(long token)
    {
        Connect.Token = token;
    }
    ...
}

Use cluster RPCs to send player data to new rooms

sRoomLauncher.cs

public class sRoomLauncher : ksServerRoomScript
{
    ...
    private ksRoomInfo m_roomInfo;

    public bool IsLaunchingRoom
    {
        get { return m_task != null || m_roomInfo != null; }
    }

    public override void Initialize()
    {
        Room.OnUpdate[0] += Update;
        Cluster.OnRPC[RPC.ACKNOWLEDGE_PLAYER_DATA] += OnAcknowledgePlayerData;
    }

    public override void Detached()
    {
        Room.OnUpdate[0] -= Update;
        Cluster.OnRPC[RPC.ACKNOWLEDGE_PLAYER_DATA] += OnAcknowledgePlayerData;
    }

    public override void Update()
    {
        ...
        else
        {
            m_roomInfo = m_task.Result;
            // Send player data to the room we just started.
            ksMultiType[] args = new ksMultiType[m_players.Count * 2];
            int index = 0;
            for (int i = 0; i < m_players.Count; i++)
            {
                // The token identifies the player. Players use this token to connect.
                args[index++] = m_players[i].Scripts.Get<sPlayer>().Token;
                args[index++] = m_players[i].Properties[Prop.NAME];
            }
            Cluster.CallRoomRPC(new uint[] { m_roomInfo.Id }, RPC.PLAYER_DATA, args);
        }
        m_task = null;
    }

    // Launches a room and sends players in the given player list to that room once it is ready.
    public void LaunchRoom(IEnumerable<ksIServerPlayer> players, string name, string scene, string roomType)
    {
        if (Cluster.IsConnected && !IsLaunchingRoom)
        {
            // Check that there's at least one connected (non-virtual) player in the list before launching a room.
            bool hasConnectedPlayer = false;
            foreach (ksIServerPlayer player in players)
            {
                if (!player.IsVirtual)
                {
                    hasConnectedPlayer = true;
                    break;
                }
            }
            if (!hasConnectedPlayer)
            {
                return;
            }
            ...
        }
    }

    // Sends players to the given room
    private void SendPlayers(ksRoomInfo roomInfo)
    {
        ...
        // Disconnect virtual players (bots).
        foreach (ksIServerPlayer player in m_players)
        {
            if (player.IsVirtual)
            {
                player.Disconnect();
            }
        }
    }

    // Once the new room acknowledges it received the player data, tell the players to switch rooms
    private void OnAcknowledgePlayerData(uint roomId, ksMultiType[] args)
    {
        if (m_roomInfo != null)
        {
            ksLog.Info(this, "Acknowledged player data. Sending players");
            SendPlayers(m_roomInfo);
            m_roomInfo = null;
        }
    }
}

Authenticate players in the game room

sGameRoom.cs

public class sGameRoom : ksServerRoomScript
{
    ...
    private Dictionary<long, string> m_playerData = new Dictionary<long, string>();
    private Dictionary<uint, long> m_playerTokens = new Dictionary<uint, long>();

    public override void Initialize()
    {
        ...
        Room.OnAuthenticate += Authenticate;
        Room.OnPlayerJoin += OnPlayerJoin;
        Cluster.OnRPC[RPC.PLAYER_DATA] += OnGetPlayerData;
    }

    public override void Detached()
    {
        ...
        Room.OnAuthenticate -= Authenticate;
        Room.OnPlayerJoin -= OnPlayerJoin;
        Cluster.OnRPC[RPC.PLAYER_DATA] -= OnGetPlayerData;
    }
    ...
    private async Task<ksAuthenticationResult> Authenticate(ksIServerPlayer player, ksMultiType[] args, 
        CancellationToken cancellationToken)
    {
        if (args.Length != 1)
        {
            return new ksAuthenticationResult(1);
        }
        if (player.IsVirtual)
        {
            // If the player is virtual, the argument is the player name.
            player.Properties[Prop.NAME] = args[0];
            return new ksAuthenticationResult(0);
        }
        long token = args[0];
        string name;
        // The player must supply a token that is in the player data to authenticate.
        if (m_playerData.TryGetValue(token, out name))
        {
            // Remove the token so it cannot be reused.
            m_playerData.Remove(token);
            player.Properties[Prop.NAME] = name;
            // Player scripts aren't loaded yet, so we store the token in a dictionary so we can set it once scripts
            // are loaded in OnPlayerJoin.
            m_playerTokens[player.Id] = token;
            return new ksAuthenticationResult(0);
        }
        // Invalid token.
        // We await a completed Task to prevent a compiler warning that the async method never awaits anything and
        // will be completed synchronously.
        return await Task.FromResult(new ksAuthenticationResult(1));
    }

    private void FinishGame(List<ksIServerPlayer> winners, List<ksIServerPlayer> losers)
    {
        ...
        // Log elimination message.
        string message = "eliminated: ";
        for (int i = 0; i < losers.Count; i++)
        {
            if (i > 0)
            {
                message += ", ";
            }
            message += losers[i].Properties[Prop.NAME];
        }
        ksLog.Info(this, message);
    }

    private void OnPlayerJoin(ksIServerPlayer player)
    {
        if (!player.IsVirtual)
        {
            player.Scripts.Get<sPlayer>().Token = m_playerTokens[player.Id];
        }
    }

    private void OnGetPlayerData(uint roomId, ksMultiType[] args)
    {
        for (int i = 0; i + 1 < args.Length; i += 2)
        {
            long token = args[i];
            string name = args[i + 1];
            if (token == 0)
            {
                // 0 token means this is a virtual player.
                Room.CreateVirtualPlayer(name);
            }
            else
            {
                m_playerData[token] = name;
            }
        }
        // Tell the room that started us that we received the player data.
        Cluster.CallRoomRPC(new uint[] { roomId }, RPC.ACKNOWLEDGE_PLAYER_DATA);
    }
}

Run and connect to the local cluster

Run and connect to an online cluster

Stopping the cluster