Systems declare what they touch in their own method signature, and the scheduler works out from that what is allowed to run at the same time.
Built for one game, now shared across three projects. It is its own solution, with Roslyn analyzers enforcing correct use of the framework, a test suite for the analyzers themselves, and a benchmark project alongside.
Declared dependencies
A system says what it touches by taking it as a parameter.
public partial class BannerSystem : IServicesSystem
{
[Update]
private void Update(in CameraRotation cameraRotation, in Banners banners) { }
}
There is no separate manifest and no registration call. The signature is the declaration, so it cannot drift from the code that depends on it, and a system that reaches for something it did not declare is rejected at compile time by the analyzers.
Factories say the same thing in both directions, with in for what they consume and out for what they produce.
public partial class GraphNodeFactory : IServiceFactory
{
[Build]
private static void Build(
in ICollection<IServiceFactory> serviceFactories,
in ServicesCollection services,
in Systems systems,
out ForNextIteration.Graph graphNode
) { }
}
The graph builder reads those declarations and turns them into edges. Nothing is wired by hand.
Read down a lane and the ordering is visible. The lane shows through a read, because reads pass each other and run together. A gate blocks it: everything above finishes before the gate starts, and everything below waits for it.
Most systems sit in several lanes at once, and the constraints compose. Terrain and Instanced both read the quad factory and sprite UVs, which forces nothing, because shared reads are still parallel. The only ordering in the whole picture comes from the four gates.
Instanced appears twice for a reason. Its reads and its gate are not the same moment: it takes its inputs alongside everything else reading them, and its gate on the instance buffer is the commit, which cannot start until every system appending to that buffer has finished. One signature, two points in the schedule.
Reaching another entity’s component
A component can hold a reference to a component on a different entity, and a system can take the target directly.
[Update]
private void Update([ComponentRef] in Position position, [Service] in SeenPositions seen) { }
The archetype requirement is the reference, not the target: the entity being iterated needs the ComponentRef<Position> and does not need a Position of its own. It must be in, because the target belongs to another entity and another batch may be writing it.
The reference caches the store and never caches the offset, which are different promises. A frozen entity cannot gain or lose a component, so it cannot change archetype, so the store its component lives in is settled for the entity’s life and is safe to hold. A position is not: a deleted entity’s slot goes back on the free list and is handed to the next entity created, so a cached offset does not fail loudly, it quietly begins referring to somebody else. The offset is resolved from the entity on every access, and entity ids come from a monotonic counter and are never reused.
Building one refuses an entity that is not frozen. That is what freezing is for: it turns “this store is probably still right” into something the type system can hold.
Parallelism is the default; ordering is opt-in
Two nodes with no edge between them run on different threads simultaneously. Ordering comes from sharing a service, and a hard barrier from marking a parameter as gated.
public partial class RenderStartSystem : IServicesSystem
{
[Update]
private static void Update([GatedAccess] in CommandListFactories commandListFactories) { }
}
public partial class ImGuiRenderSystem : IServicesSystem
{
[Update]
private void Update(in CommandListFactories commandListFactories /* ... */) { }
}
public partial class RenderCommitSystem : IServicesSystem, ISpikeExempt
{
[Update]
private static void Update(
[GatedAccess] in GraphicsDevice graphicsDevice,
in Sdl2Window sdl2Window,
[GatedAccess] in CommandListFactories commandListFactories
) { }
}
The two gated systems bracket the rest. Every system that records into a stage runs in between, in parallel, and the commit waits for all of them. Nothing had to name a phase or a priority.
Banner sits off to the side with no edges at all, because it shares no service with any of them. It runs wherever there is a thread.
The safety property lives in the graph. Nothing that shares state runs concurrently because the schedule forbids it, so the structures underneath do not need locks.
The iteration
One iteration is a frame’s worth of work. It exists as graph nodes - factories and systems, ordered by data dependencies - and as loose tasks: async continuations and batch slices, sitting in one of two queues.
The main thread and n-1 workers cooperatively drain both until the iteration is quiescent. Prioritised work is drained to completion; regular work is given a time-bounded window each frame and mopped up across iterations. Then the graph resets, pooled node arrays return to the array pool, and a double-buffered next-iteration graph is swapped in.
That boundary is what pays for everything inside it. Structures within an iteration can skip synchronisation entirely because quiescence is guaranteed somewhere else, and the same shape repeats at three scales: a batch runs a serial pre-hook, parallel workers and a serial post-hook; an iteration runs its writers and then its drainers; and the next iteration’s graph is built while the current one is still running. Because the graph is derived from declarations before any of it executes, which systems can touch each other is enumerable in advance rather than observed to have been fine so far.
Batch processing
Not all work is per-entity. A batch processor system consumes a buffer of items and splits it across worker threads, as a single node in the same graph.
public partial class UiMeasureSystem : BatchProcessorSystemBase<UiTextArena, TextRow>
{
protected override int BatchSize => 8;
protected override void BeforeUpdate() { }
protected override void ProcessBatch(int start, ReadOnlySpan<TextRow> batch) { }
protected override void AfterUpdate() { }
}
Three phases with different threading, which is what makes the shared state tractable. BeforeUpdate runs on the scheduling thread before anything is enqueued, so work that mutates a shared cache goes there. ProcessBatch runs on pool threads in parallel. AfterUpdate runs once, on whichever thread finishes last, so anything that has to be committed serially goes there. The default clears the buffer, and downstream systems see it cleared.
The buffer service becomes gated automatically. The source generator adds it to the system’s gated set, so every writer is sequenced ahead of the processor without anyone declaring it, which is the same barrier the render pipeline above asks for by hand.
The batch delegate is allocated once, by method group conversion in the constructor, so a tick that runs thousands of batches allocates nothing for them. That is the delegate cost paid at startup instead of per frame.
The scheduler runs inside itself
The next iteration’s graph is built by factories that are nodes in the current iteration’s graph.
internal partial class ExecutionIterationFactory : IServiceFactory, ICoreServiceFactory
{
[Build]
private void Build(
in ForNextIteration.Graph nextIterationGraph,
in ExecutionIteration currentIterationExecution,
out ForNextIteration.Execution nextIterationExecution
) { }
}
That is the same [Build], and the same in and out, that a game system’s factory uses. GraphNodeFactory produces the graph, this consumes it and produces the execution, and the ordering between them is an edge the graph builder derived from those two signatures without being told about either type.
So the scheduler is scheduled. It gets the parallelism, the dependency ordering and the skip-if-nothing-changed behaviour from the mechanism it exists to provide, rather than from a special path reserved for itself.
Two concurrency primitives
Which one you reach for depends on whether production and consumption overlap in wall-clock time.
Phase-separated, within one iteration. A single-buffered arena with lock-free append on an interlocked tail bump and a bulk drain that resets the count. It has no defence against a drain overlapping an add: a concurrent drain could read a half-written slot. That overlap never happens because the graph forbids it.
Overlapping, across iterations. Vyukov’s bounded queue, specialised for a single consumer. Per-cell sequence numbers mean a slot becomes visible only once its value is fully written and release-published, which is exactly the hazard the arena does not defend against.
Scripting inside the tick
Durable scripts are scheduled as systems across gather and collect phases against a simulation clock, with their own storage. They participate in the same graph and the same parallelism as everything else rather than running beside the loop.
The structures underneath
The collections all answer one question: what can be free in the common case, and where does the cost get paid instead.
LockOnResizeArray backs every component store. An append takes its slot with an interlocked increment and writes it, and the spin lock is only ever taken to grow the array, behind a double-check. Growth is the one moment two threads can disagree about where the array is.
LedgerDictionary takes writes as transactions on a concurrent queue and applies them when told to. Readers see a plain dictionary with no lock at all, and the writes land at a phase boundary the scheduler already provides.
ReadWriteLockedDictionary reads under a read lock and only escalates to a write lock when the key turns out to be absent. It hands back a ref into the dictionary’s own storage through CollectionsMarshal.GetValueRefOrAddDefault, so a hit is not a copy.
It carries a lot of GetOrAdd overloads, and the reason is closures. A factory that captures a local allocates one on every call, including the calls that find the key already there, so the state is passed as parameters instead, up to three of them, in or ref as the caller needs. The overloads exist so that nothing has to be captured.
PooledList is a list over ArrayPool whose backing array is rented once and reused across clears. It implements the collection-expression pattern, so [a, b] builds one rather than a List.
EnumIndexedArray indexes by enum value through Unsafe.As, so a lookup is an array offset instead of a hash.
CircularBuffer keeps a fixed-length history that is appended to and never removed from, carrying its own running maximum. It was written for the ImGui overlay that plots frame timings, where the peak across the window is worth more than any single sample.
IHasObjectPool uses a static abstract interface member, which gives each closed generic type its own pool. Pooling is opted into by implementing an interface rather than by registering with a container.
Several of these are safe only for the access pattern the graph permits, which is the same bargain the arena makes. The scheduler decides who runs beside whom, so a structure does not have to defend itself against everyone.
Alongside them: archetype storage and archetype ids, generated shapes, a versioned service registry, the execution graph, and a subscription system.