Home › Multiplayer & game networking › Latency hiding
algorithm medium

Rollback netcode

Problem Fast two-player games such as fighting games are unplayable online when each player's controls are delayed by the opponent's network latency. Developers need to keep input responsive over the internet while both sides stay in sync.

Run the game immediately on predicted remote inputs; when real inputs arrive and differ, restore a saved state and re-simulate to the present (GGPO).

per frame O(1) save + step; on misprediction O(r) re-simulated frames, r ≤ max prediction (≈ 8)
rollbackggpofighting-gamespredictionpeer-to-peerdeterminism
Overview

Lockstep must wait for the opponent's input, so 100 ms of ping becomes 6 frames of input delay: unplayable for a fighting game. Rollback never waits. It guesses the remote input (usually "same as last frame"; inputs are held for many frames), simulates, and renders. When the real input for frame f arrives and differs, it loads the state saved at f, re-runs frames f..now with corrected inputs in one display frame, and shows the result. Local input feels offline; remote actions may jump a few frames. The same optimistic run-ahead and undo appears in parallel simulation (Time Warp) and offline-first apps.

Key facts

Use when

  • 1v1 or small-player-count games with deterministic simulations and high input sensitivity: fighting games, platform fighters, sports and racing games with few players.
  • Peer-to-peer or relay setups where no server runs the simulation.
  • Parallel discrete-event simulation: processes run ahead optimistically and roll back when an earlier-timestamped message arrives (Time Warp).
  • Offline-first apps: pending local mutations are replayed on top of each new server state (Replicache).

Avoid when

  • The simulation is too expensive to run 8x per frame or to snapshot every frame (big physics worlds).
  • Many players: each player's late input causes rollbacks for everyone.
  • Hidden information matters (full state lives on each client).

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 + 1 frames.
  • 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.
  1. Each frame: read local input, schedule it for frame + input_delay, send it (with all unacked previous inputs) to the peer.
  2. If the newest confirmed remote input is more than max_prediction frames behind, stall this frame (time sync problem, not just latency).
  3. If a remote input arrived for a frame already simulated and differs from the predicted one, set rollback_to = min(rollback_to, that frame).
  4. If rollback_to is 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.
  5. Save state for the current frame, step with confirmed/predicted inputs, render.
  6. 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_prediction at 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

Delay-based lockstepno prediction, input delay = latency; simple but feels laggy above ~3 frames.
Client-side predictionserver-authoritative variant predicting only the local player.
Server-authoritative rollbacka server runs the canonical simulation and clients roll back to server state (Rocket League, Overwatch's "rollback" of predicted abilities).

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

namelangrepoinstallverdictlicencehealthas_ofnotes
ggrsRustgschup/ggrscargo add ggrs————Rust reimplementation of GGPO's rollback model
bevy_ggrsRustgschup/bevy_ggrscargo add bevy_ggrs————GGRS plugin for Bevy
matchboxRustjohanhelsing/matchboxcargo add matchbox_socket————Peer-to-peer WebRTC sockets, pairs with GGRS
lightyearRustcBournhonesque/lightyearcargo 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

Builds on

Alternatives and related