Roll back without a server

For Rust developers building a peer-to-peer game who want each player's own input to apply at once and both peers to agree on the world without a server deciding

You will write a small two-player coin chase as a deterministic step function using fixed-point math and a seeded random stream, then fingerprint its state with a digest. Next you roll one peer back by hand with StateHistory and InputTimeline when a late input disproves its guess. Finally two peers run RollbackSession over a simulated link with jitter, predict each other's inputs, roll back when they guessed wrong and end on the same digest as a run that knew every input up front.

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 rollback
cd rollback

Add plaza_client_utils with two features. fixed turns on the fixed module with the Fx fixed-point type and net-sim turns on the simulated link used in the last step. Your [dependencies] section should look like this:

Cargo.toml
[dependencies]
plaza_client_utils = { version = "0", features = ["fixed", "net-sim"] }

Rollback is the one netcode family in plaza with no server at all, so this tutorial needs no other plaza crate. Everything is counted in frames and the link runs on simulated time, so every run prints the same output.

Write a deterministic step#

In rollback netcode only inputs cross the wire. Each peer runs the whole simulation itself, so both peers must compute exactly the same world from the same inputs, bit for bit. Floating point breaks that, because it can round differently between builds such as a native one and a wasm one. A random number drawn from anywhere outside the world breaks it too, because it can differ between peers and between the first run of a frame and its replay.

The step below avoids both. Positions are P values made of two Fx, a signed 32-bit fixed-point number with 8 fractional bits whose arithmetic is plain integer math. The coin respawns at a spot drawn from an XorShift stored inside World. When a peer restores an old world, the random stream is restored with it, so replaying a frame draws the same numbers again.

step has the shape RollbackSession wants later: it takes the current world and one input per player and returns the next world. Each player moves a quarter unit per frame in the direction of its stick. A player who comes within reach of the coin scores and the coin moves.

digest folds the world into one u64 with StateDigest, writing the fields in a fixed order. Two peers compare digests to find out whether their worlds match. script stands in for two players: each one changes direction every 15 frames.

This first program knows every input in advance and runs 120 frames. Replace src/main.rs with this:

src/main.rs
use plaza_client_utils::fixed::{Fx, P};
use plaza_client_utils::{StateDigest, XorShift};

const SEED: u64 = 0x5EED;
const ARENA: i32 = 8;
const FRAMES: u64 = 120;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Stick {
    dx: i32,
    dy: i32,
}

#[derive(Debug, Clone, PartialEq)]
struct World {
    players: [P; 2],
    coin: P,
    scores: [u32; 2],
    rng: XorShift,
}

impl World {
    fn start() -> Self {
        let mut rng = XorShift::new(SEED);
        let coin = random_spot(&mut rng);
        World {
            players: [P::from_ints(0, 0), P::from_ints(ARENA, ARENA)],
            coin,
            scores: [0, 0],
            rng,
        }
    }
}

fn random_spot(rng: &mut XorShift) -> P {
    let x = rng.below(ARENA as u32 + 1) as i32;
    let y = rng.below(ARENA as u32 + 1) as i32;
    P::from_ints(x, y)
}

fn step(world: &World, inputs: &[Stick]) -> World {
    let speed = Fx::ratio(1, 4);
    let (low, high) = (Fx::ZERO, Fx::from_int(ARENA));
    let mut next = world.clone();
    for (i, stick) in inputs.iter().enumerate() {
        let mut p = next.players[i];
        p.x = (p.x + speed.mul(Fx::from_int(stick.dx))).max(low).min(high);
        p.y = (p.y + speed.mul(Fx::from_int(stick.dy))).max(low).min(high);
        next.players[i] = p;
        if p.dist_sq(next.coin) <= Fx::from_int(2) {
            next.scores[i] += 1;
            next.coin = random_spot(&mut next.rng);
        }
    }
    next
}

fn digest(world: &World) -> u64 {
    let mut digest = StateDigest::new();
    for p in &world.players {
        digest.write_i32(p.x.0);
        digest.write_i32(p.y.0);
    }
    digest.write_i32(world.coin.x.0);
    digest.write_i32(world.coin.y.0);
    digest.write_u32(world.scores[0]);
    digest.write_u32(world.scores[1]);
    digest.finish()
}

