Deep dive
How it works
Parts:
- Input queues — confirmed and predicted remote inputs per frame.
- State ring — saved game state for the last
max_prediction + 1frames. - Rollback driver — loads frame f, re-simulates to now with best-known inputs.
- Time sync — frame-advantage exchange so the faster side waits.
- Checksums — hashes of confirmed frames to detect desync.
- Each frame: read local input, schedule it for
frame + input_delay, send it (with all unacked previous inputs) to the peer. - If the newest confirmed remote input is more than
max_predictionframes behind, stall this frame (time sync problem, not just latency). - If a remote input arrived for a frame already simulated and differs from the predicted one, set
rollback_to = min(rollback_to, that frame). - If
rollback_tois set: load the saved state of that frame and re-simulate up to the current frame with the best known inputs (confirmed where available, predicted otherwise), saving states again. Skip rendering and audio side effects during re-simulation. - Save state for the current frame, step with confirmed/predicted inputs, render.
- Time sync: each side reports how far ahead it is (frame advantage). The side ahead waits a frame occasionally so both run at the same "frame in the past" and rollbacks are split evenly.
frame: 10 11 12 13 14 (now)
remote in: confirmed up to 11; 12-14 predicted = input@11
input@12 arrives: differs -> load state@12 -> step 12,13,14 with corrected inputs -> render 14
Code
pub trait Game: Clone {
type Input: Copy + PartialEq + Default;
fn step(&mut self, inputs: [Self::Input; 2]);
}
const RING: usize = 64; // > input delay + max prediction
pub struct Rollback<G: Game> {
pub game: G,
pub frame: u32, // next frame to simulate
me: usize,
delay: u32,
max_pred: u32,
states: Vec<Option<G>>, // state at the start of frame f, at f % RING
confirmed: Vec<[Option<G::Input>; 2]>, // confirmed inputs per frame
used: Vec<[G::Input; 2]>, // inputs actually used to simulate frame f
remote_confirmed_to: Option<u32>, // newest consecutive confirmed remote frame
rollback_to: Option<u32>,
}
impl<G: Game> Rollback<G> {
pub fn new(game: G, me: usize, delay: u32, max_pred: u32) -> Self {
let mut confirmed = vec![[None; 2]; RING];
for f in 0..delay as usize { confirmed[f] = [Some(G::Input::default()); 2]; } // pre-delay frames
Rollback { game, frame: 0, me, delay, max_pred, states: vec![None; RING], confirmed,
used: vec![[G::Input::default(); 2]; RING],
remote_confirmed_to: delay.checked_sub(1), rollback_to: None }
}
/// Returns (frame, input) to send to the peer (resend until acked).
pub fn add_local_input(&mut self, i: G::Input) -> (u32, G::Input) {
let f = self.frame + self.delay;
self.confirmed[f as usize % RING][self.me] = Some(i);
(f, i)
}
/// Remote inputs must be applied in frame order (they are resent until acked).
pub fn add_remote_input(&mut self, f: u32, i: G::Input) {
let remote = 1 - self.me;
if self.remote_confirmed_to.map_or(false, |c| f <= c) { return; } // duplicate
self.confirmed[f as usize % RING][remote] = Some(i);
self.remote_confirmed_to = Some(f);
if f < self.frame && self.used[f as usize % RING][remote] != i {
self.rollback_to = Some(self.rollback_to.map_or(f, |r| r.min(f))); // mispredicted
}
}
fn inputs_for(&self, f: u32) -> [G::Input; 2] {
let c = self.confirmed[f as usize % RING];
let remote = 1 - self.me;
// Prediction: repeat the newest confirmed remote input.
let guess = self.remote_confirmed_to
.and_then(|r| self.confirmed[r as usize % RING][remote])
.unwrap_or_default();
let mut out = [G::Input::default(); 2];
out[self.me] = c[self.me].unwrap_or_default();
out[remote] = c[remote].unwrap_or(guess);
out
}
fn sim_frame(&mut self, f: u32) {
let inputs = self.inputs_for(f);
self.states[f as usize % RING] = Some(self.game.clone()); // save before stepping
self.used[f as usize % RING] = inputs;
self.game.step(inputs);
}
/// Advance one display frame. Returns false if we must stall (too far ahead of the peer).
pub fn advance(&mut self) -> bool {
if let Some(r) = self.rollback_to.take() {
self.game = self.states[r as usize % RING].clone().expect("state saved");
for f in r..self.frame { self.sim_frame(f); } // re-simulate, no rendering/audio
}
let confirmed_to = self.remote_confirmed_to.map_or(0, |c| c + 1);
if self.frame >= confirmed_to + self.max_pred { return false; }
// Free confirmed slots that the ring will reuse.
self.confirmed[(self.frame as usize + RING / 2) % RING] = [None; 2];
let f = self.frame;
self.sim_frame(f);
self.frame += 1;
true
}
}
#[derive(Clone, PartialEq, Debug)]
struct Counter { a: i64, b: i64 }
impl Game for Counter {
type Input = i8;
fn step(&mut self, i: [i8; 2]) { self.a += i[0] as i64; self.b = self.b * 3 + i[1] as i64; }
}
#[test]
fn rollback_matches_lockstep() {
let mut p = Rollback::new(Counter { a: 0, b: 0 }, 0, 2, 8);
let remote: Vec<i8> = (0..20).map(|f| if f % 5 == 0 { 1 } else { 0 }).collect();
for f in 0..20u32 {
p.add_local_input(1);
if f >= 4 { p.add_remote_input(f - 2, remote[(f - 2) as usize]); } // arrives 4 frames late
assert!(p.advance());
}
for f in 18..22 { p.add_remote_input(f, if f < 20 { remote[f as usize] } else { 0 }); }
p.add_local_input(1);
p.advance(); // triggers the final rollback for frames 18-19
let mut truth = Counter { a: 0, b: 0 };
for f in 0..21usize {
let r = if f < 2 { 0 } else if f < 20 { remote[f] } else { 0 };
truth.step([if f < 2 { 0 } else { 1 }, r]);
}
assert_eq!(p.game, truth);
}
Complexity and constants
- Frame budget at 60 Hz: 16.7 ms for (rollback frames + 1) × step + save + render. With 8 frames of rollback the step must be order of 1 ms or less; fighting games keep it far below.
- Save state: a clone of the game state every frame. Keep it small and flat (KBs to low MBs, memcpy at 10+ GB/s is order of 100 µs per MB); pointers and heap graphs make this slow.
- Input packets: 1-4 bytes per input × unacked frames, order of 10s of bytes per packet, 60 packets/s: order of 10-30 kbit/s.
- Rollback depth ≈ one-way latency in frames − input delay: 80 ms one-way at 60 Hz with 2 frames delay ≈ 3 frames.
Efficiency
Cost: a frame costs (rollback frames + 1) × step + save + render inside 16.7 ms at 60 Hz; with 8 frames of rollback the step must be order of 1 ms or less. Saving state is a clone per frame: memcpy at 10+ GB/s is order of 100 µs per MB. Input traffic is order of 10-30 kbit/s. Bound: CPU for re-simulation and state size.
Levers:
- Small, flat, plain-data state — fast clone and restore (order of 100 µs per MB) — always; pointer graphs are too slow.
- 1-2 frames of input delay — fewer and shallower rollbacks — most links.
- Skip rendering and audio during re-simulation — re-simulated frames cost only the step — every rollback.
- Frame-advantage time sync — splits rollbacks evenly between peers — asymmetric latency.
- Cap
max_predictionat about 8 and stall beyond — bounds the worst frame — slow machines.
Naive → standard → state of the art:
- Delay-based lockstep · input delay = latency (6 frames at 100 ms)
- Predict last input, save per frame, re-simulate on mismatch · 1-3 frames delay plus up to 8 frames of rollback
- Server-authoritative rollback of predicted state (Rocket League style) · client re-simulates from authoritative snapshots
Implementation notes
- Determinism is mandatory (see deterministic lockstep): fixed point or controlled floats, ordered containers, seeded RNG inside the state.
- Exchange checksums of confirmed frames (frame number + hash) to detect desyncs early.
- Separate simulation from presentation: particles, sounds and hit-stop effects triggered during re-simulation must be deduplicated (key them by frame + event id) so a rollback doesn't replay a sound.
- Choose input delay adaptively: more delay = fewer, smaller rollbacks (less visual teleporting); 1-2 frames is the common sweet spot.
- For ≥ 3 players or spectators, route inputs through a relay; spectators simply run lockstep on confirmed inputs with a delay.
- Libraries: GGPO (C++, MIT since 2019), GGRS (Rust, with
bevy_ggrs), backroll (Rust).
Pitfalls
- Saving state by serializing a complex object graph each frame: too slow. Design the state as plain data from day one.
- Non-rollbackable side effects inside
step(network sends, achievements, file writes). - Forgetting time sync: one side runs ahead, all rollbacks land on the other player, who sees constant teleporting.
- Random-number generator outside the saved state: the first rollback desyncs.
- Long rollbacks on slow machines cause frame drops exactly when the game is most hectic; cap max prediction and stall instead.
Variants and alternatives
In production
- Games: GGPO (Street Fighter III: 3rd Strike Online Edition, Skullgirls, many fighting games), Street Fighter 6, Guilty Gear Strive, Tekken 8, Mortal Kombat 11/1, Brawlhalla, Slippi (Super Smash Bros. Melee netplay), GGRS-based Bevy games.
- Parallel simulation: Time Warp (Jefferson, 1985) lets each process run events ahead, undoes those after a straggler message, and cancels sent messages with anti-messages.
- Web apps: Replicache rewinds to the server's version, applies its patch, then replays pending mutations on top before showing the result.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| ggrs | Rust | gschup/ggrs | cargo add ggrs | — | — | — | — | Rust reimplementation of GGPO's rollback model |
| bevy_ggrs | Rust | gschup/bevy_ggrs | cargo add bevy_ggrs | — | — | — | — | GGRS plugin for Bevy |
| matchbox | Rust | johanhelsing/matchbox | cargo add matchbox_socket | — | — | — | — | Peer-to-peer WebRTC sockets, pairs with GGRS |
| lightyear | Rust | cBournhonesque/lightyear | cargo add lightyear | — | — | — | — | Prediction with rollback for Bevy |
Sources
- Cannon, GGPO: Good Game Peace Out (rollback networking SDK), 2006/2019. code
- Stallone, 8 Frames in 16ms: Rollback Networking in Mortal Kombat and Injustice 2, GDC, 2018. talk
- Pusch, Explaining how fighting games use delay-based and rollback netcode, Ars Technica, 2019. article
- GGRS documentation, code
- Jefferson, Virtual time, ACM Transactions on Programming Languages and Systems, 1985. paper
- Replicache, How Replicache Works. docs