Skip to content

Conversation

@NiftyliuS
Copy link

@NiftyliuS NiftyliuS commented Mar 10, 2025

(feat) Tick based CSP with prediction and reconciliation

What is the goal:

The goal is to allow a convenient solution to synchronize the Prediction tick and Server Replay ticks to allow for smooth
and CONSISTENT player experience when running server authoritative architecture while minimizing the latency based on network conditions such as packet loss.

Components:

Network Tick Manager (public class NetworkTickManager : NetworkBehaviour)

This is the core component that will handle the clients connection and synchronization by carefully mapping the arrival and delay of sent and received ticks
data.

The actual timing doesn't matter and can be un-equal for sending and receiving.
All we care about is that my current client inputs arrive at the correct time on the server
and that the server send me the information before i replay server characters actions on the client.

To reduce traffic and complexity i am using 0-2047 looping tick numbers. This however doesnt mean there is no absolute tick counter available through the sync
process.

Predicted tick

  • Advancing forwards (fast forward a client)
    • This is done by running 2 ticks in quick succession. This is done to predict further.
  • Advancing backwards (pause a client)
    • This is done by skipping tick execution. This is done to predict less when possible (reduces latency).

Server replay tick

  • Adjusted directly and if the distance to client prediction tick is changing will force reconcile to align the game state.

Physics Controller (public class NetworkPhysicsController : MonoBehaviour)

Since i have to change when to execute physics simulations and how many this is the result. A simple implementation that allows for networked items to register
and simulate/reconcile correctly.

