Table of Contents
- Demo
- Outline
- Case 1: Predicted entity interactions with networked entities
- Case 2: Floating point determinism
- Detection
- Smoothing
- Code
- Alternative smoothing method
- Possible improvements
- Next article
Welcome to the fourth article of the Fast-Paced Multiplayer Implementation series.
This article will conclude the server reconciliation technique by adding error smoothing.
Demo
Here’s the demo: https://fouramgames.com/posts/smoothing/main.html (Chrome recommended). The source is in the usual repository, in the smoothing branch.
Use the E and R keys to cause slight or large desyncs between the server and client states. Players display a green outline during server reconciliation smoothing. Use the Smoothing checkbox to toggle the technique on/off.
Outline
It’s possible that the client and server simulations diverge for a couple of reasons mentioned below. When this happens the client visually snaps to the position indicated by the server. This is jarring and it’s better to smooth this correction over a period of time instead.
Case 1: Predicted entity interactions with networked entities
Let’s pretend that we have a trap door that any player can open or close. When closed it would prevent movement through its position.
In the timeline below, the client and server states will diverge in T1. In T2 the client’s predicted movement becomes invalid. In T3 the client will detect the error and can begin correcting it.
Case 2: Floating point determinism
Differences between floating point math across CPUs can result in tiny errors that accumulate over time. Eventually they can affect the outcome of collisions for example which creates even more significant errors until it skyrockets.
I’ll refer once again to Glenn’s articles: Deterministic Lockstep and Floating Point Determinism. In summary, it’s very difficult to guarantee determinism on floats across different CPUs. I won’t even attempt to in this series. This means that client and server simulations slowly but surely drift apart.
Detection
Discrepancies can be detected on the client’s side:
- During local prediction, store every input with the resulting physics state (simply the X position in our case)
- On reception of the server’s response for an input, before applying reconciliation, look up the saved state for that input
- Compare the two with a bit of leeway for floating point errors
Smoothing
Smoothing is done by separating the player’s entity’s true position from its render position (where the client is actually drawn):
- When a desync happens, the client’s server reconciliation snaps the true player position to the authoritative server position before replaying saved inputs.
- Smoothing behavior is activated, which will lerp the player’s display position to the true position with a given speed until they match again.
- When there’s no desync, the display position is simply the true position.
Let’s bring this into a context with physics and collisions. This will mean that in cases where the desync causes a player to become stuck on a wall or causes him to miss a jump, the player will float through walls/floors/ceiling/etc during the client’s smooth correction to get back to the true position. It might seem counterintuitive, but ignoring collision and physics in the correction process is actually desirable, or else the player would snag on colliders and produce all sorts of irregular movement.
Code
Detect error:
reconcile(state: ServerEntityState) {
// Set authoritative position
this.x = state.position;
let idx = 0;
while (idx < this.pendingInputs.length) {
var input = this.pendingInputs[idx];
if (input.inputSequenceNumber == state.lastProcessedInput) {
let offset = state.position - input.position;
// Detect error
if (Math.abs(offset) >= 0.00001) { // Epsilon
this.error = true;
this.errorTimer = 0.0; // Reset lerp timer
}
}
idx++;
}
// Server Reconciliation. Re-apply all the inputs not yet processed by
// the server.
...
}
Correct error by lerping by a constant factor over a constant amount of time:
// Called once per frame
errorCorrect(dtSec: number) {
if (this.error) {
// Lerp
let weight = 0.65;
this.displayX = this.displayX * weight + this.x * (1.0 - weight);
// Update timer
this.errorTimer += dtSec;
// Have we been lerping for 0.25 seconds?
if (this.errorTimer > 0.25) {
this.error = false;
}
}
else {
// No error correction required
this.displayX = this.x;
}
}
Alternative smoothing method
Lerp becomes more aggressive over time, and the correction is finished when the positions actually match (more accurate than relying on a timer):
// Called once per frame
errorCorrect(dtSec: number) {
if (this.error) {
// Lerp, increasing the rate over time
let weight = Math.max(0.0, 0.75 - this.errorTimer);
this.displayX = this.displayX * weight + this.x * (1.0 - weight);
// Update timer
this.errorTimer += dtSec;
// Check if the error has been corrected
let offset = this.displayX - this.x;
if (Math.abs(offset) < 0.00001) { // Epsilon
this.error = false;
}
}
else {
// No error correction required
this.displayX = this.x;
}
}
Possible improvements
I’m not entirely satisfied with both smoothing methods so far, but I’ll call it quits for now.
The second smoothing method creates abrupt motion when errorTimer is reset. This is most visible at low server update rates and long desyncs.
The first method works better in this regard but treats both large and small errors with the same lerp constant. At high update rates and short desyncs its motion is inferior to the second method.
Next article
The next article will be about clock synchronization and converting to and from local and remote time. This will be key to the upcoming hack detection, remote entity extrapolation/interpolation, and other future systems.
You can subscribe to the mailing list to be notified when a new article appears.