Skip to main content

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.

tip

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.

Remember!

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.

Server game loop

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.

Client game loop (basic)

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 logic
  • ElympicsPlayer.All - all players and server can run logic - useful for fully predictable physics objects
  • ElympicsPlayer.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.

Prediction and inputs

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):

Prediction buffer and RTT

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.

Reconciliation

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.

Client game loop (with prediction and reconciliation)

The following example summarizes described concepts, presenting an interaction between two clients employing those features of Elympics.

Comprehensive example with reconciliation

ElympicsUpdate execution order

All the game logic is advanced inside ElympicsUpdates, 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 NetworkIds.

ElympicsUpdate execution order

Large NetworkId

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.

Network ID set manually

Case 1. Each script attached to its own object

Three objects: PlayerInputHandler, Flash, FlashLogger

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:

"Flash requested" message visible

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):

"Flash requested" message omitted

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

Two objects: PlayerInputHandler, Flash

The execution order of Elympics scripts within one object depend on the order of its components.

Components ordering

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:

FlashLogger script component placed on top of Flash

"Flash requested" message visible

Reordering the components so that FlashLogger script is placed after Flash script results in "Flash requested" message being omitted:

Flash script component placed on top of FlashLogger

"Flash requested" message 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.

See also

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 to Time.fixedDeltaTime),
  • simple incrementation/decrementation if you want to keep track of ticks (not seconds) passed,
  • (not recommended) Unity-provided Time.fixedDeltaTime or Time.deltaTime (which evaluates to Time.fixedDeltaTime because ElympicsUpdate is run in FixedUpdate).
Unity-provided delta time

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.

Tick counters

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

Server game loop

The following settings are available in ElympicsConfig:

  • Ticks per second – sets the frequency of FixedUpdates, 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.

Changing config

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.