1 - Inputs

Correct designing and implementing players inputs in Elympics with examples.

Basics

IInputWriter & IInputReader

Inputs in Elympics are binary data written in a defined order. This requires developers to ensure compatibility between serialization and deserialization.

Serialization

    inputWriter.Write(vMove); // float
    inputWriter.Write(hMove); // float
    inputWriter.Write(fire); // bool
    if (fire)
      inputWrite.Write(firePower); // float
  
Deserialization

    inputReader.Read(out float vMove);
    inputReader.Read(out float hMove);
    inputReader.Read(out bool fire);
    float firePower = 0.0f;
    if (fire)
      inputReader.Read(out firePower);
    

Collecting inputs

To collect inputs Elympics searches for IInputHandler implementations on GameObjects with ElympicsBehaviour component. Then the provided input collecting methods are called during FixedUpdate (see Game Loop)

public class PlayerInputController : ElympicsMonoBehaviour, IInputHandler
{
	public void OnInputForClient(IInputWriter inputWriter)
	{
		var vMove = Input.GetAxis("Vertical");
		var hMove = Input.GetAxis("Horizontal");
		var fire = Input.GetKey(KeyCode.Space);
		SerializeInput(inputWriter, vMove, hMove, fire);
	}

	public void OnInputForBot(IInputWriter inputWriter)
	{
		var vMove = Random.Range(-1.0f, 1.0f);
		var hMove = Random.Range(-1.0f, 1.0f);
		var fire = true;
		SerializeInput(inputWriter, vMove, hMove, fire);
	}

	private static void SerializeInput(IInputWriter inputWriter, float vMove, float hMove, bool fire)
	{
		inputWriter.Write(vMove);
		inputWriter.Write(hMove);
		inputWriter.Write(fire);
	}

	{...}
} 

From Client - OnInputForClient

Collecting inputs for clients is as simple as getting the current state of mouse, keyboard, or any other controller.

From Bot - OnInputForBot

In the example provided above, bot is programmed to perform random moves. However there are endless possibilities to implement bot behaviour. In most situations bots are run along with the server, so they are aware of full state of the game. There is also option to run bots in a similar way to player clients, as independent processes.

Empty OnInput implementation

In case you don’t want your bot (or client) to produce any input yet, you can leave OnInput* empty.

public void OnInputForClient(IInputWriter inputWriter) {...}

public void OnInputForBot(IInputWriter inputWriter)
{
	// TODO
}

In this case input won’t be serialized and sent, and ElympicsBehaviour.TryGetInput will return false like when the input is absent.

Reading inputs

ElympicsMonoBehaviour base class provides a way to access previously generated inputs - they are available in ElympicsBehaviour.TryGetInput. Of course if your class doesn’t inherit from ElympicsMonoBehaviour, you can get the necessary component by yourself using GetComponent method.

public class PlayerInputController : ElympicsMonoBehaviour, IInputHandler, IUpdatable
{
	public void OnInputForClient(IInputWriter inputWriter) {...}
	public void OnInputForBot(IInputWriter inputWriter) {...}

	private float vMove = 0.0f;
	private float hMove = 0.0f;
	private bool fire = false;

	public void ElympicsUpdate()
	{
		// Parameter 0 means that we try to read input from player 0
		if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputReader))
		{
			inputReader.Read(out vMove);
			inputReader.Read(out hMove);
			inputReader.Read(out fire);
		}

		Move(vMove, hMove);
		Fire(fire);
	}

	private void Move(float vMove, float hMove)
	{
		// Movement implementation
	}

	private void Fire(bool fire)
	{
		// Firing implementation
	}
}

Handling multiple players

Elympics doesn’t have object ownership feature built-in (yet!). However it’s really easy to implement! It is necessary to prevent players from e.g. controlling other players characters.

Let’s get back to our PlayerInputController, where OnInputForClient is implemented. Considering multiple players, we allowed all of them to create inputs for every PlayerInputController. Considering we have 2 or more of those controllers instantiated (we need one for each connected player), OnInputForClient will be called for each of those instances. To prevent this, we have to check which instance is currently allowed to call this method (are we handling the instance responsible for current player?).

To start, we need to set predictability in every character’s ElympicsBehaviour to the chosen player.

PredictableFor Player

Then we can use it (through ElympicsMonoBehaviour.PredictableFor property) in the following way:

public void OnInputForClient(IInputWriter inputWriter)
{
	// Check for ownership
	if (Elympics.Player != PredictableFor)
		return;

	var vMove = Input.GetAxis("Vertical");
	var hMove = Input.GetAxis("Horizontal");
	var fire = Input.GetKey(KeyCode.Space);
	SerializeInput(inputWriter, vMove, hMove, fire);
}

Secondly, we also need to alter our ElympicsUpdate implementation. In the previous example the input that we used was collected from player 0. Now we have to choose which player (or players) can influence the current PlayerInputController.

private float vMove = 0.0f;
private float hMove = 0.0f;
private bool fire = false;

public void ElympicsUpdate()
{
	// PredictableFor is used here to filter only the owner's input
	if (ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
	{
		inputReader.Read(out vMove);
		inputReader.Read(out hMove);
		inputReader.Read(out fire);
	}

	Move(vMove, hMove);
	Fire(fire);
}

Custom field

You can also use custom serialized int instead of PredictableFor, e.g.

[SerializeField] private int player;
{...}
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(player), out var inputReader))

but in most cases PredictableFor will be the same as player.

Important coding principles

These are the most crucial rules for inputs programming and should always be followed!

Collect-apply split

It is crucial to separate the code of collecting inputs and applying them (which is not the way you usually work on a single-player game). Collecting inputs and serializing them to simple data should be kept inside OnInput* methods. Applying inputs from simple data representation to the game world should be separated and called inside ElympicsUpdate (this is your game logic). This is required by the architecture of server authority paradigm i.e. input collected by clients have to be synchronized and cause exactly the same effect both on the client and on the server instance. If that’s not the case, the state on the client will be broken.