Execution Order

  • Tick advance - advances both client predicted and server replay ticks by 1.
    • If reconcile was requested will call OnResetNetworkState()`.
  • OnBeforeNetworkUpdate(deltaTicks, deltaTime) - this runs first.
  • OnNetworkUpdate(deltaTicks, deltaTime) - Equivalent to FixedUpdate and runst before physics simulation advance.
  • NetworkPhysicsController.PhysicsTick(float deltaTime) - if you have custom physics solution this should be overriden.
  • OnAfterNetworkUpdate(deltaTicks, deltaTime) - Runs after the physics simulation has advanced 1 step.
    • AfterNetworkReconcile() - If reconcile has completed will also run ( useful to smooth jumps in position )
 // Example using KCC (Kinematic CharacterController)
 public class KinematicPhysicsController: NetworkPhysicsController{
    public override void PhysicsTick(float deltaTime) {
      KinematicCharacterSystem.PreSimulationInterpolationUpdate(deltaTime);
      KinematicCharacterSystem.Simulate(deltaTime, KinematicCharacterSystem.CharacterMotors, KinematicCharacterSystem.PhysicsMovers);
      KinematicCharacterSystem.PostSimulationInterpolationUpdate(deltaTime);
      Physics.Simulate(deltaTime);
    }
  }

Network Physics Entity (public class NetworkPhysicsEntity)

This is a static class that stores all the registered items to execute during networked simulation. It should not be used for things like decorations or
particles or anything that does not affect the player and doesnt need to be simulated on clients.

  • NetworkPhysicsEntity.AddNetworkEntity(INetworkedItem item, int priority = 0) - Adds an item form execution list
    • item - current item to add to the registry.
    • priority - Affects the execution order - the higher the earlier it will be executed (items with same execution order will be executed in the order they
      were registered)
  • NetworkPhysicsEntity.RemoveNetworkEntity(INetworkedItem item) - Clears an item form execution list
    • item - current item to remove from the registry.
public class NetworkPlayerController : NetworkBehaviour, INetworkedItem{
    private void OnEnable() {
      NetworkPhysicsEntity.AddNetworkEntity(this, 1);
    }

    private void OnDisable() {
      NetworkPhysicsEntity.RemoveNetworkEntity(this);
    }
    
    private void OnDestroy() {
      NetworkPhysicsEntity.RemoveNetworkEntity(this);
    }
    
    public void OnResetNetworkState() {
        // for example: Position the character in the past position and clean up last ticks data if needed
    }
    
    public void OnBeforeNetworkUpdate(int deltaTicks, float deltaTime) {
        // for example: Position the character and clean up last ticks data if needed
        // if NetworkTick.IsReconciling use server character position instead
    }
    
    public void OnNetworkUpdate(int deltaTicks, float deltaTime) {
        // for example: Apply the most recent player inputs
        // if NetworkTick.IsReconciling -> set inputs from history
    }
    
    public void OnAfterNetworkUpdate(int deltaTicks, float deltaTime) {
        // for example: Compare ticks to server history and send inputs to the server
        // if NetworkTick.IsReconciling -> dont send data to server
    }
    
    // When to send the data to sever is up to the developer - im not sure whats best myself yet.
    // Regardless it is safe to send ticks both in before and after the simulation
   ...

Network Tick (public class NetworkTick)

The class NetworkTick is there with static fields to allow for access across the project and consists of several static getters:

// When packet loss is detected and compensated for  you can access their counters with
// Client ONLY
NetworkTick.ClientToServerPacketLossCompensation;
NetworkTick.ServerToClientPacketLossCompensation;
// Server ONLY
NetworkTick.GetClientToServerCompensation;
NetworkTick.GetServerToClientCompensation;

// This is crucial to send current inputs + past inputs when packet loss is present in addition to instruction the server
// to do the same using "reliable" flag.
// NOTE: These are only availabe on CLIENT and not the server. The server does not keep this information.

NetworkTick.IsServer;               // indicates if the instance has a server running
NetworkTick.IsSynchronizing;  // In the middle of synchronization with the server
NetworkTick.IsSynchronized;   // Is the client synchronized with the server
NetworkTick.IsReconciling;     // Is the currently reconciling - used to apply stored inputs vs current ones on client for example

// Note: These ticks are 0-2047 and are looping to reduce network overhead.
NetworkTick.CurrentTick;  // Will return Client or Server tick depending on IsServer
NetworkTick.ClientTick;    // Will return the current Client tick ( predicted tick )
NetworkTick.ServerTick;   // Will return the Server tick ( Replay on client and executing tick on server )

// These are absolute ticks from beginning of the session
NetworkTick.CurrentAbsoluteTick;  // Will return Client or Server tick depending on IsServer
NetworkTick.ClientAbsoluteTick;     // Will return the current Client tick ( predicted tick )
NetworkTick.ServerAbsoluteTick;     // Will return the Server tick ( Replay on client and executing tick on server )

// If you want to work with 0-2047 ticks you have two functions for correct calculations:
NetworkTick.SubtractTicks(tickOne, tickTwo);  // this will calculate the looping ticks correctly
NetworkTick.IncrementTick(tick, increment);   // Will correctly increment the tick with the looping in-mind

How the numbers are calculated:

Client will send his tick to the server and the server will simply return how far the client tick is from the server.

385445550-2c5059e0-0d96-4f55-8c65-30c09a41181b

The client will then adjust based on the available data and running minimums.
Same goes for the server but instead of sending to the server we ju
385445902-6c3e20b8-2861-46d4-99a9-477b1439d9b1
st compare current reply tick with what we receive from the server

Notes:

While testing the most likely tick offset between client predicted and server replay is going to be 2-3.
This is due to Unity fixedUpdate being tied to the framerate and Mirrors optimizations ( batching ).
![385446805-2c9bf282-bc1c-4582-b4d3-ea6b6cc24b21](https://github.com/user-attachments/a
385446858-9baa33ea-e065-446f-bf01-4a0cc1b60e14
ssets/0d89a373-ce61-404f-bb30-0c4752d38052)

While 1 tick is possible it is very rare on high tick systems ( 50+ ticks )


Inputs and States structure

Sent and Received data structure:
image

inputs-and-states-methods

The intention with these custom states is to provide a flexible yet efficient method of transfering data between server and client
To do that i employ the following strategies:

Inputs

  • Changes + full inputs
    • full inputs - while large compared to changes are necessary to ensure that if one of the changes was missed the clients can recover
    • changes in inputs - i will only send changes in between the full syncs so if you press and hold W there will be only 1 sent packet
      • note: the changes refer to previous tick inputs
    • ** durable inputs ** - this sends a minimum of 2 tick inputs ( changes or otherwise ) in case a single packet containing a change was lost causing a short
      lived desync
      • a single change will be sent twice ( once on the tick it happened and once on the next tick )
      • same goes for full inputs data

The server and client work similarly ( both for remotely controlled characters ) by actively reconstructing the full inputs by overlaying changes one over the
other.

States ( server to client only )

  • Full states - has to be sent to allow the client to verify that local simulation is on the right track, if client detects a desync it will order a
    reconcile from that tick based on the received server state and inputs
  • optional: State Changes - Allow for a much faster and less intrusive desync correction, by overlaying changes one on top of another every previous tick is
    viable to reconcile from.
  • ** durable states ** - this sends a minimum of 2 tick states ( changes or otherwise ) in case a single packet containing a change was lost causing a short
    lived desync
    • a single change will be sent twice ( once on the tick it happened and once on the next tick )
    • same goes for full state data

The changes are calculated by using GetChangedStateComparedTo and GetChangedInputsComparedTo.
The overlaying of changes on previous states/inoputs is done with OverrideInputsWith and OverrideStateWith

These accept custom additional override and compare functions. ( see bellow )

Inputs And States Additionals

To allow for full flexibility since there is no way for me to know what inputs developers might require i've exposed:

  • AdditionalState
  • AdditionalInputs
    They accept Array of Bytes and by default will be compared bitwise for any changes.

There is a way to optimized this by using:

    // Functions for comparing additional states and inputs for network optimization.
    // Each function accepts two byte arrays:
    // - For change compare functions: the first is the previous additional state/inputs, the second is the new additional state/inputs.
    // Returns a byte array representing the differences; an empty array indicates no changes.
    public Func<byte[], byte[], byte[]>? AdditionalInputsChangeCompare;
    public Func<byte[], byte[], byte[]>? AdditionalStateChangeCompare;

    // Functions for overriding additional states and inputs for network optimization.
    // Each function accepts two byte arrays:
    // - For override functions: the first is the base additional state/inputs, the second is the override state/input.
    // Returns a byte array representing the additional state/inputs after the modifications.
    public Func<byte[], byte[], byte[]>? AdditionalInputsOverride;
    public Func<byte[], byte[], byte[]>? AdditionalStateOverride;

Network Player Controller Base

This is an abstract class that is meant to control the local character. To use it you need to implement the following functions:

// Will be called when the client needs to reconcile or adjust - called on both local and remote
public override void ResetPlayerState(NetworkPlayerState state)
public override void ResetPlayerInputs(NetworkPlayerInputs inputs)


// Will be used on localPlayer - will be called when requesting inputs for the current tick to send and execute
public override NetworkPlayerInputs GetPlayerInputs()
    
// I compress inputs so to ensure that the client simulation is identical the current tick inputs should be set from here
public override void SetPlayerInputs(NetworkPlayerInputs inputs)

    
// Will be requested once per tick to get the current state - will be used to compare to server states when they arrive and to reconcile
public override NetworkPlayerState GetPlayerState()
    
// Will be called right after getting player state ( this is done to ensure the changes are significant enough to send )
// Should be used to set/adjust current player state
public override void SetPlayerState(NetworkPlayerState state)

note: Server never reconciles or adjusts

This is it nothing else is required. For network optimization it is recommended to
implement AdditionalInputsChangeCompare, AdditionalStateChangeCompare, AdditionalInputsOverride and AdditionalStateOverride

Reconcile sethods soft and hard

I've implemented 2 ways to reconcile - one is called "hard" and its there to reconcile any significant change in position, rotation or velocity.

hard reconcile
Once player history ( state in the past ) does not match the one received from the server a reconcile flag will be raised and before the next tick a reconcile
will be run.

Reconcile is run by reseting the client state to the tick where the desync was dettected and then running fast forward to the current tick. afterward the next
tick will be executed as usual.

To add additional reconcile triggers please implement

protected virtual bool CustomReconcileCheck(NetworkPlayerState localState, NetworkPlayerState remoteState)

soft reconcile
It is often the case that there are micro changes on the order of 1e-7 or 1e-8 that are not picked up during desync dettection, due to compoinding effect
however they will cause a hard reconcile down the line.

To prevent that the current (predicted) tick will bee adjusts by the small difference in position, rotation or velocity.

note: This has shown to almost negate the need for hard reconcile, however it does not fix anything besides position, rotation or velocity

@NiftyliuS NiftyliuS marked this pull request as ready for review March 10, 2025 21:46
public int GetReconcileStartTick() => _reconcileStartTick;

/// <summary> Is reconcile requested or not </summary>
public bool IsPEndingReconcile() => _pendingReconcile;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsP_E_nding typo

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true that :) will fix

@strich
Copy link

strich commented Nov 17, 2025

What ended up being the status of this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants