Smooth everyone else's movement
For Rust developers with a server-authoritative game who want remote players to move smoothly on screen and shots to land where the shooter aimed
You will build a headless program with a server and one client joined by a simulated link with latency and jitter. A runner controlled by the server paces back and forth. The client draws it a fixed beat in the past with RemoteView and InterpolationClock, coasts it through a stretch of lost snapshots and fires at it. The server then judges each shot with HistoricalStateBuffer against the moment the shooter actually saw.
Before you start
You need Rust with Cargo. plaza comes from crates.io:
plaza = "0"Set up the project#
Start a new binary crate.
Add plaza for the wire payloads, plaza_client_utils for the client side and plaza_server_utils for the rewind buffer. The net-sim feature turns on the simulated link. Your [dependencies] section should look like this:
[]
= "0"
= { = "0", = ["net-sim"] }
= "0"Everything runs on simulated time at 10ms per frame and the link's jitter comes from a seeded Rng. The output is the same on every run.
Draw a remote entity in the past#
You cannot predict an entity someone else controls, because you do not have its inputs. What you do have is a stream of snapshots, each stamped with the server time it describes. The client draws the entity at one instant behind the newest snapshot, between two snapshots it has actually received.
The runner moves at 4 units a second between x=0 and x=4 and turns at each end. runner_at gives its position and velocity at any server time. The server samples it every 50ms and sends a RemoteEntitySnapshot from plaza's game_common::reconciliation module, with the position, the velocity and the server time. The link adds 80ms plus up to 30ms of jitter, so snapshots arrive unevenly.
Ghost is the state the client draws. It implements two traits from plaza_client_utils. Interpolatable<u64> blends two snapshots. Extrapolatable<f32, f32> moves a state along its velocity for a number of seconds. RemoteView needs both.
On the client, RemoteView::new(12, 250) keeps the last 12 snapshots and coasts at most 250ms past the newest one. InterpolationClock::new(100) is the render clock. Its first observe starts it at that snapshot's server time and advance moves it forward every frame. target is the instant to draw: the clock's estimate of server time minus the 100ms delay. Every entity in a frame is drawn at that one target.
render takes the target and a RenderOpts. The default interpolates and extrapolates. With both switched off it draws the newest snapshot as it is, which is what a client without interpolation would show. The program draws both every frame and prints the last 12 frames next to where the runner really was at the drawn instant.
Replace src/main.rs with this:
use RemoteEntitySnapshot;
use Extrapolatable;
use Interpolatable;
use ;
use ;
const RUNNER: u32 = 2;
const STEP_MS: u64 = 50;
const FRAME_MS: u64 = 10;
const ONE_WAY_MS: u64 = 80;
const JITTER_MS: u64 = 30;
const RENDER_DELAY_MS: u64 = 100;
const SHOW_FROM_MS: u64 = 1160;
const RUN_MS: u64 = 1270;
type Snapshot = ;
Run it:
The newest snapshot moves in steps of 0.2 and sits still for several frames in between, which looks like stutter on screen. The interpolated runner moves 0.04 every frame and matches the true position, turn included. That is exact here because the runner moves in straight lines and the turn at 1000ms falls on a snapshot. On a curved path the line between two snapshots cuts the corner a little.
The drawn instant trails the client's clock by 200ms in this run: the 100ms delay plus the age of the first snapshot when it arrived. Once the first snapshot has started it, the clock runs on frame time and is never steered by when a packet happens to arrive. Jitter changes nothing on screen as long as two snapshots bracket the target.
Coast through a gap#
When snapshots stop arriving, the target passes the newest snapshot and there is nothing to interpolate toward. RemoteView then builds an ExtrapolationBase from the newest snapshot and moves it along that snapshot's velocity. The second argument of RemoteView::new caps how long it coasts. Past the cap the entity holds at the limit instead of jumping back. over_extrapolations counts every render that reached the cap.
This version sends every snapshot stamped from 850ms to 1100ms with 100% loss, so none of them arrive. It also caps coasting at 150ms. RenderOpts { interpolate: true, extrapolate: false } still interpolates when it can but holds the newest snapshot in a gap. The program draws the runner both ways every frame and prints every 50ms of drawn time.
Replace src/main.rs:
use RemoteEntitySnapshot;
use Extrapolatable;
use Interpolatable;
use ;
use ;
const RUNNER: u32 = 2;
const STEP_MS: u64 = 50;
const FRAME_MS: u64 = 10;
const ONE_WAY_MS: u64 = 80;
const JITTER_MS: u64 = 30;
const RENDER_DELAY_MS: u64 = 100;
const GAP_MS: Range = 850..1150;
const RUN_MS: u64 = 1400;
type Snapshot = ;
Run it:
The last snapshot before the gap is the one from 800ms. The held runner freezes at 3.20. The coasted runner keeps going at 4 units a second and matches the truth until the cap stops it at 950ms, at 3.80. At 1000ms the true runner turns. Dead reckoning has no way to know that.
When snapshots resume, the snapshot from 1150ms brackets the target together with the one from 800ms, so both columns interpolate between them again. The turn at 1000ms was never received, so the runner is drawn on the straight line between 3.20 and 3.40 and misses the corner. From 1150ms on both match the truth again. The 8 renders past the cap are the frames between 950ms and the first snapshot after the gap.
The plaza guide treats extrapolation as a short fallback for exactly this case. A steadily climbing over_extrapolations means the target is ahead of the newest snapshot all the time, which is a clock problem rather than a network one.
Judge a shot against what the shooter saw#
The client draws the runner 200ms in the past. When the player fires at what is on screen, the runner has already moved on the server. By the time the shot arrives it has moved further. Lag compensation fixes this on the server: it keeps a short history of every entity and judges the shot against the moment the shooter was drawing.
HistoricalStateBuffer from plaza_server_utils is that history. record_state stores each entity's state once per server step. state_within returns the state at a past instant, interpolated between the two recorded states that bracket it, through the same Interpolatable impl the client uses. It returns None for an instant outside what the buffer holds. get_state_at_or_before answers the same question but clamps such an instant to the oldest or newest state. new(64) keeps 64 states per entity, 3.2 seconds at 20 steps a second.
The client sends each shot as a TimestampedClientAction, carrying the instant it drew and where it aimed. It aims exactly at the runner as drawn. The server checks the shot twice, once against the runner's present position and once against the rewound one. A shot hits within 0.1 units.
Replace src/main.rs:
use ;
use Extrapolatable;
use Interpolatable;
use ;
use ;
use HistoricalStateBuffer;
const RUNNER: u32 = 2;
const STEP_MS: u64 = 50;
const FRAME_MS: u64 = 10;
const ONE_WAY_MS: u64 = 80;
const JITTER_MS: u64 = 30;
const RENDER_DELAY_MS: u64 = 100;
const FIRE_AT_MS: = ;
const HIT_RADIUS: f32 = 0.1;
const RUN_MS: u64 = 1500;
type Snapshot = ;
type Shot = ;
Run it:
Every shot misses the present, by up to 1.16 units. Every rewound check finds the runner exactly where the shooter saw it, because the server interpolates between the same snapshots with the same impl.
The rewind decides who absorbs the latency. With it the shooter's aim counts. The price is that a target can be hit after it has already reached cover on its own screen. The plaza guide covers that trade and the limits a real server puts on how far back a client may ask. oldest_time and newest_time give the edges of the buffer, so a server can cap the rewind by what it actually holds.
Where to go next#
Snapshots at 20 a second keep the straight line between samples close to the truth. At low send rates the line visibly cuts corners. HermiteView interpolates with the velocity at both ends instead, for paths that curve smoothly between snapshots. ArrivalMonitor measures how much render delay a stream needs instead of relying on a guessed constant. InterpolationClock::resync or observe_rate keeps a long-running clock aligned.
Chapter 21 of the plaza guide covers the rendering hierarchy, extrapolation as a fallback and lag compensation. The netcode_playground example puts interpolation and lag compensation behind switches. The hit_scan example counts hits granted by rewind against deaths suffered behind cover.
The plaza_client_utils usage guide and the plaza_server_utils usage guide on GitHub cover the render clock, remote views and the rewind buffer. The API references are on docs.rs for plaza_client_utils and plaza_server_utils.
