Meta

Lockstep (Rollback Netcode)

Server = input authority (order/relay/ack/checksum); client RollbackEngine + IDeterministicSim SPI.

Server
services.AddCrossplayLockstep();
Unity package
com.crossplay.lockstep
Depends on
core

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.

2601 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.

2602 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).

2603 LockstepStart

Server → client: everyone is present — frame 0 begins at a shared server time (clients already estimate the server clock via the heartbeat sync).

2604 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).

2605 LockstepPeerInputBatch

Server → client: another player's input batch, relayed verbatim with its slot.

2606 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.

2607 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.

2608 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…).

2609 LockstepPeerLeft

Server → client: a player left or disconnected.

2610 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.

2611 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 IsSpectating

    True when watching rather than playing.

  • event Action<LockstepSpectateJoined> SpectateJoined

    Spectate verdict (success + session parameters, or the refusal reason).

  • bool IsRunning

    True once the session started (frame 0 scheduled).

  • int Slot

    Your slot in the session (-1 before joining).

  • RollbackEngine Engine

    The 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 MaxBacklogFrames

    Frames 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> FellBehind

    This client can't keep up (current backlog in frames). Raised once per episode.

  • event Action<LockstepJoined> Joined

    Joined successfully (slot, playerCount) / refused (reason code in the message).

  • event Action<uint, uint> ChecksumReported

    A 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> DesyncDetected

    The session desynced at a frame — the game decides (void the match, resync…).

  • event Action<int> PeerLeft

    A 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.