State synchronization
Basics
ElympicsSnapshot
Snapshot
is a binary representation of whole synchronized state. Snapshots are generated and serialized on the server-side, and sent to clients. The structure of a snapshot is described as follows:
Tick
: number describing time quantum in which the snapshot was capturedData
: dictionary of binary data gathered fromElympicsBehaviour
s- key
NetworkId
: - int index - value
Data
: - binary chunk of data
- key
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.:
Basic components:
IObservable
- empty base interface for everything else, classes implementing this will have ElympicsVars gatheredIInputHandler
- inputs gathering, better described in the inputs sectionIInitializable
- initializing things right after Elympics initializationIUpdatable
- containsElympicsUpdate
method to advance game logic in deterministic wayElympicsVar
- all variables representing synchronized state contained in components
Advanced components:
IStateSerializationHandler
- synchronizing non-ElympicsVar
objects with network stateIClientHandler
- client callbacks for client related actionsIServerHandler
- server callbacks for game related actionsIBotHandler
- bot callbacks for bot related actionsIReconciliationHandler
- client reconciliation tweaks for local state altering e.g. force local camera rotation to stay in the same position even if server says otherwise
ElympicsBehaviour
is a mandatory part of synchronized object through the network. It has to be included in every GameObject you want to synchronize or instantiate (root GameObject of Prefab). Also remember that every networkId
has to be unique!
ElympicsMonoBehaviour
ElympicsMonoBehaviour
is a class deriving from Unity MonoBehaviour
and implementing IObservable
interface. It's meant to be used whenever there is need to have a synchronized GameObject
.
Using ElympicsMonoBehaviour
automatically adds ElympicsBehaviour
component to the GameObject
if it's not present (which, as stated before, is mandatory for any state synchronization). As ElympicsMonoBehaviour
implements IObservable
interface, its ElympicsVars
are synchronized.
ElympicsMonoBehaviour
gives you access to many utility properties and methods such as:
PredictableFor
- returns player who can predic correspondingElympicsBehaviour
; often used as ownership indicatorElympics.Player
- returns the player object related to the current game session; identifies the current "me"Elympics.IsServer
,Elympics.IsClient
,Elympics.IsBot
- booleans indicating if the current game instance is respectively Server, Client, or BotElympics.Tick
- returns the current Tick number, a moment in time relative to the start of a matchElympics.TickDuration
- indicates how much time in seconds a single Tick takesElympics.TicksPerSecond
- indicates how many Ticks happen in one secondElympicsBehaviour
- returns the correspondingElympicsBehaviour
instance, which among others gives access toTryGetInput(...)
methodElympicsInstantiate(...)
,ElympicsDestroy(...)
- for dynamic creation and deletion of objectsElympics.Disconnect()
- disconnects player from the current match (available on Client only)Elympics.EndGame(...)
- finalizes a match (mandatory, available on Server only); by default handled by theDefaultServerHandler
Note that if you don't need mentioned properties and methods in given GameObject
, ElympicsMonoBehaviour
itself isn't required to make synchronization work, but it prevents you from forgetting about adding ElympicsBehaviour
and IObservable
.
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
There are strict requirements for ElympicsVar
to be synchronized correctly:
- it must be initialized correctly
- it must be an instance field accessible within a class implementing
IObservable
interface- inherited accessible (
public
orprotected
) fields of base classes are synchronized - inaccessible (
private
) fields of base classes aren't synchronized ElympicsVar
properties aren't synchronized- static fields aren't synchronized
- inherited accessible (
- the class script must be attached as a component to a game object containing Elympics Behaviour.
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.
A snapshot is collected in the following manner:
ElympicsSystem
- (Client
,Server
orBot
) -GetSnapshot
ElympicsBehaviourManager
- iterating overElympicsBehaviour
s innetworkId
ascending orderElympicsBehaviour
- callingSerialize
overElympicsVar
s and collecting all byte data into a single array.
Deserialization
Used only on Clients to apply incoming data from a Server.
A snapshot is applied in the following manner:
ElympicsSystem
- (Client
,Server
orBot
) - 2 cases whetherClient
has enabled prediction- Without prediction
ElympicsBehaviourManager
- iterating overElympicsBehaviour
s innetworkId
ascending orderElympicsBehaviour
- callingDeserialize
overElympicsVar
s.
- With prediction
- Checking if predicted state is correct, if it's not correct then
ElympicsBehaviourManager
- iterating over PredictableElympicsBehaviour
s innetworkId
ascending orderElympicsBehaviour
- calling deserialize overElympicsVar
s.
ElympicsBehaviourManager
- iterating over Not PredictableElympicsBehaviour
s innetworkId
ascending orderElympicsBehaviour
- callingDeserialize
overElympicsVar
s.- Applying predicted input
- Checking if predicted state is correct, if it's not correct then
- Without prediction
Incorrectly predicted state triggers the process of reconciliation, which is more complicated and is explained in greater detail on its own page.
Initializing ElympicsVars
ElympicsVars
can be initialized in 2 ways, statically or using Initialize.
ElympicsVars
must be initialized at the end of IInitializable.Initialize
at the latest! Otherwise an exception will be thrown.
Static
Simple static assign in a 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 ElympicsUpdate
s.
There's always a possibility that ValueChanged
won't be fired on Client
, even if the value has changed on server. The reason is not all snapshots reach clients. Some of them may be skipped too, e.g. when two snapshots arrive in the same time, only the newer one is handled. Don't make your crucial logic dependent on these events.
For example there is a chance that your not-predicted ElympicsInt
incremented in every tick jumps from 1 to 3 instead jumping from 1 to 2 to 3 etc.
ElympicsVar
s in ValueChanged
Be careful when modifying values of ElympicsVar
s inside ValueChanged
callbacks.
It is not defined if the change is processed in the same tick or in the next one.
The previous value of the modified variable can also be lost in the process.
This behavior is to be fixed in future releases of our SDK.
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.
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
Don't synchronize RigidBody
and Transform
together, because it's very bad for performance.
ElympicsRigidBody2DSynchronizer
Used to synchronize RigidBody2D
parameters such as:
position
rotation
velocity
angularVelocity
drag
angularDrag
inertia
mass
gravityScale
Don't synchronize RigidBody2D
and Transform
together, because it's very bad for performance.
Coding principles
These are the most important rules for state programming and should always be followed.
Remember, all variables affecting the gameplay have to be synchronized using ElympicsVars or be closely linked to them!
Simple variables affecting gameplay have to be synchronized
Let's take a look into a snake game with random apple position.
The player can't see it because only the server is choosing its position using Random
, and the client will think that the apple is always at the (0,0) position.
- Problem
- Solution
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;
}
}
}
The solution for this problem is to synchronize the variables.
Other variables affecting gameplay have to be closely linked to ElympicsVars
Lets take a look at an example similar to the previous one. We are going to move the player (change transform.position
) using input, and we forgot to add ElympicsTransformSynchronizer
to player GameObject
.
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 few ticks, with time, due to some indeterministic events, the position, as viewed by the player, will start to differ more and more from what is on the server.
In a game with multiple players, the situation would be even worse, as players would not see the movement of other players' avatars.
There is no elegant solution for 2 predictables contact
In this case, we are going to analyze what happens when a player affects other player's 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 collision occurs on a client
A->B
A->B
A->B - the client receives updated positions from server
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 the server acknowlegment of the position change. Then we can observe a bigger jump in the position change - it depends on the server-client latency our client experiences.
Player hitting other player
- Problem
- Solution
public void ElympicsUpdate()
{
if (!ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
return;
inputReader.Read(out int shotPlayer);
// Modify ElympicsVar on other player's game object, which is not predictable for us
players[shotPlayer].hp -= 10;
}
public void ElympicsUpdate()
{
// Only modify other player's HP on the server
if (!Elympics.IsServer || !ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
return;
inputReader.Read(out int shotPlayer);
players[shotPlayer].hp -= 10;
}
In this case, the HP indicator will show us altered value on our game client, but just for one tick, after which the state from the server will be applied to the game object that is not predictable for us, overriding our local change. Once server processes our input and acknowledges that change, it will be applied again.
This will cause the value of hp
on our game client to change in each tick like this:
100 -> 90 -> 100 -> 100 -> 90 -> 90 -> 90
If we don't try to predict the outcome of the attack on our game client and wait for the updated state from server instead, the player will only experience a small delay, but we will eliminate any stuttering.
An alternative approach would be to extract hp
to another ElympicsBehaviour
and make it predictable for all players.
This would eliminate the delay and could also eliminate stuttering, but only if additional factors (like other players) would not invalidate the local predictions, causing reconciliation.
Be careful mixing predictable and unpredictable code
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 a predictable script which call methods from unpredictable scripts can make the game stutter.
- Problem
- Solution
// PlayerInputController - *Predictable for the local player*
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(PredictableFor, transform.localPosition);
if (spawn)
transform.localPosition = spawnController.GetSpawnPoint(PredictableFor);
}
// SpawnController - *Unpredictable*
private ElympicsList<ElympicsVector3> spawnPoints; // Every player's current spawn point
public void SetSpawnPoint(ElympicsPlayer player, Vector3 newSpawnPoint)
{
var index = GetPlayerIndex(player);
spawnPoints[index].Value = newSpawnPoint;
}
public Vector3 GetSpawnPoint(ElympicsPlayer player)
{
var index = GetPlayerIndex(player);
return spawnPoints[index].Value;
}
// PlayerInputController - *Predictable for the local player*
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(PredictableFor, transform.localPosition);
if (spawn && Elympics.IsServer)
transform.localPosition = spawnController.GetSpawnPoint(PredictableFor);
}
// SpawnController - *Unpredictable*
private ElympicsList<ElympicsVector3> spawnPoints; // Every player's current spawn point
public void SetSpawnPoint(ElympicsPlayer player, Vector3 newSpawnPoint)
{
var index = GetPlayerIndex(player);
spawnPoints[index].Value = newSpawnPoint;
}
public Vector3 GetSpawnPoint(ElympicsPlayer player)
{
var index = GetPlayerIndex(player);
return spawnPoints[index].Value;
}
Doing spawn
in the next few ticks would teleport player to the old spawn point, because SetSpawnPoint
will be overriden by recently received server snapshot and set to the correct one when this information makes a round trip between client and server.
After the spawn point changes, the client has to correct the position and make additional teleport.
Try not to mix predictable code with unpredictable one until you have to. But if you have to, it has to be rare interaction or your clients will suffer very stuttering gameplay.
NetworkIds
All NetworkIds
of ElympicsBehaviours
have 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 underlyingNetworkId
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