liblockstep - Deterministic Lockstep Framework

Templated, transport-agnostic deterministic-lockstep framework for peer-identical simulation

liblockstep - Deterministic Lockstep Framework

liblockstep is a templated, transport-agnostic framework for deterministic lockstep simulation: every peer runs the same simulation from the same inputs and, tick for tick, reaches byte-identical state. It carries turn ordering, the input-delay barrier, de-duplication, session-init negotiation, and desync detection — and nothing about what is being simulated. The RTS pilot (librts) is its first customer.

Features

  • Determinism as a caller contract — the engine is templated on the command type; the caller guarantees a pure, deterministic step(). No wall-clock, no ambient RNG, no pointer-order dependence inside the sim.
  • Transport-agnostic — the engine speaks to an abstract ITransport. A loopback transport ships for sandboxes/tests; the OctoMY node adapter (in libnode) carries frames over the comms couriers.
  • Input-delay barrier — commands issued on turn T execute on T + inputDelay, so every peer has every peer's commands before it simulates a turn.
  • Session-init negotiation — the lower-id peer (authority) offers {seed, timing, consumerParams}; the follower adopts them before turn 0. Loss-tolerant (re-offer until acknowledged).
  • Desync detection — per-turn checksums are exchanged and compared; a mismatch halts the match and is diagnosable through the OPAL surface.
  • Reliability reused, not reinvented — over the node adapter, frame retransmission is the comms ReliabilitySystem's job (the courier's requiresRetransmission mandate); the engine only re-offers unacked turns as a long-stall backstop.

Determinism contract

The framework guarantees identical state across peers only if the caller holds up its end:

  1. ISimulation::step(turn, commands) is pure and deterministic — its output depends solely on prior state and the passed commands. No QDateTime, no unseeded RNG, no iteration over pointer-keyed containers, no floating point where cross-platform bit-identity matters (the RTS sim uses fixed-point math/Fixed.hpp).
  2. All state folds into checksum() — anything that can diverge (units, resources, PRNG draw count, match rules' fired-state, match result) must be hashed, or a desync will go undetected.
  3. Seed the sim PRNG from Config::seed — which both peers receive identically (pre-agreed for sandboxes, negotiated for networked sessions).
  4. Commands serialise identically — name entities by id, never by pointer; provide QDataStream operators.

If determinism is ever in doubt, the checksum stream is the oracle: run two engines and compare checksumForTurn(t) for every t.

Core types

Type Role
LockstepEngine<Command> The turn engine: barrier, ordering, dedup, desync, negotiation.
ISimulation<Command> Caller's sim: step(turn, TurnCommands) + checksum().
ITransport Datagram sink/source: peerCount(), localPlayerId(), sendDatagram(), setReceiver().
TransportFacility QObject wrapping an ITransport — the "lockstep" ActivityContext facility.
SessionInit Negotiated match shape: {seed, tickRate, ticksPerTurn, inputDelay, consumerParams}.
LockstepEvents Signals (e.g. desyncDetected(turn)).
LockstepStatus Snapshot for the OPAL lockstep.status task (fromEngine(engine)).
LoopbackNetwork In-process multi-endpoint transport for tests/sandbox (optional delay/duplication).

Quick start

Bind a sim to the engine (sandbox / pre-agreed config)

MySim sim;                                   // implements ISimulation<MyCommand>
octomy::lockstep::LoopbackNetwork network;   // sandbox transport
auto *transport = network.createEndpoint();

octomy::lockstep::LockstepEngine<MyCommand> engine;
octomy::lockstep::LockstepEngine<MyCommand>::Config config;
config.seed = 0xC0FFEE;                       // both peers must agree
engine.configure(&sim, transport, config);
engine.start();                               // no negotiation — Config is pre-agreed

// Host cadence: a wall-clock timer drives update() at the tick rate.
engine.issueCommand(myCommand);               // executes at simTurn()+inputDelay
engine.update();                              // simulate at most one turn; false = stalled

Networked session with negotiation

The authority (the peer with the lexicographically lower full id, player 0) offers the match shape; the follower adopts it before turn 0.

// Authority (player 0):
octomy::lockstep::SessionInit offer;
offer.seed = 0xC0FFEE;
offer.consumerParams = myMatchParams.encode();  // opaque to the framework
engine.startAsAuthority(offer);

// Follower (player 1):
engine.startAsFollower([&](const octomy::lockstep::SessionInit &init) {
    // Runs synchronously, before turn 0 — seed the sim from init here.
    applyMatchParams(init.consumerParams);
});

consumerParams is an opaque QByteArray the framework transmits verbatim — the consumer chooses its own encoding (the RTS pilot ships RtsMatchParams: seed, factions, starting rules).

Detecting desync

QObject::connect(engine.events(), &octomy::lockstep::LockstepEvents::desyncDetected,
                 this, [](quint64 turn){ /* surface it; the engine has halted */ });

The OctoMY node adapter (libnode)

liblockstep never depends on libcomms; the adapter bridges the two so the game sees only the abstract ITransport.

Type (libnode) Role
LockstepCourier A Courier that ferries opaque lockstep datagrams to one peer over comms; requiresRetransmission.
NodeLockstepTransport ITransport over a LockstepCourier; derives localPlayerId from node-id ordering (lower id ⇒ player 0).
NodeLockstepSession Lifecycle helper: start(comms, localId, peerId) builds a peer-addressed courier + transport + TransportFacility; emits peerDisconnected and tears down on the peer's comms drop.
NodeLockstepHost Binds a session to an ActivityContext: begin(context, comms, localId, peerId) registers the transport as the "lockstep" facility; end() clears it; forwards matchEnded on a peer drop.

A host (e.g. an RTS lobby activity) constructs a NodeLockstepHost, begin()s it against the chosen peer, then pushes the game activity — which resolves the "lockstep" facility and, absent it, degrades to a single-node loopback sandbox.

OPAL surface

octomy::lockstep::registerOpalTasks() (in libopal/opal/lockstep/, Pattern B) registers:

  • lockstep.status — phase, turn, players, stall/halt state (read-only).
  • lockstep.diagnose-desync — the retained local/peer checksums at the desync turn (read-only).

Consumer games add their own catalog (the RTS pilot's rts.* tasks live in librts), all mutating tasks routing through the same command stream.

Architecture & seams

   consumer sim (librts)          liblockstep                 adapter (libnode)         comms
   ┌───────────────────┐   step() ┌──────────────────┐ send/  ┌──────────────────┐     ┌──────────┐
   │ ISimulation<Cmd>  │◀─────────│ LockstepEngine   │────────│ NodeLockstep     │────▶│ Courier/ │
   │  step() checksum()│─────────▶│  barrier/dedup/  │ recv() │  Transport +     │     │ Reliab.  │
   └───────────────────┘          │  desync/negotiate│        │  Courier/Session │     └──────────┘
                                  └──────────────────┘        └──────────────────┘
             ▲ "lockstep" ActivityContext facility (TransportFacility) exposes ITransport ▲

Load-bearing seams:

  • Engine ↔ sim — the only coupling is ISimulation<Command>; the engine never inspects sim internals beyond step()/checksum().
  • Engine ↔ transport — ITransport datagrams; the engine is oblivious to couriers, comms, encryption.
  • Adapter ↔ consumer — the "lockstep" facility. The game depends on libactivity + liblockstep, never libcomms.
  • Reliability boundary — loss recovery lives in comms (ReliabilitySystem), not the engine. The engine's stall re-offer is a backstop, not a complete retransmission layer; do not assume engine- or courier-level loss recovery in isolation.

Dependencies

  • libmath (math/Fixed.hpp — deterministic fixed-point for sims that need it)
  • librng (seeded PRNG for sims; the framework itself is PRNG-free)
  • Qt Core (QByteArray, QDataStream)

The node adapter additionally pulls libcomms (couriers) and libactivity (the facility/context), and lives in libnode.

Build & test

# Framework unit tests (engine, codec, barrier, negotiation, desync)
qbs build -f project.qbs profile:qt610 -p testLockstep

# Node adapter over real couriers (session lifecycle + convergence)
qbs build -f project.qbs profile:qt610 -p testLockstepCourier

# OPAL tasks against a live session + catalog visibility
qbs build -f project.qbs profile:qt610 -p testLockstepOpal

See specs/lockstep/ for the full framework spec (§LK/§LS/§NET/§TRG/§LOP/§IMP) and specs/rts-game/ for the pilot that exercises it end to end.

Topics
reference liblockstep lockstep determinism networking simulation