90 lines
7.8 KiB
Markdown
90 lines
7.8 KiB
Markdown
|
|
### Predictive Reexecution
|
||
|
|
|
||
|
|
This document explains the general principles of predictive reexecution, the mechanism by which Luprex keeps all connected clients in sync with the server while hiding network latency from the player.
|
||
|
|
|
||
|
|
## The Problem: Network Latency
|
||
|
|
|
||
|
|
In a multiplayer game, the server is the authority on the state of the world. When a player clicks a button or types a command, the command must travel to the server, the server must process it, and the result must travel back. This round trip takes time. If we made the player wait for the round trip before showing anything on screen, the game would feel sluggish and unresponsive.
|
||
|
|
|
||
|
|
Predictive reexecution solves this problem. The client predicts the outcome of the player's action and shows it immediately, without waiting for the server. Later, when the server's authoritative result arrives, the client corrects itself if the prediction was wrong.
|
||
|
|
|
||
|
|
## The Four World Models
|
||
|
|
|
||
|
|
The system uses four types of world models. Each is an instance of class World, with its own Lua interpreter.
|
||
|
|
|
||
|
|
**Master** (one, on the server). This is the authoritative state of the entire game. When a command arrives from any client, the master executes it immediately. The master contains all tangibles, all globals, and all source code. It is the single source of truth.
|
||
|
|
|
||
|
|
**Server-synchronous** (one per connected client, on the server). This is the server's record of what a particular client knows. It does not execute commands when they first arrive at the server. Instead, it executes them later, when the server issues an acknowledgement. The server needs this model in order to compute difference transmissions: it compares the master to the server-synchronous model to figure out what updates to send.
|
||
|
|
|
||
|
|
**Client-synchronous** (one, on the client). This is the client's confirmed view of the world. It receives the exact same command acknowledgements as its corresponding server-synchronous model, and because the driven portion of the engine is fully deterministic, the two remain in perfect sync at all times.
|
||
|
|
|
||
|
|
**Asynchronous** (one, on the client). This is the client-synchronous model with predictions applied on top. It is what the player actually sees on screen. It is created by taking a snapshot of the client-synchronous model and then applying predicted actions to the snapshot. It is transient: when corrections arrive, the client rolls back to the client-synchronous state and reapplies predictions as needed.
|
||
|
|
|
||
|
|
## What Happens When the Player Issues a Command
|
||
|
|
|
||
|
|
Suppose the player clicks a button that invokes a Lua function. Here is the sequence of events:
|
||
|
|
|
||
|
|
1. The client sends the command to the server.
|
||
|
|
|
||
|
|
2. The client immediately applies the command to the asynchronous model, so the player sees an instant response. This is the prediction.
|
||
|
|
|
||
|
|
3. The server receives the command and executes it on the master model.
|
||
|
|
|
||
|
|
4. The server issues an acknowledgement for the command.
|
||
|
|
|
||
|
|
5. The server-synchronous model for that client executes the command upon receiving the acknowledgement.
|
||
|
|
|
||
|
|
6. The acknowledgement travels to the client. The client-synchronous model executes the command upon receiving the acknowledgement.
|
||
|
|
|
||
|
|
7. The client can now roll back the asynchronous model to the (updated) client-synchronous state and reapply any predictions that haven't been acknowledged yet.
|
||
|
|
|
||
|
|
Because the server-synchronous and client-synchronous models always receive the same acknowledgements in the same order, and because the driven portion is deterministic, they always end up in the exact same state. They never need to exchange full state to stay in sync.
|
||
|
|
|
||
|
|
## Two Update Channels
|
||
|
|
|
||
|
|
The synchronous models receive updates through two channels:
|
||
|
|
|
||
|
|
**Command acknowledgements.** These carry the client's own actions. When an acknowledgement arrives, both the server-synchronous and client-synchronous models execute the command. This is how a client's own actions propagate into the synchronous models.
|
||
|
|
|
||
|
|
**Difference transmission.** This carries everything else: other players' actions, server-side events, tangibles entering or leaving the visibility radius, and any other changes to the master that aren't caused by this client's commands. The server compares the master model to the server-synchronous model and sends corrections. The client applies those corrections to the client-synchronous model.
|
||
|
|
|
||
|
|
Between these two channels, the synchronous models are kept up to date with the master.
|
||
|
|
|
||
|
|
## Snapshot and Rollback
|
||
|
|
|
||
|
|
The asynchronous model is not a separate, persistent world model. It is implemented using the snapshot-and-rollback mechanism built into class World.
|
||
|
|
|
||
|
|
The client-synchronous model is the persistent model on the client. When the client needs to show the player a predicted state, it takes a snapshot of the client-synchronous model's memory, applies predictions to it, and renders from the resulting state. When it is time to incorporate new acknowledgements or difference transmissions, it rolls back to the snapshot, applies the updates to the client-synchronous model, and then reapplies any predictions that are still pending.
|
||
|
|
|
||
|
|
The snapshot mechanism works at the memory level: it saves and restores the raw memory used by the Lua interpreter, without needing to understand the contents. This makes it fast.
|
||
|
|
|
||
|
|
## NoPredict and Blocking Operations
|
||
|
|
|
||
|
|
Not all operations can be predicted. Consider an HTTP request: the client cannot predict what the server will return. For operations like these, the system uses a mechanism called NoPredict.
|
||
|
|
|
||
|
|
When a Lua thread attempts a blocking operation in a nonauthoritative (predicted) context, the thread is killed rather than allowed to proceed. This prevents the asynchronous model from going down a code path whose outcome cannot be predicted. The thread will execute for real when the command reaches the master model, and the results will propagate to the client through the normal acknowledgement and difference transmission channels.
|
||
|
|
|
||
|
|
The rules for handling this are described in the document "Correct Implementation of Blocking Operations and NoPredict." In summary:
|
||
|
|
|
||
|
|
- In an authoritative context (the master model), blocking operations proceed normally.
|
||
|
|
- In a nonauthoritative context (a predicted model), blocking operations trigger a NoPredict, which kills the thread via lua_yield.
|
||
|
|
- In a probe context (not yieldable), blocking operations throw an error, because probes are not allowed to change the world state at all.
|
||
|
|
|
||
|
|
## The Scripter's View
|
||
|
|
|
||
|
|
One of the most important consequences of this design is that the Lua scripter does not write any networking code. There are no "send message" or "receive message" calls. The scripter writes Lua code as if the game were running in a single Lua state with coroutines, which is normal for Lua anyway.
|
||
|
|
|
||
|
|
The networking is handled entirely by the engine. Command acknowledgements and difference transmission keep all the models in sync automatically. The scripter occasionally needs to provide hints to the system, such as:
|
||
|
|
|
||
|
|
- Using `permit_invoke` to mark which functions may be called by blueprints, for security.
|
||
|
|
- Being aware that blocking operations will trigger NoPredict in predicted contexts.
|
||
|
|
- Using `global.set` and `global.get` for data that needs to be broadcast to all clients.
|
||
|
|
|
||
|
|
But the core game logic is written without any awareness of networking. This is a clean separation that makes scripting much simpler.
|
||
|
|
|
||
|
|
## Why Determinism Matters
|
||
|
|
|
||
|
|
The entire scheme depends on the determinism of the driven portion of the engine. If the server-synchronous and client-synchronous models processed the same acknowledgements but arrived at different states, the system would break down.
|
||
|
|
|
||
|
|
This is why the engine goes to great lengths to ensure determinism: patching Lua for deterministic table iteration and table length, using the eng::malloc heap for reproducible memory addresses, avoiding threads, avoiding real-time clocks, and avoiding unordered maps. These constraints are described in detail in the document "The Event-Driven Structure of the Engine."
|