ZEngine — System Scheduler¶
Priority: P1 — Implement alongside ECS core (Phase 1 of migration-plan.md)
Status: Implemented; retained as a completed design and migration record.
Depends on: actor-ecs-architecture.md
Blocks: animation-system.md
Maintenance boundary: the live scheduler API and behavior are defined by
ZEngine/ZEngine/ECS/WorldTick.*andWorldCommands.*. The detailed code sketches and milestone language below are historical unless they match those files. This document does not define the pending production editor transaction boundary; seefuture-plan/editor-undo-redo.mdfor that separate design.
1. Why this exists¶
Without a scheduler, the game loop calls systems sequentially:
while (running) {
InputSystem(scene, dt);
PhysicsSystem(scene, dt);
AnimationSystem(scene, dt);
RenderCullSystem(scene, dt);
AudioSystem(scene, dt);
}
This works on a single thread. The problem appears as the scene grows. Say your systems take:
InputSystem 0.1 ms
PhysicsSystem 4.0 ms
AnimationSystem 3.0 ms
RenderCullSystem 2.0 ms
AudioSystem 1.0 ms
Total: 10.1 ms (16 ms budget at 60 fps — 5.9 ms headroom)
PhysicsSystem writes TransformComponent. AnimationSystem writes AnimatorComponent.
They touch completely different data — there is no reason they cannot run simultaneously on
two threads. The wasted 3ms is free performance sitting on cores that are doing nothing.
You could parallelize manually:
auto f1 = ThreadPool::Submit([&]{ PhysicsSystem(scene, dt); });
auto f2 = ThreadPool::Submit([&]{ AnimationSystem(scene, dt); });
f1.wait(); f2.wait();
RenderCullSystem(scene, dt);
But this hardcodes dependency knowledge in the game loop. Every new system requires reasoning about all existing systems, manually reordering calls, and hoping the analysis is correct. It does not scale and produces silent data races when wrong.
The scheduler externalizes that reasoning. Each system declares what it reads and writes. The scheduler detects conflicts, groups independent systems into parallel waves, enforces ordering where conflicts exist, and asserts when you forget to declare an ordering — rather than silently running incorrectly.
It is not strictly necessary for a small game with few systems. It pays off when you have 5+ systems that could run in parallel and want that parallelism to be provably correct rather than ad-hoc. For ZEngine's scope (physics, animation, culling, audio, gameplay systems), the scheduler is the right model.
2. Overview¶
The system scheduler sits on top of WorldTick and ThreadPoolHelper. Its job is to
dispatch ECS systems in parallel where safe, and serialize them where a data conflict exists.
Safety is determined by component read/write masks declared at registration. Two systems that conflict (one writes what the other reads or writes) must not run concurrently. Two systems with no overlap can run in parallel.
Conflicts without an explicit ordering edge are a programming error — the scheduler asserts in debug builds rather than silently serializing.
3. System Registration¶
Every system declares exactly what it reads and writes at registration time:
world.RegisterSystem(PhysicsSystem, {
.ReadMask = MaskBit(ComponentTypeOf<RigidBodyComponent>()),
.WriteMask = MaskBit(ComponentTypeOf<TransformComponent>()),
});
world.RegisterSystem(AnimationSampleSystem, {
.ReadMask = MaskBit(ComponentTypeOf<AnimatorComponent>()),
.WriteMask = MaskBit(ComponentTypeOf<AnimatorComponent>()),
});
world.RegisterSystem(RenderCullSystem, {
.ReadMask = MaskBit(ComponentTypeOf<TransformComponent>())
| MaskBit(ComponentTypeOf<MeshComponent>()), // MeshComponent — pending issue #609
.WriteMask = 0, // read-only
});
If a system touches a component that is not declared in its masks, that is a programmer error — there is no enforcement at the data level, but it means the DAG is wrong and the scheduler may run conflicting systems in parallel incorrectly. Document and enforce via code review.
4. Conflict Rules¶
Two systems A and B conflict (must not run concurrently) if:
(A.WriteMask & B.ReadMask) != 0 // A writes something B reads
(A.WriteMask & B.WriteMask) != 0 // A and B both write the same component
(A.ReadMask & B.WriteMask) != 0 // A reads something B writes
Simplified: any overlap between (A.WriteMask) and (B.ReadMask | B.WriteMask), or
between (B.WriteMask) and (A.ReadMask | A.WriteMask).
Two systems that only read the same component (both WriteMask = 0 for that type)
do not conflict and may run in parallel.
5. Explicit Ordering¶
When two systems conflict, the programmer must declare an explicit ordering edge:
// PhysicsSystem must complete before RenderCullSystem
SystemID physics_id = world.RegisterSystem(PhysicsSystem, {...});
SystemID cull_id = world.RegisterSystem(RenderCullSystem, {...});
world.OrderBefore(physics_id, cull_id);
OrderBefore(A, B) adds a directed edge A → B in the DAG. This is in addition to any
edges implied by conflict detection. You may add ordering edges between non-conflicting
systems too — for example, to enforce a logical sequence even when there is no data
dependency.
If two systems conflict and no ordering edge exists between them, Commit() (see Section 6)
asserts in debug:
[ECS Scheduler] ASSERT: PhysicsSystem and RenderCullSystem both write TransformComponent
but no ordering edge exists between them. Call world.OrderBefore() to resolve.
6. DAG Construction¶
After all systems are registered and ordered, the scheduler builds a directed acyclic graph.
5.1 Nodes¶
One node per registered system. Each node stores:
struct SystemNode {
SystemFn Fn;
SystemDeps Deps; // ReadMask, WriteMask
uint32_t Index; // stable index into node array
Array<uint32_t> Successors; // indices of nodes that must run after this one
uint32_t InDegree; // number of predecessors (used during execution)
};
5.2 Edge Insertion¶
For every pair (A, B) where A was registered before B:
1. Check conflict rules (Section 3).
2. If no conflict: no implicit edge. They may run in parallel.
3. If conflict: check that an explicit OrderBefore(A, B) or OrderBefore(B, A) edge
exists. If neither exists → assert.
4. Insert the declared ordering edge into the adjacency list.
5.3 Cycle Detection¶
After all edges are inserted, run a depth-first topological sort. If a cycle is detected (back edge found), assert:
[ECS Scheduler] ASSERT: Cycle detected in system dependency graph.
Involved systems: PhysicsSystem → AnimationSampleSystem → PhysicsSystem
Cycles are always programmer errors — there is no resolution at runtime.
5.4 Topological Layers¶
Group nodes into parallel waves using Kahn's algorithm:
Wave 0: all nodes with InDegree == 0 (no predecessors — run first)
Wave 1: nodes whose InDegree becomes 0 after Wave 0 completes
Wave 2: ...
Each wave is a set of systems that can run in parallel. Systems in the same wave have no conflict with each other and no ordering edge between them.
Example:
Registered:
AnimationSampleSystem writes: AnimatorComponent
PhysicsSystem writes: TransformComponent, reads: RigidBodyComponent
RenderCullSystem reads: TransformComponent, MeshComponent // MeshComponent — pending issue #609
AudioSystem reads: TransformComponent
OrderBefore(PhysicsSystem, RenderCullSystem)
OrderBefore(PhysicsSystem, AudioSystem)
Resulting waves:
Wave 0: AnimationSampleSystem, PhysicsSystem (independent — different write masks)
Wave 1: RenderCullSystem, AudioSystem (both depend on PhysicsSystem)
7. WorldTick Updated API¶
// ZEngine/ECS/WorldTick.h
#pragma once
#include <ZEngine/Core/Containers/Array.h>
#include <ZEngine/ECS/ArchetypeMask.h>
#include <ZEngine/ECS/Scene.h>
#include <ZEngine/ECS/WorldCommands.h>
namespace ZEngine::ECS {
// SystemFn is the canonical type for registered systems.
// The third parameter is WorldCommands& so systems can enqueue deferred
// mutations (spawn, destroy, add/remove component) without calling Scene
// methods directly during parallel execution.
// Using a raw function pointer avoids std::function heap allocation and
// virtual dispatch. Systems are called once per wave per frame — not
// per-entity — so indirect call cost is negligible.
using SystemFn = void (*)(Scene&, float, WorldCommands&);
using SystemID = uint32_t;
struct SystemDeps {
ArchetypeMask ReadMask = 0;
ArchetypeMask WriteMask = 0;
};
class WorldTick {
public:
// Register a system with its component dependencies.
// Returns a stable SystemID used for OrderBefore. MUST NOT be discarded —
// ignoring the returned ID makes OrderBefore unusable for this system.
[[nodiscard]] SystemID RegisterSystem(SystemFn fn, SystemDeps deps);
// Declare that system A must complete before system B starts.
// Required whenever A and B conflict (see Section 3).
// Use the SystemID returned by RegisterSystem — SystemFn has no equality operator.
void OrderBefore(SystemID a, SystemID b);
// Build the DAG. Must be called once after all RegisterSystem/OrderBefore calls
// and before the first Tick. Asserts on conflicts without ordering and on cycles.
void Commit();
// Execute all systems. Single-system waves run inline; multi-system waves
// dispatch to ThreadPoolHelper with a spin-yield + cv barrier.
// Blocks until all waves have completed.
// WorldCommands receives deferred mutations from systems; caller must call
// commands.Flush(scene) after Tick() returns.
void Tick(Scene& scene, float delta_time, WorldCommands& commands);
private:
struct SystemNode {
SystemFn Fn;
SystemDeps Deps;
uint32_t Index = 0;
uint32_t InDegree = 0;
Array<uint32_t> Successors;
};
Array<SystemNode> m_nodes;
Array<Array<uint32_t>> m_waves; // topological layers, built by Commit()
bool m_committed = false;
void BuildEdges();
void TopologicalSort();
bool HasConflict(const SystemNode& a, const SystemNode& b) const;
};
} // namespace ZEngine::ECS
8. Execution¶
WorldTick::Tick walks the wave list produced by Commit().
Single-system waves — inline path¶
If a wave contains exactly one system, it runs inline on the main thread. There is no thread pool submission, no barrier, and no synchronization cost. This is not a v2 optimization — it is included in v1 because most DAGs have several single-system waves (input, physics, animation each typically form their own wave), and dispatching each to the thread pool at 60 fps would waste 10–20 μs per wave per frame with zero parallelism benefit.
Wave 0: InputSystem ← single → inline on main thread, zero overhead
Wave 1: PhysicsSystem ← single → inline on main thread, zero overhead
Wave 2: RenderCull, Audio ← two systems → dispatch to thread pool, parallel
Multi-system waves — spin-yield barrier¶
For waves with more than one system, all systems are dispatched to ThreadPoolHelper and
the main thread waits for the wave to complete before starting the next.
Barrier strategy: spin-yield first (user space, fast for sub-millisecond waves), fall
back to condition_variable::wait only if the wave takes longer than ~100 spin iterations
(expensive systems — physics, skinning). A pure mutex + cv barrier makes a syscall
immediately and is too heavy for fast waves. A pure spin wastes a CPU core when waiting
for a genuinely long system. The two-phase approach handles both.
void WorldTick::Tick(Scene& scene, float dt, WorldCommands& commands) {
ZENGINE_VALIDATE_ASSERT(m_committed,
"WorldTick::Tick called before Commit()")
for (uint32_t w = 0; w < m_waves.size(); ++w) {
const auto& wave = m_waves[w];
// Fast path: single-system wave runs inline — no thread pool overhead.
if (wave.size() == 1) {
SystemFn fn = m_nodes[wave[0]].Fn;
ZENGINE_VALIDATE_ASSERT(fn != nullptr,
"WorldTick: null system function pointer in wave")
fn(scene, dt, commands);
continue;
}
// Multi-system wave: each system gets its own staging WorldCommands so
// concurrent calls to SpawnEntity/AddComponent never touch shared state.
// Staging buffers are pre-allocated at Commit() and reused each frame.
for (uint32_t idx : wave) m_staging[idx].Clear();
std::atomic<uint32_t> remaining{static_cast<uint32_t>(wave.size())};
std::mutex mtx;
std::condition_variable cv;
for (uint32_t idx : wave) {
SystemFn fn = m_nodes[idx].Fn;
WorldCommands* staging = &m_staging[idx];
ThreadPoolHelper::Submit([&scene, dt, fn, staging, &remaining, &cv]() {
struct Guard {
std::atomic<uint32_t>& r;
std::condition_variable& cv;
~Guard() {
if (r.fetch_sub(1, std::memory_order_acq_rel) == 1)
cv.notify_one();
}
} guard{remaining, cv};
fn(scene, dt, *staging); // ← private staging buffer, no contention
});
}
// Phase 1: spin-yield — stays in user space for fast waves (< ~100 μs).
for (int spin = 0; spin < 100; ++spin) {
if (remaining.load(std::memory_order_acquire) == 0) break;
std::this_thread::yield();
}
// Phase 2: fall back to cv.wait for slow waves (physics, skinning, etc.).
if (remaining.load(std::memory_order_acquire) != 0) {
std::unique_lock<std::mutex> lock(mtx);
static constexpr int kWaveTimeoutSeconds = 30;
bool completed = cv.wait_for(lock,
std::chrono::seconds(kWaveTimeoutSeconds),
[&remaining] { return remaining.load() == 0; });
ZENGINE_VALIDATE_ASSERT(completed,
"WorldTick::Tick: wave timed out — a system has hung or crashed")
}
// Merge staging buffers into the authoritative commands in submission order.
for (uint32_t idx : wave) commands.Merge(m_staging[idx]);
}
}
Thread safety note: Each system in a wave reads/writes disjoint component types
(verified by Commit()). No Scene-level lock is needed — ComponentStorage<T> arrays
are partitioned by type and workers never share a storage. EntityRegistry::ForEachAlive
is read-only during Tick; mutations must go through WorldCommands, not called
directly from within a system.
WorldCommands in parallel waves: WorldCommands itself is not thread-safe.
In multi-system waves, each worker receives a private staging WorldCommands instead
of the shared one. After the wave barrier, the main thread merges all staging buffers
into the authoritative buffer in submission order. SpawnCallbackIndex values are
offset-corrected during merge. Single-system waves use the caller's buffer directly
with no staging overhead. See actor-ecs-architecture.md §7.5 for the full design.
9. Usage Example¶
// Systems follow the SystemFn signature: void(Scene&, float, WorldCommands&)
void AnimationSampleSystem(Scene& scene, float dt, WorldCommands& commands) {
scene.ForEach<AnimatorComponent>([dt](EntityID, AnimatorComponent& a) {
a.Time += dt;
});
}
void PhysicsSystem(Scene& scene, float dt, WorldCommands& commands) {
scene.ForEach<TransformComponent, RigidBodyComponent>(
[dt](EntityID, TransformComponent& t, RigidBodyComponent& rb) {
t.Position.x += rb.Velocity.x * dt;
t.Position.y += rb.Velocity.y * dt;
t.Position.z += rb.Velocity.z * dt;
});
}
void SpawnProjectileSystem(Scene& scene, float dt, WorldCommands& commands) {
scene.ForEach<WeaponComponent, TransformComponent>(
[&](EntityID, WeaponComponent& w, TransformComponent& t) {
if (w.ShouldFire) {
// Safe to enqueue — WorldCommands is not applied until after Tick
commands.SpawnEntity({});
w.ShouldFire = false;
}
});
}
// App startup
WorldTick world;
WorldCommands commands;
commands.Initialize(&ecs_arena);
SystemID anim_id = world.RegisterSystem(AnimationSampleSystem, {
.ReadMask = MaskBit(ComponentTypeOf<AnimatorComponent>()),
.WriteMask = MaskBit(ComponentTypeOf<AnimatorComponent>()),
});
SystemID physics_id = world.RegisterSystem(PhysicsSystem, {
.ReadMask = MaskBit(ComponentTypeOf<RigidBodyComponent>()),
.WriteMask = MaskBit(ComponentTypeOf<TransformComponent>()),
});
SystemID cull_id = world.RegisterSystem(RenderCullSystem, {
.ReadMask = MaskBit(ComponentTypeOf<TransformComponent>())
| MaskBit(ComponentTypeOf<MeshComponent>()), // MeshComponent — pending issue #609
.WriteMask = 0,
});
// PhysicsSystem writes Transform, RenderCullSystem reads Transform → conflict
world.OrderBefore(physics_id, cull_id);
world.Commit(); // builds DAG, asserts on any unresolved conflicts or cycles
// Game loop
while (running) {
float dt = timer.Delta();
world.Tick(scene, dt, commands); // Wave 0: AnimationSampleSystem + PhysicsSystem (parallel)
// Wave 1: RenderCullSystem (inline if alone in wave)
commands.Flush(scene); // apply deferred spawns/destroys/mutations
actor_manager.Tick(dt); // Actor OnTick — sees post-flush entity state
scene.SnapshotTransforms(); // copy Position → PreviousPosition for interpolation
renderer.Draw(scene);
}
10. Integration with Actor OnTick¶
Actor OnTick runs after WorldTick::Tick completes (see actor-ecs-architecture.md
Section 6). It is not a registered system and is not part of the DAG. This is intentional:
Actor gameplay logic reads the fully-updated component state produced by ECS systems.
If an Actor needs to write back to a component during OnTick, that write is outside the
scheduler's awareness. The rule is: Actor OnTick may write to components that no ECS
system reads in the same frame after OnTick. If that rule is violated, the programmer
must restructure — either move the logic into a proper system, or ensure the conflicting
system runs in the next frame.
11. File Layout¶
ZEngine/
ECS/
WorldTick.h
WorldTick.cpp (RegisterSystem, OrderBefore, Commit, Tick, BuildEdges, TopologicalSort)
No new files beyond what actor-ecs-architecture.md already lists.
12. Deliverables Checklist¶
- [x]
SystemDepsstruct inWorldTick.h - [x]
WorldTick::RegisterSystem(fn, deps)— returnsSystemID - [x]
WorldTick::OrderBefore(SystemID a, SystemID b) - [x]
RegisterSystemreturns distinct IDs per system (monotonically increasing) - [x]
WorldTick::Commit()— edge insertion, cycle detection, topological sort into waves - [x]
WorldTick::Tick()— wave-by-wave dispatch viaThreadPoolHelper, barrier between waves - [x] Assert on conflict with no ordering edge (debug builds)
- [x] Assert on cycle detected
- [x] Assert on
Tick()called beforeCommit() - [x]
tests/ECS/SchedulerTest.cpp: - [x] Two independent systems run in the same wave
- [x] Two conflicting systems with
OrderBeforerun in separate waves - [ ] Two conflicting systems with no
OrderBeforeassert in debug — noEXPECT_DEATHtest covers this; the runtime assert itself exists and works (see line 510 above), only the test is missing - [ ] Cycle detection asserts — same gap: runtime assert exists (line 511), no test covers it
- [x] Systems execute in correct order (write before read verified via component state)
13. Commit convention¶
All commits on this feature must follow the project's conventional commit rules enforced
by commitlint (.commitlintrc.json at repo root).
Allowed types:
| Type | When to use |
|---|---|
feat |
New system, new capability (e.g. feat(ecs): implement WorldTick with wave dispatch) |
fix |
Correctness bug in scheduler or wave barrier |
perf |
Performance improvement (e.g. spin-yield barrier, inline single-system wave) |
test |
Adding or updating SchedulerTest.cpp |
docs |
Changes to this document |
refactor |
Internal restructure with no behaviour change |
Rules enforced by commitlint:
- Type must be lowercase and one of the list above —
build,ci,chore,revert,styleare also valid for non-ECS work - Scope must be lowercase — use
ecsfor scheduler work (e.g.feat(ecs): ...) - Subject must not be empty and must not end with a period
- Header (type + scope + subject) must not exceed 100 characters
- Body and footer must each begin with a blank line if present
Example commit messages: