Skip to content

Architecture: deterministic fixed-timestep simulation loop + seeded RNG (unlocks the existing rollback netcode) #452

Closed

Description

@drsnuggles8

Problem

The gameplay simulation is neither deterministic nor on a unified fixed timestep:

  • Only Jolt 3D physics has a fixed-step accumulator. Box2D and all gameplay systems (scripts, animation, AI, particles, navigation) advance on raw variable frame delta — see the variable-delta path in Core/Application.cpp Run() (std::min(rawDelta, s_MaxTimestep) * m_TimeScale).
  • Global RNG is seeded from system time (RandomUtils::GetGlobalRandom() / GetTimeBasedSeed() in Math/FastRandom.h) with no fixed-seed injection.

Consequence: gameplay behavior is frame-rate dependent and runs aren't reproducible.

The kicker

You already built Networking/P2P/RollbackManager + lockstep P2P, which require a deterministic simulation (any float divergence or RNG difference desyncs). That feature is effectively dead weight until determinism exists. Fixing this also unlocks replays and rock-steady physics.

Proposed approach

  • Wrap the whole gameplay update in a fixed-timestep accumulator. The headless RunHeadless() path already does this at a fixed tick — generalize it to the windowed loop. Render at display rate; interpolate visual state between ticks.
  • Step Box2D and gameplay systems on the fixed dt, not the frame dt.
  • Add a SetRandomSeed(u64) entry point and route gameplay RNG through a seeded deterministic stream (PCG/Xoshiro already exist — just stop time-seeding).
  • Audit gameplay systems for other nondeterminism (container iteration order feeding sim, fast-math).

Acceptance criteria

  • Two runs with the same seed + same inputs produce identical gameplay state.
  • Gameplay/physics behavior is independent of frame rate.
  • RollbackManager P2P stays in sync across peers in a smoke test.

Tier A — architecture. Highest-ROI: it makes the already-built rollback netcode actually usable.

Score

capability: 13
craft: 2
stability: 13
decay: 5
effort: 8
confidence: 0.8
learning: 3
fun: 3
kano: table-stakes
blocked_by: []
blocks: []

Rated per issue-scoring · score = confidence × (capability + craft + stability + decay) / effort, derived by the picker.

Activity

  1. coderabbitai commented on Jun 28, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #84 - Feature/rng [merged]
    #305 - ci(physics): retry flaky PhysicsLayerFilteringTest + harden JoltJobSy… [merged]
    #311 - feat(physics): breakable 3D joints (break force / break torque) [merged]
    #400 - fix(net,task): isfinite-guard untrusted float ingestion (network snap… [merged]


    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. drsnuggles8 commented on Jun 30, 2026

    @drsnuggles8
    OwnerAuthor

    First shippable slice landed — deterministic fixed-timestep loop + seeded RNG

    Branch feature/deterministic-fixed-timestep (commits 080dc573, 69aa9357).

    What changed

    • Fixed-timestep gameplay loop. Scene::OnUpdateRuntimeFixed(frameTs, fixedDt) accumulates the raw frame delta and advances the gameplay simulation in fixed fixedDt steps (N catch-up steps, clamped against a spiral of death), rendering once per displayed frame. Windowed hosts (editor Play, OloRuntime) use it; the headless server and the test/Functional harness keep the exact-single-step OnUpdateRuntime contract they rely on. OnUpdateRuntime was split into UpdateStreaming / SimulateRuntimeStep / RenderRuntime so both paths share one sim step.
    • Seeded gameplay RNG. RandomUtils::SetGlobalSeed(u64) + Application::SetRandomSeed/GetRandomSeed, re-seeded at Scene::OnRuntimeStart so every Play / runtime launch replays from a known seed (fixed default → deterministic by default). The stream is deterministic-by-design, not cryptographically secure — competitive anti-prediction is a separate server-authority concern, intentionally out of scope.
    • Simulation tick counter Scene::GetSimulationTick() — the addressable tick index that rollback/replay keys off.

    Acceptance criteria

    • Two runs with the same seed + same inputs produce identical gameplay state — FixedTimestepDeterminismTest.SeededRngReplaysIdenticallyFromASeed + the existing DeterministicReplayProducesSameStateTest.
    • Gameplay/physics behavior is independent of frame rate — FixedTimestepDeterminismTest.GameplayIsFrameRateIndependent (three frame pacings → identical fixed-step count and final state).
    • RollbackManager P2P stays in sync across peers in a smoke test — P2PRollbackDeterminismTest.RollbackResimReconstructsIdenticalCrossPeerState (an out-of-order input forces a rollback + EntitySnapshot restore + re-sim; the rolled-back peer ends bit-for-bit identical to the straight-through peer).

    Full GoogleTest suite green (3827 passed, 0 failed, 1 unrelated skip).

    Deferred follow-ups (leaving this epic open to track)

    • Between-tick visual interpolation for smooth rendering at non-60 frame rates.
    • The exhaustive container-iteration-order / fast-math nondeterminism audit.
    • Unify the headless server's sim rate to the canonical fixed dt (it currently ticks at its configured HeadlessTickRate).
    • Full RollbackManager cross-peer sync proof over the real transport (this slice covers the deterministic-reconstruction smoke test).

    Incidental: pre-existing renderer bug fixed

    While validating the full suite, root-caused and fixed a latent bug surfaced by test ordering (not part of this feature): OpenGLTextureCubemap::GetFaceData / OpenGLTexture2D::GetData checked glGetError() after their readback without draining a pre-existing error first, so an error leaked by an earlier render was misattributed as a readback failure (a spurious all-black cubemap in ProceduralSkyBakeTest, reproducing only under full-suite ordering). Fixed in 69aa9357, with a regression test that leaks a GL error before a cubemap readback.

  3. drsnuggles8 commented on Jul 1, 2026

    @drsnuggles8
    OwnerAuthor

    Delivered by #484 (merged 2026-07-01); worktree removed. Added a canonical fixed-timestep gameplay update path (gameplay systems + Box2D stepped on fixed dt, render at display rate) and a configurable runtime random seed (SetRandomSeed) applied at runtime start, plus functional coverage for fixed-timestep determinism, seeded-RNG replay, and strengthened rollback-determinism. Meets all three acceptance criteria. Flagging for closure.

  4. drsnuggles8 commented on Jul 9, 2026

    @drsnuggles8
    OwnerAuthor

    Follow-up: the particle/sub-emitter determinism gap this issue flagged (particle jitter rode the thread-local, time-seeded RNG stream and was not reproducible off the game thread) is now closed by #588. Each ParticleSystem owns a per-system RNG seeded from runSeed × entity UUID, so emission replays identically regardless of thread placement or how many other consumers touched the shared global stream. This also enabled the worker-dispatched ParticlesCPU split (#576 item 2).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureEngine architecture / cross-cutting designfeatureNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions