Skip to main content

RPCs vs NetworkVariables Examples

This page has examples of how the Small Coop Sample (Boss Room) uses RPCs and NetworkVariables. It gives guidance on when to use RPCs versus NetworkVariables in your own projects.

See the RPC vs NetworkVariable tutorial for more information.

RPCs for movement

Boss Room uses RPCs to send movement inputs.

Assets/Scripts/Gameplay/UserInput/ClientInputSender.cs
using System;
using Unity.BossRoom.Gameplay.Actions;
using Unity.BossRoom.Gameplay.Configuration;
using Unity.BossRoom.Gameplay.GameplayObjects;
using Unity.BossRoom.Gameplay.GameplayObjects.Character;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Assertions;
using UnityEngine.EventSystems;

namespace Unity.BossRoom.Gameplay.UserInput
{
/// <summary>
/// Captures inputs for a character on a client and sends them to the server.
/// </summary>
[RequireComponent(typeof(ServerCharacter))]
public class ClientInputSender : NetworkBehaviour
{
const float k_MouseInputRaycastDistance = 100f;

//The movement input rate is capped at 40ms (or 25 fps). This provides a nice balance between responsiveness and
//upstream network conservation. This matters when holding down your mouse button to move.
const float k_MoveSendRateSeconds = 0.04f; //25 fps.

const float k_TargetMoveTimeout = 0.45f; //prevent moves for this long after targeting someone (helps prevent walking to the guy you clicked).

float m_LastSentMove;

// Cache raycast hit array so that we can use non alloc raycasts
readonly RaycastHit[] k_CachedHit = new RaycastHit[4];

// This is basically a constant but layer masks cannot be created in the constructor, that's why it's assigned int Awake.
LayerMask m_GroundLayerMask;

LayerMask m_ActionLayerMask;

const float k_MaxNavMeshDistance = 1f;

RaycastHitComparer m_RaycastHitComparer;

[SerializeField]
ServerCharacter m_ServerCharacter;

/// <summary>
/// This event fires at the time when an action request is sent to the server.
/// </summary>
public event Action<ActionRequestData> ActionInputEvent;

/// <summary>
/// This describes how a skill was requested. Skills requested via mouse click will do raycasts to determine their target; skills requested
/// in other matters will use the stateful target stored in NetworkCharacterState.
/// </summary>
public enum SkillTriggerStyle
{
None, //no skill was triggered.
MouseClick, //skill was triggered via mouse-click implying you should do a raycast from the mouse position to find a target.
Keyboard, //skill was triggered via a Keyboard press, implying target should be taken from the active target.
KeyboardRelease, //represents a released key.
UI, //skill was triggered from the UI, and similar to Keyboard, target should be inferred from the active target.
UIRelease, //represents letting go of the mouse-button on a UI button
}

bool IsReleaseStyle(SkillTriggerStyle style)
{
return style == SkillTriggerStyle.KeyboardRelease || style == SkillTriggerStyle.UIRelease;
}

/// <summary>
/// This struct essentially relays the call params of RequestAction to FixedUpdate. Recall that we may need to do raycasts
/// as part of doing the action, and raycasts done outside of FixedUpdate can give inconsistent results (otherwise we would
/// just expose PerformAction as a public method, and let it be called in whatever scoped it liked.
/// </summary>
/// <remarks>
/// Reference: https://answers.unity.com/questions/1141633/why-does-fixedupdate-work-when-update-doesnt.html
/// </remarks>
struct ActionRequest
{
public SkillTriggerStyle TriggerStyle;
public ActionID RequestedActionID;
public ulong TargetId;
}

/// <summary>
/// List of ActionRequests that have been received since the last FixedUpdate ran. This is a static array, to avoid allocs, and
/// because we don't really want to let this list grow indefinitely.
/// </summary>
readonly ActionRequest[] m_ActionRequests = new ActionRequest[5];

/// <summary>
/// Number of ActionRequests that have been queued since the last FixedUpdate.
/// </summary>
int m_ActionRequestCount;

BaseActionInput m_CurrentSkillInput;

bool m_MoveRequest;

Camera m_MainCamera;

public event Action<Vector3> ClientMoveEvent;

/// <summary>
/// Convenience getter that returns our CharacterData
/// </summary>
CharacterClass CharacterClass => m_ServerCharacter.CharacterClass;

[SerializeField]
PhysicsWrapper m_PhysicsWrapper;

public ActionState actionState1 { get; private set; }

public ActionState actionState2 { get; private set; }

public ActionState actionState3 { get; private set; }

public System.Action action1ModifiedCallback;

ServerCharacter m_TargetServerCharacter;

void Awake()
{
m_MainCamera = Camera.main;
}

public override void OnNetworkSpawn()
{
if (!IsClient || !IsOwner)
{
enabled = false;
// dont need to do anything else if not the owner
return;
}

m_ServerCharacter.TargetId.OnValueChanged += OnTargetChanged;
m_ServerCharacter.HeldNetworkObject.OnValueChanged += OnHeldNetworkObjectChanged;

if (CharacterClass.Skill1 &&
GameDataSource.Instance.TryGetActionPrototypeByID(CharacterClass.Skill1.ActionID, out var action1))
{
actionState1 = new ActionState() { actionID = action1.ActionID, selectable = true };
}
if (CharacterClass.Skill2 &&
GameDataSource.Instance.TryGetActionPrototypeByID(CharacterClass.Skill2.ActionID, out var action2))
{
actionState2 = new ActionState() { actionID = action2.ActionID, selectable = true };
}
if (CharacterClass.Skill3 &&
GameDataSource.Instance.TryGetActionPrototypeByID(CharacterClass.Skill3.ActionID, out var action3))
{
actionState3 = new ActionState() { actionID = action3.ActionID, selectable = true };
}

m_GroundLayerMask = LayerMask.GetMask(new[] { "Ground" });
m_ActionLayerMask = LayerMask.GetMask(new[] { "PCs", "NPCs", "Ground" });

m_RaycastHitComparer = new RaycastHitComparer();
}

public override void OnNetworkDespawn()
{
if (m_ServerCharacter)
{
m_ServerCharacter.TargetId.OnValueChanged -= OnTargetChanged;
m_ServerCharacter.HeldNetworkObject.OnValueChanged -= OnHeldNetworkObjectChanged;
}

if (m_TargetServerCharacter)
{
m_TargetServerCharacter.NetLifeState.LifeState.OnValueChanged -= OnTargetLifeStateChanged;
}
}

void OnTargetChanged(ulong previousValue, ulong newValue)
{
if (m_TargetServerCharacter)
{
m_TargetServerCharacter.NetLifeState.LifeState.OnValueChanged -= OnTargetLifeStateChanged;
}

m_TargetServerCharacter = null;

if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(newValue, out var selection) &&
selection.TryGetComponent(out m_TargetServerCharacter))
{
m_TargetServerCharacter.NetLifeState.LifeState.OnValueChanged += OnTargetLifeStateChanged;
}

UpdateAction1();
}

void OnHeldNetworkObjectChanged(ulong previousValue, ulong newValue)
{
UpdateAction1();
}

void OnTargetLifeStateChanged(LifeState previousValue, LifeState newValue)
{
UpdateAction1();
}

void FinishSkill()
{
m_CurrentSkillInput = null;
}

void SendInput(ActionRequestData action)
{
ActionInputEvent?.Invoke(action);
m_ServerCharacter.RecvDoActionServerRPC(action);
}

void FixedUpdate()
{
//play all ActionRequests, in FIFO order.
for (int i = 0; i < m_ActionRequestCount; ++i)
{
if (m_CurrentSkillInput != null)
{
//actions requested while input is active are discarded, except for "Release" requests, which go through.
if (IsReleaseStyle(m_ActionRequests[i].TriggerStyle))
{
m_CurrentSkillInput.OnReleaseKey();
}
}
else if (!IsReleaseStyle(m_ActionRequests[i].TriggerStyle))
{
var actionPrototype = GameDataSource.Instance.GetActionPrototypeByID(m_ActionRequests[i].RequestedActionID);
if (actionPrototype.Config.ActionInput != null)
{
var skillPlayer = Instantiate(actionPrototype.Config.ActionInput);
skillPlayer.Initiate(m_ServerCharacter, m_PhysicsWrapper.Transform.position, actionPrototype.ActionID, SendInput, FinishSkill);
m_CurrentSkillInput = skillPlayer;
}
else
{
PerformSkill(actionPrototype.ActionID, m_ActionRequests[i].TriggerStyle, m_ActionRequests[i].TargetId);
}
}
}

m_ActionRequestCount = 0;

if (EventSystem.current.currentSelectedGameObject != null)
{
return;
}

if (m_MoveRequest)
{
m_MoveRequest = false;
if ((Time.time - m_LastSentMove) > k_MoveSendRateSeconds)
{
m_LastSentMove = Time.time;
var ray = m_MainCamera.ScreenPointToRay(UnityEngine.Input.mousePosition);

var groundHits = Physics.RaycastNonAlloc(ray,
k_CachedHit,
k_MouseInputRaycastDistance,
m_GroundLayerMask);

if (groundHits > 0)
{
if (groundHits > 1)
{
// sort hits by distance
Array.Sort(k_CachedHit, 0, groundHits, m_RaycastHitComparer);
}

// verify point is indeed on navmesh surface
if (NavMesh.SamplePosition(k_CachedHit[0].point,
out var hit,
k_MaxNavMeshDistance,
NavMesh.AllAreas))
{
m_ServerCharacter.SendCharacterInputServerRpc(hit.position);

//Send our client only click request
ClientMoveEvent?.Invoke(hit.position);
}
}
}
}
}

/// <summary>
/// Perform a skill in response to some input trigger. This is the common method to which all input-driven skill plays funnel.
/// </summary>
/// <param name="actionID">The action you want to play. Note that "Skill1" may be overriden contextually depending on the target.</param>
/// <param name="triggerStyle">What sort of input triggered this skill?</param>
/// <param name="targetId">(optional) Pass in a specific networkID to target for this action</param>
void PerformSkill(ActionID actionID, SkillTriggerStyle triggerStyle, ulong targetId = 0)
{
Transform hitTransform = null;

if (targetId != 0)
{
// if a targetId is given, try to find the object
NetworkObject targetNetObj;
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out targetNetObj))
{
hitTransform = targetNetObj.transform;
}
}
else
{
// otherwise try to find an object under the input position
int numHits = 0;
if (triggerStyle == SkillTriggerStyle.MouseClick)
{
var ray = m_MainCamera.ScreenPointToRay(UnityEngine.Input.mousePosition);
numHits = Physics.RaycastNonAlloc(ray, k_CachedHit, k_MouseInputRaycastDistance, m_ActionLayerMask);
}

int networkedHitIndex = -1;
for (int i = 0; i < numHits; i++)
{
if (k_CachedHit[i].transform.GetComponentInParent<NetworkObject>())
{
networkedHitIndex = i;
break;
}
}

hitTransform = networkedHitIndex >= 0 ? k_CachedHit[networkedHitIndex].transform : null;
}

if (GetActionRequestForTarget(hitTransform, actionID, triggerStyle, out ActionRequestData playerAction))
{
//Don't trigger our move logic for a while. This protects us from moving just because we clicked on them to target them.
m_LastSentMove = Time.time + k_TargetMoveTimeout;

SendInput(playerAction);
}
else if (!GameDataSource.Instance.GetActionPrototypeByID(actionID).IsGeneralTargetAction)
{
// clicked on nothing... perform an "untargeted" attack on the spot they clicked on.
// (Different Actions will deal with this differently. For some, like archer arrows, this will fire an arrow
// in the desired direction. For others, like mage's bolts, this will fire a "miss" projectile at the spot clicked on.)

var data = new ActionRequestData();
PopulateSkillRequest(k_CachedHit[0].point, actionID, ref data);

SendInput(data);
}
}

/// <summary>
/// When you right-click on something you will want to do contextually different things. For example you might attack an enemy,
/// but revive a friend. You might also decide to do nothing (e.g. right-clicking on a friend who hasn't FAINTED).
/// </summary>
/// <param name="hit">The Transform of the entity we clicked on, or null if none.</param>
/// <param name="actionID">The Action to build for</param>
/// <param name="triggerStyle">How did this skill play get triggered? Mouse, Keyboard, UI etc.</param>
/// <param name="resultData">Out parameter that will be filled with the resulting action, if any.</param>
/// <returns>true if we should play an action, false otherwise. </returns>
bool GetActionRequestForTarget(Transform hit, ActionID actionID, SkillTriggerStyle triggerStyle, out ActionRequestData resultData)
{
resultData = new ActionRequestData();

var targetNetObj = hit != null ? hit.GetComponentInParent<NetworkObject>() : null;

//if we can't get our target from the submitted hit transform, get it from our stateful target in our ServerCharacter.
if (!targetNetObj && !GameDataSource.Instance.GetActionPrototypeByID(actionID).IsGeneralTargetAction)
{
ulong targetId = m_ServerCharacter.TargetId.Value;
NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out targetNetObj);
}

