I wanted TinyHistoryDemo, the nation-simulation sample I was building for Reactive SO, to have thousands of armies marching and fighting every simulation tick. I capped it at MAX_ARMIES = 20000 in the sample script.
A plain foreach over that many armies on the main thread every tick was never going to hold a frame rate — but the whole reason I’d built ReactiveEntitySet the way I did was its managed surface: Register, SetData, OnDataChanged events, a ScriptableObject you can drag into an Inspector.
The Job System and Burst want the opposite of that — unmanaged structs, NativeArray, no C# events firing from a worker thread. I had two APIs I’d have to make talk to each other, and neither one wanted to.
TinyHistoryDemo — the sample whose army simulation this orchestrator drives — 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 post is about ReactiveEntitySetOrchestrator<TData>, the class I ended up writing to sit between those two worlds: double buffering so a Job can write while the main thread keeps reading, and a change list so firing events after the Job completes doesn’t mean scanning every entity to find out what moved.
Two APIs that don’t want to talk to each other
ReactiveEntitySetSO<TData> is a Sparse Set: Register/Unregister/SetData mutate its internal arrays directly and fire events synchronously, on whatever thread called them.
That’s fine for “player picks up an item” — it’s not fine for “20,000 armies resolve movement and combat every tick,” because Burst-compiled IJob/IJobParallelFor code can only touch unmanaged memory (NativeArray<T>, NativeList<T>, blittable structs) and can’t call back into managed code at all.
A Job has no way to raise OnDataChanged, and reaching into the entity set’s live array from a worker thread while the main thread might also be reading it is exactly the race the Job Safety System exists to catch.
So I couldn’t run the entity set’s own update loop inside a Job, and I couldn’t use the entity set’s API from inside a Job either. I needed something to sit between them, copying data across the managed/native boundary in a shape both sides could use.
Why I kept the orchestrator out of the core
I could have bolted a “Job mode” flag onto ReactiveEntitySetSO<TData> itself. I didn’t — ReactiveEntitySetOrchestrator<TData> is a separate class:
public class ReactiveEntitySetOrchestrator<TData> : IDisposable
where TData : unmanaged
{
// ...
}
The reasoning was mostly about what I wanted to keep testable without a Job in sight. The core (ReactiveEntitySetSO<TData>) stays a plain reactive data container — Register, SetData, events, the Snapshot API — with no Unity.Jobs dependency at all, and I can use it without ever touching an orchestrator.
The orchestrator wraps an existing entity set instance and owns everything Job-related: double buffers, a change list, the pending JobHandle. That split lets me write the core’s EditMode tests as ordinary logic (register an entity, assert an event fired — no scheduling, no Complete()) while all the Job bookkeeping lives in one place I can reason about on its own.
Double buffering
The orchestrator allocates two buffers when I construct it:
public ReactiveEntitySetOrchestrator(
ReactiveEntitySetSO<TData> entitySet,
int initialCapacity = 0,
int changeListCapacity = 0)
Leave initialCapacity at its default and the buffers size themselves off the entity set’s current capacity; pass a number instead and that’s what gets reserved up front, for the orchestrator’s whole lifetime — which is exactly what I do in TinyHistoryDemo, sizing for MAX_ARMIES instead of trusting the default (more on why below).
changeListCapacity is a separate opt-in: leave it at 0 and the change-tracking list I cover next simply never gets allocated.
The usage shape I settled on is: GetBackBuffer() for a Job to write into, ScheduleUpdate(handle, count) to register the pending JobHandle with the orchestrator, and CompleteAndApply() — I call mine from LateUpdate — to complete the Job and make the result visible.
The part I was most deliberate about is what CompleteAndApply does internally: it doesn’t copy the new data into the entity set’s array. It hands the entity set the back buffer directly and takes the entity set’s old array back as the orchestrator’s new back buffer — a pointer exchange between the two, not a value copy.
That’s O(1) regardless of how many entities are in the set, which mattered to me once I was thinking in terms of 20,000 armies: I didn’t want a copy that scaled with entity count sitting on the frame’s critical path.
The main thread keeps reading the entity set’s front buffer for the entire frame the Job is running; the Job only ever touches the back buffer. Two writers never touch the same memory in the same frame, so there’s no lock to take.
Buffer A is the front buffer being read while Buffer B is being written by the Job; at CompleteAndApply they trade roles for the next frame. The diagram only covers the default DataOnly path with change tracking on — buffer growth and the DataAndIds path aren’t shown, both are described below.
DataOnly vs. DataAndIds
I split ScheduleUpdate on an UpdateMode, because “the Job changed values” and “the Job changed which entities exist” turned out to need different amounts of copying:
public enum UpdateMode
{
DataOnly,
DataAndIds
}
DataOnly is the default and the cheap path: only the TData buffer swaps, and it requires newCount == entitySet.Count — the Job isn’t allowed to change which entities exist, only their values. That’s the right fit for “armies moved and fought, the same 20,000 IDs still exist,” which is most of what my per-tick simulation Jobs do.
DataAndIds also swaps the ID buffer, for Jobs that add, remove, or reorder entities — and in that mode the Job has to write GetBackBufferIds() itself. I didn’t make the orchestrator copy IDs automatically, because doing that unconditionally would add an O(n) pass that most callers running DataOnly don’t need and shouldn’t have to pay for.
TinyHistoryDemo’s army simulation stays on DataOnly for the per-tick march/combat Jobs. I don’t remove dead armies inside the Job — RemoveEliminatedArmies() runs back on the main thread, after CompleteAndApply, iterating the entity set the ordinary way and calling Unregister.
Structural changes (Register/Unregister) happen outside the Job entirely; the Job’s only job is to update values for entities that already exist.
Change tracking without an O(n) scan
Once a Job touches a handful of entities out of thousands, firing individual OnDataChanged events means knowing which ones changed. Scanning the whole buffer to compare old and new values is O(n) every tick regardless of how many actually changed, and that scan cost bothered me more than the copy did. So the orchestrator hands out a writer instead:
public NativeList<int>.ParallelWriter GetChangeListWriter()
A Job that wants to be counted calls changedIndices.AddNoResize(index) for the indices it actually modifies. CompleteAndApply(fireIndividualEvents: true) then walks only that list — O(changes), not O(n) — firing OnDataChanged per entry before it clears the list for the next tick.
I made change tracking opt-in rather than always-on: construct the orchestrator with changeListCapacity == 0 and GetChangeListWriter() throws "Change tracking is not enabled. Create the Orchestrator with changeListCapacity > 0." instead of handing back a writer. That was a deliberate nudge to myself as much as anyone else using it — if you don’t need per-entity events, don’t pay for the list.
The pre-Job snapshot, for oldData
OnDataChanged in the rest of ReactiveEntitySet carries both the old and new value, and I wanted the Job-driven path to keep that contract. The problem is timing: by the time CompleteAndApply runs, the “old” data is already sitting in what’s about to become the discarded back buffer — reachable, but only if I’d captured it before the swap.
ScheduleUpdate(handle, count, captureOldData: true) does that capture up front, using the entity set’s own CreateSnapshot(Allocator.TempJob) before the Job is even registered as pending, and disposes any snapshot left over from a previous call first.
I picked Allocator.TempJob specifically because its lifetime (up to 4 frames) comfortably outlives “one Job, then CompleteAndApply in LateUpdate the same frame” — I didn’t need Persistent’s open-ended lifetime for something that’s read once and disposed before the next tick.
When events fire, the captured snapshot’s data at each changed index becomes oldData and the back buffer’s data becomes newData, and the snapshot is disposed at the end of the same CompleteAndApply call that used it — its whole lifetime is one Job cycle. If a caller doesn’t ask for captureOldData, changed events still fire, just with default in place of oldData.
What actually breaks, and what doesn’t
The draft outline for this post had three “things that bit me.” Going back through the source and TinyHistorySimulation’s actual usage, one of the three turned out to be describing an old fear rather than current behavior, so here’s what I could actually verify.
Buffers the orchestrator doesn’t own are still your problem
Dispose() on the orchestrator completes any pending JobHandle before disposing its own buffers — that part is automatic and you don’t need to guard it yourself.
But a real Job usually reads and writes more than just the orchestrator’s back buffer. TinyHistorySimulation’s march/combat Jobs also touch armyCommands, assignedTargets, and combatResults — plain NativeArray/NativeList fields owned directly by the MonoBehaviour, not by the orchestrator.
CleanupSimulation() completes the pending handle before disposing those, on top of what armyOrchestrator.Dispose() already does for its own buffers:
private void CleanupSimulation()
{
if (hasPendingJob)
{
pendingJobHandle.Complete();
hasPendingJob = false;
}
armyOrchestrator?.Dispose();
armyOrchestrator = null;
if (armyCommands.IsCreated) armyCommands.Dispose();
if (assignedTargets.IsCreated) assignedTargets.Dispose();
if (combatResults.IsCreated) combatResults.Dispose();
// ...
}
That redundancy is deliberate, not defensive copy-paste: the orchestrator’s own Dispose() protects the orchestrator’s own buffers. Anything else your Job reads or writes is outside its knowledge, and the Job Safety System will throw the moment you dispose a NativeContainer a scheduled-but-not-completed Job still holds.
The rule I ended up with: every extra NativeArray/NativeList you hand into a Job needs its own place in the disposal order, and “the orchestrator handles cleanup” only covers what the orchestrator allocated.
Growth is handled automatically — TinyHistoryDemo avoids relying on it anyway
Every call to GetBackBuffer() checks whether the entity set’s capacity has outgrown the orchestrator’s buffers.
If a Job is currently pending and the buffers are now too small, it calls CompleteAndApply() before touching anything — a pending Job might still be reading or writing those NativeArrays, and disposing one out from under a running Job isn’t safe. Once nothing’s pending, it disposes the old (undersized) buffers and allocates new, larger ones at the entity set’s current capacity.
So a capacity mismatch doesn’t corrupt anything on its own — it forces an early Complete() I didn’t explicitly ask for, on whatever frame the entity set happens to cross the old capacity. That’s correct, but it’s a stall that shows up at an unpredictable moment if I’d relied on it.
TinyHistoryDemo’s InitializeSimulation() sidesteps the whole path instead of trusting it: bufferCapacity = MAX_ARMIES + 100, passed as the orchestrator’s initialCapacity up front, with a comment that says exactly why I did it that way —
// Note: We pre-allocate for max capacity in InitializeSimulation
// to avoid use-after-free when entitySet grows.
// This method only handles armyCommands as a safety fallback.
Sizing for the known ceiling once, rather than depending on the auto-resize path to catch every case correctly under a growing entity count, is the more predictable choice when the ceiling is known — which for a MAX_ARMIES-capped simulation, it is.
Scheduling over a pending Job warns and recovers — it doesn’t throw
The outline’s original framing was “trying to schedule a new Job while one is pending fails loudly.” That’s not what ScheduleUpdate(JobHandle jobHandle, int newCount, ...) does.
Call it while a previous update is still pending and it logs "[ReactiveEntitySetOrchestrator] ScheduleUpdate called while a previous update is pending. Completing previous update immediately.", completes the previous update for you, then proceeds.
Nothing throws, nothing corrupts — but if you weren’t expecting CompleteAndApply() to run at that exact point in your frame, you’ve just moved a synchronization stall to a spot you didn’t choose.
TinyHistorySimulation doesn’t lean on this recovery path; it keeps its own hasPendingJob flag and gates ScheduleSimulationTick() behind it, so the orchestrator’s own pending-job check is never actually exercised in that sample.
Given that the sample built around this class avoids depending on the auto-recovery, I read that as the intended usage: track your own pending state, and treat the orchestrator’s warning as a bug report, not a supported flow.
Where I’m cautious about going further
The Orchestrator API in this post — double buffering, DataOnly/DataAndIds, the change list, captureOldData — has been part of the package since 2.1.0, alongside the rest of the Reactive Entity Set system and the Snapshot API it builds on.
The 3.0.0 line currently in progress touches adjacent ground — reentry protection during notification dispatch is being made unconditional rather than compiled out in release builds, and view notification is being isolated per-subscriber so one throwing view doesn’t block the rest — but I’m not committing to specifics here while that work is still unreleased.
One thing the Orchestrator deliberately doesn’t do: help with parallel structural changes. Adding or removing entities inside a Job — as opposed to on the main thread after CompleteAndApply, which is what TinyHistoryDemo does — needs atomic index reservation for parallel adds and stream compaction for parallel removes, and none of that is the orchestrator’s problem to solve.
ReactiveEntitySetSO’s own removal is swap-and-pop, which isn’t thread-safe for parallel execution in the first place. TinyHistoryDemo keeps every structural change single-threaded and outside the Job for exactly that reason.
Wrap-up
The orchestrator’s job is narrower than it might sound: two buffers, a pointer swap, an optional list of touched indices. Everything about “is this safe to run in parallel” — structural changes, atomic counters, compaction — stays outside it, on purpose, because that’s a per-simulation design decision and not something a generic wrapper should decide for you.
What surprised me going back through this code wasn’t the double buffering; it was how much of the original “things that bit me” list turned out to already be handled, and how the one real gap — cleanup order for Job-local buffers the orchestrator never sees — doesn’t show up anywhere in the orchestrator’s own code at all. It only shows up in how you use it.
References
- Reactive SO — Asset Store
- Reactive SO 2.1 release video (YouTube)
- Companion: Bringing ECS Insights to GameObject Workflows with ReactiveEntitySet
- Companion: Why ReactiveEntitySet needed its own scene-persistence holder
- Companion: What is Reactive SO?
- Unity — C# Job System Manual
- Unity — Burst Compiler Manual
- Unity — NativeArray<T> Reference
- Unity — JobHandle Reference