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.

cargo new ghosts
cd ghosts

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:

Cargo.toml
[dependencies]
plaza = "0"
plaza_client_utils = { version = "0", features = ["net-sim"] }
plaza_server_utils = "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:

src/main.rs
use plaza::game_common::reconciliation::RemoteEntitySnapshot;
use plaza_client_utils::extrapolation::Extrapolatable;
use plaza_client_utils::interpolation::Interpolatable;
use plaza_client_utils::net_sim::{LatencyLink, Rng};
use plaza_client_utils::{FixedTimestep, InterpolationClock, RemoteView, RenderOpts};

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 = RemoteEntitySnapshot<u32, u64, f32, ()>;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Ghost {
    x: f32,
}

impl Interpolatable<u64> for Ghost {
    fn interpolate(&self, other: &Self, t: f32, _a: u64, _b: u64) -> Self {
        Ghost {
            x: self.x + (other.x - self.x) * t,
        }
    }
}

impl Extrapolatable<f32, f32> for Ghost {
    fn extrapolate_with_velocity(&self, velocity: &f32, dt_secs: f32) -> Self {
        Ghost {
            x: self.x + velocity * dt_secs,
        }
    }
}

fn runner_at(time_ms: u64) -> (f32, f32) {
    let phase = time_ms % 2000;
    if phase < 1000 {
        (phase as f32 / 250.0, 4.0)
    } else {
        ((2000 - phase) as f32 / 250.0, -4.0)
    }
}

fn main() {
    let mut rng = Rng::new(42);
    let mut down: LatencyLink<Snapshot> = LatencyLink::new();
    let mut server_ticker = FixedTimestep::from_step_ms(STEP_MS);
    let mut server_ms = 0;

    let mut view: RemoteView<Ghost, f32> = RemoteView::new(12, 250);
    let mut clock = InterpolationClock::new(RENDER_DELAY_MS);
    let raw = RenderOpts {
        interpolate: false,
        extrapolate: false,
    };

    let mut now_ms = 0;
    while now_ms < RUN_MS {
        now_ms += FRAME_MS;

        for step in server_ticker.advance(FRAME_MS) {
            server_ms += step.as_millis() as u64;
            let (x, v) = runner_at(server_ms);
            let snapshot = Snapshot {
                entity_id: RUNNER,
                server_time: server_ms,
                position: x,
                rotation: (),
                linear_velocity: Some(v),
                angular_velocity: None,
            };
            down.send(now_ms, snapshot, ONE_WAY_MS, JITTER_MS, 0.0, &mut rng);
        }

        for snapshot in down.drain_due(now_ms) {
            clock.observe(snapshot.server_time);
            let velocity = snapshot.linear_velocity.unwrap_or_default();
            view.push(
                snapshot.server_time,
                Ghost {
                    x: snapshot.position,
                },
                velocity,
            );
        }

        clock.advance(FRAME_MS);
        let Some(target) = clock.target() else {
            continue;
        };
        let smooth = view
            .render(Some(target), RenderOpts::default())
            .expect("a snapshot started the clock");
        let newest = view
            .render(Some(target), raw)
            .expect("a snapshot started the clock");
        if now_ms >= SHOW_FROM_MS {
            println!(
                "t={now_ms:>4}ms  drawing {target:>4}ms  newest x={:.2}  interpolated x={:.2}  true x={:.2}",
                newest.x,
                smooth.x,
                runner_at(target).0,
            );
        }
    }
}

Run it:

$ cargo run
t=1160ms  drawing  960ms  newest x=3.80  interpolated x=3.84  true x=3.84
t=1170ms  drawing  970ms  newest x=3.80  interpolated x=3.88  true x=3.88
t=1180ms  drawing  980ms  newest x=3.80  interpolated x=3.92  true x=3.92
t=1190ms  drawing  990ms  newest x=3.80  interpolated x=3.96  true x=3.96
t=1200ms  drawing 1000ms  newest x=3.60  interpolated x=4.00  true x=4.00
t=1210ms  drawing 1010ms  newest x=3.60  interpolated x=3.96  true x=3.96
t=1220ms  drawing 1020ms  newest x=3.60  interpolated x=3.92  true x=3.92
t=1230ms  drawing 1030ms  newest x=3.60  interpolated x=3.88  true x=3.88
t=1240ms  drawing 1040ms  newest x=3.40  interpolated x=3.84  true x=3.84
t=1250ms  drawing 1050ms  newest x=3.40  interpolated x=3.80  true x=3.80
t=1260ms  drawing 1060ms  newest x=3.40  interpolated x=3.76  true x=3.76
t=1270ms  drawing 1070ms  newest x=3.40  interpolated x=3.72  true x=3.72

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:

src/main.rs
use plaza::game_common::reconciliation::RemoteEntitySnapshot;
use plaza_client_utils::extrapolation::Extrapolatable;
use plaza_client_utils::interpolation::Interpolatable;
use plaza_client_utils::net_sim::{LatencyLink, Rng};
use plaza_client_utils::{FixedTimestep, InterpolationClock, RemoteView, RenderOpts};

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: std::ops::Range<u64> = 850..1150;
const RUN_MS: u64 = 1400;

type Snapshot = RemoteEntitySnapshot<u32, u64, f32, ()>;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Ghost {
    x: f32,
}

impl Interpolatable<u64> for Ghost {
    fn interpolate(&self, other: &Self, t: f32, _a: u64, _b: u64) -> Self {
        Ghost {
            x: self.x + (other.x - self.x) * t,
        }
    }
}

impl Extrapolatable<f32, f32> for Ghost {
    fn extrapolate_with_velocity(&self, velocity: &f32, dt_secs: f32) -> Self {
        Ghost {
            x: self.x + velocity * dt_secs,
        }
    }
}

fn runner_at(time_ms: u64) -> (f32, f32) {
    let phase = time_ms % 2000;
    if phase < 1000 {
        (phase as f32 / 250.0, 4.0)
    } else {
        ((2000 - phase) as f32 / 250.0, -4.0)
    }
}

fn main() {
    let mut rng = Rng::new(42);
    let mut down: LatencyLink<Snapshot> = LatencyLink::new();
    let mut server_ticker = FixedTimestep::from_step_ms(STEP_MS);
    let mut server_ms = 0;

    let mut view: RemoteView<Ghost, f32> = RemoteView::new(12, 150);
    let mut clock = InterpolationClock::new(RENDER_DELAY_MS);
    let hold = RenderOpts {
        interpolate: true,
        extrapolate: false,
    };

    let mut now_ms = 0;
    while now_ms < RUN_MS {
        now_ms += FRAME_MS;

        for step in server_ticker.advance(FRAME_MS) {
            server_ms += step.as_millis() as u64;
            let (x, v) = runner_at(server_ms);
            let snapshot = Snapshot {
                entity_id: RUNNER,
                server_time: server_ms,
                position: x,
                rotation: (),
                linear_velocity: Some(v),
                angular_velocity: None,
            };
            let loss_pct = if GAP_MS.contains(&server_ms) {
                100.0
            } else {
                0.0
            };
            down.send(now_ms, snapshot, ONE_WAY_MS, JITTER_MS, loss_pct, &mut rng);
        }

        for snapshot in down.drain_due(now_ms) {
            clock.observe(snapshot.server_time);
            let velocity = snapshot.linear_velocity.unwrap_or_default();
            view.push(
                snapshot.server_time,
                Ghost {
                    x: snapshot.position,
                },
                velocity,
            );
        }

        clock.advance(FRAME_MS);
        let Some(target) = clock.target() else {
            continue;
        };
        let held = view
            .render(Some(target), hold)
            .expect("a snapshot started the clock");
        let coasted = view
            .render(Some(target), RenderOpts::default())
            .expect("a snapshot started the clock");
        if target % 50 == 0 && (750..=1200).contains(&target) {
            println!(
                "drawing {target:>4}ms  held x={:.2}  coasted x={:.2}  true x={:.2}",
                held.x,
                coasted.x,
                runner_at(target).0,
            );
        }
    }
    println!(
        "renders past the coasting cap: {}",
        view.over_extrapolations()
    );
}

Run it:

$ cargo run
drawing  750ms  held x=3.00  coasted x=3.00  true x=3.00
drawing  800ms  held x=3.20  coasted x=3.20  true x=3.20
drawing  850ms  held x=3.20  coasted x=3.40  true x=3.40
drawing  900ms  held x=3.20  coasted x=3.60  true x=3.60
drawing  950ms  held x=3.20  coasted x=3.80  true x=3.80
drawing 1000ms  held x=3.20  coasted x=3.80  true x=4.00
drawing 1050ms  held x=3.34  coasted x=3.34  true x=3.80
drawing 1100ms  held x=3.37  coasted x=3.37  true x=3.60
drawing 1150ms  held x=3.40  coasted x=3.40  true x=3.40
drawing 1200ms  held x=3.20  coasted x=3.20  true x=3.20
renders past the coasting cap: 8

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:

src/main.rs
use plaza::game_common::reconciliation::{RemoteEntitySnapshot, TimestampedClientAction};
use plaza_client_utils::extrapolation::Extrapolatable;
use plaza_client_utils::interpolation::Interpolatable;
use plaza_client_utils::net_sim::{LatencyLink, Rng};
use plaza_client_utils::{FixedTimestep, InterpolationClock, RemoteView, RenderOpts};
use plaza_server_utils::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: [u64; 3] = [730, 1020, 1310];
const HIT_RADIUS: f32 = 0.1;
const RUN_MS: u64 = 1500;

type Snapshot = RemoteEntitySnapshot<u32, u64, f32, ()>;
type Shot = TimestampedClientAction<f32, u64>;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Ghost {
    x: f32,
}

impl Interpolatable<u64> for Ghost {
    fn interpolate(&self, other: &Self, t: f32, _a: u64, _b: u64) -> Self {
        Ghost {
            x: self.x + (other.x - self.x) * t,
        }
    }
}

impl Extrapolatable<f32, f32> for Ghost {
    fn extrapolate_with_velocity(&self, velocity: &f32, dt_secs: f32) -> Self {
        Ghost {
            x: self.x + velocity * dt_secs,
        }
    }
}

fn runner_at(time_ms: u64) -> (f32, f32) {
    let phase = time_ms % 2000;
    if phase < 1000 {
        (phase as f32 / 250.0, 4.0)
    } else {
        ((2000 - phase) as f32 / 250.0, -4.0)
    }
}

fn verdict(aim: f32, runner: &Ghost) -> &'static str {
    if (aim - runner.x).abs() <= HIT_RADIUS {
        "hit"
    } else {
        "miss"
    }
}

fn main() {
    let mut rng = Rng::new(42);
    let mut down: LatencyLink<Snapshot> = LatencyLink::new();
    let mut up: LatencyLink<Shot> = LatencyLink::new();

    let mut server_ticker = FixedTimestep::from_step_ms(STEP_MS);
    let mut server_ms = 0;
    let mut runner = Ghost { x: 0.0 };
    let mut history: HistoricalStateBuffer<u32, Ghost, u64> = HistoricalStateBuffer::new(64);

    let mut view: RemoteView<Ghost, f32> = RemoteView::new(12, 250);
    let mut clock = InterpolationClock::new(RENDER_DELAY_MS);

    let mut now_ms = 0;
    while now_ms < RUN_MS {
        now_ms += FRAME_MS;

        for shot in up.drain_due(now_ms) {
            let aim = shot.action_data;
            let drawn_at = shot.client_action_time;
            println!("server {server_ms:>4}ms  shot at x={aim:.2}, drawn at {drawn_at}ms");
            println!(
                "    against the present: x={:.2}  {}",
                runner.x,
                verdict(aim, &runner)
            );
            match history.state_within(&RUNNER, drawn_at) {
                Some(past) => println!(
                    "    rewound to {drawn_at}ms: x={:.2}  {}",
                    past.x,
                    verdict(aim, &past)
                ),
                None => println!("    {drawn_at}ms is outside the history, refused"),
            }
        }

        for step in server_ticker.advance(FRAME_MS) {
            server_ms += step.as_millis() as u64;
            let (x, v) = runner_at(server_ms);
            runner = Ghost { x };
            history.record_state(RUNNER, server_ms, runner);
            let snapshot = Snapshot {
                entity_id: RUNNER,
                server_time: server_ms,
                position: x,
                rotation: (),
                linear_velocity: Some(v),
                angular_velocity: None,
            };
            down.send(now_ms, snapshot, ONE_WAY_MS, JITTER_MS, 0.0, &mut rng);
        }

        for snapshot in down.drain_due(now_ms) {
            clock.observe(snapshot.server_time);
            let velocity = snapshot.linear_velocity.unwrap_or_default();
            view.push(
                snapshot.server_time,
                Ghost {
                    x: snapshot.position,
                },
                velocity,
            );
        }

        clock.advance(FRAME_MS);
        let Some(target) = clock.target() else {
            continue;
        };
        let drawn = view
            .render(Some(target), RenderOpts::default())
            .expect("a snapshot started the clock");
        if FIRE_AT_MS.contains(&now_ms) {
            let shot = Shot {
                client_action_time: target,
                action_data: drawn.x,
            };
            up.send(now_ms, shot, ONE_WAY_MS, JITTER_MS, 0.0, &mut rng);
        }
    }
}

Run it:

$ cargo run
server  800ms  shot at x=2.12, drawn at 530ms
    against the present: x=3.20  miss
    rewound to 530ms: x=2.12  hit
server 1100ms  shot at x=3.28, drawn at 820ms
    against the present: x=3.60  miss
    rewound to 820ms: x=3.28  hit
server 1400ms  shot at x=3.56, drawn at 1110ms
    against the present: x=2.40  miss
    rewound to 1110ms: x=3.56  hit

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.