Tutorial 4 - Using Properties and RPCs to Communicate Between Client and Server

Summary

This tutorial demonstrates the use of RPCs and properties as ways to sync information between the server and clients. RPCs will be used to send messages between tagged methods while properties will be used to sync simple data values. A linear predictor asset will be created and configured to interpolate a color property.

Requirements

Setup

Create a Room

  1. Right click in the hierarachy window and create a 'Reactor->Physics Room' game object.
  2. Set the gravity in the inspector for the ksPhysicsSettings to (0, -3, 0).

Create a Cube Entity Prefab

  1. Create a cube.
  2. Add a ksEntityComponent.
  3. Add a Rigidbody component.
  4. Make the cube a prefab in the 'Resources' folder.
  5. Delete the cube from the hierarachy.

Create a Linear Predictor to Interpolate the Color Property

Predictors are used to interpolate/predict entity transforms and entity, room, or player properties. We will create a ksLinearPredictorAsset and configure it to interpolate a color property on the 'Cube' entities. For more information on predictors, see here.

  1. Right-click in the project browser and select 'Create->Reactor->Linear Predictor.
  2. Name the predictor 'Color Predictor'.
  3. Expand the 'Predicted Properties' section in the inspector and click the + button to add a new predicted property.
  4. Set the 'Property Id' to zero. This will be the id of the color property.
  5. Set the 'Type' to 'Linear Color'. This tells the predictor to interpret property 0 as a color and to linearly interpolate it.
  6. Select the 'Cube' prefab. In the ksEntityComponent inspector, check the box for 'Predictor', then set the 'Predictor' to the 'Color Predictor' you just created.
    • This makes the entity use the predictor you created to linearly interpolate the transform and property 0 as a color, instead of just the transform.
    • The 'Controller Predictor' is used instead of the 'Predictor' when the entity has a player controller with ksPlayerController.UseInputPredictor set to true. Only entities controlled by the local player will have player controllers.

Scripting

Create a Class for Constants

The consts script defines const ids for properties and RPCs used on both the client and the server. The id of the COLOR property must be the same as the property id we set in the 'Color Predictor' we created earlier.

  1. In 'Assets/ReactorScripts/Common', create a script named 'Consts'.
    • All scripts in common folders are available on both the client and the server, and may not contain any Unity references.
    • To create a common folder, first create a folder. Then right-click in that folder in the project browser and select 'Create->Assembly Defintion Reference'. In the inspector for the assembly reference, set 'Assembly Defintion' to 'KSScripts-Common'

Consts.cs

// Synced property IDs
public class Prop
{
    public const uint COLOR = 0;
    public const uint NAME = 1;
    public const uint POSITION = 2;
    public const uint SCORE = 3;
}

// RPC IDs
public class RPC
{
    public const uint SHOOT = 0;
    public const uint FADE = 1;
    public const uint AMMO = 2;
}

Create and Attach a Server Entity Script to the Cube Prefab

The server cube script sets a color property on the cube. Every second, the script chooses a new color and interpolates the color property towards the new color.

Every room, player, and entity has a collection of properties that are synced from the server to clients. Properties are accessed by a uint key, and property values are ksMultiTypes. ksMultiTypes can store most basic types and common structs, and have implicit conversions between these types for convenience. The following types are supported:

You can also convert to and room classes that implement ksIBufferable using new ksMultiType(ksIBufferable) and ksMultiType.ToBufferable<T>().

  1. Select the 'Cube' prefab.
  2. In the 'Add Component' menu, select 'Reactor->New Server Entity Script'.
  3. Name the script 'ServerCube'.

ServerCube.cs

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

public class ServerCube : ksServerEntityScript
{
    private static ksRandom m_random = new ksRandom();

    // Color interpolation variables
    private ksColor m_startColor;
    private ksColor m_endColor;
    private float m_t = 0f;

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

        // Choose random start and end colors for interpolation.
        m_startColor = RandomColor();
        m_endColor = RandomColor();

        // Set the color property to the start color.
        Properties[Prop.COLOR] = m_startColor;
    }

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

    // Called during the update cycle.
    private void Update()
    {
        m_t += Time.Delta;
        if (m_t > 1f)
        {
            // We've interpolated passed the end color. Set the start color to the end color and choose a new
            // random end color.
            m_t -= 1f;
            m_startColor = m_endColor;
            m_endColor = RandomColor();
        }
        // Interpolate and set the color property.
        Properties[Prop.COLOR] = ksColor.Lerp(m_startColor, m_endColor, m_t);
    }

    private ksColor RandomColor()
    {
        // Randomize the first 3 bytes of a uint which correspond to the RGB color values.
        ksColor color = new ksColor((uint)(m_random.Next(1 << 24) << 8));
        color.A = 1f;
        return color;
    }
}

Create a Fader MonoBehaviour

The fader fades the alpha on the object's material to zero, then deletes the game object.

  1. Create a new MonoBehaviour named 'Fader'.

Fader.cs

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

public class Fader : MonoBehaviour
{
    MeshRenderer m_renderer;

    // Start is called before the first frame update.
    void Start()
    {
        m_renderer = GetComponent<MeshRenderer>();
    }

    // Update is called once per frame.
    void Update()
    {
        // Reduce the material alpha.
        Color color = m_renderer.material.color;
        color.a -= Time.deltaTime * 3f;
        if (color.a <= 0)
        {
            // Destroy the object when alpha reaches zero.
            Destroy(gameObject);
        }
        else
        {
            m_renderer.material.color = color;
        }
    }
}

Create and Attach a Client Cube Script to the Cube Prefab

The client cube script sets the color of the cube to the synced color property. The script fades out and destroys the game object by attaching a Fader when it receives a fade entity RPC from the server.

In the first tutorial, we used a client-to-server RPC to tell the server to spawn a cube. The server can also send RPCs to clients. There are two kinds of RPCs available to both the server and the client: room RPCs, and entity RPCs. Room RPCs are called on the room. The handler functions tagged with ksRPCAttributes must be in room scripts. The RPC used in the first tutorial was a client-to-server room RPC. Entity RPCs are called on an entity and the handler functions tagged with ksRPCAttributes must be in entity scripts on that entity.

  1. Select the 'Cube' prefab.
  2. In the 'Add Component' menu, select 'Reactor->New Client Entity Script'.
  3. Name the script 'ClientCube'.
  4. Create a public material field named 'FadeMaterial'. In the inspector, assign it the 'Sprites-Default' material.

ClientCube.cs

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

public class ClientCube : ksEntityScript
{
    // Material to use when fading-out the object.
    public Material FadeMaterial;

    private MeshRenderer m_renderer;

    // Called after properties are initialized.
    public override void Initialize()
    {
        m_renderer = GetComponent<MeshRenderer>();
    }

    // Called every frame.
    private void Update()
    {
        // Set the material color to the color property.
        m_renderer.material.color = Properties[Prop.COLOR];
    }

    // The ksRPC attribute is used in entity scripts to tag a method we want to invoke when we receive an entity RPC.
    [ksRPC(RPC.FADE)]
    private void OnFade()
    {
        // Set the material to the fade material and set the material color.
        m_renderer.material = FadeMaterial;
        m_renderer.material.color = Properties[Prop.COLOR];

        // Don't destroy the game object when the server destroys the entity so we can fade-out the game object and
        // destroy it when its alpha reaches zero.
        Entity.DestroyWithServer = false;

        // Attach a fader script to fade-out and destroy the game object.
        gameObject.AddComponent<Fader>();

        // Remove this script.
        Destroy(this);
    }
}

Create a Server Player Script to Manage Ammo

Just like rooms and entities, players can have scripts too. The server player script tracks how much ammo a player has. A Reload function starts a timer and the Update function maxes out the player's ammo when the timer reaches zero. An RPC is used to tell players how much ammo they have.

The proxy MonoBehaviours for player scripts are attached to the game object with the ksRoomType component like room script proxies, but unlike room scripts, they are attached to the ksPlayer object on the client or ksIServerPlayer object on the server that gets created for each player intead of the room object.

There are three overloads of the CallRPC method on the server for specifying which clients receive the RPC. One takes a ksIServerPlayer as the first parameter that sends an RPC to only one player. One takes an IList of ksIServerPlayers to send the RPC to, and one does not take any player arguments and sends the RPC to all clients.

RPCs calls can have any number of ksMultiType arguments after the RPC id.

We use an RPC to inform the player of how much ammo they have rather than syncing it as a property because clients only need to know about their own ammo. Properties are synced to everyone, whereas RPCs can be sent to individual clients. When a player joins, all properties are synced to them, whereas RPCs are one-time events that are only sent to connected clients at the time of the RPC call.

We only need to send an ammo RPC to the client when they first connect and when they reload. The client already knows it loses one ammo every time it shoots, so we don't need to send an RPC to tell it that.

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

ServerPlayer.cs

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

public class ServerPlayer : ksServerPlayerScript
{
    // The amount of ammo at the start and after reloading.
    // The ksEditable tag makes fields editable from the inspector in Unity.
    [ksEditable] public int MaxAmmo = 20;

    // The number of seconds it takes to reload ammo.
    [ksEditable] public float ReloadTime = 5f;

    public int Ammo;

    private float m_reloadTimer = 0f;

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

        // Call an RPC on the player to tell them how much ammo they have.
        Room.CallRPC(Player, RPC.AMMO, Ammo);
    }

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

    // Called during the update cycle.
    private void Update()
    {
        if (m_reloadTimer > 0f)
        {
            // Decrement the reload timer.
            m_reloadTimer -= Time.Delta;

            // Check if we are done reloading.
            if (m_reloadTimer <= 0f)
            {
                // Reset ammo and tell the player how much ammo they have.
                Ammo = MaxAmmo;
                Room.CallRPC(Player, RPC.AMMO, Ammo);
            }
        }
    }

    public void Reload()
    {
        // Sets the reload timer. Ammo will be refilled when the reload timer reaches zero.
        m_reloadTimer = ReloadTime;
    }
}

Create a ConnectHandler MonoBehaviour

This script will contain an event handler for ksConnect 'OnGetRooms' events. The 'OnGetRooms' handler allows developers to control which room the client connects to and pass optional authentication arguments to the server. This version of the script connects to the first room available and defines a username property which is included in the connection.

Just like RPCs, the Connect method can take any number of optional ksMultiType arguments. These arguments are passed to any Room.OnAuthenticate handler functions in server room scripts.

  1. Create a new MonoBehaviour named 'ConnectHandler'.
  2. Attach it to the 'Room' game object.
  3. Edit the file to match the code below.
  4. Select the ksConnect component on the room and add the 'ConnectHandler' 'OnGetRooms' method to the ksConnect 'OnGetRooms' event list.

ConnectHandler.cs

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

public class ConnectHandler : MonoBehaviour
{
    public string Username = "Player";

    // Handler for ksConnect.OnGetRooms events.
    public void OnGetRooms(ksConnect.GetRoomsEvent e)
    {
        if (e.Status != ksBaseRoom.ConnectStatus.SUCCESS)
        {
            ksLog.Error(this, "Error fetching rooms: " + e.Error);
        }
        else if (e.Rooms == null || e.Rooms.Count == 0)
        {
            ksLog.Warning(this, "No rooms found.");
        }
        else
        {
            // The caller is the ksConnect script instance which invoked this event handler.
            e.Caller.Connect(e.Rooms[0], Username);
        }
    }
}

Create a Server Room Script

The server room script registers an authentication handler that validates and sets a player's name property. It assigns players a random position property when they connect. It continuously spawns cubes that fall from the sky. It does a sphere sweep when it receives a shoot RPC from a client if they have any ammo. It calls an entity RPC on any entities hit by the sphere sweep, then destroys the entity.

Authentication handlers are async methods that return a Task, so you can await external asynchronous authentication calls. For more information, see here. Authentication handlers cannot be added or removed after the room is loaded and* Initialize * is called on the initial scripts.*

The ksAsyncResult constructor takes a uint code parameter and an optional number of ksMultiTypes. Authentication fails if the uint code is non-zero. The ksAsyncResult is sent to the client and can be accessed in the Room.OnConnect event. The code and ksMultiTypes are logged by the ksConnect script when authentication fails. In this tutorial we pass an error message string as a ksMultiType argument when authentication fails. If there are multiple authentication handler functions and authentication fails, the ksAsyncResult sent to the client will be from the first authentication function that failed. If all authentication functions pass, the ksMultiType arguments from all authentication functions are combined into one array and sent to the client.

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

