Recursive AI

Rollback and resimulation

All onephysics recipes

Build a rollback loop with OnePhysics: a ring of snapshots, a late input that rewinds five frames, quiet resimulation, and a check that the result equals a world that had the input on time.

A stack of ten boxes and a ball. The ball is pushed at frame 60; at frame 120 a peer’s input for frame 115 arrives late.

C#
using RecursiveAI.OneMath;
using RecursiveAI.OnePhysics;
using RecursiveAI.OnePhysics.Physics2D;
 
const float dt = 1f / 60;
var world = new PhysicsWorld2D(new WorldOptions2D { WorldId = 1 });
world.AddBox(new Vector2F(20, 1), at: new Vector2D(0, -0.5), type: BodyType.Static);
for (int i = 0; i < 10; i++) world.AddBox(new Vector2F(0.8f, 0.8f), at: new Vector2D(0.05 * i, 0.5 + i));
var ball = world.AddCircle(0.5f, at: new Vector2D(-6, 1));
int sounds = 0;
ball.OnCollisionEnter += c => sounds++;
world.Warmup();                                   // steps, snapshots and restores no longer allocate
 
const int Ring = 8;
var ring = new byte[Ring][];
var lengths = new int[Ring];
for (int i = 0; i < Ring; i++) ring[i] = new byte[world.GetSnapshotSizeBound()];
 
int frame = 0;
void Advance(Vector2F? push = null)
{
    int slot = frame % Ring;
    world.TrySnapshot(ring[slot], out lengths[slot]);
    if (push is { } v) ball.LinearVelocity += v;  // this frame's input, if any
    world.Step(dt);
    frame++;
}
 
for (int i = 0; i < 120; i++) Advance(i == 60 ? new Vector2F(12, 0) : null);
Console.WriteLine($"frame {frame}: hash {world.ComputeHash():X16}, sounds {sounds}");
 
// A peer's input for frame 115 arrives late: a second push. Roll back five frames and resimulate.
int target = frame - 5;
world.Restore(ring[target % Ring].AsSpan(0, lengths[target % Ring]));
frame = target;
using (world.SuppressCallbacks())                 // handlers stay quiet while frames replay
{
    for (int i = 0; i < 5; i++) Advance(frame == 115 ? new Vector2F(0, 6) : null);
}
ulong corrected = world.ComputeHash();
Console.WriteLine($"frame {frame} after rollback: hash {corrected:X16}, sounds {sounds}");
 
// A second world fed the same inputs from the start reaches the same bits.
var replay = new PhysicsWorld2D(new WorldOptions2D { WorldId = 1 });
replay.AddBox(new Vector2F(20, 1), at: new Vector2D(0, -0.5), type: BodyType.Static);
for (int i = 0; i < 10; i++) replay.AddBox(new Vector2F(0.8f, 0.8f), at: new Vector2D(0.05 * i, 0.5 + i));
var ball2 = replay.AddCircle(0.5f, at: new Vector2D(-6, 1));
for (int f = 0; f < 120; f++)
{
    if (f == 60) ball2.LinearVelocity += new Vector2F(12, 0);
    if (f == 115) ball2.LinearVelocity += new Vector2F(0, 6);
    replay.Step(dt);
}
Console.WriteLine($"replayed from scratch: hash {replay.ComputeHash():X16}, equal: {replay.ComputeHash() == corrected}");
Text
frame 120: hash A30B068101DD5CBF, sounds 3
frame 120 after rollback: hash 3013CDBC50D11693, sounds 3
replayed from scratch: hash 3013CDBC50D11693, equal: True

The pieces

  • Warmup(), after the bodies exist, sizes storage and exercises the snapshot, restore and hash paths, so steady-state steps, TrySnapshot and Restore allocate nothing.
  • GetSnapshotSizeBound() is an upper bound on the snapshot size while no storage grows, so the ring is allocated once. TrySnapshot writes into it and returns false if it is too small.
  • Restore validates the whole snapshot first and changes nothing if it is invalid. A WorldId you set keeps Body handles valid across restores, and in a freshly built world.
  • SuppressCallbacks() keeps handlers quiet while frames replay, so the impact sound doesn’t play twice; polled events are still produced.
  • ComputeHash() is what peers exchange every few frames to catch a desync.

Good to know

Inputs must be applied the same way on every peer and in every replay. Writing LinearVelocity += (0, 0) on frames without input is not a no-op: it turns -0 into +0, a different bit pattern, and the hashes part ways. Apply an input only when there is one, as Advance does.

The whole loop, OptionChanges and handle rules are in rollback and lockstep.