Lockstep (Rollback Netcode)
Server = input authority (order/relay/ack/checksum); client RollbackEngine + IDeterministicSim SPI.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayLockstep(); and it wins.
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).
-
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).
-
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 MaxSpectatorsPerSession { get; set; }Maximum spectators per session (bounds the relay fan-out).
-
int PlayersPerSession { get; set; }Players required before a session starts.
-
ushort SimRateHz { get; set; }Shared simulation rate every client runs at.
-
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).
-
int ChecksumIntervalFrames { get; set; }A checksum is captured every this many CONFIRMED frames.
-
int InputDelayFrames { get; set; }Local inputs apply this many frames ahead (softens rollback depth; 0 = pure rollback).
-
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).
Wire messages 11
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.
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. 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, 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.
-
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).
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.