Build Log

Time travel in ReactiveEntitySet (snapshot, restore, and branching)

  • Unity
  • Reactive SO
  • Time Travel
  • Snapshot
  • Game Development

TinyHistoryDemo, the nation-simulation sample for Reactive SO, has a year-by-year seekbar. Drag it back to year 50 while the simulation is at year 100, and the map, armies, and nations jump to what they looked like then. Press play again, and the years after 50 have to disappear — resuming from a past state can’t leave year 51 through 100 sitting around as a future that no longer happens.

This post is about the mechanism behind that: EntitySetSnapshot<TData>, the CreateSnapshot/RestoreSnapshot pair built on it, and HistoryManager, the sample’s circular buffer of snapshots that makes scrubbing and branching both cheap.

TinyHistoryDemo, whose seekbar is what this snapshot/restore system serves, was introduced in the Reactive SO 2.1 release. Here’s the release video for that version:

Unity Asset StoreReactive SO | Game ToolkitsScriptableObject-based reactive architecture for Unity. Variables, Event Channels, Runtime Sets, GPU Sync, Reactive Entity Sets, and dedicated debugging windows.

This is a companion to the earlier ReactiveEntitySet posts, in particular the one on wiring the same entity sets into Burst Jobs — the two features turn out to share more history than I expected going in.

What the seekbar actually needs

“Time travel” undersells what HistoryManager does, because a real timeline only needs to go one direction. TinyHistoryDemo’s is a branch point: scrubbing back to year 50 to look is free — nothing changes, you’re just viewing an old snapshot.

Resuming play from year 50 is the operation that matters, because the simulation is about to generate a new year 51 that has nothing to do with the year 51 that already happened. The old future has to go away, and it has to go away without disturbing the years before it or costing anything the frame it happens.

That splits into three separate problems: a cheap way to copy a ReactiveEntitySetSO<TData>’s entire state out (a snapshot), a cheap way to put it back (a restore), and a cheap way to discard everything after a point without touching what’s still valid (a truncate).

None of the three were designed together as a time-travel feature — which is the part of this I didn’t expect until I went back through the history.

A struct that predates the sample it’s used in

EntitySetSnapshot<TData> first appears in the same commit that added ReactiveEntitySetOrchestrator and its Job System integration guide — five days before TinyHistoryDemo and its HistorySnapshot struct show up in the repository. The commit message is about giving RES “fast memory copy,” not about history or rewinding.

The struct’s own XML doc still lists all three use cases it was built to cover, in the order they were added: persistence, time-travel, and Job System capture-before/restore-after. Time-travel is the middle one, and it arrived by fitting an existing shape rather than by being the reason the shape exists.

public struct EntitySetSnapshot<TData> : IDisposable
    where TData : unmanaged
{
    public NativeArray<TData> Data;
    public NativeArray<int> EntityIds;
    public NativeArray<ulong> TraitMasks;
    public int Count;
}

The design is unremarkable on its own — a struct holding the two (or three, once traits shipped in 2.2.0) arrays a ReactiveEntitySetSO<TData> keeps internally, plus a count, with ownership of the NativeArrays transferred to whoever holds the snapshot.

CreateSnapshot(Allocator allocator) lets the caller pick the allocator, which is what makes the same struct fit two very different lifetimes: Allocator.TempJob for the orchestrator’s pre-Job capture that gets disposed the same frame, and Allocator.Persistent for HistoryManager’s buffers that live for the whole session.

Both EntitySetSnapshot<TData> and the Snapshot API around it shipped in 2.1.0, in the same release as TinyHistoryDemo itself — the code was five days older, the release was simultaneous.

RestoreSnapshot fires one event, not thousands

Putting a snapshot back is a bulk copy: RestoreSnapshot does NativeArray<TData>.Copy for the data and IDs, rebuilds the set’s internal ID-to-index lookup from scratch, restores trait masks if the set has any, and rebuilds any active views.

The part worth calling out is what it doesn’t do — it doesn’t walk the restored entities and fire per-entity change events. TinyHistoryDemo’s ArmyStateSet alone is sized for MAX_ARMIES = 20000; firing OnDataChanged for each one on every seek would turn a UI scrub into a frame that inspects twenty thousand delegates for a value nobody asked to be notified about individually.

RestoreSnapshot raises OnSetChanged exactly once instead, and everything downstream — the map renderer, the army renderer — is expected to re-read the whole set rather than diff it.

That decision has a quieter side effect on views: OnEnter/OnExit don’t fire during RestoreSnapshot either, and the library says so directly in the XML doc for both events — “Bulk operations such as set Clear and RestoreSnapshot rebuild membership silently for performance, so this event is not raised during those operations.”

A view still ends up with the right members after a restore; it just doesn’t narrate how it got there.

There’s also a guard that only shows up if something tries to call RestoreSnapshot from inside an event handler that a set is currently dispatching: reentering during notification dispatch is rejected with a ReactiveEntitySetReentryException rather than being allowed to corrupt the sparse set mid-notification.

As shipped in 2.2.0, that check compiles out of Release and non-Development player builds — it’s an Editor/Development safety net, not something a shipped build enforces. The in-progress 3.0.0 line, per its Unreleased changelog entry, makes the same guard unconditional in every build configuration, so a reentrant restore throws instead of silently corrupting state in a Release build too.

I’m noting the direction because it’s already written down in the changelog, not because I know when it ships.

TruncateAfter invalidates slots, it doesn’t free them

Branching a timeline means discarding every snapshot after the point you resumed from, and HistoryManager.TruncateAfter(frameNumber) does that by walking backward from the newest snapshot, setting FrameNumber = -1 on each one past the cutoff, and stopping the moment it finds a snapshot at or before the frame:

while (count > 0)
{
    var newest = GetSnapshotAt(count - 1);
    if (newest.FrameNumber <= frameNumber)
        break;

    int newestIndex = GetActualIndex(count - 1);
    snapshots[newestIndex].FrameNumber = -1;
    count--;
    removed++;

    head = (head - 1 + maxSnapshots) % maxSnapshots;
}

Nothing here calls Dispose().

snapshots is a HistorySnapshot[] sized to maxSnapshots (84 by default) and every slot’s NativeArrays are allocated once, in the constructor, with Allocator.Persistent — the invalidated slots keep their buffers and just wait to be overwritten by the next CaptureSnapshot call, the same way any other slot in the circular buffer gets reused once head wraps around to it.

-1 is a sentinel, not a deallocation.

To see why the arithmetic needs head to move backward too, it helps to trace an actual run rather than describe it. With maxSnapshots = 6 (smaller than TinyHistoryDemo’s default of 84, so it fits in a diagram) and one snapshot per year, capturing years 0 through 7 wraps the buffer once — years 0 and 1 get overwritten by 6 and 7, because there’s nowhere else for them to go.

Seeking to year 4 and then resuming play calls TruncateAfter(48) (year 4 at a 12-frame interval), which has to invalidate years 5, 6, and 7 — the newest three snapshots — without touching the buffer positions holding years 2, 3, and 4:

A 6-slot circular buffer, raw index order, before and after TruncateAfter. Before: slot0=year6, slot1=year7, slot2=year2, slot3=year3, slot4=year4, slot5=year5, head=2, count=6. After: slot0 and slot1 invalidated (FrameNumber=-1, buffers kept), slot5 invalidated, slots 2-4 unchanged, head=5, count=3. Logical (oldest-to-newest) order is annotated separately from raw slot position, since the buffer wraps.

Read the slot numbers as raw array positions, not chronological order — GetActualIndex(logicalIndex) = (head - count + logicalIndex + maxSnapshots) % maxSnapshots is what turns “the 3rd-oldest snapshot” into an actual index, and it’s the same modular arithmetic in both CaptureSnapshot (writing forward) and TruncateAfter (unwinding backward).

Getting head’s post-truncate value wrong here doesn’t crash — it just makes GetSnapshotAt read the wrong slot the next time CaptureSnapshot writes into it, which is a much slower bug to notice than an exception would be.

HistoryManager’s pre-allocated slots

HistoryManager’s constructor allocates all maxSnapshots slots up front, each one already holding correctly-sized NativeArrays for armies, provinces, and nations — CaptureSnapshot is then a handful of NativeArray.CopyTo calls into the current head slot, not an allocation:

armyData.CopyTo(slot.Armies.Data.GetSubArray(0, armyCount));
armyIds.CopyTo(slot.Armies.EntityIds.GetSubArray(0, armyCount));
slot.Armies = new EntitySetSnapshot<ArmyState>(slot.Armies.Data, slot.Armies.EntityIds, armyCount);

GetSubArray is what makes a fixed-size buffer able to hold a variable-size snapshot: the slot’s NativeArray<ArmyState> is always MAX_ARMIES (20000) long, and each capture only copies into the first armyCount entries — the trailing slots just aren’t part of the logical snapshot that frame.

The buffer itself, though, is sized once for the ceiling and stays that size for the rest of the session regardless of how many armies actually exist. ArmyState is 24 bytes (five int fields plus one float), plus 4 bytes for the entity ID per army, so one slot’s army buffer alone is 20000 * 28 bytes — about 547 KiB — whether the simulation has 20,000 armies or 100.

Multiplied by 84 pre-allocated slots, that’s roughly 45 MiB of Persistent memory reserved for army history the moment the simulation starts, computed from the struct sizes rather than measured with a profiler.

Things still bugging me

Sizing every slot for MAX_ARMIES is the trade-off I’m least settled on. It buys CaptureSnapshot its no-allocation guarantee, but it means the memory cost of history depth is fixed at whatever the worst case is, not whatever the game state actually needs — a 100-army skirmish pays the same ~45 MiB as a 20,000-army late-game sprawl.

A snapshot pool sized to the entity set’s current capacity instead of a hard ceiling would track actual usage better, at the cost of the resize-and-copy path RestoreSnapshot already has to handle for growth in other contexts.

There’s no delta encoding. Every CaptureSnapshot call is a full copy of every array, every time — the 547 KiB per slot above is what one snapshot costs regardless of how many armies actually moved since the last one.

For an 84-slot, 12-frame-interval history that’s the price of TruncateAfter staying O(1) per invalidated slot: nothing has to reconstruct state by replaying deltas forward, restoring is always “copy this slot,” period. Whether that trade is worth it for a longer history window than 84 years is a question I haven’t needed to answer yet.

Wrap-up

The thing I keep coming back to is that EntitySetSnapshot<TData> wasn’t built for this. It was built so a Job could get a consistent read of a set’s state before scheduling and the orchestrator could hand back an oldData value after.

Time-travel got to reuse it because “copy everything out, copy everything back” turned out to be the same operation whether the reason is a worker thread or a seekbar — the struct doesn’t know which one is asking, and it didn’t have to be designed to know.

HistoryManager is the part that’s actually shaped like time travel; the snapshot underneath it is shaped like a memcpy with an allocator parameter, which is a more useful thing to be.

References