fn script(player: usize, frame: u64) -> Stick {
    const TURNS: [(i32, i32); 8] = [
        (1, 0),
        (1, 1),
        (0, 1),
        (-1, 1),
        (-1, 0),
        (-1, -1),
        (0, -1),
        (1, -1),
    ];
    let (dx, dy) = TURNS[(frame as usize / 15 + player * 5) % TURNS.len()];
    Stick { dx, dy }
}

fn main() {
    let mut world = World::start();
    for frame in 0..FRAMES {
        world = step(&world, &[script(0, frame), script(1, frame)]);
        if (frame + 1) % 30 == 0 {
            println!(
                "frame {:>3}  scores {:?}  coin ({}, {})  digest {:016x}",
                frame + 1,
                world.scores,
                world.coin.x.to_int(),
                world.coin.y.to_int(),
                digest(&world),
            );
        }
    }
}

Run it:

$ cargo run
frame  30  scores [3, 2]  coin (1, 4)  digest cebe073e1ad283af
frame  60  scores [3, 2]  coin (1, 4)  digest 5142a4b8e9fa52c2
frame  90  scores [4, 3]  coin (1, 7)  digest eea36a5acff71351
frame 120  scores [5, 4]  coin (1, 5)  digest 520456573a131401

The digest at frame 120 is the reference for the rest of the tutorial. Any peer that ends up with a different digest for frame 120 has diverged. The coin's position is printed with to_int. The client crate's docs reserve to_f32 for the renderer and nothing in a simulation may call it.

Roll back by hand#

A peer knows its own input for every frame but hears the other player's input late. Instead of waiting, it guesses the missing input, simulates the frame and keeps going. When the real input arrives and differs from the guess, it restores the world from before that frame and runs every frame since again with the right input. Doing it once by hand shows what RollbackSession does for you in the next step.

Two pieces from the rollback module do the bookkeeping. StateHistory is a ring of whole worlds by frame: save stores the world before each frame and restore hands a copy back. InputTimeline holds the inputs known for one player by frame. confirmed_at gives the input for a frame if it has arrived. last_confirmed_at_or_before gives the newest known input up to that frame, which is the guess: the remote player is assumed to keep doing whatever they did last.

Here the peer plays player 0. Player 1's input for each frame arrives exactly 4 frames late. Peer::simulate saves the world, picks both inputs, records the guess it used in a second StateHistory and steps. When an input arrives that differs from the guess recorded for its frame, the peer restores that frame's world and simulates forward to the present again.

Keep Stick, World, random_spot, step, digest and script from the first program as they are, along with the SEED, ARENA and FRAMES constants. Replace the use lines at the top with these and add the new constants:

src/main.rs
use plaza_client_utils::fixed::{Fx, P};
use plaza_client_utils::rollback::{Frame, InputTimeline, StateHistory};
use plaza_client_utils::{StateDigest, XorShift};

const DELAY_FRAMES: u64 = 4;
const MAX_ROLLBACK: usize = 64;
const NEUTRAL: Stick = Stick { dx: 0, dy: 0 };

Then replace main with the peer and the new main:

src/main.rs
struct Peer {
    world: World,
    history: StateHistory<World>,
    local: InputTimeline<Stick>,
    remote: InputTimeline<Stick>,
    guessed: StateHistory<Stick>,
}

impl Peer {
    fn simulate(&mut self, frame: Frame) {
        self.history.save(frame, self.world.clone());
        let mine = *self
            .local
            .confirmed_at(frame)
            .expect("local input is always known");
        let theirs = match self.remote.confirmed_at(frame) {
            Some(input) => *input,
            None => *self
                .remote
                .last_confirmed_at_or_before(frame)
                .unwrap_or(&NEUTRAL),
        };
        self.guessed.save(frame, theirs);
        self.world = step(&self.world, &[mine, theirs]);
    }
}

fn main() {
    let mut peer = Peer {
        world: World::start(),
        history: StateHistory::new(MAX_ROLLBACK),
        local: InputTimeline::new(MAX_ROLLBACK),
        remote: InputTimeline::new(MAX_ROLLBACK),
        guessed: StateHistory::new(MAX_ROLLBACK),
    };

    for head in 0..=FRAMES + DELAY_FRAMES {
        if let Some(sent) = head.checked_sub(DELAY_FRAMES) {
            let input = script(1, sent);
            peer.remote.confirm(sent, input);
            if peer.guessed.restore(sent) != Some(input) {
                peer.world = peer
                    .history
                    .restore(sent)
                    .expect("within the rollback window");
                for frame in sent..head {
                    peer.simulate(frame);
                }
                println!(
                    "frame {head:>3}  input for frame {sent} was not the guess, rolled back {} frames",
                    head - sent
                );
            }
        }
        peer.local.confirm(head, script(0, head));
        peer.simulate(head);
    }

    let at = peer
        .history
        .restore(FRAMES)
        .expect("frame 120 is still in the window");
    println!(
        "frame {FRAMES}  scores {:?}  digest {:016x}",
        at.scores,
        digest(&at)
    );
}

Run it:

$ cargo run
frame   4  input for frame 0 was not the guess, rolled back 4 frames
frame  19  input for frame 15 was not the guess, rolled back 4 frames
frame  34  input for frame 30 was not the guess, rolled back 4 frames
frame  49  input for frame 45 was not the guess, rolled back 4 frames
frame  64  input for frame 60 was not the guess, rolled back 4 frames
frame  79  input for frame 75 was not the guess, rolled back 4 frames
frame  94  input for frame 90 was not the guess, rolled back 4 frames
frame 109  input for frame 105 was not the guess, rolled back 4 frames
frame 124  input for frame 120 was not the guess, rolled back 4 frames
frame 120  scores [5, 4]  digest 520456573a131401

Player 1 turns every 15 frames and the peer hears each turn 4 frames later. The peer guessed "same as before" each time, so it rolls back 4 frames and simulates them again with the real input. The first rollback at frame 4 is the same thing for frame 0, where the guess was the neutral stick. After the last input arrives, the world for frame 120 has the same scores and the same digest as the program that knew every input at once.

Run two peers with RollbackSession#

RollbackSession bundles that loop. It keeps a StateHistory of worlds, an InputTimeline per player and a record of every guess it used. new takes the starting world, one neutral input per player, a RollbackConfig and the step function. Each frame a peer calls queue_local_input with its own input and confirm_remote_input for every remote input that has arrived, for this frame or an earlier one. advance_frame then rolls back if a confirmed input contradicts a guess, simulates the current frame and moves on. last_rollback_frames says how far the last frame rolled back.

Now both players are real peers. Each runs its own session and sends (frame, input) to the other over a LatencyLink with 50ms of latency and up to 40ms of jitter. At 16ms a frame an input arrives 4 to 6 frames late. The jitter decides which. max_rollback_frames: 64 in RollbackConfig is the furthest back a session can roll, which has to cover the worst latency in frames.

The peers run 132 frames, so every input up to frame 119 has arrived and been applied by the end. state_at(120) is the saved world at the start of frame 120 and is_frame_confirmed(119) says whether both players' inputs for frame 119 are in.

Keep the same shared code again. Replace the use lines and the constants above SEED with these:

src/main.rs
use plaza_client_utils::fixed::{Fx, P};
use plaza_client_utils::net_sim::{LatencyLink, Rng};
use plaza_client_utils::rollback::{Frame, RollbackConfig, RollbackSession};
use plaza_client_utils::{StateDigest, XorShift};

const FRAME_MS: u64 = 16;
const ONE_WAY_MS: u64 = 50;
const JITTER_MS: u64 = 40;
const RUN_FRAMES: u64 = 132;
const NEUTRAL: Stick = Stick { dx: 0, dy: 0 };

Then replace everything after script with this:

src/main.rs
struct Peer {
    name: &'static str,
    player: usize,
    session: RollbackSession<World, Stick>,
    inbox: LatencyLink<(Frame, Stick)>,
}

impl Peer {
    fn new(name: &'static str, player: usize) -> Self {
        let config = RollbackConfig {
            max_rollback_frames: 64,
        };
        Peer {
            name,
            player,
            session: RollbackSession::new(World::start(), vec![NEUTRAL, NEUTRAL], config, step),
            inbox: LatencyLink::new(),
        }
    }
}

fn main() {
    let mut rng = Rng::new(11);
    let mut peers = [Peer::new("A", 0), Peer::new("B", 1)];

    for tick in 0..RUN_FRAMES {
        let now_ms = tick * FRAME_MS;
        let mut sent = Vec::new();
        for peer in peers.iter_mut() {
            let frame = peer.session.current_frame();
            let input = script(peer.player, frame);
            peer.session.queue_local_input(peer.player, input);
            sent.push((frame, input));
        }
        peers[1]
            .inbox
            .send(now_ms, sent[0], ONE_WAY_MS, JITTER_MS, 0.0, &mut rng);
        peers[0]
            .inbox
            .send(now_ms, sent[1], ONE_WAY_MS, JITTER_MS, 0.0, &mut rng);

        for peer in peers.iter_mut() {
            let remote = 1 - peer.player;
            for (frame, input) in peer.inbox.drain_due(now_ms) {
                peer.session.confirm_remote_input(remote, frame, input);
            }
            peer.session.advance_frame();
            let rolled = peer.session.last_rollback_frames();
            if rolled > 0 {
                println!(
                    "frame {:>3}  peer {} rolled back {rolled} frames",
                    peer.session.current_frame() - 1,
                    peer.name
                );
            }
        }
    }

    for peer in &peers {
        let at = peer
            .session
            .state_at(FRAMES)
            .expect("frame 120 is still in the window");
        println!(
            "peer {}  all inputs in {}  rollbacks {}  longest {}  frame {FRAMES} digest {:016x}",
            peer.name,
            peer.session.is_frame_confirmed(FRAMES - 1),
            peer.session.rollback_count(),
            peer.session.max_rollback_frames(),
            digest(&at),
        );
    }
}

Run it:

$ cargo run
frame   4  peer A rolled back 4 frames
frame   6  peer B rolled back 6 frames
frame  19  peer A rolled back 4 frames
frame  19  peer B rolled back 4 frames
frame  34  peer A rolled back 4 frames
frame  35  peer B rolled back 5 frames
frame  49  peer B rolled back 4 frames
frame  50  peer A rolled back 5 frames
frame  65  peer A rolled back 5 frames
frame  66  peer B rolled back 6 frames
frame  80  peer A rolled back 5 frames
frame  80  peer B rolled back 5 frames
frame  94  peer B rolled back 4 frames
frame  95  peer A rolled back 5 frames
frame 110  peer A rolled back 5 frames
frame 110  peer B rolled back 5 frames
frame 124  peer A rolled back 4 frames
frame 125  peer B rolled back 5 frames
peer A  all inputs in true  rollbacks 9  longest 5  frame 120 digest 520456573a131401
peer B  all inputs in true  rollbacks 9  longest 6  frame 120 digest 520456573a131401

Each peer rolls back 9 times: once for the other player's first input at frame 0 and once for each of their 8 direction changes. Peer B rolls back 6 frames at frame 6 because jitter held up A's first input. Elsewhere the rollbacks run 4 or 5 frames as the arrival times vary. The two peers never wait for each other and never exchange any state, yet both end with the digest 520456573a131401, the same one the first program printed for frame 120.

Where to go next#

A peer whose world has diverged looks completely healthy, so the digest is the only way to notice. A real game sends it alongside the inputs every few frames and compares it with its own digest for the same frame. RollbackSession::with_predictor replaces the "repeat the last input" guess and set_rollback_enabled(false) keeps every wrong guess, which shows why rollback is needed.

Chapter 02 of the plaza guide places rollback among the other netcode families and says when to choose it. The rollback_playground example runs two peers side by side with prediction and rollback each switchable, plus input redundancy and acknowledgements for a lossy link.

The plaza_client_utils usage guide on GitHub covers rollback, deterministic arithmetic and state digests. The API reference is on docs.rs.