Authoritative Client Sample

Summary

This sample shows how to use RPCs and properties to sync the local player's transform and animation state from the client to all other players, and to use an entity factory to spawn a different prefab for local and remote players. This can be useful when you have an existing Unity-based player controller you want to use without porting it to a Reactor player controller to make it server authoritative. This sample uses the Third-Person Character Controller from Unity's starter assets, though the scripts could be used with other controllers as well.

There are drawbacks to using a client-authoritative controller like this sample does instead of a server-authoritative one. Client-authoritative games are easier for clients to cheat and not recommended for highly competitive play. This can be mitigated by writing server-side validation of client movement, but this is outside the scope of this sample. Client-authoritative controllers are also not well suited for dynamic physics interactions that affect gameplay.

Requirements

Scene and Prefab Setup

Open the Scene

  1. Open the Playground scene from 'Assets/StarterAssets/ThirdPersonController/Scenes/Playground'. If you want to preserve the orignal scene, save a copy of this scene.

Create the Player Prefab

  1. Select the 'PlayerArmature' object.
  2. Add a ksEntityComponent.
  3. In the inspector for the CharacterController, expand the 'Reactor Collider Data' foldout and set the 'Existance' to 'Client-Only'. This prevents the component from being written to server configs.
  4. Drag the object into a 'Resources' folder and make it a prefab variant.
  5. Rename the prefab to 'Player'. This will be the prefab for the local player.

Create the Remote Player Prefab

  1. Select the 'PlayerArmature' object.
  2. Remove the CharacterController, ThirdPersonController, BasicRigidBodyPush, StarterAssetInputs, and PlayerInput scripts.
  3. Disable the 'PlayerCameraRoot' child object.
  4. Drag the object into a 'Resources' folder and make it a prefab variant.
  5. Rename the prefab to 'Player_Remote'. This will be the prefab for remote players.

Setup the Scene

  1. Delete the 'PlayerArmature' object. We will be spawning the player object through the server instead.
  2. Select the 'PlayerFollowCamera' object. The 'Follow' field in the CinemachineVirtualCamera script should be a missing object. It used to be the 'PlayerArmature' object you deleted. Set it to none.
  3. Right click in the hierarachy window and create a 'Reactor->Room' game object.

Scripting

Create a Class for Constants

The Consts class defines the RPC and property ids we will use.

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

Consts.cs

public static class Prop
{
    public const uint OWNER = 0;
    public const uint STATE = 1;
    public const uint STANCE = 2;
    public const uint MOVEMENT = 3;

    // 1000-1099 is reserved for animation states (one per layer)
    public const uint ANIMATION_STATES = 1000;
    // 1100+ is reserved for animation parameters
    public const uint ANIMATION_PARAMS = 1100;
}

public static class RPC
{
    public const uint TRANSFORM = 0;
    public const uint ANIMATION_STATE = 1;
    public const uint ANIMATION_PARAMS = 2;
    public const uint ANIMATION_TRIGGER = 3;
}

Create a Client Authority Client Script

The ceClientAuthority script syncs transform state to the server if the local client is the owner of the entity. Entity ownership is determined by an entity property containing the id of the player who owns the entity.

  1. Select the 'Player' prefab.
  2. In the 'Add Component' menu, select 'Reactor->New Client Entity Script'.
  3. Name the script 'ceClientAuthority'.

ceClientAuthority.cs

using System;
using KS.Reactor.Client.Unity;

// Attach this to entities controlled by clients. Sends transform updates to the server if the local player
// controls the entity.
public class ceClientAuthority : ksEntityScript
{
    // The number of updates sent to the server per second. 0 or less to send every frame.
    public float UpdatesPerSecond = 60f;

    // Called when it's time to send an update to the server.
    public event Action OnSendUpdate;

    // Does the local player control this entity?
    public bool IsOwner
    {
        get { return m_isOwner; }
    }
    private bool m_isOwner;

    private float m_timer = 0f;

    // Called after properties are initialized.
    public override void Initialize()
    {
        m_isOwner = Properties[Prop.OWNER] == Room.LocalPlayerId;
        if (m_isOwner)
        {
            // Stop Reactor from updating the game object transform so it can be controlled by the client.
            Entity.ApplyTransformUpdates = false;
            Entity.PredictionEnabled = false;
        }
        else
        {
            enabled = false;// Prevent LateUpdate from being called.
        }
    }

    // Called every frame.
    private void LateUpdate()
    {
        m_timer -= Time.RealDelta;
        if (m_timer > 0f)
        {
            return;
        }
        if (UpdatesPerSecond > 0)
        {
            m_timer += 1f / UpdatesPerSecond;
        }
        m_timer = Math.Max(0f, m_timer);
        Entity.CallRPC(RPC.TRANSFORM, transform.position, transform.rotation);
        if (OnSendUpdate != null)
        {
            OnSendUpdate();
        }
    }
}

Create an Animation Sync Client Script

The ceAnimationSync script syncs animation parameters with the server. It requires a ceClientAuthority script on game object. It can optionally sync the animation states for each layer as well. This is only needed if you are setting animation states from code. If your animation states are driven by animation parameters, which is the case in this sample, you can leave the 'Sync Layer States' field unchecked. The 'Cross Fade Duration' is only used when syncing animation layer states.

  1. Select the 'Player' prefab.
  2. In the 'Add Component' menu, select 'Reactor->New Client Entity Script'.
  3. Name the script 'ceAnimationSync'.

ceAnimationSync.cs

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

// Syncs animation state for a client-controlled entity. Requires a ceClientAuthority script to sync the transform.
[RequireComponent(typeof(ceClientAuthority))]
[RequireComponent(typeof(Animator))]
public class ceAnimationSync : ksEntityScript
{
    // If true, will also sync the animation states of each layer. If your layer states are driven by animation
    // parameters, leave this false. If your layer states are set programmatically, set this to true.
    public bool SyncLayerStates = false;
    // The cross fade duration in seconds when changing layer states. This is only used when SyncLayerStates is
    // true.
    public float CrossFadeDuration = .2f;

    private Animator m_animator;
    private int[] m_states;
    private ksMultiType[] m_parameterValues;
    private uint[] m_parameterChangedFlags;

    // Does the local player control the entity's animations?
    public bool IsOwner
    {
        get { return m_states != null; }
    }

    // Use Start instead of Initialize to ensure the ceClientAuthority script is already initialized.
    private void Start()
    {
        ceClientAuthority clientAuthority = GetComponent<ceClientAuthority>();
        if (clientAuthority == null)
        {
            Destroy(this);
            return;
        }
        m_animator = GetComponent<Animator>();
        if (m_animator == null)
        {
            Destroy(this);
            return;
        }
        if (clientAuthority.IsOwner)
        {
            m_states = new int[m_animator.layerCount];
            m_parameterValues = new ksMultiType[m_animator.parameterCount];
            m_parameterChangedFlags = new uint[1 + (m_animator.parameterCount - 1) / 32];
            clientAuthority.OnSendUpdate += SendChanges;
        }
        else
        {
            if (SyncLayerStates)
            {
                // Bind property change event handers for animation layer states.
                for (int i = 0; i < m_animator.layerCount; i++)
                {
                    BindLayerState(i);
                }
            }
            // Bind property change event handlers for animation parameters.
            for (int i = 0; i < m_animator.parameterCount; i++)
            {
                if (m_animator.GetParameter(i).type != AnimatorControllerParameterType.Trigger)
                {
                    BindAnimatorProperty(i);
                }
            }
            enabled = false;// Prevent Update from being called.
        }
    }

    // Called when the script is detached.
    public override void Detached()
    {
        if (IsOwner)
        {
            ceClientAuthority clientAuthority = GetComponent<ceClientAuthority>();
            if (clientAuthority != null)
            {
                clientAuthority.OnSendUpdate -= SendChanges;
            }
        }
    }

    // Called every frame.
    private void Update()
    {
        // Sync animation triggers. These must be checked every frame since they are only true for one frame.
        for (int i = 0; i < m_animator.parameterCount; i++)
        {
            AnimatorControllerParameter parameter = m_animator.GetParameter(i);
            if (parameter.type == AnimatorControllerParameterType.Trigger)
            {
                if (m_animator.GetBool(parameter.nameHash))
                {
                    Entity.CallRPC(RPC.ANIMATION_TRIGGER, i);
                }
            }
        }
    }

