CallbackSystem
Proactive system that runs every tick for non-entity work — timers, input draining, global state.
Status: ✅ Implemented · Visibility: Public · Level: 🔵 Core · Category: Runtime
🎯 What it solves
Some tick logic has nothing to do with entity iteration and must still run every tick regardless of what
changed: draining a network input queue, advancing a global clock, flushing pending entity destroys. Routing
that through an entity-input system would mean creating a View it never uses just to get dispatched.
CallbackSystem is the proactive base type — no View, no ChangeFilter, no reactive skip — for logic that the
runtime should simply invoke once per tick.
⚙️ How it works (in brief)
Derive from CallbackSystem, implement Configure(SystemBuilder b) (name, dependencies, optional
ShouldRun) and Execute(TickContext ctx). The dispatching worker runs Execute inline — no entity-prep
step, no input refresh. The runtime creates a per-system Transaction on the same worker before Execute
runs and auto-commits it after; ctx.Entities is empty since there is no View input. ShouldRun (optional) is
evaluated once before dispatch — returning false skips the system for this tick while still dispatching
successors.
💻 Usage
public class InputDrain : CallbackSystem
{
protected override void Configure(SystemBuilder b) => b
.Name("InputDrain")
.Priority(SystemPriority.High);
protected override void Execute(TickContext ctx)
{
// Drain network command queues, write results into components via ctx.Transaction.
var entity = ctx.Transaction.OpenMut(someEntityId);
ref var cmd = ref entity.Write<PendingCommand>();
// ...
}
}
dag.Add(new InputDrain());
// Lambda shorthand — trivial logic, no class boilerplate
dag.CallbackSystem("Cleanup", ctx => ctx.FlushDestroys(), after: "InputDrain");
// Conditional proactive system — skips most ticks
dag.CallbackSystem("ZoneRotation", ctx => RotateZone(ctx),
shouldRun: () => zoneState.RotationDue, after: "InputDrain");
⚠️ Guarantees & limits
- Proactive — runs every tick unless
b.ShouldRun(...)(or the lambda'sshouldRun:) returns false. Cost of a falseShouldRun: ~200-300ns, successors still dispatch. - No entity input is possible:
Build()rejectsb.Input(...)andb.ChangeFilter(...)on aCallbackSystem. - Gets its own
Transactionper tick (ctx.Transaction), created and committed by the runtime — never callCommit()/Dispose()insideExecute. - A thrown exception rolls back the system's Transaction and skips its successors
(
SkipReason.DependencyFailed); other DAG branches continue. b.Parallel()is not valid on a plainCallbackSystem— useChunkedCallbackSystemandb.ChunkedParallel(N)for chunk-parallel non-entity work.- Registered via
dag.Add(new MySystem())(class-based) ordag.CallbackSystem(name, ctx => ..., ...)(lambda) — both coexist in the same DAG.
🧪 Tests
- ClassBasedSystemTests —
Add_CallbackSystem_ExecutesEveryTick, unnamed-system rejection - ScheduleValidationTests —
Build_ChangeFilterOnCallbackSystem_Throws,Build_ParallelOnCallbackSystem_Throws— no entity input/parallel on a plainCallbackSystem - ShouldRunTests — proactive
ShouldRungate: skip cost, successors still dispatch, telemetry recording
🔗 Related
- Parent feature: System Types
- Sibling: Typed Event Queues — proactive
CallbackSystemproducers/consumers commonly drive event-queue cascades.