//sanity check that this is indeed a valid target.
if (targetNetObj == null || !ActionUtils.IsValidTarget(targetNetObj.NetworkObjectId))
{
return false;
}

if (targetNetObj.TryGetComponent<ServerCharacter>(out var serverCharacter))
{
//Skill1 may be contextually overridden if it was generated from a mouse-click.
if (actionID == CharacterClass.Skill1.ActionID && triggerStyle == SkillTriggerStyle.MouseClick)
{
if (!serverCharacter.IsNpc && serverCharacter.LifeState == LifeState.Fainted)
{
//right-clicked on a downed ally--change the skill play to Revive.
actionID = GameDataSource.Instance.ReviveActionPrototype.ActionID;
}
}
}

Vector3 targetHitPoint;
if (PhysicsWrapper.TryGetPhysicsWrapper(targetNetObj.NetworkObjectId, out var movementContainer))
{
targetHitPoint = movementContainer.Transform.position;
}
else
{
targetHitPoint = targetNetObj.transform.position;
}

// record our target in case this action uses that info (non-targeted attacks will ignore this)
resultData.ActionID = actionID;
resultData.TargetIds = new ulong[] { targetNetObj.NetworkObjectId };
PopulateSkillRequest(targetHitPoint, actionID, ref resultData);
return true;
}