    private void SendChanges()
    {
        if (SyncLayerStates)
        {
            // Sync changed animation layer states
            for (int i = 0; i < m_animator.layerCount; i++)
            {
                int state = m_animator.GetCurrentAnimatorStateInfo(i).fullPathHash;
                if (state != m_states[i])
                {
                    m_states[i] = state;
                    Entity.CallRPC(RPC.ANIMATION_STATE, i, state);
                }
            }
        }

        // Send changed animation parameters
        List<ksMultiType> args = new List<ksMultiType>();
        for (int i = 0; i < m_animator.parameterCount; i++)
        {
            AnimatorControllerParameter parameter = m_animator.GetParameter(i);
            if (parameter.type != AnimatorControllerParameterType.Trigger)
            {
                ksMultiType value = GetParameterValue(parameter);
                if (!value.Equals(m_parameterValues[i]))
                {
                    // Set a flag indicating this parameter changed.
                    SetParameterChanged(i);
                    m_parameterValues[i] = value;
                    args.Add(value);
                }
            }
        }
        if (args.Count == 0)
        {
            return;
        }
        // Send all changed parameters in one RPC.
        args.Insert(0, m_parameterChangedFlags);
        Entity.CallRPC(RPC.ANIMATION_PARAMS, args.ToArray());
        ClearParameterChangedFlags();
    }

    private ksMultiType GetParameterValue(AnimatorControllerParameter parameter)
    {
        switch (parameter.type)
        {
            case AnimatorControllerParameterType.Trigger:
            case AnimatorControllerParameterType.Bool:
            {
                return m_animator.GetBool(parameter.nameHash);
            }
            case AnimatorControllerParameterType.Float:
            {
                return m_animator.GetFloat(parameter.nameHash);
            }
            case AnimatorControllerParameterType.Int:
            {
                return m_animator.GetInteger(parameter.nameHash);
            }
        }
        return null;
    }

    private void SetParameterValue(int index, ksMultiType value)
    {
        AnimatorControllerParameter parameter = m_animator.GetParameter(index);
        switch (value.Type)
        {
            case ksMultiType.Types.BOOL:
            {
                m_animator.SetBool(parameter.nameHash, value);
                break;
            }
            case ksMultiType.Types.INT:
            {
                m_animator.SetInteger(parameter.nameHash, value);
                break;
            }
            case ksMultiType.Types.FLOAT:
            {
                m_animator.SetFloat(parameter.nameHash, value);
                break;
            }
        }
    }

    // Sets a flag to indicate a parameter changed.
    private void SetParameterChanged(int index)
    {
        int flagIndex = index / 32;
        int bitIndex = index % 32;
        m_parameterChangedFlags[flagIndex] |= 1u << bitIndex;
    }

    // Gets a flag indicating if a parameter changed.
    private bool GetParameterChanged(int index)
    {
        int flagIndex = index / 32;
        int bitIndex = index % 32;
        return (m_parameterChangedFlags[flagIndex] & (1u << bitIndex)) != 0;
    }

    private void ClearParameterChangedFlags()
    {
        for (int i = 0; i < m_parameterChangedFlags.Length; i++)
        {
            m_parameterChangedFlags[i] = 0;
        }
    }

    private void BindLayerState(int index)
    {
        uint propertyId = Prop.ANIMATION_STATES + (uint)index;
        Entity.OnPropertyChange[propertyId] += (ksMultiType oldValue, ksMultiType newValue) =>
            m_animator.CrossFade(newValue.Int, CrossFadeDuration, index);
        m_animator.Play(Properties[propertyId].Int, index);
    }

    private void BindAnimatorProperty(int index)
    {
        uint propertyId = Prop.ANIMATION_PARAMS + (uint)index;
        Entity.OnPropertyChange[propertyId] +=
            (ksMultiType oldValue, ksMultiType newValue) => SetParameterValue(index, newValue);
        SetParameterValue(index, Properties[propertyId]);
    }

    [ksRPC(RPC.ANIMATION_TRIGGER)]
    private void OnTrigger(int index)
    {
        if (!IsOwner)
        {
            AnimatorControllerParameter parameter = m_animator.GetParameter(index);
            m_animator.SetTrigger(parameter.nameHash);
        }
    }
}

Create a Client Authority Server Script

The seClientAuthority script tracks the player that owns the entity and handles the RPCs from the owning client with transform and animation state.

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

seClientAuthority.cs

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

// Server script for entities controlled by clients. The owning client uses RPCs to set the transform from a
// ceClientAuthority script, and can optionally sync animation state with a ceAnimationSync script.
public class seClientAuthority : ksServerEntityScript
{
    // Should the entity be destroyed when the owner disconnects?
    [ksEditable]
    public bool DestroyOnOwnerDisconnect = true;

    // The player who controls this entity.
    public ksIServerPlayer Owner
    {
        get { return m_owner; }
        set
        {
            if (m_owner != null)
            {
                m_owner.OnLeave -= OwnerDisconnected;
            }
            m_owner = value;
            Properties[Prop.OWNER] = value == null ? uint.MaxValue : value.Id;
            if (value != null)
            {
                value.OnLeave += OwnerDisconnected;
            }
        }
    }
    private ksIServerPlayer m_owner;

    [ksRPC(RPC.TRANSFORM)]
    private void SetTransform(ksIServerPlayer player, ksVector3 position, ksQuaternion rotation)
    {
        if (player == m_owner)
        {
            Transform.Position = position;
            Transform.Rotation = rotation;
        }
    }

    [ksRPC(RPC.ANIMATION_STATE)]
    private void SetAnimationState(ksIServerPlayer player, uint index, ksMultiType value)
    {
        if (player == m_owner)
        {
            Properties[Prop.ANIMATION_STATES + index] = value;
        }
    }

    [ksRPC(RPC.ANIMATION_PARAMS)]
    private void SetAnimationParameter(ksIServerPlayer player, uint[] changedFlags, ksMultiType[] values)
    {
        if (player != m_owner)
        {
            return;
        }
        int index = 0;
        for (int i = 0; i < changedFlags.Length; i++)
        {
            uint flags = changedFlags[i];
            for (int j = 0; j < 32; j++)
            {
                if ((flags & 1u << j) != 0)
                {
                    uint propertyNum = (uint)(i * 32 + j);
                    Properties[Prop.ANIMATION_PARAMS + propertyNum] = values[index++];
                    if (index == values.Length)
                    {
                        return;
                    }
                }
            }
        }
    }

    [ksRPC(RPC.ANIMATION_TRIGGER)]
    private void SetAnimationTrigger(ksIServerPlayer player, int index)
    {
        if (player == m_owner)
        {
            Entity.CallRPC(RPC.ANIMATION_TRIGGER, index);
        }
    }

    private void OwnerDisconnected()
    {
        if (DestroyOnOwnerDisconnect && Entity != null)
        {
            Entity.Destroy();
        }
    }
}

Create a Player Spawner Server Room Script

The srPlayerSpawner script spawns a player entity when a player connects and assigns them as the owner of the entity. The 'Prefab' field must be set to the name of your local player prefab, which in this sample is 'Player'.

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

srPlayerSpawner.cs

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

public class srPlayerSpawner : ksServerRoomScript
{
    // Prefab to spawn for connecting players.
    [ksEditable]
    public string Prefab;

    [ksEditable]
    public ksVector3 SpawnPoint;

    // Called when the script is attached.
    public override void Initialize()
    {
        Room.OnPlayerJoin += PlayerJoin;
    }

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

    // Called when a player connects.
    private void PlayerJoin(ksIServerPlayer player)
    {
        ksIServerEntity entity = Room.SpawnEntity(Prefab, SpawnPoint);
        seClientAuthority clientAuthority = entity.Scripts.Get<seClientAuthority>();
        if (clientAuthority == null)
        {
            clientAuthority = new seClientAuthority();
            entity.Scripts.Attach(clientAuthority);
        }
        clientAuthority.Owner = player;
    }
}
  1. Set the 'Prefab' field to 'Player'.

Create a Camera Target Script to make the Camera Follow the Player

The Third-Person Character Controller uses Cinemachine to control the camera. We need a simple script to tell Cinemachine which game object the camera should follow.

  1. Open the 'Player' prefab.
  2. Select the 'PlayerCameraRoot' child object.
  3. Click the 'Add Component' button in the inspector and create a new C# Script named 'CameraTarget'.

CameraTarget.cs

using UnityEngine;
using Cinemachine;

// Makes the Cinemachine active virtual camera follow this object and locks the mouse.
public class CameraTarget : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        if (Camera.main != null)
        {
            CinemachineBrain cbrain = Camera.main.GetComponent<CinemachineBrain>();
            if (cbrain != null && cbrain.ActiveVirtualCamera != null)
            {
                cbrain.ActiveVirtualCamera.Follow = transform;
                Cursor.lockState = CursorLockMode.Locked;
            }
        }
    }
}

Create a Script to Play Footstep Sounds for Remote Players

The ThirdPersonController script plays footstep sounds when a character walks, however we don't use this script for remote players since it also tries to move the character using player inputs, which we only want to do for the local player. So we need to create another script to just play footstep sounds for remote players.

  1. Select the 'Player_Remote' prefab.
  2. Click the 'Add Component' button in the inspector and create a new C# Script named 'Footsteps'.

Footsteps.cs

using UnityEngine;

public class Footsteps : MonoBehaviour
{
    public AudioClip LandingAudioClip;
    public AudioClip[] FootstepAudioClips;
    [Range(0, 1)] public float FootstepAudioVolume = 0.3f;

    private void OnFootstep(AnimationEvent animationEvent)
    {
        if (animationEvent.animatorClipInfo.weight > 0.5f)
        {
            if (FootstepAudioClips.Length > 0)
            {
                var index = Random.Range(0, FootstepAudioClips.Length);
                AudioSource.PlayClipAtPoint(FootstepAudioClips[index], transform.position, FootstepAudioVolume);
            }
        }
    }

    private void OnLand(AnimationEvent animationEvent)
    {
        if (animationEvent.animatorClipInfo.weight > 0.5f)
        {
            AudioSource.PlayClipAtPoint(LandingAudioClip, transform.position, FootstepAudioVolume);
        }
    }
}
  1. Set the 'Landing Audio Clip' to 'Assets/StarterAssets/ThirdPersonController/Character/Sfx/Player_Land'
  2. Select the 'Player' prefab.
  3. Right-click the 'Footstep Audio Clips' field in the inspector for the ThirdPersonController script and click 'Copy'.
  4. Select the 'Player_Remote' prefab.
  5. Right-click the 'Footstep Audio Clips' field in the inspector for the Footsteps script and click 'Paste'.

Create an Entity Factory script to Choose Which Prefab to Spawn for Player Entities.

Entity factories can be used to override the behaviour for spawning and destroying game objects. We will use an entity factory to switch which prefab is spawned for remote players. Our entity factory checks if the entity has an owner property set to the id of a different player and if it does, it appends "_Remote" to the prefab name it tries to load from Resources. If there is no remote version of the prefab, it loads the default prefab.

  1. Create a new C# script named 'RemoteEntityFactory'.

RemoteEntityFactory.cs

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

// If the entity has an owner property set to a different player's id, appends "_Remote" to the name of the prefab
// to load from Resources. If it does not find a remote prefab, it loads the default prefab.
public class RemoteEntityFactory : ksEntityFactory
{
    private Dictionary<uint, GameObject> m_prefabCache = new Dictionary<uint, GameObject>();

    // Gets the game object to use for an entity. Must be a scene object with a ksEntityComponent.
    public override GameObject GetGameObject(ksEntity entity, GameObject prefab)
    {
        if (entity.Properties.Contains(Prop.OWNER) && entity.Properties[Prop.OWNER] != entity.Room.LocalPlayerId)
        {
            // Try to get a remote prefab.
            GameObject remotePrefab = GetRemotePrefab(entity);
            if (remotePrefab != null)
            {
                return GameObject.Instantiate(remotePrefab);
            }
        }
        // Use the default prefab.
        return base.GetGameObject(entity, prefab);
    }

    private GameObject GetRemotePrefab(ksEntity entity)
    {
        // Asset id 0 means the entity has no prefab.
        if (entity.AssetId == 0)
        {
            return null;
        }
        // Try to get the prefab from the cache.
        GameObject prefab;
        if (m_prefabCache.TryGetValue(entity.AssetId, out prefab))
        {
            return prefab;
        }
        // Append "_Remote" to the prefab name and try to load it from Resources.
        prefab = Resources.Load<GameObject>(entity.Type + "_Remote");
        if (prefab != null && prefab.GetComponent<ksEntityComponent>() != null)
        {
            m_prefabCache[entity.AssetId] = prefab;
            return prefab;
        }
        return null;
    }
}

Create a Room Initializer Script

The RoomInitializer registers the entity factory we created earlier before once connected. The factory is registered for the entity type 'Player'. This should be the name of your local player prefab.

The string parameter for registering an entity factory is a regular expression. You can use "*" to register a factory for all prefabs.

  1. Select the 'Room' object.
  2. Click the 'Add Component' button in the inspector and create a new C# Script named 'RoomInitializer'.

RoomInitializer.cs

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

// Room initialization script.
[RequireComponent(typeof(ksConnect))]
public class RoomInitializer : MonoBehaviour
{
    // Run when the game starts.
    private void Start()
    {
        Application.runInBackground = true;
        GetComponent<ksConnect>().OnConnect.AddListener(OnConnect);
    }

    // Called when connected to the room.
    private void OnConnect(ksConnect.ConnectEvent ev)
    {
        // Register the entity factory for creating local and remote players. "Player" must be the name of your
        // player prefab. Alternatively you can register with "*" to use this factory for all prefab.
        ev.Caller.Room.AddEntityFactory("Player", new RemoteEntityFactory());
    }
}

Testing

You are now ready to test your game. You will need to build a game client with your scene added to the build in order to connect multiple clients at once. When you move your character on one client, you should see it move on other clients.