Typed Event Queues
Lock-free multi-producer→single-consumer buffers for signalling between systems within a tick.
Status: ✅ Implemented · Visibility: Public · Level: 🟣 Advanced · Category: Runtime
🎯 What it solves
Game systems need to react to what another system did this tick — drop loot after a kill, resolve damage after combat — without polling shared state or scanning every entity every tick. Typhon's DAG is static (no dynamic system insertion), so conditional cascades need a channel that lets a downstream system stay statically wired into the schedule yet do nothing on a quiet tick. Typed event queues give producer and consumer systems a structured data channel whose emptiness doubles as the consumer's skip signal.
⚙️ How it works (in brief)
A queue is created once at schedule-build time (Dag.CreateEventQueue<T>) and wired to its
producer/consumer systems either declaratively (SystemBuilder.WritesEvents / .ReadsEvents,
which also derives the DAG ordering edge) or via the lambda-shorthand Dag.Produces /
Dag.Consumes. Producers push events during Execute through ctx.Writer(queue); DAG ordering guarantees every
producer fully completes before the consumer starts, so the producer→consumer handoff needs no
synchronization. The queue owns one segment per worker, so a .Parallel() system may push from
every chunk worker concurrently — each writes its own segment, and no atomics are involved. A reactive system
(QuerySystem/PipelineSystem) that declares ReadsEvents auto-skips when every consumed queue is
empty and it has no other dirty-entity trigger — its Execute never runs, no Transaction is
created. Every queue is cleared automatically at the start of each tick.
💻 Usage
public struct LootEvent
{
public EntityId Source;
public int ItemId;
}
// using System.Buffers;
var dag = schedule.PublicTrack.DeclareDag("Game");
var lootQueue = dag.CreateEventQueue<LootEvent>("LootEvents", capacity: 256);
public class CombatSystem : QuerySystem
{
private readonly EventQueue<LootEvent> _lootQueue;
public CombatSystem(EventQueue<LootEvent> lootQueue) => _lootQueue = lootQueue;
protected override void Configure(SystemBuilder b) => b
.Name("Combat").Input(() => combatView)
.WritesEvents(_lootQueue);
protected override void Execute(TickContext ctx)
{
// Resolve this worker's segment ONCE, then push through it — that is what keeps a
// multi-producer queue at single-producer cost.
var loot = ctx.Writer(_lootQueue);
foreach (var id in ctx.Entities)
{
if (BossKilled(ctx.Transaction, id))
{
loot.Push(new LootEvent { Source = id, ItemId = 42 });
}
}
}
}
public class LootDropSystem : QuerySystem
{
protected override void Configure(SystemBuilder b) => b
.Name("LootDrop").Input(() => combatView)
.ReadsEvents(_lootQueue)
.After("Combat");
// Skipped entirely on ticks where Combat produced no LootEvent.
protected override void Execute(TickContext ctx)
{
var queue = (EventQueue<LootEvent>)ctx.ConsumedQueues[0];
// Heap-rent rather than stackalloc: under skew Count can reach capacity per worker slot.
var events = ArrayPool<LootEvent>.Shared.Rent(queue.Count);
var n = queue.Drain(events);
for (var i = 0; i < n; i++)
{
SpawnLoot(ctx.Transaction, events[i]);
}
ArrayPool<LootEvent>.Shared.Return(events);
}
}
| Option | Default | Effect |
|---|---|---|
capacity (CreateEventQueue<T>) |
1024 | Power of 2. Expected events per tick — split across worker segments, and the per-segment growth ceiling. Reported unchanged as Capacity; live allocation is AllocatedCapacity |
allowGrowth (new EventQueue<T>) |
true |
When false a full segment drops instead of doubling — a fixed bound with loud telemetry |
⚠️ Guarantees & limits
- Push is allocation-free in steady state and safe from every worker at once — obtain a writer
with
ctx.Writer(queue)and it binds the calling worker's own segment. Never share a writer between threads; it is aref struct, so the compiler already prevents capturing or storing one. - Producing from a lifecycle hook (
OnFirstTick,OnShutdown) throws: those contexts carryTickContext.NonWorkerIdand own no segment. - Drain is single-consumer and relies on DAG ordering. Both halves are enforced at build time: a
queue may have at most one consumer, and a
.Parallel()system may produce but not consume. Any number of systems — and any number of chunk workers — may produce. Drainthrows if the destination span is shorter thanCount; size it fromCount. Truncating silently would be event loss that no counter reports.- Events are not ordered across workers. Each worker's events arrive in push order, workers in slot order; which worker ran which chunk is not reproducible, so never depend on inter-event order.
- Queues are reset at the start of every tick and events never carry over. A full segment doubles
up to
capacity; past that a push is dropped and counted in the queue's overflow telemetry, andPushreturnsfalse. It never throws — a queue sizing mistake must not be able to abort a tick. Grown buffers surviveReset, so a workload stops allocating after a few ticks. Thas nounmanagedconstraint — both structs and reference types work; reference-type slots are cleared after eachDrain/Resetso they don't pin garbage.- A reactive system whose only trigger is
ReadsEventsskips completely when its queue(s) are empty — noExecute, no Transaction created. - Intra-tick signalling only — queues are not persisted, not part of the WAL, and invisible across ticks, snapshots, or processes.
📊 Performance
Guarded by EventQueueBenchmarks (test/Typhon.Benchmark, categories Runtime + Regression). Pushing through
ctx.Writer(queue) costs ~0.22 ns/push more than the single-producer buffer it replaced (0.78 ns vs 0.56 ns,
allocation-free) — and that buys pushing from every chunk worker at once. Advancing one shared tail with
Interlocked.Increment instead measures 10x the writer single-threaded, and far worse under contention.
🧪 Tests
- EventQueueTests — push/drain round-trip, growth past the initial allocation, drop-and-count at the ceiling, power-of-2 capacity, reference-type slot clearing on drain/reset
- EventQueueConcurrencyTests — a
.Parallel()producer delivers every event exactly once (exact multiset equality), telemetry folds every worker's pushes, lifecycle hooks cannot produce - EventQueueIntegrationTests — producer→consumer handoff across a DAG edge,
ctx.ConsumedQueueswiring, reactive skip when empty
🔗 Related
- Related feature: Declarative System Scheduling
- Sibling: CallbackSystem — a common proactive producer/consumer for these queues.