Summary
Graduate the per-frame command buffer (deferred world-mutation staging + conflict detection) from a per-game plugin into a first-class labelle-engine facility, parameterized over a game-supplied Command type — the same comptime-trait pattern labelle-core's Ecs(Backend) already uses.
Today every game wires its own command buffer (a CommandLog ECS component + a vendored command_buffer plugin). The mechanism — push commands during the frame, then at a safe sync point detect conflicts (two commands mutating the same entity) and clear — is generic and runtime-owned. It should live in the engine, next to the scheduler that defines "end of frame."
Originated from a downstream discussion in the flying-platform game (Flying-Platform/flying-platform-labelle#516, which tracks the game-side precursor — moving storage out of the game CommandLog component into the plugin Controller). This issue is the upstream half: the facility itself.
Why the engine (not core)
labelle-core is the zero-dependency contract layer — it owns the Ecs(Backend) comptime trait, save_policy, dispatcher, serde. It defines what a backend must support and runs nothing.
labelle-engine is the runtime — scheduler.zig, script_runner.zig, game.zig, query.zig. It owns the frame loop, script execution order, and the concrete Ecs instance.
A command buffer is two separable pieces:
- Flush lifecycle (push during frame → detect/apply/clear at end-of-frame, wired into script order). Inherently runtime: only the scheduler/script_runner knows when "end of frame" is and owns the apply step. → engine.
- Conflict-detection primitive (write-key overlap over a command slice; allocation-free; no lifecycle). A pure utility. → optionally core, next to
ecs.zig, if we want it reusable without the runtime.
The design constraint that makes this real work
The current Command is a game-domain union (assign_work, assign_delivery, begin_wander, begin_need_fulfillment, interrupt_for_combat, …). The engine can't own those verbs and stay game-agnostic.
So the facility must be parameterized like Ecs(Backend):
// engine provides the MECHANISM; game supplies the Command TYPE + contract.
pub fn CommandBuffer(comptime Command: type) type {
comptime validateCommandContract(Command); // requires: writeKeys, releasesWorker, acquiresWorker
return struct {
commands: std.ArrayListUnmanaged(Command) = .empty,
// push / slice / count / clear / detectConflicts(over the slice)
...
};
}
- Engine owns: growable storage,
push, clear (retain capacity), detectConflictsSlice (write-key overlap + the release-before-acquire handoff exemption), the per-frame flush hook, the conflict report type.
- Game supplies: the
Command type and its writeKeys() [N]?EntityId / releasesWorker() / acquiresWorker() methods (the contract the engine validates at comptime).
This keeps the engine ECS-agnostic, mirrors the existing Ecs(Backend) trait approach, and lets any game reuse the buffer without hand-rolling storage, conflict detection, or the flush script.
Scope / steps
- Engine: add
CommandBuffer(Command) (storage + push/clear + conflict detection), the conflict-report type, and the comptime Command contract validation. Decide where flush is driven (scheduler hook vs. a blessed end-of-frame stage) so games don't hand-wire a flush script.
- Engine: expose lifecycle (per-world setup/deinit) so the buffer's growable storage is owned + freed by the engine, not a game component.
- (Optional) core: factor the pure write-key-overlap conflict primitive into
labelle-core next to ecs.zig if we want it usable independent of the runtime.
- Downstream (game): flying-platform migrates its
CommandLog component + vendored command_buffer plugin onto the engine facility, supplying its domain Command type. Tracked by Flying-Platform/flying-platform-labelle#516.
Non-goals
- Do not make the engine aware of game-domain command verbs. The
Command type stays game-supplied.
- Do not relax the conflict detector's release-before-acquire exemption to silence false positives; that exemption is the correct legal-handoff rule and masking acquire-before-release would hide genuine double-acquire races.
References (downstream prior art)
- Flying-Platform/flying-platform-labelle:
libs/command_buffer/src/command_buffer.zig (Command union, detectConflictsSlice, write-key overlap, handoff exemption), libs/command_buffer/src/controller.zig (per-world State/lifecycle, the "move storage into the plugin" follow-up docs), components/command_log.zig (growable storage), scripts/playing/01_command_buffer_flush.zig (the flush script the engine would subsume).
labelle-core/src/ecs.zig — the Ecs(Backend) comptime-trait pattern this should mirror.
Scope addition: the applied-event ring log moves with this
The command buffer's natural sibling — the game's worker_controller dual-ring audit log — should graduate as part of this family, not separately. They are two halves of the same data-capture philosophy:
- CommandBuffer(Command) — records intended/staged mutations during the frame for conflict detection (this issue).
- RingLog / EntityRing — records applied events/transitions after the fact, newest-first, for replay/debugging.
Today the ring lives in flying-platform's libs/worker_controller/src/transition.zig (per-entity TransitionHistory) and command_log.zig (global CommandLog ring). The ring code is fully game-agnostic: append-on-event, recent(i) newest-first, wrap tracking, total_pushed LSN. Nothing in it knows about workers.
Generalize as:
pub fn RingLog(comptime Entry: type, comptime capacity: usize) type { ... } // global ring
pub fn EntityRing(comptime Entry: type, comptime capacity: usize) type { ... } // per-entity ring
The only game-coupled type is the Transition record (event: WorkerEvent, result: Result) — that stays in the game, parameterized in as Entry. The WorkerEvent/WorkerState FSM (apply.zig, worker_state.zig) stays in the game too; only the ring mechanism moves.
Landing both together means the engine ships one coherent "capture mutations as data" facility (stage → detect → apply → record) rather than a buffer here and a ring there.
Downstream prior art: flying-platform-labelle/libs/worker_controller/src/{transition.zig:46-105, command_log.zig:29-69}.
Summary
Graduate the per-frame command buffer (deferred world-mutation staging + conflict detection) from a per-game plugin into a first-class
labelle-enginefacility, parameterized over a game-suppliedCommandtype — the same comptime-trait patternlabelle-core'sEcs(Backend)already uses.Today every game wires its own command buffer (a
CommandLogECS component + a vendoredcommand_bufferplugin). The mechanism — push commands during the frame, then at a safe sync point detect conflicts (two commands mutating the same entity) and clear — is generic and runtime-owned. It should live in the engine, next to the scheduler that defines "end of frame."Originated from a downstream discussion in the flying-platform game (Flying-Platform/flying-platform-labelle#516, which tracks the game-side precursor — moving storage out of the game
CommandLogcomponent into the pluginController). This issue is the upstream half: the facility itself.Why the engine (not core)
labelle-coreis the zero-dependency contract layer — it owns theEcs(Backend)comptime trait,save_policy,dispatcher,serde. It defines what a backend must support and runs nothing.labelle-engineis the runtime —scheduler.zig,script_runner.zig,game.zig,query.zig. It owns the frame loop, script execution order, and the concreteEcsinstance.A command buffer is two separable pieces:
ecs.zig, if we want it reusable without the runtime.The design constraint that makes this real work
The current
Commandis a game-domain union (assign_work,assign_delivery,begin_wander,begin_need_fulfillment,interrupt_for_combat, …). The engine can't own those verbs and stay game-agnostic.So the facility must be parameterized like
Ecs(Backend):push,clear(retain capacity),detectConflictsSlice(write-key overlap + the release-before-acquire handoff exemption), the per-frame flush hook, the conflict report type.Commandtype and itswriteKeys() [N]?EntityId/releasesWorker()/acquiresWorker()methods (the contract the engine validates at comptime).This keeps the engine ECS-agnostic, mirrors the existing
Ecs(Backend)trait approach, and lets any game reuse the buffer without hand-rolling storage, conflict detection, or the flush script.Scope / steps
CommandBuffer(Command)(storage + push/clear + conflict detection), the conflict-report type, and the comptimeCommandcontract validation. Decide where flush is driven (scheduler hook vs. a blessed end-of-frame stage) so games don't hand-wire a flush script.labelle-corenext toecs.zigif we want it usable independent of the runtime.CommandLogcomponent + vendoredcommand_bufferplugin onto the engine facility, supplying its domainCommandtype. Tracked by Flying-Platform/flying-platform-labelle#516.Non-goals
Commandtype stays game-supplied.References (downstream prior art)
libs/command_buffer/src/command_buffer.zig(Command union,detectConflictsSlice, write-key overlap, handoff exemption),libs/command_buffer/src/controller.zig(per-world State/lifecycle, the "move storage into the plugin" follow-up docs),components/command_log.zig(growable storage),scripts/playing/01_command_buffer_flush.zig(the flush script the engine would subsume).labelle-core/src/ecs.zig— theEcs(Backend)comptime-trait pattern this should mirror.Scope addition: the applied-event ring log moves with this
The command buffer's natural sibling — the game's
worker_controllerdual-ring audit log — should graduate as part of this family, not separately. They are two halves of the same data-capture philosophy:Today the ring lives in flying-platform's
libs/worker_controller/src/transition.zig(per-entityTransitionHistory) andcommand_log.zig(globalCommandLogring). The ring code is fully game-agnostic: append-on-event,recent(i)newest-first, wrap tracking,total_pushedLSN. Nothing in it knows about workers.Generalize as:
The only game-coupled type is the
Transitionrecord (event: WorkerEvent,result: Result) — that stays in the game, parameterized in asEntry. TheWorkerEvent/WorkerStateFSM (apply.zig,worker_state.zig) stays in the game too; only the ring mechanism moves.Landing both together means the engine ships one coherent "capture mutations as data" facility (stage → detect → apply → record) rather than a buffer here and a ring there.
Downstream prior art:
flying-platform-labelle/libs/worker_controller/src/{transition.zig:46-105, command_log.zig:29-69}.