Example for moving a character:

  • OnInputForClient(): Save joystick axes positions as float values to the input writer
  • ElympicsUpdate(): Read joystick axes positions from the input reader and move the character appropriately.

Input absence vs empty input

Remember to properly handle the absence of input. Below you can see a simple example explaining how the absence of input can be exploited in your game. Let’s modify the previous example a bit:

public void ElympicsUpdate()
{
	if (!ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputReader))
		return;

	inputReader.Read(out var vMove);
	inputReader.Read(out var hMove);
	inputReader.Read(out var fire);

	Move(vMove, hMove);
	Fire(fire);
}

private void Move(float vMove, float hMove)
{
	rigidbody.velocity = new Vector2(hMove, vMove);
}

Can you see the danger of not handling the absence of input?
If there is no decelerating force or friction, the rigidbody will continue to move at previously set velocity instead of stopping (like it would with zero-input).

The absence of input could be handled in many other ways. For example you could extrapolate missing input and use the one cached from a previous tick as we did at the first example in this artice (recommended way). Or you could interpret it as zero-input. It really depends on your specific use case.

Examples

FPS-like camera controller

To create FPS-like camera controller you have to read mouse movements properly and process its accumulated increments. We won’t do it inside OnInputForClient because this method is called during FixedUpdate, and with framerate greater than tickrate some increments would be lost.

To collect these, we have to use Unity Update instead and cache mouse movements in a variable. Then, during OnInputForClient we can read current mouse position and serialize it as network input.

public class PlayerInputController : ElympicsMonoBehaviour, IInputHandler, IUpdatable
{
	[SerializeField] private float mouseSensivity = 1.5f;

	private Vector2 mouseAxis = Vector2.zero;

	private void Update()
	{
		var mouseX = Input.GetAxis("Mouse X");
		var mouseY = Input.GetAxis("Mouse Y");
		mouseAxis += new Vector2(mouseY, mouseX) * mouseSensivity;
	}

	public void OnInputForClient(IInputWriter inputWriter)
	{
		if (Elympics.Player != PredictableFor)
			return;

		inputWriter.Write(mouseAxis.x);
		inputWriter.Write(mouseAxis.y);
	}

	public void ElympicsUpdate()
	{
		if (ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
		{
			inputReader.Read(out float mouseX);
			inputReader.Read(out float mouseY);

			// Set only if received input
			transform.localRotation = Quaternion.Euler(new Vector3(mouseX, mouseY, 0));
		}
	}

	{...}
}

Multiple players controlling the same object

TicTacToe is the perfect example of a game in which both players can change state of a shared game board, so ownership is hard to define.
In such cases, the proper way to configure such shared synchronized object to work seamlessly is to set its predictability to None (ElympicsPlayer.World in code).
Then we can send the last chosen field (desired move) as player’s input, and validate inside ElympicsPlayer whether that player is allowed to make such move.

public void GetInputForClient(IInputWriter inputWriter)
{
	var field = playerInputProvider.GetLastClickedField();
	inputWriter.Write(field);
}

public void ElympicsUpdate()
{
	for(var i = 0; i < 2; i++) {
		var player = ElympicsPlayer.FromIndex(i);
		if (ElympicsBehaviour.TryGetInput(player, out var inputReader))
		{
			inputReader.Read(out float field);

			if (!gameState.ValidateMove(player, field))
				continue;

			gameState.SetFieldForPlayer(player, field);
			gameState.ChangeTurn();
		}
	}
}

2 - State synchronization

What is network state and how is it synced between server and players?

Basics

ElympicsSnapshot

Snapshot is a binary representation of whole synchronized state. Its structure is described as follows:

  • Tick: number describing time quantum in which the snapshot was captured
  • Data: dictionary of binary data gathered from ElympicsBehaviours
    • key NetworkId: - int index
    • value Data: - binary chunk of data

ElympicsBehaviour

ElympicsBehaviour represents a single object synchronized through the network between server and clients as a part of a snapshot. It is described by unique networkId assigned to it. It enables all Elympics components attached to its parent i.e.:

  • IObservable - empty base interface for everything else, classes implementing this will have ElympicsVars gathered
  • IInputHandler - inputs gathering, better described in the previous section
  • IInitializable - initializing things right after Elympics initialization
  • IUpdatable - contains ElympicsUpdate method to advance game logic in deterministic way
  • IStateSerializationHandler - synchronizing non-ElympicsVar objects with network state
  • IClientHandler - client callbacks for client related actions
  • IServerHandler - server callbacks for game related actions
  • IBotHandler - bot callbacks for bot related actions
  • IReconciliationHandler - client reconciliation tweaks for local state altering e.g. force local camera rotation to stay in the same position even if server says otherwise

and:

  • ElympicsVar - all variables representing synchronized state

ElympicsVar

It’s a base class for all variables shared between clients and server e.g. ElympicsInt, ElympicsVector3 etc. They’re used to:

  • hold underlying synchronized variables (respectively int, Vector3 etc.)
  • serialize and deserialize those variables into binary snapshot
  • check if value has changed over time
  • check if local predicted state matches the data received from server

Serialization / Deserialization

Serialization

Mainly used on Server to collect state and send it to players. Also called on Clients with prediction turned on to save predicted state for further comparison.

The snapshot is collected in the following manner:

  • ElympicsSystem - (Client, Server or Bot) - GetSnapshot
  • ElympicsBehaviourManager - iterating over ElympicsBehaviours in networkId ascending order
  • ElympicsBehaviour - calling Serialize over ElympicsVars and collecting all byte data into a single array.

Deserialization

Used only on Clients to apply incoming data from Server. The snapshot is applied in the following manner:

  • ElympicsSystem - (Client, Server or Bot) - 2 cases whether Client has enabled prediction
    • Without prediction
      • ElympicsBehaviourManager - iterating over ElympicsBehaviours in networkId ascending order
      • ElympicsBehaviour - calling Deserialize over ElympicsVars.
    • With prediction
      • Checking if predicted state is correct, if it’s not correct then
        • ElympicsBehaviourManager - iterating over Predictable ElympicsBehaviours in networkId ascending order
        • ElympicsBehaviour - calling deserialize over ElympicsVars.
      • ElympicsBehaviourManager - iterating over Not Predictable ElympicsBehaviours in networkId ascending order
      • ElympicsBehaviour - calling Deserialize over ElympicsVars.
      • Applying predicted input

Initializing ElympicsVars

ElympicsVars can be initialized in 2 ways, statically or using Initialize.

Static

Simple static assign in class field

private readonly ElympicsInt                 _ticksAlive     = new ElympicsInt(10);
private readonly ElympicsArray<ElympicsBool> _playerAccepted = new ElympicsArray<ElympicsBool>(5, () => new ElympicsBool(false));

Initialize

It requires class to implement IInitializable interface for Initialize method.

public class TemplateBehaviour : ElympicsMonoBehaviour, IInitializable
{
	[SerializeField] private ElympicsBehaviour[] multipleGameObjectReferences;

	private ElympicsArray<ElympicsBool>      _playerAccepted              = null;
	private ElympicsList<ElympicsGameObject> _listWithElympicsGameObjects = null;

	public void Initialize()
	{
		int numberOfPlayers = ElympicsConfig.Load().GetCurrentGameConfig().Players;
		_playerAccepted = new ElympicsArray<ElympicsBool>(numberOfPlayers, () => new ElympicsBool(false));

		_listWithElympicsGameObjects = new ElympicsList<ElympicsGameObject>(() => new ElympicsGameObject(null));
		foreach (var behaviour in multipleGameObjectReferences) 
			_listWithElympicsGameObjects.Add().Value = behaviour;
	}
}

Available types

Simple

  • ElympicsBool
  • ElympicsInt
  • ElympicsFloat
  • ElympicsString
  • ElympicsGameObject

Structs

  • ElympicsVector2
  • ElympicsVector3
  • ElympicsQuaternion

Collections

  • ElympicsArray
  • ElympicsList

ValueChanged

It’s an event called after the value of ElympicsVar changes (taking accuracy tolerance into account).

ValueChanged isn’t fired instantly but only after states becomes consistent – at the start of a tick, after receiving inputs, before calling ElympicsUpdates.

private readonly ElympicsFloat _timerForDelay  = new ElympicsFloat();

private void Awake()
{
	_timerForDelay.ValueChanged += OnValueChanged;
}

private void OnValueChanged(float lastValue, float newValue)
{
	if (lastValue <= 0 && newValue > 0)
		animator.SetTrigger(true);
}

Prebuilt Synchronizers

Reusable components to synchronize non-trival objects using Elympics.

How it works is explained later on this page, here.

They can be added manually or through buttons in ElympicsBehaviour and respective components are required on the same GameObject.

Synchronizers

ElympicsGameObjectActiveSynchronizer

Simply synchonizing GameObject.active property.

ElympicsTransformSynchronizer

Used to synchronize Transform parameters such as:

  • localPosition
  • localScale
  • localRotation

ElympicsRigidBodySynchronizer

Used to synchronize RigidBody parameters such as:

  • position
  • rotation
  • velocity
  • angularVelocity
  • mass
  • drag
  • angularDrag
  • useGravity

ElympicsRigidBody2DSynchronizer

Used to synchronize RigidBody2D parameters such as:

  • position
  • rotation
  • velocity
  • angularVelocity
  • drag
  • angularDrag
  • inertia
  • mass
  • gravityScale

Coding principles

These are the most important rules for state programming and should always be followed!

Simple variables affecting gameplay have to be synchronized

Lets take a look into snake game with random apple position. The player can’t see it because only server are choosing its position using Random, and he will think that the apple is always at the (0,0) position.

private Vector2 applePosition = Vector2.zero;
private bool isEaten;

public void ElympicsUpdate()
{
	// Server authoritive decision
	if (Elympics.IsServer) {
		if (isEaten) {
			applePosition = new Vector2(Random.Range(-10.0f, 10.0f));
			isEaten = false;
		}
	}
}
private ElympicsVector2 applePosition = new ElympicsVector2(Vector2.zero);
private ElympicsBool isEaten = new ElympicsBool();

public void ElympicsUpdate()
{
	// Server authoritive decision
	if (Elympics.IsServer) {
		if (isEaten) {
			applePosition.Value = new Vector2(Random.Range(-10.0f, 10.0f));
			isEaten.Value = false;
		}
	}
}

It will be a surprise for him if he eats an apple but does not get a point!
Solution for this problem is to synchronize variables.

Other variables affecting gameplay have to be closely linked to ElympicsVars

Lets take a look on example similar to previous one. We are gonna move player (change his transform.position) using input, and we forgot to add ElympicsTransformSynchronizer to player GameObject.

No transform sync

public void ElympicsUpdate()
{
	if (!ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
		return;

	inputReader.Read(out var vMove);
	inputReader.Read(out var hMove);

	transform.localPosition += new Vector2(hMove, vMove);
}

Even if the position would be correct for the first ticks in the matter of time and some indeterministic events the position as viewed by the player will differ more and more from what is on the server.

There is no elegant solution for 2 predictables contact

In this case we are going to analyze what happens when player affects other player behaviour.

Player colliding with other player

Both players have ElympicsRigidBodySynchronizer, and we can predict physics in our game. When we collide with others there will be some inaccuracy in position prediction, like on this diagram:

A->   B
 A->  B
  A-> B
   A->B
   A->B
   A->B
      A->B
       A->B
        A  B
        A  B
        A  B
        A  B

this happens because the position of the colliding enemy cannot be predicted by us and therefore we have to wait for server acknowlegment of position change. Then we can observe some bigger jump in position change - it depends on latency with server our client experiences.

Player hitting other player
public void ElympicsUpdate()
{
	if (!ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
		return;

	inputReader.Read(out int chosenPlayerForLightningAttack);

	players[chosenPlayerForLightningAttack].hp -= 10;
}

In this case, the HP indicator will show us altered value on our game client, but just for one tick and then when server acknowledge us about the change:

100 -> 90 -> 100 -> 100 -> 90 -> 90 -> 90

Be careful mixing predictable and unpredictable code

Common problem, when creating lots of dependent ElympicsBehaviours.
If you create multiple scripts e.g. one for controlling player and input and one for spawn point be careful if they are predictable to the same player. Otherwise actions from predictable script which call methods from unpredictable scripts can make the game stuttering.

// PlayerInputController - *Predictable for players*
public void ElympicsUpdate()
{
	if (!ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
		return;

	inputReader.Read(out bool setSpawn);
	inputReader.Read(out bool spawn);

	if (setSpawn)
		spawnController.SetSpawnPoint(transform.localPosition);

	if (spawn)
		transform.localPosition = spawnController.GetSpawnPoint();
}
```

// SpawnController - *Unpredictable*
private ElympicsVector3 spawnPoint = new ElympicsVector3();
{/**Other players spawn points**/}

public void SetSpawnPoint(Vector3 newSpawnPoint)
{
	spawnPoint.Value = newSpawnPoint;
}

public Vector3 GetSpawnPoint()
{
	return spawnPoint.Value;
}
// PlayerInputController - *Predictable for players*
public void ElympicsUpdate()
{
	if (!ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
		return;

	inputReader.Read(out bool setSpawn);
	inputReader.Read(out bool spawn);

	if (setSpawn && Elympics.IsServer)
		spawnController.SetSpawnPoint(transform.localPosition);

	if (spawn && Elympics.IsServer)
		transform.localPosition = spawnController.GetSpawnPoint();
}
```

// SpawnController - *Unpredictable*
private ElympicsVector3 spawnPoint = new ElympicsVector3();
{/**Other players spawn points**/}

public void SetSpawnPoint(Vector3 newSpawnPoint)
{
	spawnPoint.Value = newSpawnPoint;
}

public Vector3 GetSpawnPoint()
{
	return spawnPoint.Value;
}

Doing Spawn in the next few ticks would teleport player to old spawn point, because SetSpawnPoint will be overriden by recently received server snapshot and set to correct when this information make round trip between client and server. After spawn point change, the client has to correct his position and make additional teleport.

NetworkIds

All NetworkIds of ElympicsBehaviours has to be unique.

Keep in mind that they also describe execution order of all methods called by Elympics like ElympicsUpdate or Initialize. You can read more about this here.

If you want to check order of ElympicsBehaviour there is a button in Elympics GameObject - Refresh Elympics Behaviours.

NetworkId can be set to custom in ElympicsBehaviour component but it has to be unique.

In case of any problems with NetworkIds there is an option Tools -> Elympics -> Reset Networkd Ids.

Advanced

WIP

Synchronized reference

  • ElympicsGameObject and underlying NetworkId
  • Elympics.TryGetBehaviour

PredictableFor

  • player with ownership
  • instant feedback on state with cached inputs

VisibleFor

  • server data intended only for one of the players
  • not seen synchronized reference with bad design

Optimizing snapshots size

  • turning off synchronizer parts like ElympicsRigidBodySynchronizer component -> inertia -> tick
  • ElympicsBehaviour component -> StateUpdateFrequency - will be included in snapshot less often with not changing variables

Accuracy tolerance

  • ElympicsRigidBodySynchronizer component -> Position -> Tolerance
  • adapting tolerance to e.g. position change on big map
  • less reconciliations based on indeterminism on collisions
  • Comparers implementations, how to check if equals in given tolerance

Creating your own synchronizer

  • IStateSerializationHandler
  • case study of rigidbody synchronizer

3 - Instantiate / Destroy

Creating and destroying networked objects.

Prefabs

Instantiated objects are basically the same as synchronized objects placed on the scene during development. They are, as the name suggests, spawned at runtime. Thus they have one extra requirement: they have to be placed in prefabs with an ElympicsBehaviour as the root GameObject.

Prefab Example

Instantiate

ElympicsInstantiate

The only way to spawn synchronized prefab in runtime is to use ElympicsMonoBehaviour.ElympicsInstantiate.

This method has 2 parameters:

  • pathInResources: path to prefab in Resources directory e.g. "BlueBall"
  • player: predictable for player or ownership
    • ElympicsPlayer.World - only server can spawn with this option and it means it won’t be predictable to any player - useful for random server events
    • ElympicsPlayer.All - object predictable for all players, can be spawned by any player - useful for fully predictable physics objects
    • ElympicsPlayer.FromIndex(i) - object predictable for specific player, can be spawned by this specific player or server - useful for projectiles created by player

Output of ElympicsInstantiate is of type GameObject and there is currently no generic override of the method available. To get desired component from prefab, simply use Unity GetComponent method, like in the example below.

// spawns "BlueBall" every 100 ticks
private readonly ElympicsInt _tick = new ElympicsInt(0);

public void ElympicsUpdate()
{
	_tick.Value++;
	var tickPeriod = _tick.Value % 100;
	if (tickPeriod == 99)
	{
		GameObject prefabInstance = ElympicsInstantiate("BlueBall", ElympicsPlayer.All);
		CustomScript custom = prefabInstance.GetComponent<CustomScript>();
		custom.CustomMethod();
	}
}

NetworkId

Objects created using ElympicsInstantiate have their NetworkIds assigned using formula:

var networkId = (playerIndex + 4) * 10 000 000 + i;

where i means index of instantiated object increased with every new object.

This means that all instantiated objects have these NetworkId ranges:

  • for All players - [10 000 000; 19 999 999]
  • for World player - [20 000 000; 29 999 999]
  • for Player 0 - [40 000 000; 49 999 999]
  • etc.

Destroy

Self destroy

The easiest way to destroy an object instantiated at runtime is to do this from a script within that object. So this object should have a script attached to it similar to:

private readonly ElympicsInt _ticksAlive = new ElympicsInt(0);

public void ElympicsUpdate()
{
	_ticksAlive.Value++;
	if (_ticksAlive.Value > 60)
		ElympicsDestroy(gameObject);
}

From another object

Another way to destroy an instantiated object is to save its reference and destroy it from the outside of this object.

private readonly ElympicsInt        _tick = new ElympicsInt(0);
private readonly ElympicsGameObject _cube = new ElympicsGameObject(null);

public void ElympicsUpdate()
{
	_tick.Value++;
	var tickPeriod = _tick.Value % 200;
	if (tickPeriod == 80) // or (tickPeriod >= 80 && tickPeriod <= 199 && ReferenceEquals(_cube.Value, null))
		_cube.Value = ElympicsInstantiate("Cube", ElympicsPlayer.All).GetComponent<ElympicsBehaviour>();

	if (tickPeriod == 199 && !ReferenceEquals(_cube.Value, null))
	{
		ElympicsDestroy(_cube.Value.gameObject);
		_cube.Value = null;
	}
}

ElympicsFactory

How do objects instantiated at runtime on the server appear on the clients?

There is an alternative to ElympicsBehaviour designed for this: ElympicsFactory.
It’s used internally by Elympics when you call ElympicsInstantiate or ElympicsDestroy methods.

ElympicsFactory is split into parts, where each part contains information about instantiated objects and has its predictability set to corresponding player ID (World, All, 0, 1 etc.) Thus each player can predict the state of their factory part and check if their prediction was successful.

Other parts that are not predictable are just simply deserialized and proper objects are instantiated.

Coding principles

These are the most important rules for instantiation programming and should always be followed!

Deal only with your objects

As mentioned earlier, clients should only instantiate objects they desire to use or interact with and destroy the ones created by themselves.

This applies also to the server. There are some special cases when server should also instantiate or destroy objects intended to be used by players. For example a special bonus weapon for a specific player (with PredictableFor or ownership set for this player), intended to be used for some specific time duration.

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

&ldquo;Flash requested&rdquo; 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):

&ldquo;Flash requested&rdquo; 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

&ldquo;Flash requested&rdquo; 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

&ldquo;Flash requested&rdquo; 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.

5 - Lifecycle events and actions

Providing listeners for network or game events and managing the game lifetime.

Gameplay implementation is just one of the steps in creating a perfect game. There are many other factors that affect player experience. One of them, staying in close relation to gameplay, is maintaining the match lifetime and the currently playing squad.

If you prefer hands-on experience, check out “Match events” sample project included in Unity SDK package.

Joining a match

First things first, players can join a match using the following methods:

  • IElympics.ConnectAndJoinAsPlayer(Action<bool> connectedCallback, CancellationToken ct) – to join as a full player and have one’s input gathered and sent to the server,
  • IElympics.ConnectAndJoinAsSpectator(Action<bool> connectedCallback, CancellationToken ct)1 – to join in a read-only mode where only one-way (server to client) communication occurs and one is not counted as a match player.

Both of these methods are coroutines returning IEnumerator. The correct way of running them is using Unity StartCoroutine wrapper.

Normally, there is no need to call ConnectAndJoinAsPlayer manually as it is done by Elympics when client instance initializes. This behavior is customizable using “Connect On Start” checkbox of Elympics Client editor:

Elympics Client editor

ConnectAndJoinAsPlayer and ConnectAndJoinAsSpectator consist of three steps: they establish a connection to the game server, then perform user authentication and finally register a player in the match. Completion of each step can be tracked by implementing the following methods from IClientHandler interface:

  • IClientHandler.OnConnected(TimeSynchronizationData data),
  • IClientHandler.OnAuthenticated(string userId)userId becomes null when joining as a spectator,
  • IClientHandler.OnMatchJoined(string matchId)

along with their corresponding error handlers:

  • IClientHandler.OnConnectingFailed(),
  • IClientHandler.OnAuthenticatedFailed(string errorMessage),
  • IClientHandler.OnMatchJoinedFailed(string errorMessage).

Meanwhile on the server, only one callback is executed:

  • IServerHandler.OnPlayerConnected(ElympicsPlayer player)

ElympicsPlayer passed here is used to distinguish a player from others in player data array passed as the only parameter of server initialization callback (described below).

Leaving a match

A match can be also left using IElympics.Disconnect(). If the match isn’t ended immediately by the server, players can rejoin it.

As matches with players missing have a great chance of becoming unplayable, you may want to close the server after some delay (using the action described in the next section). To make sure the server is notified about such an event, you have to implement IServerHandler.OnPlayerDisconnected(ElympicsPlayer player).
As before, received in-match player identifier can be used as an index to player data array passed in server initialization callback.

After a client disconnects from the server, it runs its IClientHandler.OnDisconnectedByClient().

Finishing a match

As non-finished games take up resources, ending a match is a crucial step. Game server lifetime is currently limited to 24 hours, so no game runs indefinitely. But surely you don’t want your players to wait a whole day until they can play again. Or a 5-minute match to cost you much more than planned.

The method you need to call is IElympics.EndGame(). Depending on your circumstances, you can pass an optional parameter of type ResultMatchPlayerDatas containing match results.

The inclusion of ResultMatchPlayerDatas object is essential if you want to take advantage of intelligent matchmaking (using Elo rating or machine learning) or other score-related services provided by Elympics.

ResultMatchPlayerDatas is a list of ResultMatchPlayerData. Each entry should contain data associated with the corresponding player. As there is no player ID included, you should check InitialMatchPlayerDatas received at the start of the game for the correct order.

ResultMatchPlayerData stores game-specific byte[] GameEngineData and float[] MatchmakerData with a structure defined by Elympics. MatchmakerData must contain two elements:

  1. Matchmaker-compatible score. The greater this number is, the higher matchmaker rating. If your game takes a different approach (e.g. you’re creating a racing game where time becomes the score), you have to transform your data appropriately.
  2. Game-specific score details. You can put original, unprocessed score here.

You can read more about the matchmaker in the next chapter.

Of course, there are some situations when a match ends with no meaningful results to send to the matchmaker, e.g. not all players joined the game to begin with. In such case you should call EndGame() with no arguments.

When a match ends, the following callbacks are run on clients (in the order listed):

  • IClientHandler.OnMatchEnded(string matchId),
  • IClientHandler.OnDisconnectedByServer().

DefaultServerHandler

As mentioned earlier, it’s really important to end a game correctly. This is why Elympics prefab includes DefaultServerHandler which closes the match if any connected player leaves. It also calls EndGame() if not all players join the game in the first 30 seconds of server lifetime.

DefaultServerHandler object inside Elympics prefab

The handler outputs logs to console when players connect and disconnect and when it decides if the game should be ended.
Messages such as “Waiting for game to start” could make you think that games are started using a method analogous to EndGame(). However, that is not true. The start of a server means the start of a match.

Providing a custom implementation

If the behavior described above is not suited for your game, you can disable the built-in implementation of IServerHandler.

DefaultServerHandler script checks Behaviour.isActiveAndEnabled property in every implemented IServerHandler method, so you can untick DefaultServerHandler component or the whole DefaultServerHandler object in inspector to prevent it from executing.

Disabling DefaultServerHandler component

Deactivating DefaultServerHandler game object

Alternatively you can just remove DefaultServerHandler component from DefaultServerHandler object in the instance of Elympics prefab.

Removing DefaultServerHandler component

DefaultServerHandler class is a good starting point for implementing a custom server lifetime guard. After copying the code, you can modify it to suit your needs.

Other events

Initialization

When a game instance starts, an initialization callback is run with game and player data passed as the only parameter. To retrieve the data you can implement appriopriate methods of IServerHandler, IClientHandler and IBotHandler interfaces.

The data is stored as instances of InitialMatchPlayerData. The class consists of the following properties:

  • ElympicsPlayer Player – in-match player identifier,
  • string UserId – globally unique player identifier,
  • bool IsBot – information if player is a bot,
  • double BotDifficulty – how well a bot should play (if the player is a bot),
  • byte[] GameEngineData – optional game-specific data which you can use to provide initial settings for a match,
  • float[] MatchmakerData – optional game and player-related data for matchmaker to learn from (if ML matchmaking is enabled).

For a server, the method is IServerHandler.OnServerInit(InitialMatchPlayerDatas initialMatchPlayerDatas). It provides not a single instance of InitialMatchPlayerData, but a list of such objects. Each object corresponds to one of match players.

For a client, you should implement IClientHandler.OnStandaloneClientInit(InitialMatchPlayerData data).
There is also IClientHandler.OnClientsOnServerInit(InitialMatchPlayerDatas data) used only in “Local Player And Bots” development mode. For convenience, it provides a list of all clients run inside the server instance.

For a bot, you can use IBotHandler.OnBotsOnServerInit(InitialMatchPlayerDatas initialMatchPlayerDatas) as running bots inside the server instance is the default option. As in the case of OnClientsOnServerInit, the method provides a list of data of all bots running in the server.
If you changed the setting, you should use OnStandaloneBotInit(InitialMatchPlayerData initialMatchPlayerData) instead.
The setting is available in Elympics Game Config:

&ldquo;Bots inside server&rdquo; setting in Elympics Game Config

Providing InitialMatchPlayerDatas

There are two points at which you can input GameEngineData and MatchmakerData for your game to use during the initialization process.

The first is when you call ElympicsLobbyClient.PlayOnline(). This method accepts four optional parameters, including float[] matchmakerData = null and byte[] gameEngineData = null. Check out the next chapter for more details.

The second approach requires using your own back end. The definition of needed endpoints can be found in External Game Backend integration chapter, along with instructions on how to configure the back-end address to be used by your game.

Clock synchronization

The one last callback to describe is IClientHandler.OnSynchronized(TimeSynchronizationData data). It is called every time a client synchronizes its clock with the server (using NTP protocol).

An implementation of this method can be used to track the condition of network connection using information from TimeSynchronizationData object passed as the only parameter. For example RoundTripDelay property can be displayed as “ping” in an in-game UI.


  1. The spectator mode is an experimental feature. Its behavior may change in future releases. ↩︎

6 - Lobby & Matchmaker

Play with friends or organize championships with our matchmaker!

Social Queues

Concept

Social Matchmaker Queues offer the ability to assign players to subqueues of already defined queue.

Sample flow of matching friends:

  1. Player 1 chooses queue Default with a randomized suffix (i.e.: 3c2t35bMyPwDc5Lt) and uses it in their game
  2. Player 1 passes this information to Player 2
  3. Player 2 uses information about queue received from Player 1
  4. Player 1 and Player 2 join this queue
  5. Player 1 and Player 2 are matched into the same match
  6. Flow of joining continues as normal

Usage

You just use queueName:suffix instead of queueName in ElympicsLobbyClient.PlayOnline method.

Actions & events

Authentication

ElympicsLobbyClient

  • Authenticate()
  • Authenticated event – bool success, string userId, string jwtToken, string error

Matchmaking

ElympicsLobbyClient

  • PlayOnline(float[] matchmakerData = null, byte[] gameEngineData = null, string queueName = null, bool loadGameplaySceneOnFinished = true)

ElympicsLobbyClient.Matchmaker

  • MatchmakingFinished event – Action<(string MatchId, string TcpUdpServerAddress, string WebServerAddress, string UserSecret, List<string> MatchedPlayers)>
  • MatchmakingError event – Action<string>

7 - External Game Backend integration

Subscribe to match events with your additional game backend service

What is the purpose of External Game Backend?

If you want to add to your game leaderboards, player progression or any play to earn functionalities and smart contract integrations, your game has to know about matches running on Elympics. To provide that information we have implemented a feature to add additional callbacks to your API called by our services.

How to enable it?

You just have to define endpoint for external game backend in web panel or via Elympics CLI. Changes should be applied within 5 minutes. Use keys provided in the web panel or in the CLI to verify request integrity and authenticity.

Available events

OnMatchCreate

Called after the match is created in our database, but before bootstrapping real game on one of ours game servers. It could be used to:

  • Insert information about the match in your database e.g. for statistical / progression purposes
  • Verify player-provided data for match initiation
  • Add and/or replace players initial data using one stored in your database
  • Block some players, game versions etc.
{
    "MatchId": "zxcv",
    "GameId": "asdf",
    "GameVersion": "123",
    "UserDatas": [
        {
            "UserId": "u1",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [1.0]
        },
        {
            "UserId": "u2",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [0.0]
        }
    ]
}
  
{
    "Allow": true,
    "RejectionCause: "",
    "UserDatas": [
        {
            "UserId": "u1",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [
                1
            ]
        },
        {
            "UserId": "u2",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [
                0
            ]
        }
    ]
}
  
curl --location --request POST 'https://{YOUR_BACKEND_URL}/elympics/match/create' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE2NTIzNjU0MjYsImV4cCI6MTk2NzcyNTQyNiwiaWF0IjoxNjUyMzY1NDI2fQ.EZ09qotly3uNzIntRzHYmo8G6dCNcCd5-LBCrlCzhye8WrmKWHkokFD2tXi54r0h1buLaMG7Q1O_fHLr0CxhJi1C5_pXrVjgpVrsJeYa-sIdlJnGnqS0QgmVeqvnIeacf8Y-hthQwysxtmy3pAzAdDk27AI5SfoIjJXVpIJZ6WRjdVfPMWjMUMqqtr9PNHUOklsg3umgPlmHe5Nc0RpPTX5tCpvyZQVlvXD7BZ1drrquhhXiN5wmnvVP8Ma8snjug0Btchw-kx2wGwR0U_1b5VMO1hMneybwNP8nwZSbZb9j3yurcrb2Q8MhHZDGyNBFXMQR3nCH76duvGtmr-byPg' \
--header 'Content-Type: application/json' \
--data-raw '{
    "MatchId": "zxcv",
    "GameId": "asdf",
    "GameVersion": "123",
    "UserDatas": [
        {
            "UserId": "u1",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [1.0]
        },
        {
            "UserId": "u2",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [0.0]
        }
    ]
}'
  
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Pjlz0DIJKyhOmw5t4Ih
D8wXOP3lN93+petucmvn7sC6TTtaGUAIyzMCaHh080LE2aCnLaF5aSWDown5u80X
tvL8NXLt9NsTriHxKpwgx5UQthpzsgd1AdkzFLmHQ84aFKndZly39nX/XiWBu3N4
VuPeWI4U9l23fGM3X14bEv1f7uz2GOipFkdfvl6TuFZmpRC9nJG57lfdKvifesei
jD2AbsyZi3WMU5Zz2znquxa/zzFGCxkjSmx6zKkNFMJYUM7As25Cp87mMEoIn8Pm
gEqFJX4DUtJibbw/dDUy+VIglAoCf9kHohM+aNH9WPgZ1DbXJKKHns4K/vQbgGus
5QIDAQAB
-----END PUBLIC KEY-----
  

OnMatchFinish

Called after the match is finished. It contains the information about match results.

{
    "MatchId": "zxcv",
    "GameId": "asdf",
    "GameVersion": "123",
    "UserDatas": [
        {
            "UserId": "u1",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [0.0, 3.0, -2.0]
        },
        {
            "UserId": "u2",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [1.0, -1.0, 4.0]
        }
    ]
}
  
{}
  
curl --location --request POST 'https://{YOUR_BACKEND_URL}/elympics/match/finish' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE2NTIzNjU0MjYsImV4cCI6MTk2NzcyNTQyNiwiaWF0IjoxNjUyMzY1NDI2fQ.EZ09qotly3uNzIntRzHYmo8G6dCNcCd5-LBCrlCzhye8WrmKWHkokFD2tXi54r0h1buLaMG7Q1O_fHLr0CxhJi1C5_pXrVjgpVrsJeYa-sIdlJnGnqS0QgmVeqvnIeacf8Y-hthQwysxtmy3pAzAdDk27AI5SfoIjJXVpIJZ6WRjdVfPMWjMUMqqtr9PNHUOklsg3umgPlmHe5Nc0RpPTX5tCpvyZQVlvXD7BZ1drrquhhXiN5wmnvVP8Ma8snjug0Btchw-kx2wGwR0U_1b5VMO1hMneybwNP8nwZSbZb9j3yurcrb2Q8MhHZDGyNBFXMQR3nCH76duvGtmr-byPg' \
--header 'Content-Type: application/json' \
--data-raw '{
    "MatchId": "zxcv",
    "GameId": "asdf",
    "GameVersion": "123",
    "UserDatas": [
        {
            "UserId": "u1",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [1.0]
        },
        {
            "UserId": "u2",
            "IsBot": false,
            "BotDifficulty": 0,
            "GameEngineData": "",
            "MatchmakerData": [0.0]
        }
    ]
}'
  
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Pjlz0DIJKyhOmw5t4Ih
D8wXOP3lN93+petucmvn7sC6TTtaGUAIyzMCaHh080LE2aCnLaF5aSWDown5u80X
tvL8NXLt9NsTriHxKpwgx5UQthpzsgd1AdkzFLmHQ84aFKndZly39nX/XiWBu3N4
VuPeWI4U9l23fGM3X14bEv1f7uz2GOipFkdfvl6TuFZmpRC9nJG57lfdKvifesei
jD2AbsyZi3WMU5Zz2znquxa/zzFGCxkjSmx6zKkNFMJYUM7As25Cp87mMEoIn8Pm
gEqFJX4DUtJibbw/dDUy+VIglAoCf9kHohM+aNH9WPgZ1DbXJKKHns4K/vQbgGus
5QIDAQAB
-----END PUBLIC KEY-----
  

User authentication

When user authenticates with ElympicsLobbyClient, he receives a JwtToken used to authenticate to external services:

{
    "IsSuccess": true,
    "UserId": "23dc10e9-fc00-4622-9ab1-92d9bc19fc89",
    "JwtToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiIyM2RjMTBlOS1mYzAwLTQ2MjItOWFiMS05MmQ5YmMxOWZjODkiLCJuYmYiOjE2NTc1MzUzNDIsImV4cCI6MTk3Mjg5NTM0MiwiaWF0IjoxNjU3NTM1MzQyfQ.BPq0JwtzMZlyWUxUexY3wi6eAxLoPD6QiWAOUce2NmDE5IOWYVAHiDwCXZW-9xUP-3tQrUQBYLgOfwyMhVyE89P3RQw2QatTpITIMJd-dlmUCAFmA1sQ_wKFS2_AoCu0SUo4qMremIAfwQCz4y8sIh40xG5oGTE-P725M_YFfWiLi0Am4kdL1ZCb2bnTr4elGop6o_PTX4WyTdVPXY6pJEPs6x2jEteH4hE2aXLUHYcO5TBzpW8rwRcdyi-w5WLXk-B1xrHPWQGVnfX6FQKk8R5ojTHsshfcuTeBTBewAY6Wo8Iftu7TyErdvsrNA5GpD3Faasl52YV-iPHHyq-tMg"
}
  
curl --location --request POST 'https://{YOUR_BACKEND_URL}/auth' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiIyM2RjMTBlOS1mYzAwLTQ2MjItOWFiMS05MmQ5YmMxOWZjODkiLCJuYmYiOjE2NTc1MzUzNDIsImV4cCI6MTk3Mjg5NTM0MiwiaWF0IjoxNjU3NTM1MzQyfQ.BPq0JwtzMZlyWUxUexY3wi6eAxLoPD6QiWAOUce2NmDE5IOWYVAHiDwCXZW-9xUP-3tQrUQBYLgOfwyMhVyE89P3RQw2QatTpITIMJd-dlmUCAFmA1sQ_wKFS2_AoCu0SUo4qMremIAfwQCz4y8sIh40xG5oGTE-P725M_YFfWiLi0Am4kdL1ZCb2bnTr4elGop6o_PTX4WyTdVPXY6pJEPs6x2jEteH4hE2aXLUHYcO5TBzpW8rwRcdyi-w5WLXk-B1xrHPWQGVnfX6FQKk8R5ojTHsshfcuTeBTBewAY6Wo8Iftu7TyErdvsrNA5GpD3Faasl52YV-iPHHyq-tMg'
  
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdv45sXwa3rrOgU1iamm
ndC3jqR3xTgKpL9RfJWyf7Wv9XbAf+jxnZop49S2x+A5tcfr67clVyGGSUldQyF1
JbEeXWdfvwlTf6BJka2qPhvkiNUDTnHJnCeGnTDR6g+YWF0jHpVv28CTU7eKvaiZ
dA9EaEjPKHcT6GcRONumH0pXmxkRgfVjk3PXPmuR7OSoj63EHfd8e1GORW/FI87X
1KI1D/ajK8FYBOgqmzaSHsTNz+nRztYYbkb7PM6d5bv5O0hu8PFi7TAzwP9Smw/I
UNf8jCLUlojYlwT1cSxkgCzE44PZaCP59fxMdcORH5Px+H3thctXwBBFajMuHMHy
+wIDAQAB
-----END PUBLIC KEY-----
  

8 - Coming from Client Authoritative

Key things to know when coming from client authoritative frameworks

Checking for “isHost”

How to replace “isHost”?

In client authoritative model you often use if (isHost) { ... } to make sure some part of the code runs only once, preferably on the host machine. For example, ending the match, tallying up the score etc. In the server authoritative paradigm there is no host player - every player is a client, and a dedicated machine is the server.

In Elympics SDK you can use if (Elympics.IsServer) { ... } to perform some code only on the server. However, the use cases are a bit more rare than in client-authoritative code, because large parts of the code are predictable, and can be run both on clients and on the server.

Use cases

Use cases in which it is a good practice to perform code on the server only are:

Code that is unpredictable

For example everything that involves randomisation, like spawning random items, map generating etc. Enabling the client to predict this will result in costly and unnecessary reconciliations.

Code that is hard to roll back

Every prediction might be wrong, so if the effects are highly impactful for the gameplay and hard to reconcile, it’s better to not predict them. For example, dying from damage - if a client predicts its death, it will show the deathcam, the visual effects, sound effects, lock player input, and start a respawn countdown. Reconciling all these things will not only be complicated, it will also look bad, as if the game glitched and broke and it will be very confusing for the player.

How to add a RPC?

RPCs, or Remote Procedure Calls, are at the core of client-authoritative multiplayer. A client informs all other clients of an action it performed, for example, dealt damage to another client.

In Elympics, there are no RPCs. Instead, there are player inputs and the game state.

Inputs

Player inputs are serialized and deserialized in IInputHandler and this is the only way a player can inform other players and the server about their action. Each tick, serialized inputs are sent to the server, who deserializes and applies them using the code you’ve written in IInputHandler. The changes made with this code are then synchronised with clients using ElympicsVars.

So, the example of dealing damage would be implemented this way:

  1. “Fire” input is serialized in IInputHandler
  2. “Fire” input is deserialized in IInputHandler
  3. Code with firing logic is performed, with this input passed as an argument
  4. This code modifies ElympicsFloat hp somewhere
  5. Code that is attached as a listener via hp.ValueChanged += ... will happen both on the server and on the clients

Server-side “RPC”

Sometimes you want to inform players about something that happened in code that was performed only on the server, for example, about a death of a player. Since there are no RPCs, the correct way to do this is to attach a listener to a variable, ElympicsBool IsDead; IsDead.ValueChanged += HandlePlayerDied;.

Then you can change the value somewhere

if (Elympics.IsServer)
{
    if (player.hp.Value <= 0)
        player.IsDead.Value = true;
}

Since ElympicsVars are synchronised every tick, all players as well as the server will perform HandlePlayerDied.

Remember that code executed this way will not be predictable.