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 (inlibnode) 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'srequiresRetransmissionmandate); 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:
ISimulation::step(turn, commands)is pure and deterministic — its output depends solely on prior state and the passed commands. NoQDateTime, no unseeded RNG, no iteration over pointer-keyed containers, no floating point where cross-platform bit-identity matters (the RTS sim uses fixed-pointmath/Fixed.hpp).- 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. - Seed the sim PRNG from
Config::seed— which both peers receive identically (pre-agreed for sandboxes, negotiated for networked sessions). - Commands serialise identically — name entities by id, never by pointer; provide
QDataStreamoperators.
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 beyondstep()/checksum(). - Engine ↔ transport —
ITransportdatagrams; the engine is oblivious to couriers, comms, encryption. - Adapter ↔ consumer — the
"lockstep"facility. The game depends onlibactivity+liblockstep, neverlibcomms. - 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.