Skip to content

engine: first-class CommandBuffer(Command) facility — deferred-mutation staging + conflict detection, parameterized over a game Command type #615

@apotema

Description

@apotema

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 runtimescheduler.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:

  1. 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.
  2. 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

  1. 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.
  2. Engine: expose lifecycle (per-world setup/deinit) so the buffer's growable storage is owned + freed by the engine, not a game component.
  3. (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.
  4. 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}.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions