Game loop
Anatomy of Tick
Tick
is a fundamental time unit in Elympics.
It is time quantum where the state advances. Each advance is done by applying input on the old state to create a new state.
State(n) + Input(n) = State(n+1)
Such advances are happening in ElympicsUpdates
.
Every game state is described by a tick number and states of all constituent variables.
By design, Client and Server are creating new states in the same way, but there is a chance that Client will desynchronize from Server for many different reasons like Server-only modification of Client-predictable state, bad network conditions, floating number errors or even processor architecture difference. And Elympics provides a way to solve this misunderstanding by recreating correct state using reconciliation.
ElympicsUpdate
ElympicsUpdate
should be used as the main game loop instead of standard Unity FixedUpdate
or Update
.
It's called in order of increasing NetworkId
which combined with constant rate of TicksPerSecond
,
guarantees that game logic will advance in a deterministic way, which is required by client-server architecture to work properly.
Because of that, all the game logic should be applied in ElympicsUpdate
.
Only exceptions are UI or audio-visual effects which are not considered crucial logic, so there is no real need to apply them there.
To use ElympicsUpdate
, GameObject
needs to implement IUpdatable
interface and have ElympicsBehaviour
attached.
ElympicsUpdates
are called only for entities predictable for corresponding ElympicsBehaviour
.
All ElympicsUpdates
run regardless of the active / inactive state of the GameObject
. If such behaviour is not desirable, you have to perform required checks by yourself.
As logic should be applied in ElympicsUpdate
, Elympics also processes all physics there.
It means that collision-related callbacks (e.g. OnCollisionEnter
) have same predictability as all physics (ElympicsPlayer.All
) regardless of which ElympicsBehaviour
contains it.
Physics is calculated after all other ElympicsUpdates
, but note that order of its events is still natural - based on Unity physics and unbound to NetworkIds
.
All the game logic has to be inside ElympicsUpdates
!
Server
The most basic game loop is played by Server. This loop is basically the same as loop in any Single Player game.
All game logic is executed locally based on the recieved inputs from Clients.
The image above places parts of Elympics between Unity FixedUpdates
as they are executed there.
Numbers on the right side are the Script Execution Order values in Unity settings.
The general idea is that Elympics logic is played at the start of FixedUpdate
, and the state ready to send to clients is collected at the end of FixedUpdate
.
Client
Putting aside the concepts of prediction and reconciliation, the loop run on Client is pretty straightforward, complementary to the Server one. Input is collected and sent to the server and then the last received snapshot is applied.
However, such a simple solution would be unacceptable for fast-paced competitive multiplayer games, especially for players with slow Internet connection. Some kind of mechanism is required to keep the gameplay experience seamless and fluid. This brings us to prediction.
Prediction
Prediction is a cheat that resolves the issue of client state lagging behind and client input not arriving in time for corresponding tick played on the server.
Given enough data, Clients can simulate the game world execution. For example input for the current player is known right away, so it can be applied to the local state before receiving a server-authoritative update (confirmation). Another cases are physics simulation, or things like incrementing a deterministic counter. You can learn more about the general idea here.
Data for specified ElympicsBehaviour can be predictable for:
ElympicsPlayer.World
- only server can run logic here - useful for server authoritative logicElympicsPlayer.All
- all players and server can run logic - useful for fully predictable physics objectsElympicsPlayer.FromIndex(i)
- only specific player and server can apply logic - useful for implementing player controller
It's set by the developer in the inspector of ElympicsBehaviour and cannot be changed at runtime. As you could notice, server by design runs all the logic regardless of prediction.
Each Client estimates its current tick number based on connection quality – round-trip time (RTT) to be exact – and runs the simulation RTT/2
ticks in advance for its input to reach the server right before the corresponding tick is played there. The concept is shown in the image below.
Now, if a behaviour is marked as predictable, it gets updated even if no server-generated snapshot arrives in several ticks. After an authoritative snapshot is received, Client compares it to corresponding predicted one and marks it as confirmed if they match.
For such comparison to be possible, Client has to store at least RTT
last predictions (in a local prediction buffer):
At the same time, going more than RTT
ticks in the future makes little sense as the farther from the last server-provided snapshot, the less reliable prediction becomes. That's why there's a hard limit on the size of prediction buffer. Developers can restrict that size further using Prediction limit slider in Client settings.
However, errors can appear within those limits as well. The next section describes what happens if a predicted state cannot be confirmed.
Reconciliation
If the locally simulated state differs from corresponding snapshot sent by the Server, it is discarded and resimulated tick by tick (from the last valid one) using full data provided by the server. In other words, the local prediction buffer is invalidated and then refilled based on the received data.
See the page on reconciliation concept for more details.
Wrap-up
After exploring the details of prediction and reconciliation, we can now look at the fully representative diagram of the client loop.
The following example summarizes described concepts, presenting an interaction between two clients employing those features of Elympics.
ElympicsUpdate execution order
All the game logic is advanced inside ElympicsUpdate
s, but their execution order differs a bit from Unity Script Execution Order. The difference is that Elympics execution order has to be exactly the same on Server and Clients, so the order is well defined and deterministic, determined by ascending NetworkId
s.
The last NetworkId in the picture above might have grabbed your attention because of its unexpectedly large value. Actually, this is an example of an ID generated while instantiating a prefab (learn more here).
Example
[RequireComponent(typeof(Light))]
public class Flash : ElympicsMonoBehaviour, IUpdatable
{
public ElympicsBool ShouldFlash = new ElympicsBool();
private Light _light;
public void ElympicsUpdate()
{
if (ShouldFlash.Value)
{
Debug.Log("Flashing");
_light.enabled = true;
ShouldFlash.Value = false;
}
else
_light.enabled = false;
}
}
public class PlayerInputHandler : ElympicsMonoBehaviour, IInputHandler, IUpdatable
{
[SerializeField] private Flash flash;
public void ElympicsUpdate()
{
// Don't forget to set the "Predictable for" setting for ElympicsBehaviour
if (!ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
return;
inputReader.Read(out bool shouldFlash);
if (shouldFlash)
flash.ShouldFlash.Value = true;
}
{...}
}
public class FlashLogger : ElympicsMonoBehaviour, IUpdatable
{
[SerializeField] private Flash flash;
public void ElympicsUpdate()
{
if (flash.ShouldFlash.Value)
Debug.Log("Flash requested");
else
Debug.Log("Flash not requested");
}
}
NetworkIds
are set manually by unchecking Auto assign network ID setting and putting a unique integer of choice in Network ID field in ElympicsBehaviour
attached to each object.
Case 1. Each script attached to its own object
Putting FlashLogger
script before Flash
script (for example by setting ID of FlashLogger
object to 1 and Flash
object to 2) gives the correct results:
However, if you put FlashLogger
script after Flash
script (for example by setting ID of FlashLogger
object to 2 and Flash
object to 1), "Flash requested" message is omitted (as ShouldFlash
variable is reset before FlashLogger
accesses it):
The order of PlayerInputHandler
has no visible impact on the result. But if it's placed at the end, there's a one-tick delay between retrieving the input and actually using it.
Case 2. Flash and FlashLogger attached to the same object
The execution order of Elympics scripts within one object depend on the order of its components.
Scripts are queried using GetComponents
method for each behaviour. The method orders returned components in accordance with the order applied in the Inspector as stated here (in Reorder GameObject components section).
Placing FlashLogger
script before Flash
script gives the desired results:
Reordering the components so that FlashLogger
script is placed after Flash
script results in "Flash requested" message being omitted:
Physics
In-game physics simulation is run programatically (using PhysicsScene.Simulate
and PhysicsScene2D.Simulate
methods) in order to maximize scene determinism and allow replays when reconciling.
The script responsible for this behaviour (ElympicsUnityPhysicsSimulator
) is attached to Physics
game object in Elympics
prefab. In its ElympicsUpdate
, the script advances the physics scene state by a time step equal to Elympics.TickDuration
.
With physics run in an ElympicsUpdate
, collision-related callbacks (e.g. OnCollisionEnter
) are also run in ElympicsUpdate
callback. That means calls to ElympicsInstantiate
, ElympicsDestroy
and TryGetInput
are allowed.
As the physics simulation step is essential for every game runner, its predictability is set to All, regardless of the predictability of behaviours implementing physics callbacks. Otherwise, it would be skipped by some client instances.
Globally predictable physics greatly reduces overall need for reconciliation with the exception of interactions between player-controlled objects and the rest of the game world.
Mixing predictable and unpredictable code is an analogous problem described in detail here.
The network ID of Physics
game object is set to 2 000 000 100. That means it is always run after updating all other objects, including those instantiated at runtime (which have network IDs between 10 000 000 and 1 999 999 999 as stated here).
Timers
When ensuring an event (e.g. object spawning or animation) occurs at a specific game time point, timers come in handy. The only thing to remember is to make the timer variable synchronizable (using an ElympicsVar
). Its value has to be updated in ElympicsUpdate
, but there are no restriction for how it should be done – you can use:
Elympics.TickDuration
which defines the fixed duration of a single tick (and is currently equal toTime.fixedDeltaTime
),- simple incrementation/decrementation if you want to keep track of ticks (not seconds) passed,
- (not recommended) Unity-provided
Time.fixedDeltaTime
orTime.deltaTime
(which evaluates toTime.fixedDeltaTime
becauseElympicsUpdate
is run inFixedUpdate
).
Time.fixedDeltaTime
is currently constant throughout a game run but will be updated based on network condition.
It is thus not recommended to use Time.fixedDeltaTime
or Time.deltaTime
in timers anymore.
When counting ticks, keep in mind that any conversion between the counter value and real time should be done using Elympics.TickDuration
as number of ticks per second is user-configurable.
Floating-point timer example
private readonly ElympicsFloat _loadingTimeLeft;
public void ResetLoadingTimer()
{
_loadingTimeLeft.Value = 2.0f;
}
public void ElympicsUpdate()
{
if (_loadingTimeLeft > 0)
DecreaseLoadingTimer();
}
private void DecreaseLoadingTimer()
{
_loadingTimeLeft.Value -= Elympics.TickDuration;
if (_loadingTimeLeft <= 0)
Shoot();
}
The same example, but using tick counter
private readonly ElympicsInt _loadingTicksLeft;
public void ResetLoadingTimer()
{
_loadingTicksLeft.Value = Mathf.RoundToInt(2.0f / Elympics.TickDuration);
}
public void ElympicsUpdate()
{
if (_loadingTicksLeft > 0)
DecreaseLoadingTimer();
}
private void DecreaseLoadingTimer()
{
--_loadingTicksLeft.Value;
if (_loadingTicksLeft <= 0)
Shoot();
}
Client settings
The following settings are available in ElympicsConfig:
- Ticks per second – sets the frequency of
FixedUpdate
s, specifying how often game state is advanced (collecting inputs and snapshots) - Send snapshot every – specifies how often snapshots are sent (maximal delay is 1 second)
- Input lag – defines the safety margin for server-side input acknowledge
- Max allowed lag – limits the size of input and snapshot buffers
- Prediction – enables/disables the prediction algorithm
- Prediction limit – sets the maximal number of predicted snapshots
Total prediction limit displays user-specified Prediction limit summed with Input lag and the delay between sending two consecutive snapshots.
Tweaking the settings is all about trade-offs. For example decreasing Input lag means better responsiveness, but it makes losing input in action more possible (in the case of lag spike, server may not receive the packet). Similarly, sending snapshots less frequently saves bandwidth, but clients have to depend on prediction more.