ServerRoom.cs

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

public class ServerRoom : ksServerRoomScript
{
    // The number of cubes to spawn per second.
    [ksEditable] public float SpawnRate = 10f;

    private ksRandom m_random = new ksRandom();
    private float m_spawnTimer;

    // Called when the script is attached.
    public override void Initialize()
    {
        Room.OnAuthenticate += Authenticate;
        Room.OnUpdate[0] += Update;
        Room.OnPlayerJoin += PlayerJoin;
        Room.OnPlayerLeave += PlayerLeave;
        if (SpawnRate > 0)
        {
            m_spawnTimer = 1f / SpawnRate;
        }
    }

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

    // Authenticates a player. Return a non-zero result to fail authentication.
    private async Task<ksAuthenticationResult> Authenticate(ksIServerPlayer player, ksMultiType[] args, 
        CancellationToken cancellationToken)
    {
        // args contains the arguments we passed to ksRoom.Connect().
        if (args.Length != 1)
        {
            // Returning a non-zero authentication result means authentication failed.
            return new ksAuthenticationResult(1);
        }
        // Validate the user name is not empty and does not exceed 30 characters.
        string name = args[0];
        if (string.IsNullOrEmpty(name))
        {
            return new ksAuthenticationResult(2, "Name cannot be empty");
        }
        if (name.Length > 30)
        {
            return new ksAuthenticationResult(3, "Name cannot exceed 30 characters");
        }
        // Set the name to the value provided by the client.
        player.Properties[Prop.NAME] = name;
        // 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));
    }

    // Called during the update cycle.
    private void Update()
    {
        // Check the Y position of all entities and destroy those with values less than -10.
        // This prevents dynamic entities such as the cubes from falling forever.
        foreach (ksIServerEntity entity in Room.Entities)
        {
            if (entity.Transform.Position.Y < -10.0f)
            {
                entity.Destroy();
            }
        }

        if (SpawnRate <= 0)
        {
            return;
        }

        m_spawnTimer -= Time.Delta;
        while (m_spawnTimer <= 0f)
        {
            m_spawnTimer += 1f / SpawnRate;

            // Spawn a cube at a random location in a circle of radius 7.5f at a height of 10.
            ksVector2 position = m_random.NextVector2() * 7.5f;
            ksIServerEntity cube = Room.SpawnEntity("Cube", new ksVector3(position.X, 10f, position.Y));
        }
    }

    // Called when a player connects.
    private void PlayerJoin(ksIServerPlayer player)
    {
        // Log a player join message with the player's name property.
        ksLog.Info(player.Properties[Prop.NAME] + " joined.");

        // Set the player's position property to a random point on the edge of a circle with radius 15.
        player.Properties[Prop.POSITION] = m_random.NextUnitVector2() * 15f;

        // Set the player's score property to zero.
        player.Properties[Prop.SCORE] = 0;
    }

    // Called when a player disconnects.
    private void PlayerLeave(ksIServerPlayer player)
    {
        // Log a player leave message with the player's name property.
        ksLog.Info(player.Properties[Prop.NAME] + " left.");
    }

