Lockstep (Rollback Netcode)
Server = input authority (order/relay/ack/checksum); client RollbackEngine + IDeterministicSim SPI.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayLockstep(); and it wins.
IBufferedDeterministicSim game SPI
The allocation-free flavour of IDeterministicSim. The engine hands the sim a pre-allocated buffer to write its state into and a span to read it back from, so per-frame snapshots and rollbacks produce zero garbage — the "copy the frame heap" shape deterministic engines use. Opt-in and additive: a sim that implements this is driven through the span members and a fixed ring of MaxStateBytes-sized buffers sized once at engine creation; a sim that implements only IDeterministicSim keeps the byte-array path unchanged.
public sealed class PongSim : IBufferedDeterministicSim
{
private State _s;
public int MaxStateBytes => State.Size; // fixed layout, known up front
public int SaveState(Span<byte> dst) => _s.WriteTo(dst); // returns bytes written
public void LoadState(ReadOnlySpan<byte> src) => _s = State.ReadFrom(src);
public byte[] SaveState() { var b = new byte[MaxStateBytes]; SaveState(b); return b; } // rare path
public void LoadState(byte[] state) => LoadState(state.AsSpan());
public void AdvanceFrame(byte[][] inputs) => _s.Step(inputs);
public uint Checksum() => _s.Fnv1a();
} -
void LoadState(ReadOnlySpan<byte> state)Restores a state previously written by SaveState.
-
int MaxStateBytes { get; }Upper bound of bytes one SaveState call may write. Read once when the engine is created (it sizes the snapshot ring), so it must not grow during a match.
-
int SaveState(Span<byte> destination)Writes the COMPLETE state into destination (its length is at least MaxStateBytes) and returns the number of bytes written.
IDeterministicSim game SPI
The SPI a game implements to run under rollback netcode — the same three-callback shape GGPO pioneered. The framework NEVER interprets the state: it stores the opaque snapshots, decides when to rewind, and tells the sim which frames to re-run. The game's obligations: Bit-perfect determinism. Same inputs in the same order must produce the identical state on every machine: fixed-point or rigidly controlled float math, seeded RNG inside the state, no engine physics, no wall-clock reads, iteration order fixed.Fast save/load. Snapshots are taken every frame and restored on rollback; keep the state compact (structs, arrays) so a 7-frame re-simulation fits in a render frame.Sim ≠ presentation. AdvanceFrame mutates pure state; rendering reads it afterwards. Effects/sounds triggered during re-simulated frames should be deduplicated by the game. A text client, a 2D client and a 3D client implement this identically — the piece cannot tell.
public sealed class PongSim : IDeterministicSim
{
private State _s; // fixed-point, seeded RNG inside the state — no floats, no wall-clock reads
public byte[] SaveState() => _s.Serialize();
public void LoadState(byte[] state) => _s = State.Deserialize(state);
public void AdvanceFrame(byte[][] inputs) => _s.Step(inputs); // inputs[slot] = that player's input
public uint Checksum() => _s.Fnv1a();
} -
void AdvanceFrame(byte[][] inputs)Advances exactly one frame. inputs has one entry per player slot (index = slot); an entry may be empty (player gave no input that frame). The outer array is owned by the engine and REUSED between calls (no per-frame garbage) — copy it if you retain it. See IBufferedDeterministicSim for the allocation-free snapshot contract.
-
uint Checksum()A deterministic checksum of the current state (desync detection across machines).
-
void LoadState(byte[] state)Restores a state previously returned by SaveState.
-
byte[] SaveState()Serializes the COMPLETE simulation state (opaque to the framework).
Configuration 2
Every tunable lives in an options object — there are no magic numbers to hunt for.
LockstepOptions
Configurable lockstep rules (defaults here, never inline).
-
int HistoryLimitFrames { get; set; }Input history kept per player (reconnect replay window; 0 = unbounded).
-
byte InputDelayFrames { get; set; }Input delay frames every client applies (shipped to clients in the join reply).
-
LockstepInputSubstitution InputSubstitution { get; set; }What a substituted frame contains.
-
int InputSubstitutionGraceFrames { get; set; }0 = off (byte-for-byte legacy: a stalled player's peers wait at their prediction cap until it speaks again). When > 0, once the session clock is this many frames past frame F and a player's input for F still has not arrived, the server writes a substitute into that player's history (see InputSubstitution), relays it to everyone INCLUDING that player, and ignores the late original. The match keeps moving; the stalled player is the one who rolls back. Size it above the input delay plus one network trip, or healthy players get substituted. While on, input acks travel on the reliable-ordered channel behind the relay, so a client never learns a frame is "delivered" before it has seen the server's version of it. The paired client option is RollbackOptions.ServerAuthoritativeLocalInput.
-
int MaxBatchFrames { get; set; }Maximum frames in one input batch (bounds a hostile packet).
-
int MaxInputBytes { get; set; }Maximum bytes per single input frame.
-
int MaxKeyLength { get; set; }Maximum session-key length.
-
int MaxSnapshotBytes { get; set; }Total bytes one snapshot may carry before the request is abandoned (hostile donor guard).
-
int MaxSpectatorsPerSession { get; set; }Maximum spectators per session (bounds the relay fan-out).
-
int MaxSubstitutionsPerTick { get; set; }Per player per tick cap on substituted frames (bounds the work a long-dead player causes). 0 = substitute nothing (ADR-0006); the documented off switch for the feature as a whole remains InputSubstitutionGraceFrames.
-
int PlayersPerSession { get; set; }Players required before a session starts.
-
ushort SimRateHz { get; set; }Shared simulation rate every client runs at.
-
int SnapshotChunkBytes { get; set; }Snapshot chunk size the donor is asked for (keep under the transport's message cap).
-
bool SnapshotLateJoin { get; set; }Off by default (a late spectator or reconnecting player replays the whole input history from frame 0). On, the server asks a connected player for its latest CONFIRMED state snapshot, forwards it to the joiner in chunks, and then replays only the inputs from that frame on — a joiner at minute twenty no longer re-simulates twenty minutes. Falls back to the full replay when no donor answers within SnapshotTimeoutMs or the donor disconnects.
-
int SnapshotTimeoutMs { get; set; }How long to wait for a donor before falling back to the full history replay.
-
int StartDelayMs { get; set; }Countdown between the last join and frame 0 (clients arm against the shared clock).
RollbackOptions
Engine tuning (defaults here, never inline).
-
bool AdaptiveInputDelay { get; set; }Off by default — the delay stays at InputDelayFrames for the whole match, exactly as before. On, the engine re-targets its own input delay from the round-trip samples the transport feeds it through ReportRoundTrip: the desired delay is ceil(rtt in frames) + InputDelayMarginFrames, clamped to [MinInputDelayFrames, MaxInputDelayFrames], approached one frame per InputDelayAdjustIntervalFrames. The stamped local-input timeline stays contiguous across every change (a raise duplicates the current input into the gap, a lowering absorbs inputs until the frontier is reached), so peers never see a hole. Assumes peers sit at a similar ping — a client-only heuristic that needs no protocol change; a server-driven per-player offset is a follow-up seam.
-
int ChecksumIntervalFrames { get; set; }A checksum is captured every this many CONFIRMED frames.
-
int InputDelayAdjustIntervalFrames { get; set; }The adaptive controller moves the delay by at most one frame per this many simulated frames.
-
int InputDelayFrames { get; set; }Local inputs apply this many frames ahead (softens rollback depth; 0 = pure rollback). With AdaptiveInputDelay on this is only the STARTING delay.
-
int InputDelayMarginFrames { get; set; }Frames added on top of the round-trip-derived delay (jitter headroom).
-
int MaxInputDelayFrames { get; set; }Adaptive ceiling (frames).
-
int MaxPredictionFrames { get; set; }Refuse to simulate further than this many frames past the last confirmed frame — beyond it the engine stalls instead of speculating into the void (peer died / vast lag).
-
int MinInputDelayFrames { get; set; }Adaptive floor (frames).
-
bool ServerAuthoritativeLocalInput { get; set; }Off by default: a local stamp is truth the moment it is made (the original contract — nothing the server sends can change your own input). Turn it ON when the server runs input substitution for stalled players: your stamps then count as confirmed only once the server has ACKED them, so a frame the server filled in for you is still inside the rollback window when its override arrives (AddServerInput) and can be replayed. Costs a little prediction depth (the ack trails by half a round trip) — raise MaxPredictionFrames accordingly. The paired server option is LockstepOptions.InputSubstitutionGraceFrames.
-
int SimRateHz { get; set; }Simulation rate; converts a measured round-trip into frames for the adaptive input delay. Only read when AdaptiveInputDelay is on.
Wire messages 14
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
LockstepJoinRequest Client → server: join (or create) the lockstep session identified by an opaque key — typically the match id handed out by matchmaking/lobbies. The game defines what a key means.
LockstepJoined Server → client: join verdict. On success carries your slot and the session's shared simulation parameters (every client must run the same delay + rate).
LockstepStart Server → client: everyone is present — frame 0 begins at a shared server time (clients already estimate the server clock via the heartbeat sync).
LockstepInputBatch Client → server: this player's inputs for frames [StartFrame … StartFrame+N-1]. Sent UNRELIABLE every send tick with the whole unacked window — redundancy beats packet loss without reliable- channel head-of-line blocking (the standard rollback transport shape).
LockstepPeerInputBatch Server → client: another player's input batch, relayed verbatim with its slot.
LockstepInputAck Server → client: the highest contiguous input frame the server holds for YOU — everything at or below it can drop out of your redundant send window.
LockstepChecksum Client → server: the simulation checksum at a CONFIRMED frame (all inputs known — every honest client must produce the identical value). The server compares across slots.
LockstepDesync Server → client: checksums disagreed at a frame — the session desynced (or a client lied about its simulation, which reads identically). The game decides what to do (void the match…).
LockstepPeerLeft Server → client: a player left or disconnected.
LockstepSpectateRequest Client → server: watch an existing session. Never creates one (joining creates; spectating observes). The reply is LockstepSpectateJoined; on success the full input history streams back as ordinary LockstepPeerInputBatch messages, then the live relay continues — the spectator runs the same deterministic sim and simply never inputs.
LockstepSpectateJoined Server → client: spectate verdict + the session's shared simulation parameters.
LockstepSnapshotRequest Server → a DONOR player: a late joiner needs the match state — copy out your latest fully confirmed snapshot (RollbackEngine.TryGetConfirmedSnapshot) and send it back in LockstepSnapshotChunks of at most ChunkBytes each, all carrying RequestId. Only sent when the server's snapshot-late-join option is on.
LockstepSnapshotChunk Client (donor) → server: one chunk of the confirmed state at Frame (the state BEFORE that frame runs). Chunks may arrive in any order; the server forwards them to the joiner.
LockstepStateChunk Server → the JOINER: one chunk of a peer's confirmed state at Frame. Once all ChunkCount chunks are in, resume the engine from the assembled bytes (RollbackEngine.ResumeFromSnapshot); the input history from Frame on follows as ordinary LockstepPeerInputBatch messages.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
ILockstepClient
The Lockstep piece, client side: join an input-authority session by key, hand over your IDeterministicSim, then pump RunFrames from your fixed-rate driver with the local input each frame. The shared RollbackEngine (same code the server tests run) does prediction + rewind; this client does the wiring: redundant input windows out, relayed peer inputs in, checksums up, desync/peer events surfaced, round-trip samples into the adaptive delay, server overrides of your own frames applied, snapshots donated to and adopted from late joiners. Presentation — what a frame LOOKS like — is 100% the game's.
-
void Join(string sessionKey, IDeterministicSim sim)Joins (or creates) the session. The sim starts when the server says everyone is in.
-
void Spectate(string sessionKey, IDeterministicSim sim)Watches an EXISTING session: the server streams the full input history (or, when its snapshot late-join option is on, a peer's confirmed state plus the tail), then the live relay, and a confirmed-only engine replays the match (zero rollbacks, a beat behind live). Pump RunFrames with null input; render from your sim as usual.
-
bool IsSpectatingTrue when watching rather than playing.
-
event Action<LockstepSpectateJoined> SpectateJoinedSpectate verdict (success + session parameters, or the refusal reason).
-
bool IsRunningTrue once the session started (frame 0 scheduled).
-
int SlotYour slot in the session (-1 before joining).
-
RollbackEngine EngineThe engine (null until the session starts) — frames, rollback diagnostics.
-
RollbackOptions EngineOptionsThe rollback tuning the engine is created with. Set the opt-in levers here BEFORE the session starts (adaptive input delay, server-authoritative local input, prediction window); the server-shipped parameters (input delay, sim rate) overwrite theirs on start.
-
int RunFrames(byte[] localInput, int maxFrames = 4)Advances up to maxFrames frames toward the shared clock, using localInput for each new frame, and flushes inputs/checksums to the server. Call once per Update with your sampled input. Returns frames actually simulated.
-
int MaxBacklogFramesFrames this client may fall behind the shared clock before FellBehind raises (the spiral-of-death guard: a machine that can't hold the sim rate should slow-mo, resync, or concede — the game decides; the engine never silently death-spirals trying to catch up).
-
event Action<uint> FellBehindThis client can't keep up (current backlog in frames). Raised once per episode.
-
event Action<LockstepJoined> JoinedJoined successfully (slot, playerCount) / refused (reason code in the message).
-
event Action<uint, uint> ChecksumReportedA confirmed-state checksum left the engine (frame, value) — the sync proof a HUD or log can display. Players raise it as each checksum is reported to the server; spectators as each is locally captured (watchers verify, they never send).
-
event Action<uint> DesyncDetectedThe session desynced at a frame — the game decides (void the match, resync…).
-
event Action<int> PeerLeftA peer's slot went dark (disconnect; it may reconnect and resume).
-
event Action<uint> ResyncRequiredThe server overrode one of YOUR frames (it substituted input while you stalled) and the engine could not replay it — this machine has left the match's history. Spectate/rejoin to resync. Never fires with ServerAuthoritativeLocalInput on.
-
event Action<uint> ResumedFromSnapshotA peer's confirmed snapshot was adopted (late join) — the sim now sits at this frame.
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayLockstepView
Narrow contract the presenter drives. UI-only, read-only: joining or spectating a lockstep session requires the game's IDeterministicSim, so this monitor raises no intents — it only watches the session the game runs.