/// <summary>
/// Populates the ActionRequestData with additional information. The TargetIds of the action should already be set before calling this.
/// </summary>
/// <param name="hitPoint">The point in world space where the click ray hit the target.</param>
/// <param name="actionID">The action to perform (will be stamped on the resultData)</param>
/// <param name="resultData">The ActionRequestData to be filled out with additional information.</param>
void PopulateSkillRequest(Vector3 hitPoint, ActionID actionID, ref ActionRequestData resultData)
{
resultData.ActionID = actionID;
var actionConfig = GameDataSource.Instance.GetActionPrototypeByID(actionID).Config;

//most skill types should implicitly close distance. The ones that don't are explicitly set to false in the following switch.
resultData.ShouldClose = true;

// figure out the Direction in case we want to send it
Vector3 offset = hitPoint - m_PhysicsWrapper.Transform.position;
offset.y = 0;
Vector3 direction = offset.normalized;

switch (actionConfig.Logic)
{
//for projectile logic, infer the direction from the click position.
case ActionLogic.LaunchProjectile:
resultData.Direction = direction;
resultData.ShouldClose = false; //why? Because you could be lining up a shot, hoping to hit other people between you and your target. Moving you would be quite invasive.
return;
case ActionLogic.Melee:
resultData.Direction = direction;
return;
case ActionLogic.Target:
resultData.ShouldClose = false;
return;
case ActionLogic.Emote:
resultData.CancelMovement = true;
return;
case ActionLogic.RangedFXTargeted:
resultData.Position = hitPoint;
return;
case ActionLogic.DashAttack:
resultData.Position = hitPoint;
return;
case ActionLogic.PickUp:
resultData.CancelMovement = true;
resultData.ShouldQueue = false;
return;
}
}

