Rollback Netcode
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
rollback.ts
import type { Tracer } from "../trace";/** Cells on the one-dimensional track the two players share. */export const ROLLBACK_TRACK_LENGTH = 16;/** Where the local player starts; the remote player starts to its right and the two never pass. */export const ROLLBACK_P1_START = 3;export const ROLLBACK_P2_START = 12;/** Frames a remote input takes to arrive. */export const MAX_ROLLBACK_LAG = 6;/** Frames a press waits before it applies, on both machines. */export const MAX_INPUT_DELAY = 3;/** Frames the session will let rest on guesses before it stalls. */export const MAX_ROLLBACK_WINDOW = 8;export const MAX_ROLLBACK_FRAMES = 20;/** One frame's press: left, stay, or right. */export type RollbackInput = -1 | 0 | 1;export interface RollbackOptions { /** Frames a remote input takes to reach this machine, 1 to MAX_ROLLBACK_LAG (default 3). */ lag?: number; /** Frames a press waits before the frame it applies to, 0 to MAX_INPUT_DELAY (default 0). */ delay?: number; /** Frames that may rest on guesses at once, 1 to MAX_ROLLBACK_WINDOW (default 6); past it the session stalls. */ window?: number; /** Game frames to play, 1 to MAX_ROLLBACK_FRAMES (default 14). */ frames?: number;}/** The whole game state: two positions on the track. */export interface RollbackState { p1: number; p2: number;}export interface RollbackReport { frames: number; /** Wall-clock frames the run took, stalls included. */ wallFrames: number; rollbacks: number; /** Frames run a second time, over every rollback. */ resimulated: number; largestRollback: number; stalls: number; /** The last frame with every frame before it confirmed, or -1. */ confirmedThrough: number;}/** * Rollback netcode, the way fighting games play over the internet: both * machines run the same deterministic game, and a frame needs both * players' inputs. The local press is at hand; the remote one is still * in flight, so the session guesses it (the last input it knows, * repeated), saves the state, and runs the frame anyway. When the real * input lands, a right guess confirms the frame and frees the saved * state; a wrong one rolls the game back to the state saved before that * frame and runs every frame since again with the truth, which can move * the local player too, because the two block each other. An input * delay on both sides shrinks how far a wrong guess reaches, and a * window caps how many frames may rest on guesses before the session * stalls and waits instead. * * Time: O(window) per wall frame, the frames a rollback can redo. * Space: O(window) saved states. */export class RollbackSession { readonly lag: number; readonly delay: number; readonly window: number; readonly frames: number; private local: readonly RollbackInput[] = []; private remote: readonly RollbackInput[] = []; private state: RollbackState = { p1: ROLLBACK_P1_START, p2: ROLLBACK_P2_START }; /** Each simulated frame's result, by frame. */ private readonly results: RollbackState[] = []; /** The state before each frame that rests on a guess, by frame. */ private readonly saved = new Map<number, RollbackState>(); /** The guess each unconfirmed frame ran on, by frame. */ private readonly guesses = new Map<number, RollbackInput>(); /** Remote inputs that have arrived, frame 0 first; they land in order. */ private readonly known: RollbackInput[] = []; /** The next game frame to run. */ private g = 0; private nextFrame = 0; /** The wall-clock frame under way. */ private w = 0; private clockFrame = 0; private rollbacks = 0; private resimulated = 0; private largest = 0; private stalls = 0; constructor(options: RollbackOptions = {}, private readonly tracer?: Tracer) { this.lag = RollbackSession.checkRange("lag", options.lag ?? 3, 1, MAX_ROLLBACK_LAG); this.delay = RollbackSession.checkRange("delay", options.delay ?? 0, 0, MAX_INPUT_DELAY); this.window = RollbackSession.checkRange("window", options.window ?? 6, 1, MAX_ROLLBACK_WINDOW); this.frames = RollbackSession.checkRange("frames", options.frames ?? 14, 1, MAX_ROLLBACK_FRAMES); } /** The game: each player moves one cell, clamped to the track, and neither may meet or pass the other. */ static simulate(state: RollbackState, in1: RollbackInput, in2: RollbackInput): RollbackState { static simulate(state: RollbackState, localPress: RollbackInput, remotePress: RollbackInput): RollbackState { let p1 = Math.min(ROLLBACK_TRACK_LENGTH - 1, Math.max(0, state.p1 + in1)); let p1 = Math.min(ROLLBACK_TRACK_LENGTH - 1, Math.max(0, state.p1 + localPress)); let p2 = Math.min(ROLLBACK_TRACK_LENGTH - 1, Math.max(0, state.p2 + in2)); let p2 = Math.min(ROLLBACK_TRACK_LENGTH - 1, Math.max(0, state.p2 + remotePress)); if (p1 >= p2) { p1 = state.p1; p2 = state.p2; } return { p1, p2 }; } /** The local press that applies to a frame: made `delay` frames earlier, so nothing before frame `delay`. */ localInput(frame: number): RollbackInput { return frame < this.delay ? 0 : (this.local[frame - this.delay] ?? 0); } /** The wall frame at which the remote input for a frame lands here: pressed `delay` frames early, in flight for `lag`. */ arrivalOf(frame: number): number { return frame - this.delay + this.lag; } /** Both players' presses by wall frame; a missing entry is stay. Runs to the end and until every frame is confirmed. */ run(local: readonly RollbackInput[], remote: readonly RollbackInput[]): RollbackReport { this.local = RollbackSession.checkScript("local", local); this.remote = RollbackSession.checkScript("remote", remote); // Frames before the delay apply no press at all, so their inputs are known from the start. for (let frame = 0; frame < Math.min(this.delay, this.frames); frame++) this.known.push(0); const cap = this.frames + this.lag + this.window + 2; for (this.w = 0; this.w < cap && (this.g < this.frames || this.guesses.size > 0); this.w++) { for (this.clockFrame = 0; this.clockFrame < cap && (this.nextFrame < this.frames || this.guesses.size > 0); this.clockFrame++) { this.arrive(); if (this.g < this.frames) this.advance(); if (this.nextFrame < this.frames) this.advance(); } return this.report(); } report(): RollbackReport { const pending = [...this.guesses.keys()]; const confirmedThrough = pending.length === 0 ? this.g - 1 : Math.min(...pending) - 1; const confirmedThrough = pending.length === 0 ? this.nextFrame - 1 : Math.min(...pending) - 1; return { frames: this.frames, wallFrames: this.w, wallFrames: this.clockFrame, rollbacks: this.rollbacks, resimulated: this.resimulated, largestRollback: this.largest, stalls: this.stalls, confirmedThrough, }; } /** The remote input landing this wall frame, if one does: confirm the frame it was guessed for, or roll back. */ private arrive(): void { const frame = this.known.length; if (frame >= this.frames || frame < this.delay || this.arrivalOf(frame) !== this.w) return; if (frame >= this.frames || frame < this.delay || this.arrivalOf(frame) !== this.clockFrame) return; const input = this.remote[frame - this.delay] ?? 0; this.known.push(input); const guess = this.guesses.get(frame); const guessed = guess !== undefined; // ▸ a remote input lands, lag frames after it was pressed this.tracer?.emit("receive", { wall: this.w, forFrame: frame, input, guessed, lateBy: guessed ? this.w - frame : 0 }); this.tracer?.emit("receive", { wall: this.clockFrame, forFrame: frame, input, guessed, lateBy: guessed ? this.clockFrame - frame : 0 }); if (!guessed) return; if (guess === input) { this.saved.delete(frame); this.guesses.delete(frame); // ▸ it matches the guess that frame ran on: the frame is confirmed and its saved state freed this.tracer?.emit("confirm", { wall: this.w, forFrame: frame, input, pending: this.guesses.size }); this.tracer?.emit("confirm", { wall: this.clockFrame, forFrame: frame, input, pending: this.guesses.size }); return; } this.rollback(frame, guess, input); } /** Restore the state saved before the frame and run every frame since again, now with the real input. */ private rollback(frame: number, wrong: RollbackInput, actual: RollbackInput): void { const depth = this.g - frame; const depth = this.nextFrame - frame; this.rollbacks++; this.resimulated += depth; this.largest = Math.max(this.largest, depth); // ▸ it does not match: go back to the state saved before that frame this.tracer?.emit("rollback", { wall: this.w, toFrame: frame, depth, wrong, actual }); this.tracer?.emit("rollback", { wall: this.clockFrame, toFrame: frame, depth, wrong, actual }); this.state = this.saved.get(frame)!; for (let k = frame; k < this.g; k++) { for (let redoFrame = frame; redoFrame < this.nextFrame; redoFrame++) { const old = this.results[k]!; const old = this.results[redoFrame]!; const in1 = this.localInput(k); const localPress = this.localInput(redoFrame); const in2 = this.remoteInput(k); const remotePress = this.remoteInput(redoFrame); this.state = RollbackSession.simulate(this.state, in1, in2); this.state = RollbackSession.simulate(this.state, localPress, remotePress); this.results[k] = this.state; this.results[redoFrame] = this.state; const moved = Math.abs(this.state.p1 - old.p1) + Math.abs(this.state.p2 - old.p2); // ▸ run every frame since then again, with the real input where it is known and new guesses beyond this.tracer?.emit("resimulate", { wall: this.w, redo: k, in1, in2, guessed: this.guesses.has(k), p1: this.state.p1, p2: this.state.p2, oldP1: old.p1, oldP2: old.p2, moved }); this.tracer?.emit("resimulate", { wall: this.clockFrame, redo: redoFrame, localPress, remotePress, guessed: this.guesses.has(redoFrame), p1: this.state.p1, p2: this.state.p2, oldP1: old.p1, oldP2: old.p2, moved }); } } /** Run the next frame, on a guess if the remote input is still in flight, unless too many frames already rest on guesses. */ private advance(): void { const frame = this.g; const frame = this.nextFrame; if (frame >= this.known.length && this.guesses.size >= this.window) { this.stalls++; // ▸ too many frames already rest on guesses: do not run this one, wait for the oldest input this.tracer?.emit("stall", { wall: this.w, frame, waitingFor: Math.min(...this.guesses.keys()), pending: this.guesses.size }); this.tracer?.emit("stall", { wall: this.clockFrame, frame, waitingFor: Math.min(...this.guesses.keys()), pending: this.guesses.size }); return; } if (frame >= this.known.length) { const basedOn = this.known.length - 1; // ▸ the remote input for this frame has not landed, so guess it: the last one known, repeated this.tracer?.emit("predict", { wall: this.w, frame, guess: this.lastKnown(), basedOn }); this.tracer?.emit("predict", { wall: this.clockFrame, frame, guess: this.lastKnown(), basedOn }); } const in1 = this.localInput(frame); const localPress = this.localInput(frame); const in2 = this.remoteInput(frame); const remotePress = this.remoteInput(frame); this.state = RollbackSession.simulate(this.state, in1, in2); this.state = RollbackSession.simulate(this.state, localPress, remotePress); this.results[frame] = this.state; this.g = frame + 1; this.nextFrame = frame + 1; // ▸ run the frame on whatever inputs we have; a guessed frame had its state saved first this.tracer?.emit("frame", { wall: this.w, frame, in1, in2, guessed: this.guesses.has(frame), p1: this.state.p1, p2: this.state.p2 }); this.tracer?.emit("frame", { wall: this.clockFrame, frame, localPress, remotePress, guessed: this.guesses.has(frame), p1: this.state.p1, p2: this.state.p2 }); } /** The remote input a frame runs on: the real one once it has landed, else a guess, with the state saved first. */ private remoteInput(frame: number): RollbackInput { const known = this.known[frame]; if (known !== undefined) { this.saved.delete(frame); this.guesses.delete(frame); return known; } const guess = this.lastKnown(); this.saved.set(frame, this.state); this.guesses.set(frame, guess); return guess; } /** The guess: the newest remote input that has landed, repeated; stay before any has. */ private lastKnown(): RollbackInput { return this.known[this.known.length - 1] ?? 0; } private static checkRange(name: string, value: number, min: number, max: number): number { if (!Number.isInteger(value) || value < min || value > max) throw new RangeError(`${name} must be an integer in [${min}, ${max}], got ${value}`); return value; } private static checkScript(name: string, script: readonly RollbackInput[]): readonly RollbackInput[] { if (script.length > MAX_ROLLBACK_FRAMES) throw new RangeError(`${name} script has ${script.length} presses, more than ${MAX_ROLLBACK_FRAMES}`); for (const press of script) if (press !== -1 && press !== 0 && press !== 1) throw new RangeError(`${name} script holds ${press}; a press is -1, 0, or 1`); return [...script]; }}14 frames in 17 wall frames: 2 rollbacks ran 6 frames again, the deepest 3 frames, and the session stalled 0 times. Confirmed through frame 13.