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.
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:
[]
= { = "0", = ["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:
use ;
use ;
const SEED: u64 = 0x5EED;
const ARENA: i32 = 8;
const FRAMES: u64 = 120;
Run it:
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:
use ;
use ;
use ;
const DELAY_FRAMES: u64 = 4;
const MAX_ROLLBACK: usize = 64;
const NEUTRAL: Stick = Stick ;Then replace main with the peer and the new main:
Run it:
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:
use ;
use ;
use ;
use ;
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 ;Then replace everything after script with this:
Run it:
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.