/// <summary>
/// Request an action be performed. This will occur on the next FixedUpdate.
/// </summary>
/// <param name="actionID"> The action you'd like to perform. </param>
/// <param name="triggerStyle"> What input style triggered this action. </param>
/// <param name="targetId"> NetworkObjectId of target. </param>
public void RequestAction(ActionID actionID, SkillTriggerStyle triggerStyle, ulong targetId = 0)
{
Assert.IsNotNull(GameDataSource.Instance.GetActionPrototypeByID(actionID),
$"Action with actionID {actionID} must be contained in the Action prototypes of GameDataSource!");

if (m_ActionRequestCount < m_ActionRequests.Length)
{
m_ActionRequests[m_ActionRequestCount].RequestedActionID = actionID;
m_ActionRequests[m_ActionRequestCount].TriggerStyle = triggerStyle;
m_ActionRequests[m_ActionRequestCount].TargetId = targetId;
m_ActionRequestCount++;
}
}

void Update()
{
if (Input.GetKeyDown(KeyCode.Alpha1))
{
RequestAction(actionState1.actionID, SkillTriggerStyle.Keyboard);
}
else if (Input.GetKeyUp(KeyCode.Alpha1))
{
RequestAction(actionState1.actionID, SkillTriggerStyle.KeyboardRelease);
}
if (Input.GetKeyDown(KeyCode.Alpha2))
{
RequestAction(actionState2.actionID, SkillTriggerStyle.Keyboard);
}
else if (Input.GetKeyUp(KeyCode.Alpha2))
{
RequestAction(actionState2.actionID, SkillTriggerStyle.KeyboardRelease);
}
if (Input.GetKeyDown(KeyCode.Alpha3))
{
RequestAction(actionState3.actionID, SkillTriggerStyle.Keyboard);
}
else if (Input.GetKeyUp(KeyCode.Alpha3))
{
RequestAction(actionState3.actionID, SkillTriggerStyle.KeyboardRelease);
}

if (Input.GetKeyDown(KeyCode.Alpha5))
{
RequestAction(GameDataSource.Instance.Emote1ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (Input.GetKeyDown(KeyCode.Alpha6))
{
RequestAction(GameDataSource.Instance.Emote2ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (Input.GetKeyDown(KeyCode.Alpha7))
{
RequestAction(GameDataSource.Instance.Emote3ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}
if (Input.GetKeyDown(KeyCode.Alpha8))
{
RequestAction(GameDataSource.Instance.Emote4ActionPrototype.ActionID, SkillTriggerStyle.Keyboard);
}

if (!EventSystem.current.IsPointerOverGameObject() && m_CurrentSkillInput == null)
{
//IsPointerOverGameObject() is a simple way to determine if the mouse is over a UI element. If it is, we don't perform mouse input logic,
//to model the button "blocking" mouse clicks from falling through and interacting with the world.

if (Input.GetMouseButtonDown(1))
{
RequestAction(CharacterClass.Skill1.ActionID, SkillTriggerStyle.MouseClick);
}

if (Input.GetMouseButtonDown(0))
{
RequestAction(GameDataSource.Instance.GeneralTargetActionPrototype.ActionID, SkillTriggerStyle.MouseClick);
}
else if (Input.GetMouseButton(0))
{
m_MoveRequest = true;
}
}
}

void UpdateAction1()
{
var isHoldingNetworkObject =
NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(m_ServerCharacter.HeldNetworkObject.Value,
out var heldNetworkObject);

NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(m_ServerCharacter.TargetId.Value,
out var selection);

var isSelectable = true;
if (isHoldingNetworkObject)
{
// show drop!

actionState1.actionID = GameDataSource.Instance.DropActionPrototype.ActionID;
}
else if ((m_ServerCharacter.TargetId.Value != 0
&& selection != null
&& selection.TryGetComponent(out PickUpState pickUpState))
)
{
// special case: targeting a pickup-able item or holding a pickup object

actionState1.actionID = GameDataSource.Instance.PickUpActionPrototype.ActionID;
}
else if (m_ServerCharacter.TargetId.Value != 0
&& selection != null
&& selection.NetworkObjectId != m_ServerCharacter.NetworkObjectId
&& selection.TryGetComponent(out ServerCharacter charState)
&& !charState.IsNpc)
{
// special case: when we have a player selected, we change the meaning of the basic action
// we have another player selected! In that case we want to reflect that our basic Action is a Revive, not an attack!
// But we need to know if the player is alive... if so, the button should be disabled (for better player communication)

actionState1.actionID = GameDataSource.Instance.ReviveActionPrototype.ActionID;
isSelectable = charState.NetLifeState.LifeState.Value != LifeState.Alive;
}
else
{
actionState1.SetActionState(CharacterClass.Skill1.ActionID);
}

actionState1.selectable = isSelectable;

action1ModifiedCallback?.Invoke();
}

public class ActionState
{
public ActionID actionID { get; internal set; }

public bool selectable { get; internal set; }

internal void SetActionState(ActionID newActionID, bool isSelectable = true)
{
actionID = newActionID;
selectable = isSelectable;
}
}
}
}

Boss Room wants the full history of inputs sent, not just the latest value. There is no need for NetworkVariables, you just want to blast your inputs to the server. Since Boss Room isn't a twitch shooter, it sends inputs as reliable RPCs without worrying about the latency an input loss would add.

Arrow's GameObject vs Fireball's VFX

The archer's arrows use a standalone GameObject that's replicated over time. Since this object's movements are slow, the Boss Room development team decided to use state (via the NetworkTransform) to replicate the ability's status (in case a client connected while the arrow was flying).

Assets/Scripts/Gameplay/GameplayObjects/Projectiles/PhysicsProjectile.cs
using System;
using System.Collections.Generic;
using Unity.BossRoom.Gameplay.Actions;
using Unity.BossRoom.Gameplay.GameplayObjects.Character;
using Unity.BossRoom.Utils;
using Unity.BossRoom.VisualEffects;
using Unity.Netcode;
using UnityEngine;

namespace Unity.BossRoom.Gameplay.GameplayObjects
{
/// <summary>
/// Logic that handles a physics-based projectile with a collider
/// </summary>
public class PhysicsProjectile : NetworkBehaviour
{
bool m_Started;

[SerializeField]
SphereCollider m_OurCollider;

/// <summary>
/// The character that created us. Can be 0 to signal that we were created generically by the server.
/// </summary>
ulong m_SpawnerId;

/// <summary>
/// The data for our projectile. Indicates speed, damage, etc.
/// </summary>
ProjectileInfo m_ProjectileInfo;

const int k_MaxCollisions = 4;
const float k_WallLingerSec = 2f; //time in seconds that arrows linger after hitting a target.
const float k_EnemyLingerSec = 0.2f; //time after hitting an enemy that we persist.
Collider[] m_CollisionCache = new Collider[k_MaxCollisions];

/// <summary>
/// Time when we should destroy this arrow, in Time.time seconds.
/// </summary>
float m_DestroyAtSec;

int m_CollisionMask; //mask containing everything we test for while moving
int m_BlockerMask; //physics mask for things that block the arrow's flight.
int m_NpcLayer;

/// <summary>
/// List of everyone we've hit and dealt damage to.
/// </summary>
/// <remarks>
/// Note that it's possible for entries in this list to become null if they're Destroyed post-impact.
/// But that's fine by us! We use <c>m_HitTargets.Count</c> to tell us how many total enemies we've hit,
/// so those nulls still count as hits.
/// </remarks>
List<GameObject> m_HitTargets = new List<GameObject>();

/// <summary>
/// Are we done moving?
/// </summary>
bool m_IsDead;

[SerializeField]
[Tooltip("Explosion prefab used when projectile hits enemy. This should have a fixed duration.")]
SpecialFXGraphic m_OnHitParticlePrefab;

[SerializeField]
TrailRenderer m_TrailRenderer;

[SerializeField]
Transform m_Visualization;

const float k_LerpTime = 0.1f;

PositionLerper m_PositionLerper;

/// <summary>
/// Set everything up based on provided projectile information.
/// (Note that this is called before OnNetworkSpawn(), so don't try to do any network stuff here.)
/// </summary>
public void Initialize(ulong creatorsNetworkObjectId, in ProjectileInfo projectileInfo)
{
m_SpawnerId = creatorsNetworkObjectId;
m_ProjectileInfo = projectileInfo;
}

public override void OnNetworkSpawn()
{
if (IsServer)
{
m_Started = true;

m_HitTargets = new List<GameObject>();
m_IsDead = false;

m_DestroyAtSec = Time.fixedTime + (m_ProjectileInfo.Range / m_ProjectileInfo.Speed_m_s);

m_CollisionMask = LayerMask.GetMask(new[] { "NPCs", "Default", "Environment" });
m_BlockerMask = LayerMask.GetMask(new[] { "Default", "Environment" });
m_NpcLayer = LayerMask.NameToLayer("NPCs");
}

if (IsClient)
{
m_TrailRenderer.Clear();

m_Visualization.parent = null;

m_PositionLerper = new PositionLerper(transform.position, k_LerpTime);
m_Visualization.transform.rotation = transform.rotation;
}

}

public override void OnNetworkDespawn()
{
if (IsServer)
{
m_Started = false;
}


if (IsClient)
{
m_TrailRenderer.Clear();
m_Visualization.parent = transform;
}
}

void FixedUpdate()
{
if (!m_Started || !IsServer)
{
return; //don't do anything before OnNetworkSpawn has run.
}

if (m_DestroyAtSec < Time.fixedTime)
{
// Time to return to the pool from whence it came.
var networkObject = gameObject.GetComponent<NetworkObject>();
networkObject.Despawn();
return;
}

var displacement = transform.forward * (m_ProjectileInfo.Speed_m_s * Time.fixedDeltaTime);
transform.position += displacement;

if (!m_IsDead)
{
DetectCollisions();
}
}

void Update()
{
if (IsClient)
{
// One thing to note: this graphics GameObject is detached from its parent on OnNetworkSpawn. On the host,
// the m_Parent Transform is translated via PhysicsProjectile's FixedUpdate method. On all other
// clients, m_Parent's NetworkTransform handles syncing and interpolating the m_Parent Transform. Thus, to
// eliminate any visual jitter on the host, this GameObject is positionally smoothed over time. On all other
// clients, no positional smoothing is required, since m_Parent's NetworkTransform will perform
// positional interpolation on its Update method, and so this position is simply matched 1:1 with m_Parent.

if (IsHost)
{
m_Visualization.position = m_PositionLerper.LerpPosition(m_Visualization.position,
transform.position);
}
else
{
m_Visualization.position = transform.position;
}
}

}

void DetectCollisions()
{
var position = transform.localToWorldMatrix.MultiplyPoint(m_OurCollider.center);
var numCollisions = Physics.OverlapSphereNonAlloc(position, m_OurCollider.radius, m_CollisionCache, m_CollisionMask);
for (int i = 0; i < numCollisions; i++)
{
int layerTest = 1 << m_CollisionCache[i].gameObject.layer;
if ((layerTest & m_BlockerMask) != 0)
{
//hit a wall; leave it for a couple of seconds.
m_ProjectileInfo.Speed_m_s = 0;
m_IsDead = true;
m_DestroyAtSec = Time.fixedTime + k_WallLingerSec;
return;
}

if (m_CollisionCache[i].gameObject.layer == m_NpcLayer && !m_HitTargets.Contains(m_CollisionCache[i].gameObject))
{
m_HitTargets.Add(m_CollisionCache[i].gameObject);

if (m_HitTargets.Count >= m_ProjectileInfo.MaxVictims)
{
// we've hit all the enemies we're allowed to! So we're done
m_DestroyAtSec = Time.fixedTime + k_EnemyLingerSec;
m_IsDead = true;
}

//all NPC layer entities should have one of these.
var targetNetObj = m_CollisionCache[i].GetComponentInParent<NetworkObject>();
if (targetNetObj)
{
RecvHitEnemyClientRPC(targetNetObj.NetworkObjectId);

//retrieve the person that created us, if he's still around.
NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(m_SpawnerId, out var spawnerNet);
var spawnerObj = spawnerNet != null ? spawnerNet.GetComponent<ServerCharacter>() : null;

if (m_CollisionCache[i].TryGetComponent(out IDamageable damageable))
{
damageable.ReceiveHP(spawnerObj, -m_ProjectileInfo.Damage);
}
}

if (m_IsDead)
{
return; // don't keep examining collisions since we can't damage anybody else
}
}
}
}

[ClientRpc]
private void RecvHitEnemyClientRPC(ulong enemyId)
{
//in the future we could do quite fancy things, like deparenting the Graphics Arrow and parenting it to the target.
//For the moment we play some particles (optionally), and cause the target to animate a hit-react.

NetworkObject targetNetObject;
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(enemyId, out targetNetObject))
{
if (m_OnHitParticlePrefab)
{
// show an impact graphic
Instantiate(m_OnHitParticlePrefab.gameObject, transform.position, transform.rotation);
}
}
}
}
}

Boss Room might have used an RPC instead (for the Mage's projectile attack). Since the Mage's projectile fires quickly, the player experience isn't affected by the few milliseconds where a newly connected client might miss the projectile. In fact, it helps Boss Room save on bandwidth when managing a replicated object. Instead, Boss Room sends a single RPC to trigger the FX client side.

Assets/Scripts/Gameplay/GameplayObjects/Projectiles/FXProjectile.cs
loading...

Breakable state

Boss Room might have used a "break" RPC to set a breakable object as broken and play the appropriate visual effects. Applying the "replicate information when a player joins the game mid-game" rule of thumb, the Boss Room development team used NetworkVariables instead. Boss Room uses the OnValueChanged callback on those values to play the visual effects (and an initial check when spawning the NetworkBehaviour).

Assets/Scripts/Gameplay/GameplayObjects/Breakable.cs
loading...

The visual changes:

Assets/Scripts/Gameplay/GameplayObjects/Breakable.cs
loading...
Lesson Learned

Error when connecting after imps have died: The following is a small gotcha the Boss Room development team encountered while developing Boss Room. Using NetworkVariables isn't magical. If you use OnValueChanged, you still need to make sure you initialize your values when spawning for the first time. OnValueChanged isn't called when connecting for the first time, only for the next value changes.

imp not appearing dead

Assets/Scripts/Gameplay/GameplayObjects/Character/ServerAnimationHandler.cs
public override void OnNetworkSpawn()
{
if (IsServer)
{
// Wait until next frame before registering on OnValueChanged to make sure NetworkAnimator has spawned before.
StartCoroutine(WaitToRegisterOnLifeStateChanged());
}
}

Hit points

Boss Room syncs all character and object hit points through NetworkVariables, making it easy to collect data.

If Boss Room synced this data through RPCs, Boss Room would need to keep a list of RPCs to send to connecting players to ensure they get the latest hit point values for each object. Keeping a list of RPCs for each object to send to those RPCs on connecting would be a maintainability nightmare. By using NetworkVariables, Boss Room lets the SDK do the work.