3 - Prediction
Overview
Fusion 103 will explain prediction and how it is used to provide snappy feedback on the client in a server authoritative network game.
At the end of this section, the project will allow the player to spawn a predicted kinematic ball.
Consult the Manual for an in-depth description of this topic
Kinematic Object
To be able to spawn any object, it must first have a prefab.
- Create a new empty GameObject in the Unity Editor
- Rename it to
Ball
- Add a new
NetworkTransform
component to it. - Fusion will show a warning about a missing
NetworkObject
component, so go ahead and pressAdd Network Object
. - Change
Interpolation Data Source
toPredicted
and set it toWorld Space
. - Add a Sphere child to the
Ball
- Scale it down to 0.2 in all directions
- Drag the child to the
InterpolationTarget
of theNetworkTransform
component on the parent object. This allow theNetworkTransform
to separate the smooth interpolated visual (child object) from the main networked object itself (which will snap to network state). - Remove the collider from the child sphere
- Create a new sphere collider on the parent object instead and give it radius 0.1 so that it completely covers the visual representation of the child object.
- Add a new script to the game object and call it
Ball.cs
- Finally drag the entire
Ball
object into the project folder to create a prefab - Save the scene to bake the network object and delete the prefab instance from the scene.
Predicted Movement
The goal is to have instances of the Ball
behave identically on all peers simultaneously.
"Simultaneous" in this context means "on the same simulation tick", not the same actual world time. The way this is achieved is as follows:
- The server runs a simulation at specific, evenly spaced, ticks and calls
FixedUpdateNetwork()
on each tick. The server only and always moves forward from one tick to the next - this is exactly likeFixedUpdate()
for regular Unity behaviours in the local physics simulation. After each simulation tick the server calculates, compresses and broadcasts the changes in network state relative to the previous tick. - Clients receive these snapshots at regular intervals, but obviously always lagging behind the server. When a snapshot is received, the client sets its internal state back to the tick of that snapshot, but then immediately re-simulates all ticks between the received snapshot and the clients current tick, by running its own simulation.
- The clients current tick is always ahead of the server by a wide enough margin, that the input it collects from the user can be sent to the server before the server reaches the given tick, and needs the input to run its simulation.
This has a number of implications:
- Clients run
FixedUpdateNetwork()
many times per frame and simulates the same tick several times as it receives updated snapshots. This works for networked state because Fusion resets it to the proper tick before callingFixedUpdateNetwork()
, but this is not true for non-networked state, so be real careful how you use local state inFixedUpdateNetwork()
. - Each peer can simulate a predicted future state of any object based on known previous position, velocity, acceleration and other deterministic properties. The one thing it cannot predict is the input from other players, so predictions will fail.
- While local inputs are applied instantly on the client for immediate feedback, they are not authoritative. It is still the snapshot generated by the server that eventually defines that tick - local application of input is just a prediction.
With that in mind, open the Ball script, change the base class to NetworkBehaviour
to include it in Fusions simulation loop, and replace the pre-generated boilerplate code with an override of Fusions FixedUpdateNetwork()
.
In this simple example, the Ball
will move with a constant velocity in its forward direction for 5 seconds before de-spawning itself. Go ahead and add a simple linear motion to the objects transform, like this:
C#
using Fusion;
public class Ball : NetworkBehaviour
{
public override void FixedUpdateNetwork()
{
transform.position += 5 * transform.forward * Runner.DeltaTime;
}
}
This is almost exactly the same code used to move a regular non-networked Unity object, except that the time step is not Time.deltaTime
but Runner.DeltaTime
corresponding to the time between ticks. The secret to why this works across the network on a seemingly local property like the Unity transform
is of course the NetworkTransform
component added earlier. NetworkTransform
is a convenient way to ensure that transform properties are part of the network state.
The code still needs to de-spawn the object after a set time has expired so it does not fly off into infinity, eventually looping around and hitting the player in the neck. Fusion provides a convenient helper type for timers, aptly named TickTimer
. Instead of storing the current remaining time, it stores the end-time in ticks. This means the timer does not need to be sync'ed on every tick but just once, when it is created.
To add a TickTimer to the games networked state, add a property to the Ball named life
of type TickTimer
, provide empty stubs for the getter and setter and mark it with the [Networked]
attribute.
C#
[Networked] private TickTimer life { get; set; }
The timer should be set before the object is spawned, and because Spawned()
is called only after a local instance has been created, it should not be used to initialize network state.
Instead, create an Init()
method that can be called from the Player and use it to set the life property to be 5 seconds into the future. This is best done with the static helper method CreateFromSeconds()
on the TickTimer
itself.
C#
public void Init()
{
life = TickTimer.CreateFromSeconds(Runner, 5.0f);
}
Finally, FixedUpdateNetwork()
must be updated to check if the timer has expired and if so, de-spawn the ball:
C#
if(life.Expired(Runner))
Runner.Despawn(Object);
All in all, the Ball
class should now look like this:
C#
using Fusion;
public class Ball : NetworkBehaviour
{
[Networked] private TickTimer life { get; set; }
public void Init()
{
life = TickTimer.CreateFromSeconds(Runner, 5.0f);
}
public override void FixedUpdateNetwork()
{
if(life.Expired(Runner))
Runner.Despawn(Object);
else
transform.position += 5 * transform.forward * Runner.DeltaTime;
}
}
Spawning Prefabs
Spawning any prefab works the same as spawning the player avatar, but where the player spawn was triggered by a network event (the player joining the game session), the ball will be spawned based on user input.
For this to work, the input data structure needs to be augmented with additional data. This follows the same pattern as movement, and requires three steps:
- Add data to the Input Structure
- Collect data from Unity's Input
- Apply the Input in the players
FixedUpdateNetwork()
implementation
Open NetworkInputData
and add a new byte field called buttons
and define a const for the first mouse button:
C#
using Fusion;
using UnityEngine;
public struct NetworkInputData : INetworkInput
{
public const byte MOUSEBUTTON1 = 0x01;
public byte buttons;
public Vector3 direction;
}
Open BasicSpawner
, go to the OnInput()
method and add checks for the primary mouse button and set the first bit of the buttons
field if it is down. To ensure that quick taps are not missed, the mouse button is sampled in Update() and reset once it has been recorded in the input structure:
C#
private bool _mouseButton0;
private void Update()
{
_mouseButton0 = _mouseButton0 | Input.GetMouseButton(0);
}
public void OnInput(NetworkRunner runner, NetworkInput input)
{
var data = new NetworkInputData();
if (Input.GetKey(KeyCode.W))
data.direction += Vector3.forward;
if (Input.GetKey(KeyCode.S))
data.direction += Vector3.back;
if (Input.GetKey(KeyCode.A))
data.direction += Vector3.left;
if (Input.GetKey(KeyCode.D))
data.direction += Vector3.right;
if (_mouseButton0)
data.buttons |= NetworkInputData.MOUSEBUTTON1;
_mouseButton0 = false;
input.Set(data);
}
Open the Player
class and, inside the check for GetInput()
, get the button bits and spawn a prefab if the first bit is set. The prefab can be provided with a regular Unity [SerializeField]
member that can be assigned from the Unity inspector. To be able to spawn in different directions also add a member variable to store the last move direction and use this as the forward direction of the ball.
C#
[SerializeField] private Ball _prefabBall;
private Vector3 _forward;
...
if (GetInput(out NetworkInputData data))
{
...
if (data.direction.sqrMagnitude > 0)
_forward = data.direction;
if ((data.buttons & NetworkInputData.MOUSEBUTTON1) != 0)
{
Runner.Spawn(_prefabBall,
transform.position+_forward, Quaternion.LookRotation(_forward),
Object.InputAuthority);
}
...
}
...
To limit the spawn frequency, wrap the call to spawn in a networked timer that must expire between each spawn. Only reset the timer when a button press is detected:
C#
[Networked] private TickTimer delay { get; set; }
...
if (delay.ExpiredOrNotRunning(Runner))
{
if ((data.buttons & NetworkInputData.MOUSEBUTTON1) != 0)
{
delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
Runner.Spawn(_prefabBall,
transform.position+_forward, Quaternion.LookRotation(_forward),
Object.InputAuthority);
...
The actual call to Spawn
needs to be modified a bit because the ball requires additional initialization before it is synced. Specifically, the Init()
method added earlier must be called to ensure that the tick timer is set correctly.
For this purpose, fusion allow a callback to be provided to Spawn()
that will be invoked after instantiating the prefab, but before it is synchronized.
In summary, the class should look like this:
C#
using Fusion;
using UnityEngine;
public class Player : NetworkBehaviour
{
[SerializeField] private Ball _prefabBall;
[Networked] private TickTimer delay { get; set; }
private NetworkCharacterControllerPrototype _cc;
private Vector3 _forward;
private void Awake()
{
_cc = GetComponent<NetworkCharacterControllerPrototype>();
_forward = transform.forward;
}
public override void FixedUpdateNetwork()
{
if (GetInput(out NetworkInputData data))
{
data.direction.Normalize();
_cc.Move(5*data.direction*Runner.DeltaTime);
if (data.direction.sqrMagnitude > 0)
_forward = data.direction;
if (delay.ExpiredOrNotRunning(Runner))
{
if ((data.buttons & NetworkInputData.MOUSEBUTTON1) != 0)
{
delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
Runner.Spawn(_prefabBall,
transform.position+_forward, Quaternion.LookRotation(_forward),
Object.InputAuthority, (runner, o) =>
{
// Initialize the Ball before synchronizing it
o.GetComponent<Ball>().Init();
});
}
}
}
}
}
Final step before testing is to assign the prefab to the _prefabBall
field on the Player
prefab. Select PlayerPrefab
in the project, then drag the Ball
prefab into the Prefab Ball
field.