    // Invoke this when we receive a shoot RPC. The first parameter is the player who sent the RPC.
    [ksRPC(RPC.SHOOT)]
    private void OnShoot(ksIServerPlayer player, ksVector3 direction)
    {
        ServerPlayer playerScript = player.Scripts.Get<ServerPlayer>();
        if (playerScript.Ammo <= 0)
        {
            // Don't shoot if we have no ammo.
            return;
        }
        playerScript.Ammo--;
        if (playerScript.Ammo <= 0)
        {
            // Start reloading ammo.
            playerScript.Reload();
        }
        // Do a sphere sweep from the player's position property in the direction from the RPC call.
        ksVector2 position2d = player.Properties[Prop.POSITION];
        ksVector3 position = new ksVector3(position2d.X, 0f, position2d.Y);
        ksSweepParams sweep = new ksSweepParams(new ksSphere(.5f), position, ksQuaternion.Identity, direction, 35f);
        ksQueryHitResults<ksSweepResult> hits = Physics.Sweep(sweep);
        if (hits.Count >= 0)
        {
            // Increase the player's score property by the number of entities hit by the raycast.
            player.Properties[Prop.SCORE] += hits.Count;
            if (hits.Count == 1)
            {
                // Physics queries can be used from player controllers on both the client and server, so they return
                // ksIEntity, an entity interface for both server and client entities. We have to cast it to ksIServerEntity.
                ksIServerEntity entity = (ksIServerEntity)hits.Touches[0].Entity;

                // Call the fade RPC on the entity the player hit to tell clients to fade-out the entity.
                entity.CallRPC(RPC.FADE);

                // Destroy the entity we hit.
                entity.Destroy();
            }
            else
            {
                ksIServerEntity[] entities = new ksIServerEntity[hits.Count];
                for (int i = 0; i < entities.Length; i++)
                {
                    entities[i] = (ksIServerEntity)hits.Touches[i].Entity;
                }
                // Batch RPCs allow us to call entity RPCs on multiple entities at once.
                // Call the fade RPC on all the entities the player hit to tell clients to fade-out the entities.
                Room.CallBatchRPC(RPC.FADE, entities);

                // Destroy the entities.
                foreach (ksIServerEntity entity in entities)
                {
                    entity.Destroy();
                }
            }
        }
    }
}

Create a Client Room Script

The client room script sends an RPC to the server to shoot when the mouse is clicked and the client has ammo. It decrements its ammo each time is shoots. It sets the camera position based on the local player's position property. It logs messages when the local player's score or ammo changes.

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

ClientRoom.cs

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

public class ClientRoom : ksRoomScript
{
    private int m_ammo = 0;

    // Called after properties are initialized.
    public override void Initialize()
    {
        Room.OnPlayerJoin += PlayerJoin;
        Room.OnPlayerLeave += PlayerLeave;
    }

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

    // Called every frame.
    private void Update()
    {
        if (Input.GetMouseButtonDown(0) && m_ammo > 0)
        {
            // Generate a ray that extends from the camera into the scene where the mouse was clicked.
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

            // Call the shoot RPC on the server and pass the ray direction.
            Room.CallRPC(RPC.SHOOT, ray.direction);

            // Decrement ammo.
            OnAmmoChange(m_ammo - 1);
        }
    }

    // Called when a player connects.
    private void PlayerJoin(ksPlayer player)
    {
        // Check if this is the local player.
        if (player.IsLocal)
        {
            // Register a property change handler for the local player's score property.
            player.OnPropertyChange[Prop.SCORE] += OnScoreChange;

            // Convert the player's ksVector2 position property to a Vector3 and move the camera to that position.
            ksVector2 position = player.Properties[Prop.POSITION];
            Camera.main.transform.position = new Vector3(position.X, 0f, position.Y);

            // Look at the origin.
            Camera.main.transform.LookAt(Vector3.zero);
        }
        // Log a player join message with the player's name property.
        ksLog.Info(player.Properties[Prop.NAME] + " joined.");
    }

    // Called when a player disconnects.
    private void PlayerLeave(ksPlayer player)
    {
        if (player.IsLocal)
        {
            player.OnPropertyChange[Prop.SCORE] -= OnScoreChange;
        }
        // Log a player leave message with the player's name property.
        ksLog.Info(player.Properties[Prop.NAME] + " left.");
    }

    private void OnScoreChange(ksMultiType oldValue, ksMultiType newValue)
    {
        // Log the player's score.
        ksLog.Info("Score: " + newValue);
    }

    // Invoke this method when we receive an ammo RPC from the server.
    [ksRPC(RPC.AMMO)]
    private void OnAmmoChange(int ammo)
    {
        m_ammo = ammo;
        ksLog.Info("Ammo: " + ammo);
    }
}

Testing

  1. Build your scene config (CTRL + F2).
  2. Start a local server.
  3. Enter play mode.

You should see colorful cubes slowly falling from the sky. The cubes will slowly change color. When you connect you should see a message with your username (which will be 'MyName' unless you changed it) in both the client console and server logs. When you click, the server will do a sphere sweep in the direction you clicked if you have ammo and destroy any cubes that you hit. When you shoot a cube, the cube will fade away and your score will be logged on the client. When you run out of ammo, you'll get your ammo back after five seconds.