Game loop

How the ticks work?

Anatomy of Tick

Tick is fundamental 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 bad network conditions or processor architecture difference and floating number errors. And Elympics provides a way to solve this misunderstanding and recreate correct state using reconciliation.

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 and the feedback for inputs is instantaneous.

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 inacceptable 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.

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

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.

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.

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

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. 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.

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 one of ElympicsVars). 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).

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.