← All essays

Undo When You Cannot Re-Simulate

Stepping backwards through a physics-driven lab, where the authored state, the simulated state, and the state on screen are three different things

The lab I work on runs as a sequence of steps. A step sets up a scene, waits for the student to do something, posts the resulting world to a physics backend, and plays back whatever comes home — poses over time, fluid meshes, telemetry, colour fields.

Then the student presses Back.

I assumed this was a small feature. It is the single most intricate thing in the codebase, and the reason is that "the state at step 4" is not one thing. It is three things that disagree, and picking the wrong one produces bugs that look like physics glitches.

The obvious answer, and why it is wrong

Every step carries an authored snapshot — where the author said each object should be when the step begins. Undo looks like replaying that snapshot. Load step 4's authored poses, apply them, done.

That works until anything moves for a reason the author did not write down.

A brick gets knocked off the desk in step 3. It is now on the floor. It should be on the floor for step 4, step 5, and every step after — and back on the desk for every step before. But the authored snapshot for step 4 says desk, because the author wrote that scene expecting a brick on a desk. They did not anticipate the student knocking it off, and they could not have.

So the authored snapshot cannot drive undo. It describes intent, not history. Restoring it teleports the brick back onto the desk in step 4, which reads to the student as the simulation forgetting what just happened.

The second candidate is the simulation response — where the physics backend says everything ended up. That is genuine history, but it is the state at the end of a step, and undo wants the state at the beginning. It is also unavailable for steps that never ran a simulation.

That leaves the third option, which is the one that works: capture the live scene at the moment each step begins, and keep it.

Reading the world, not the script

The cache reads positions straight out of the shared scene buffer — the same per-frame float array Unity writes every object's world pose into. Not the authored document, not the response. The actual screen.

That single choice is what makes carryover work. A brick on the floor is cached on the floor, because the buffer says it is on the floor. There is no model of why it is there and no need for one.

The doc comment on the class puts it plainly: the authored snapshot is always the desk pose, so it cannot drive undo for such objects; the cache is the only per-step source of truth.

When to capture

Each step opens with a freeze phase — a beat where the step has begun and nothing has moved yet. That is the capture point, and its safety is not incidental.

The previous step's simulation signals completion through an explicit handshake with Unity. By the time freeze fires, that completion has already landed, which means every carried-over pose and every telemetry sample from the previous step is in the buffer. Capturing at freeze is capturing after everything settled but before anything new happens.

Get that ordering wrong and you cache a scene mid-flight — objects halfway through a return trajectory, fluid meshes from two keyframes ago. The step boundary is not a timestamp; it is a point in a phase sequence, and only one point in it is quiet.

What gets captured: poses, per-object parameters, fluid geometry, indicator colours, telemetry. Roughly, everything that is not derivable.

Two restores with different lifetimes

This is the part I did not anticipate, and it is where the design earns its complexity.

Restoring is not one operation. It is two, and they end at different times.

One-shot channels. Parameters, fluid meshes, indicator colours get pushed to Unity once when the restore begins. They are messages: here is the fluid geometry for step 4, here are the colours, apply them. Unity applies them and that is that. This restore ends when the re-entered step reaches its own freeze phase.

The pose override. Poses are different. They are written into the override buffer every frame, for as long as the restore is active — which is deliberately longer than the one-shot channels last.

The reason is what the student sees. After pressing Back they land on step 4, and step 4 begins by waiting: freeze, then a camera move, then a pause for their gesture. Throughout that wait, the scene on screen must be the start-of-step-4 scene. If the pose restore ended when the one-shot channels did, the orchestrator would immediately reassert the authored poses and the brick would climb back onto the desk while the student was looking at it.

So the pose override persists through exactly three phases — freeze, camera, gesture — and ends the moment the step actually starts running again. Any other phase means the student has re-done the step and control belongs to the simulation.

There is a nice detail in there: objects the student is actively driving are excluded. If you undo and then immediately start dragging, your drag wins. The restore does not fight you.

The race, and who wins it

Both the orchestrator and the restore want to write poses to the same objects in the same frame. The orchestrator writes the authored pin. The restore writes the cached history. They disagree, by design — that disagreement is the feature.

The override buffer resolves this the simple way: last write wins. Whoever writes later in the frame is what Unity applies.

And the order of writes is the order handlers were registered:

const orchestrator = new StepOrchestratorHandler(config, engine);
engine.use(orchestrator);

const stepStateCache = new StepStateCache(…);
engine.use(stepStateCache);

The cache is registered second. That is the entire mechanism. One line of ordering in a wiring file is what allows a carried-over object to reach its correct per-step pose, and the class's own comment says so: this handler is registered after the orchestrator, so its pose writes win over the orchestrator's authored pin.

I have mixed feelings about this. It works, it is documented where someone will find it, and it costs nothing at runtime. It is also completely invisible to the type system, and a well-meaning tidy-up of that file would break undo in a way that looks like a physics bug. It is load-bearing architecture expressed as an array index.

The bug that proved the cache had a seam

The first version restored geometry correctly and colour incorrectly.

Pour a blue liquid. Pour a red one. Undo. You get the right amount of liquid at the right level — and the wrong colour.

The cause was that geometry and colour came from different places. Fluid meshes were cached per step. Colour was not; the viewer kept a separate "last known colour per instance" map, harvested from the live bindings just before they were destroyed. So the restore rebuilt step 3's geometry and painted it with the colour of the step you were leaving.

The first fix made it worse in an instructive way. It restored colour from the last-known map more reliably — which just meant it was consistently wrong instead of intermittently wrong.

The real fix was to stop having two caches. Colour moved into the same cached payload as the mesh, resolved from the same simulation response, keyed to the same step. Now geometry and colour cannot disagree, because there is nothing left to disagree with.

The general shape: if two facts must always match, do not store them separately and synchronise them. Store them together and let the structure make disagreement unrepresentable.

The two frames nobody would guess

One more, because it is the kind of thing that only shows up in a system with a boundary in the middle of it.

When a restore finishes, JavaScript tells Unity it is done. That signal is delayed by two animation frames:

requestAnimationFrame(() => requestAnimationFrame(() => {
    engine.sendMessage('JsBridge', 'UndoFinish', String(step ?? ''));
}));

The reason is a dependency Unity has on its own side. UndoFinish triggers work that computes fluid-to-container offsets — where the water sits relative to the vessel holding it. That calculation reads object positions. Those positions were written by the restore, through the override buffer, which Unity reads at the end of its frame.

Send UndoFinish immediately and the offsets are computed against the old positions, because the new ones have not been applied yet. The water ends up misaligned with its container by exactly the distance the container moved during the undo.

Two frames is empirical. One was not always enough. I would like this to be an explicit acknowledgement rather than a delay, and it is on the list — but I would rather ship a documented requestAnimationFrame sandwich with the reason written above it than an undocumented one-frame version that works on my machine.

What this actually taught me

Undo is not a feature you add. It is a report on where you kept your state.

Every difficulty here came from state living in more places than I had acknowledged: the authored document, the simulation response, the live scene buffer, a per-instance colour map, and Unity's own internal bindings. Undo simply walks through all of them and asks each one what it thinks the world looked like. Wherever two of them answer differently, you get a bug — and the bug presents as physics misbehaving, not as a state-management problem, which is what makes it expensive to chase.

The fixes were all the same shape. Read from the source that reflects reality rather than intent. Capture at a moment the system guarantees is quiet. Store things that must agree in one place. And when two writers legitimately compete, make the winner explicit and write down why.

None of that is about undo. It is about knowing how many copies of the truth you have. Undo is just the feature that counts them for you.