Skip to content

Determinism concerns for lockstep/rollback simulation #2190

Description

@Eragon-Brisingr

Determinism concerns for lockstep/rollback simulation

flecs version: 4.1.3 (C, single-threaded pipeline)

We're building a deterministic lockstep simulation on flecs: multiple machines must produce bit-identical results given the same initial state + the same inputs. A client also restores a serialized world snapshot and re-simulates forward, and that re-simulation must match what the authoritative peer computed. We've hit two flecs behaviors that make this hard, and I'd like to know the recommended way to handle them (or whether they're considered bugs).


Problem 1 — a system that activates mid-frame runs in the same frame, so per-frame results depend on world history

We use a level-of-detail pattern that splits one logical per-entity update into mutually-exclusive systems gated by a tag, all in the same phase:

SysLow : (Position, !FastTag)   // handles entities WITHOUT the tag
SysHigh: (Position,  FastTag)   // handles entities WITH the tag

Both run in EcsOnUpdate (SysLow first), and both do the same kind of per-entity work. The design assumes each entity is handled by exactly one of them per frame.

A separate system in an earlier phase (EcsPreUpdate) adds FastTag to an entity on some frame (like a relevance/interest system re-classifying it). The add is deferred, so it's still pending when the OnUpdate variants run.

Observed: whether the entity is processed once or twice on the transition frame depends only on whether SysHigh was already active (i.e., whether any other entity already had FastTag), because:

  • If SysHigh is dormant (its query matches no non-empty table), adding FastTag mid-progress makes it match → the pipeline is rebuilt → the newly-active SysHigh runs in the same progress, after SysLow already processed the just-tagged entity → double-processed.
  • If SysHigh was already active, the entity is processed once.

So the same per-frame inputs produce a different result depending on world history. In lockstep this desyncs: a client that restored a snapshot in which SysHigh happened to be dormant computes a different result than a peer where it was active.

Minimal repro (attached repro.c)

Runs the identical scenario twice, differing only in whether a dummy entity keeps SysHigh active:

--- Scenario A: target alone (SysHigh initially DORMANT) ---
frame 3: target->count = 4  (delta this frame = 2  <<< DOUBLE-PROCESSED)
...
--- Scenario B: dummy present (SysHigh initially ACTIVE) ---
frame 3: target->count = 3  (delta this frame = 1)
...
==== RESULT ====
Total count Run A (SysHigh dormant): 7
Total count Run B (SysHigh active) : 6
>>> NON-DETERMINISTIC: identical per-frame inputs, different result (7 vs 6),
>>> solely because of whether SysHigh was already active (world history).

Questions

  1. Is a just-activated system running in the same frame (after a sibling that the now-transitioned entity previously matched) intended behavior?
  2. For deterministic sims, is there a supported way to make a system's presence in the pipeline schedule independent of whether its query currently matches — i.e., keep it always scheduled so the op structure is history-independent? (We tried EcsQueryMatchEmptyTables, but flecs_pipeline_build still marks a system inactive when its matched tables are EcsEmpty.)
  3. Or is there a recommended pattern for LOD/rate-limited split systems that stays deterministic? (Our current fallback is to fold the variants into a single system that reads the "rate" as a component field instead of gating multiple systems by a tag, or to add a per-entity "already updated this frame" guard.)

Problem 2 — table iteration order is not stable across machines after deserialization

For determinism, every peer must iterate entities in the same order each frame (order affects any order-sensitive logic, and it affects our per-frame state hashing). After we deserialize a world snapshot on a client, the table order (and therefore the order in which a system's query visits tables/entities) can differ from the authoritative peer, because table creation order depends on the order components/tags were added during load, which isn't guaranteed to match.

Current workaround: we sort tables (impose a deterministic ordering on the tables a query visits) so iteration order is stable across peers. This works but feels like fighting the

framework.

Questions

  1. Is there a supported/first-class way to get a deterministic, machine-independent table iteration order for a query (e.g., a stable sort key, order_by on a component, or a serialization mode that reproduces table order)?
  2. Does flecs guarantee anything about table order being reproducible after ecs_world deserialization if entities/components are restored in the same order?
  3. Any recommended approach for deterministic iteration order in a lockstep/rollback context that avoids a manual per-frame table sort?

Thanks! Happy to provide more detail or additional repros. Overall flecs has been great to build on; these are the two spots where determinism needs extra care and we'd like to align with the intended approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions