Tech Design
Introduction
This section of Pirate Adventure's documentation will go over some aspects of the game's technical design in more detail ranging from the Main Menu to how NavMesh Agents are used and more. More in-depth code documentation can be found throughout the sample's Unity project.
Shared Mode Concept: State Authority & RPCs
Unlike Server or ClientHost topologies where the server or host is has State Authority over all objects in the simulation, Shared Mode allows other players to have State Authority over various objects. Because of this, when trying to affect an object that the player does not have State Authority over, they need to either:
- Send an RPC to the StateAuthority
- Request State Authority by calling
NetworkObject.RequestAuthority
.
If the latter is used, it's important to make sure the NetworkObject
has Allow State Authority Override
enabled; otherwise, NetworkObject.ReleaseAuthority
must be called first by the player that has State Authority.
The following is code from the OnPlayerLeft
method of PirateAdventureSimulationBehaviour
, which is present on the NetworkRunner
prefab used in this sample:
C#
public void OnPlayerLeft(NetworkRunner runner, PlayerRef player)
{
if (!runner.IsSharedModeMasterClient)
return;
var objects = runner.GetAllNetworkObjects();
foreach (var obj in objects)
{
// If the state authority cannot be overridden, it is skipped.
if ((obj.Flags & NetworkObjectFlags.AllowStateAuthorityOverride) != NetworkObjectFlags.AllowStateAuthorityOverride ||
(obj.Flags & NetworkObjectFlags.MasterClientObject) == NetworkObjectFlags.MasterClientObject ||
(obj.Flags & NetworkObjectFlags.DestroyWhenStateAuthorityLeaves) == NetworkObjectFlags.DestroyWhenStateAuthorityLeaves)
continue;
// If the state authority of the object is equal to the player who left, we transfer ownership to the shared mode master client.
if (obj.StateAuthority == player)
obj.RequestStateAuthority();
}
}
What this does is iterate through every NetworkObject
associated with the NetworkRunner
, and if the player who left was the one who had State Authority, then the SharedModeMasterClient
requests State Authority over it, preventing that NetworkObject
from entering a situation in which it has no State Authority. Usually, these are objects that will be transferring State Authority periodically, so even if the player who left rejoins, the SharedMasterModeClient having State Authority over it and not the rejoining player, should not create any issues.
Local Vs. Remote Response
If a player has a poor connection, possibly because they did not choose the best region to connect to, the time it can take to send an RPC to the State Authority of an object, have the State Authority confirm the event, and then have the original player detect changes to a NetworkPropery
, some actions can appear to lag. To minimize this, many objects in this sample utilize the concept of having an immediate local reaction. For example, when the pink gems are picked up by a player, it look like they were picked up immediately due to the sound and particle effect being played; however, the confirmation of this collection hasn't happened immediately. This is demonstrated in the following code from PickUp.cs
:
C#
public void OnPickUpLocal(Player player)
{
collectedFX.PlayFX();
shrinkTransition?.BeginTransition(true);
RPC_Collect(player);
}
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
public void RPC_Collect(Player collector)
{
if (Collected)
return;
collector.RPC_Reward(_rewardValue, _healthRegenValue);
Collected = true;
shrinkTransition?.BeginTransition(false);
}
void OnCollectedChanged()
{
if (Collected)
{
collectedFX.PlayFX();
}
}
Here, collectedFX.PlayFX
is called as soon as the player has picked up the object; this will trigger visual and sound effects. It then sends an RPC to the State Authority, who has to confirm it. It checks if Collected
, a NetworkProperty
, is true. If so, this item has already been collected. If so, the RPC from the player is ignored; this would usually happen if two players collide with the same item around the same time. If the object has not been collected it, an RPC is sent to the player who sent the original RPC and Collected
is set to true. OnCollectedChanged
is triggered when Collected
has been changed, but collectedFX.PlayFX
attempts to prevent it from being replayed.
Multi-Peer Mode
Multi-peer Mode is used in this sample to test multiple players quickly within the Unity Editor of the game without having to make new builds and run multiple instances. You can read more about setting up and specifics of Multi-peer mode here. This mode, though very useful for testing, does have a few unique concepts to keep in mind.
For example, all player avatars are spawned within the simulations created. If you are running two clients, there will actually be four avatars, 2 per client. Because of this, when using Unity-based methods such as OnTriggerEnter
, it's important to make sure that the Colliders
belong to NetworkObjects
that are part of the same simulation. This can be done by comparing the NetworkRunner
of the both objects. If this isn't done, false positives can occur, causing confusion when testing in Multi-Peer mode. The following is a sample of an OnTriggerEnter
method that uses this found in Switch.cs
:
C#
private void OnTriggerEnter(Collider other)
{
// We ignore if the runner is not visible
if (!Runner.GetVisible())
return;
var player = other.GetComponentInParent<Player>();
// This check is to make sure the switch doesn't react to the wrong player when in multi-peer mode.
if (player == null || player.Object.Runner != Object.Runner)
return;
if (localInRangePlayers.Contains(player.Object))
return;
else
localInRangePlayers.Add(player.Object);
Triggered = localInRangePlayers.Count > 0;
}
Additionally, NetworkObjects
that are rendered visually should have an OnEnableSingleRunner
Component on them. In this sample, the Preferred Runner
value is set to Client
meaning the objects will be enabled for the first visible client found. Then, the Components
that will be toggled depending on the visibility of the NetworkRunner
are listed. These usually will consists of Renderers
but can also include other Components
such as AudioSources
and Lights
. Note, it's very important to make sure there are no null objects in this list; if so, the game will not run properly when starting. Additionally, the NetworkRunner
prefab must have a RunnerEnableVisibilty
component on it for this functionality to work at all.
Multi-Peer Singletons
When the local player of a game spawns, some games will store this reference as a static variable. This usually works fine; however, in Multi-Peer mode, each NetworkRunner
will set this to the last spawned player, so the value is not accurate. In this sample, this is addressed by using a static Dictionary
whose keys are represented by the NetworkRunner
the object belongs in. The following is from Spawned
method of Player.cs
:
C#
// Only the local player will have state authority over a player, so the local player dictionary will be populated with said player.
// A dictionary is used to account for multi-peer mode.
if (HasStateAuthority)
{
if (LocalPlayerDictionary == null)
LocalPlayerDictionary = new Dictionary<NetworkRunner, Player>()
{
{Runner,this },
};
else
LocalPlayerDictionary.Add(Runner, this);
}
Main Menu
The main menu of Pirate Adventure is meant to be very straightforward, allowing players to join the first, open room available after clicking Start
.
Region Selection
The Region Select
dropdown allows players to test multiple regions. There current regions are available by default:
- Best Region
- Europe,
eu
- US, East,
us
- US, West,
usw
- South America,
sa
Different regions can be set by adding them to the PirateAdventureMainMenu
's Region Info
array. Region Display
determines what will be displayed in the dropdown; Region Code
represents the id used to determine which region to go to. A blank region code will direct Fusion to try and find the best region. You can find out which regions are available, their codes, and an overview about regions here.
Starting the Session
After clicking the Start
button on the main menu, the application calls StartGameUtility.AddPlayer
. This static method begins the session creation process. The following is a quick rundown of said process:
- A new NetworkRunner instance is instantiated.
- If a new session,
PhotonAppSettings.Global.AppSettings.FixedRegion
is set to the selected region.PhotonAppSettings.Global.AppSettings
accesses theScriptableObject
atAssets/Photon/Fusion/Resources/PhotonAppSettings.asset
. - Creates a new
Fusion.StartGameArgs
struct that sets theGameMode
toGameMode.Shared
since that is the topology being used, and setsPlayerCount
to the predefined value set byMAX_PLAYER_COUNT
inStartGameUtility.cs
. - If, after waiting for the start game results, the session is joined successfully and the player is the SharedMasterClient, the gameplay scene will be loaded using
NetworkRunner.LoadScene
.
TaskManager.Delay
Due to limitations with threading and WebGL builds, TaskManager.Delay
is used to wait for NetworkSceneAsyncOp
to complete instead of just await
. The following is from StartGameUtility
's AddPlayer
method:
C#
if (newRunner.IsSharedModeMasterClient)
{
NetworkSceneAsyncOp sceneLoad = newRunner.LoadScene(sceneToLoad, UnityEngine.SceneManagement.LoadSceneMode.Single);
// We wait until the scene is done loading.
while (!sceneLoad.IsDone)
{
// TaskManager.Delay is to prevent issues with threading and WebGL.
await TaskManager.Delay(1000);
}
}
else if (NetworkRunner.Instances.Count > 1)
{
newRunner.ProvideInput = false;
newRunner.SetVisible(false);
}
TaskManager.Delay
is a Fusion-specific solution to the issues with WebGL that NetworkRunner.StartGame
uses internally, which is why
C#
await NetworkRunner.StartGame
will work in WebGL, but
C#
NetworkSceneAsyncOp sceneLoad = newRunner.LoadScene(sceneToLoad, UnityEngine.SceneManagement.LoadSceneMode.Single);
await sceneLoad;
will not. Again, TaskManager.Delay
is used to address this.
The NetworkRunner Prefab
In addition to the NetworkRunner
Component
, the prefab instantiated by StartGameUtility
contains three additional Components
:
Runner Enable Visibility
: As mentioned previously, thisComponent
is used for Multi-peer mode to determine whichComponents
to enable and disable depending on whichNetworkRunner
visible first.Pirate Adventure Simulation Behaviour
: aSimulationBehaviour
that uses theINetworkRunnerCallbacks
interface to trigger callbacks for various methods such as instantiating a new player when one joins.Runner Simulate Physics 3D
: derived from the physics add-on, this must be present so the game's physics work properly in the simulation.
Pirate Adventure Simulation Behaviour
utilizes the following of the INetworkRunnerCallbacks
:
- OnPlayerJoined: spawns a new player, making sure the local player is instantiating the player prefab. By doing so, this player will have State Authority over the newly spawned player.
- OnPlayerLeft: makes sure that if the player who left has state authority over any objects, said state authority is transferred to the SharedModeMasterClient to prevent NetworkObjects from entering a state where no player has state authority over them.
- OnShutdown: clears game-related singletons and loads the player back to the main menu.
Note, though OnInput
can be used in Shared topology, it is not necessary since players do not need to send inputs to a server like other topologies. Instead, the inputs can be polled within the FixedUpdateNetwork
method since only players with StateAuthority execute this method in Shared mode.
The Player
The Player
NetworkBehaviour
is the main object controlled by each player in a session. In Shared Mode, each player has State Authority over their own Player
and is charge of polling inputs and local collision. The player utilizes the FSM add-on to determine which state and animation is currently displayed as well.
Networked Properties
Some properties that Player
uses the NetworkProperty
attribute for are elements shared between players such as:
HueValue
: the color of the player, determined byNetworkObject.StateAuthority.AsIndex
.Level
: The level of the player, which determines maximum health as well as the attack string that can be used by the player and the player is at, and when changed, plays the "Level Up" animationDamagedTimer
: A TickTimer that determines when and how long the player has been damaged, and when active, controls a flicker effect on the damaged player.
Properties such as Money
and Health
are networked but are not directly visualized by other players; however, it's still a common practice to network elements such as these to manually handle reconnections. This sample does not implement this feature, but the idea would be, because the status of the player who left can be preserved, these values can be reapplied if and when they reconnect.
Detecting Collision
Pick-ups and in this sample such as the pink gems and blue fish are collected by the player colliding with them using NetworkRunner.GetPhysicsScene().OverlapCapsule
. This cast a capsule with the specified paramters to see which objects are in range using the physics scene simulated by the NetworkRunner
. The value returned is the number of objects collided with. If any of these objects are pick-ups, then the game attempts to pick them up.
Local Vs. Remote Detection
To create a sense of responsiveness, the gem reacts as if its been picked-up immediately by creating a particle and sound effect locally. Because the player may not have state authority over the pick-up, an RPC is sent to said State Authority, in this case, the SharedMasterModeClient
, by utilizing the RPC, RPC_Collect
after colliding. The State Authority, once the pick-up has been confirmed, sends an RPC back to the player that picked it up using Player.RPC_Reward
.
It's important to note the following regarding this approach:
- Players with lower latency, especially the SharedMasterModeClient since they have direct StateAuthority over pick-ups, will be more likely to pick-up the object if two players tries to around the same time.
- A delay may be more observable when pick-ups are interacted with by players with high latency. This is because the
OverlapCapsule
call is only being called by the local player and not all players. The effects played by the pick-up will only be triggered onceCollected
is set to true by the StateAuthority and that change is detected by the local player.
Breakable objects such as barrels and the bone walls use a similar flow of creating an immediate local reaction and then sending an RPC to the object's State Authority.
Switches
In this sample, there are switches represented by red X's that when stepped on, will turn green and cause a bridge to translate into position. These switches require cooperation since when no one is standing on the X, the bridge translates back to its previous state.
Similar to pick-ups, switches and the objects aim to feel more responsive to the local player. To do this, they utilize OnTriggerEnter
and OnTriggerExit
methods instead of using Network Properties and RPCs. When a player, even if not local, enters the switch's trigger, it will react. The assumption is that, even with latency, the NetworkTransform
on the remote player will eventually cause them to enter the switch's trigger and activate it.
Enemies
Sword-wielding sharks are the main enemies in this sample. They have their own FSM system, NavMesh navigation, and utilize State Authority transfer.
The main enemy AI works as follows:
- The sharks search for any players are nearby.
- If the enemies detects a player nearby, they begin chasing them.
- The player being chased request to have the State Authority over the shark so when fighting and being chased by them can feel more responsive.
- If closed enough to the target player, the shark will attack.
- If the chased player is defeated or no longer in range, the sharks attempt to move back to their starting location.
The destination that the sharks aim for is Networked so that when State Authority changes, the sharks will continue to pursue the same target position.
Boss
At the end of the islands players explore, there is a set of tentacles. These tentacles belong to the game's boss. The SharedMasterModeClient has State Authority over them, choosing a random animation and setting AttackIndex
. This is a NetworkProperty
that utilizes an OnChangedRender
attribute. When a change is detected, the animation is played locally. This is so the tentacle attacking can feel more responsive to the local players. In a high latency session, it may appear that other players are being hit when the tentacles aren't attacking, but a smooth experience for the local player is the main focus.
Once four tentacles are defeated, a boss character is summoned. They don't have any attacks and just react to being hit by players and once defeated, the game is over. The State Authority of the boss will send an RPC to all behaviours with the BossDeathListener
component on them. This is intended to deactivate enemies and stop players from moving as the game shuts down.