Skip to content

Status: approved

ecs-schedule

Summary

@workspace/ecs-schedule v0 adds a manual-stepped system scheduler on top of koota, which has none. Systems are plain (world, dt) functions assigned to flecs-style phases (builtin and custom), ordered with before/after, optionally grouped in sets, and shippable as plugins. Phases are ordered by dependsOn/before edges; a custom phase that is not connected to the builtin chain, or is marked manual, is skipped by run and only stepped with runPhase/runPhases. The order is fixed when the schedule is built. A caller drives it with run(world, dt) for a full pass of the automatic phases, or with runPhase/runPhases to step part of it, for real-time loops (requestAnimationFrame, R3F useFrame) and turn-based stepping alike. A system runs iff it is enabled and its conditions hold: use runIf for data-driven gating (turn state etc.) and enable/disable for explicit toggles (devtools, tests). Per-world schedule state (disabled systems, OnStart done) lives in a koota world trait, exposed as schedule.state and carrying the schedule's name, so it follows the world through reset() and save/resume. A built schedule exposes its systems and optional hooks for devtools. The driving demo is apps/demo-dnd (brief), whose turn pipeline (start of turn → move → act → resolve → end of turn) is a detached, manual phase chain stepped once per turn on its own schedule.

References

  • flecs: Systems.md § Pipelines (builtin pipeline, phases, custom phases, OnStart, delta_time, disabling systems), reference/flecs/test/addons/src/Pipeline.c (Pipeline_system_order_*_after_disable, Pipeline_system_order_*_after_activate).
  • becsy: reference/becsy/src/schedule.ts (explicit before/after edges, cycles reported at build).
  • koota: World type and createWorld (reference/koota/packages/core/src/index.ts). Systems use koota's public API (queries, world.query, traits) unchanged. Per-world state uses world traits (world.add, world.has, world.get, world.set; reference/koota/packages/core/src/world/types.ts) and is cleared by world.reset(). Trait schemas reject object/array literals (validateSchema), so array defaults need a factory (() => []).
  • koota runtime guidance: .github/skills/koota/references/runtime.md (delta capped at 0.1 s) and reference/koota/examples/boids/src/sim/systems/update-time.ts (capped at 1 / 30). koota traits carry no name in the public API, so the schedule name is stored as a field of the state trait (SCH-063).
  • miniplex: reference/miniplex/packages/bucket/src/Bucket.ts (reverse iteration, shuffle-pop on removal), as evidence that entity iteration order is not stable across ECS libraries.
  • concept §6, package-rules.

Requirements

All requirements are [test] (headless Vitest, fresh koota world per test) unless tagged otherwise. "Declaration order" is the order of the flattened lists (plugins in array order, then the schedule's own entries, SCH-136); phases, systems, and sets in requirements mean those flattened lists (see API sketch). Retired IDs are struck through and never reused. New IDs are numbered from SCH-100 in order of creation, so they do not follow document order.

SCH-F01 Phases

  • SCH-001 [test]: The package MUST export the builtin phases OnStart, OnLoad, PostLoad, PreUpdate, OnUpdate, OnValidate, PostUpdate, PreStore, OnStore, ordered in that sequence.
  • SCH-002 [test]: Systems in different builtin phases MUST run in phase order regardless of their declaration order.
  • SCH-003 [test]: definePhase(name, { dependsOn }) MUST create a custom phase that runs after every phase in dependsOn.
  • SCH-004 [test]: A custom phase MAY depend on builtin phases and on other custom phases. Ordering MUST be transitive (A depends on B depends on C ⇒ C, B, A).
  • SCH-005 Retired: replaced by SCH-101/SCH-102. Custom phases no longer run before OnLoad just because their dependsOn is empty; that caused turn pipelines to run on every full run and left non-root custom phases unplaceable.
  • SCH-006 [test]: createSchedule MUST throw if a phase's dependsOn or before contains a phase that is neither builtin nor in phases. The message MUST name both phases.
  • SCH-007 [test]: createSchedule MUST throw if the same phase appears twice in phases, or if phases contains a builtin phase.
  • SCH-008 [test]: schedule.phases MUST list all phases (builtin and custom, manual included) in the order defined by SCH-102, as a readonly array with the same identity on every access. Examples, one test each (X is a custom phase, builtins elided):
  • X with dependsOn: [OnUpdate] → […, OnUpdate, X, OnValidate, …].
  • X with before: [OnValidate] → the same position.
  • X with before: [OnStart] → [X, OnStart, …].
  • X with dependsOn: [OnStore] → […, OnStore, X].
  • X with dependsOn: [OnUpdate], before: [OnStore] → directly after OnUpdate.
  • A, B with dependsOn: [OnUpdate], declared A, B → […, OnUpdate, A, B, OnValidate, …].
  • A and B with dependsOn: [OnUpdate] and C with dependsOn: [A]: declared A, B, C → [OnUpdate, A, B, C, OnValidate]; declared A, C, B → [OnUpdate, A, C, B, OnValidate].
  • Y with dependsOn: [OnUpdate] and X with before: [Y] → [OnUpdate, X, Y, OnValidate].
  • A detached chain T1 → T2 → T3 → […, OnStore, T1, T2, T3].
  • SCH-009 Retired: replaced by SCH-102 and SCH-107 (see SCH-005).
  • SCH-100 [test]: definePhase(name, { dependsOn?, before?, manual? }) MUST return a phase with name, dependsOn (default []), before (default []), and manual (default false). dependsOn lists phases it runs after (flecs name), before lists phases it runs before. Both MAY reference builtin or custom phases. Builtin phases are never manual. name is required: definePhase MUST throw if it is missing or empty, and the message MUST name definePhase and say that name is missing or empty.
  • SCH-101 [test]: phases MUST satisfy every edge of the phase graph: the builtin chain OnStart → OnLoad → PostLoad → PreUpdate → OnUpdate → OnValidate → PostUpdate → PreStore → OnStore, each dependsOn edge (dependency → phase), and each before edge (phase → target).
  • SCH-102 [test]: phases MUST be produced by this deterministic procedure. A custom phase is rooted if a builtin phase reaches it through dependsOn/before edges (e.g. dependsOn: [OnUpdate], or dependsOn a rooted phase). Every other custom phase is floating. A phase is ready when all its predecessors are emitted. Repeat until every phase is emitted:
  • A floating phase is promoted when it directly precedes an unemitted builtin, rooted, or promoted phase S whose predecessors are all emitted or floating. Repeat to a fixpoint.
  • If any rooted or promoted custom phase is ready, emit the earliest-declared one. Otherwise, if the next builtin is ready, emit it.
  • Otherwise emit the earliest-declared ready floating phase. Floating phases that are never promoted, including all detached ones (SCH-104), therefore run after everything else, in declaration order (see Trade-offs).
  • If no phase is ready, the graph has a cycle (SCH-103).

Consequences: a rooted phase runs directly after its last predecessor, and a floating phase with before runs directly before its earliest successor. Other custom phases that become ready at the same point may sit between them, in declaration order. The builtin chain is never reordered. Examples: SCH-008. - SCH-103 [test]: createSchedule MUST throw if the phase graph (builtin chain, dependsOn, before) contains a cycle, e.g. a phase with dependsOn: [OnStore], before: [OnLoad], or a phase B with both dependsOn: [A] and before: [A]. The message MUST name the custom phases on the cycle. - SCH-104 [test]: A custom phase is detached if no builtin phase is in its connected component. Components are formed by dependsOn and before edges only (builtin phases referenced by an edge are members, the builtin chain does not join components). Detached phases and phases with manual: true are manual. Builtin phases and custom phases connected to a builtin phase are automatic. A custom phase with no edges and manual: false is detached. schedule.isManual(phase) MUST report this and MUST throw for a phase not in the schedule. - SCH-105 [test]: manual MUST propagate to nothing else. A phase that depends on, or runs before, a manual: true phase is automatic unless it is detached itself. Marking a phase manual MUST NOT change phases order.

SCH-F02 Systems and ordering

  • SCH-010 [test]: defineSystem({ name, run, phase?, before?, after?, sets?, runIf? }) MUST return a system descriptor. phase defaults to OnUpdate. name is required: defineSystem MUST throw if it is missing or empty, and the message MUST name defineSystem and say that name is missing or empty.
  • SCH-011 [test]: A system's run MUST be called as run(world, dt) with the world and dt passed to the run API.
  • SCH-012 [test]: Systems in the same phase with no before/after edges MUST run in declaration order.
  • SCH-013 [test]: A system with after: [a] MUST run after a, and a system with before: [b] MUST run before b, when both are in the same phase.
  • SCH-014 [test]: Within a phase, the order MUST be a stable topological sort. Among systems that are ready, the one declared first MUST run first.
  • SCH-015 [test]: A before/after edge between systems in different phases that agrees with phase order MUST be accepted and have no further effect.
  • SCH-016 [test]: A before/after edge between systems in different phases that contradicts phase order MUST throw at build. This includes edges expanded from sets (SCH-130).
  • SCH-017 [test]: createSchedule MUST throw if the before/after edges contain a cycle (including a system referencing itself). The error message MUST name the systems in the cycle.
  • SCH-018 [test]: createSchedule MUST throw if a before/after entry refers to a system that is not in systems. The message MUST name the referring system and the unknown one.
  • SCH-019 [test]: createSchedule MUST throw if a system's phase is neither builtin nor in phases.
  • SCH-020 [test]: createSchedule MUST throw if the same system object appears twice in systems. The message MUST name the duplicate.
  • SCH-023 [test]: System names MUST be unique within a schedule. createSchedule MUST throw if two entries of systems have the same name, and the message MUST name the duplicate. Identity stays by object: before/after take System objects, and an entry that is not the same object as one in systems is rejected by SCH-018 even if its name matches.
  • SCH-021 [test]: Systems, phases, and edges MUST be validated only in createSchedule, except the required name of each define* function (SCH-010, SCH-100, SCH-126, SCH-135), which is checked when the object is defined. A schedule that was built MUST NOT throw ordering errors when run.
  • SCH-022 [test]: The execution order MUST depend only on the arrays passed to createSchedule (and the plugins in them), not on the order in which defineSystem/definePhase/defineSet were called, and MUST be identical for every createSchedule call with the same arrays.
  • SCH-106 [test]: createSchedule MUST compute the full execution plan once: phase order, per-phase system order, and set/edge resolution. Run APIs MUST NOT re-sort or re-validate. Observable behaviour: schedule.phases, schedule.systems, and schedule.systemsOf(phase) keep the same identity across accesses and runs, and mutating the arrays passed to createSchedule after the build MUST NOT change the order or the systems that run. That no sorting happens per call is not observable; the allocation side is SCH-117 [manual].

SCH-F03 Running and stepping

  • SCH-030 [test]: schedule.run(world, dt) MUST run every enabled system whose conditions hold (SCH-118) in every automatic phase, phase by phase in phases order, and return after the last one finished (synchronous). Manual phases are skipped (SCH-107).
  • SCH-031 [test]: dt is in seconds. A valid dt (SCH-041) MUST be passed unchanged to every system. The schedule MUST NOT read a clock, and MUST NOT require a Time trait.
  • SCH-032 [test]: schedule.runPhase(world, phase, dt) MUST run only the enabled systems of that phase whose conditions hold, in order.
  • SCH-033 [test]: schedule.runPhases(world, from, to, dt) with from and to both automatic MUST run the automatic phases from from to to inclusive, in phases order. This includes phases between them that neither depend on from nor on to. Manual phases positioned between them MUST NOT run.
  • SCH-034 [test]: runPhase and runPhases MUST throw if a phase is not in the schedule, and runPhases MUST throw if from comes after to in phases order.
  • SCH-035 [test]: The schedule MUST NOT keep a current-phase or round cursor. Running consecutive ranges that together cover all automatic phases MUST execute the same systems in the same order as one run. The caller owns progression, e.g. in a world trait.
  • SCH-036 [test]: OnStart systems MUST run only the first time OnStart is executed for a given world, by any of run, runPhase, or runPhases. Later executions of OnStart MUST skip them, whichever run API is used. Other phases are unaffected. When it counts as executed: SCH-112..114.
  • SCH-037 [test]: An error thrown by a system or by a condition (SCH-118) MUST propagate unchanged from the run API. Systems after it in that call MUST NOT run. The schedule MUST remain usable for later calls.
  • SCH-038 [test]: Running a schedule with no systems, or a phase with no systems, MUST be a no-op and MUST NOT throw.
  • SCH-039 [test]: Real-time use: calling run(world, dt) repeatedly with changing dt MUST execute a full pass each call and pass each call's dt to the systems.
  • SCH-040 [test]: Turn-based use: after runPhases(world, a, b, dt) returns, no system outside a..b MUST have run, so the caller can wait for input before the next call.
  • SCH-041 [test]: run, runPhase, and runPhases MUST throw if dt is NaN, not finite (±Infinity), or negative. The check MUST happen before any system runs and before any schedule state changes (e.g. OnStart stays not done).
  • SCH-042 [test]: dt of 0 MUST be accepted and passed to the systems as 0. It MUST NOT be replaced with a measured time (turn-based callers pass 0).
  • SCH-043 [test]: Calling run, runPhase, or runPhases on a world while the same schedule is executing on that world (e.g. from inside one of its systems) MUST throw. The nested call MUST NOT run any system.
  • SCH-044 [test]: The executing marker MUST be cleared when a run API returns or throws, including when a system throws (SCH-037). After that, calls on that world MUST work again.
  • SCH-045 [test]: The re-entrancy check MUST be scoped per schedule and per world. A system of schedule A MAY call schedule B on the same world, and a system of schedule A running on world 1 MAY call A on world 2.
  • SCH-107 [test]: run MUST NOT execute any system of a manual phase (SCH-104), whether enabled or not. Manual phases run only through runPhase (SCH-108) and runPhases (SCH-109). A schedule whose only systems are in detached phases MUST run nothing on run.
  • SCH-108 [test]: runPhase(world, phase, dt) on a manual phase MUST run that phase's enabled systems whose conditions hold, and nothing else.
  • SCH-109 [test]: runPhases(world, from, to, dt) with from and to both manual and in the same component (SCH-104) MUST run the manual phases of that component from from to to inclusive, in phases order (for a detached component, its own topological order). Automatic phases and manual phases of other components MUST NOT run.
  • SCH-110 [test]: runPhases MUST throw if from and to are both manual but in different components. The message MUST name both phases. No system runs and no state changes.
  • SCH-111 [test]: runPhases MUST throw if exactly one of from and to is manual. The message MUST name both phases. A range is defined by its endpoints and never mixes automatic and manual phases. No system runs and no state changes.
  • SCH-112 [test]: onStartDone MUST be set only after all OnStart systems have completed without throwing, at the end of the OnStart phase (not at the end of the call). If an OnStart system throws, onStartDone stays false, and the next call whose range includes OnStart runs the whole phase again, including systems that completed before the throw.
  • SCH-113 [test]: An OnStart phase whose systems were all disabled or skipped by conditions, or that has no systems, MUST still count as done once the phase completed.
  • SCH-114 [test]: OnStart MUST run only when a run call's range includes it. A call that excludes it (runPhase on another phase, or runPhases between manual phases) MUST NOT run OnStart or mark it done.
  • SCH-115 [test]: When several errors apply to one call, the schedule MUST throw the first of: (1) unknown phase, from after to, or invalid manual endpoints (SCH-034, SCH-110, SCH-111); (2) invalid dt (SCH-041); (3) re-entrancy (SCH-043).
  • SCH-116 [test]: A system's run MUST be synchronous. If it returns a thenable (a value with a then function), the schedule MUST throw an error naming the schedule and the system right after it returns (the type is => void). Later systems MUST NOT run, and the executing marker MUST be cleared (SCH-044).
  • SCH-117 [manual]: In steady state (after the first call on a world), a run call with no hooks SHOULD NOT allocate arrays, closures, or iterators per call. Verify with a benchmark or allocation profile of many run calls.

SCH-F04 Enable, disable and conditions

  • SCH-050 [test]: Systems MUST be enabled by default.
  • SCH-051 [test]: schedule.disable(world, system) MUST make every run API skip that system for that world. It MUST be recorded by system name in the schedule's world state (SCH-063). Other systems MUST keep their relative order.
  • SCH-052 [test]: schedule.enable(world, system) MUST restore the system at its original position. Disabling and re-enabling MUST NOT change the order (flecs: Pipeline_system_order_same_phase_after_disable, ..._after_activate).
  • SCH-053 [test]: Disabling a system MUST NOT drop the before/after constraints it took part in. The order of the other systems MUST be the same as with it enabled.
  • SCH-054 [test]: schedule.isEnabled(world, system) MUST report the current enabled flag for that world. It does not evaluate conditions.
  • SCH-055 [test]: enable, disable, and isEnabled MUST throw for a system that is not in the schedule. enable and disable MUST be idempotent.
  • SCH-056 [test]: enable and disable MUST apply immediately, including when called from inside a running system. The enabled flag MUST be checked right before each system runs, so a change affects later systems in the same call.
  • SCH-057 [test]: A system that disables a later system of the same call MUST cause that system to be skipped. A system that enables a disabled system that has not run yet MUST cause it to run in that call.
  • SCH-058 [test]: A system that disables itself, or one that already ran, MUST NOT change the current call. The change MUST apply to later calls.
  • SCH-118 [test]: A system MUST run iff it is enabled AND the runIf of each of its sets returns true AND its own runIf returns true. The checks are evaluated right before that system runs, in this order, short-circuiting (a failed check skips the later ones): enabled flag, set conditions in the order of the system's sets array, own condition. Conditions are evaluated per system and per call, never cached, so earlier systems' changes are visible.
  • SCH-119 [test]: A skipped system (disabled, or a condition returned false) MUST NOT call run.
  • SCH-120 [test]: runIf MUST be called synchronously as runIf(world) with only the world.
  • SCH-121 [test]: A condition that returns a non-boolean (e.g. a Promise or undefined) MUST cause a throw naming the schedule, the system, and the set if it was a set condition. A throwing condition propagates as in SCH-037.
  • SCH-122 [test]: Conditions MUST apply in runPhase and runPhases too, including manual phases.
  • SCH-123 [test]: The disabled list of schedule.state (SCH-063) is the source of truth for the enabled state. The schedule MAY cache a lookup derived from it (e.g. a Set of names) per world, never in module-level state (SCH-064). Such a cache MUST be invalidated when the disabled value of the state trait is replaced (its identity changes, as world.set(schedule.state, { disabled: [...] }) does), and after world.reset() (the state is re-added with a fresh disabled, SCH-065). enable and disable MUST replace disabled with a new array and MUST NOT mutate it in place, so the same invalidation rule covers them. Tests: warm the cache with isEnabled/run, then (a) disable/enable, (b) world.set with a new disabled, © world.reset(), and check each time that the result matches the trait.
  • SCH-153 [test]: isEnabled and every run API MUST reflect world.set(schedule.state, { disabled: [...] }) made between calls, with no enable/disable call in between and no stale result, whether or not earlier calls warmed a cache. This includes a world.set made from inside a running system, which affects the systems after it in the same call (SCH-056). Tests: (a) isEnabled is true, set disabled to the system's name, isEnabled is false; (b) run, set disabled, run again: the system is skipped on the second run; © set disabled back to []: enabled again; (d) set disabled in a system: a later system of the same call is skipped.
  • SCH-152 [manual]: Mutating the disabled array of the state trait in place (e.g. world.get(schedule.state).disabled.push(name)) is unsupported, because it does not change the identity SCH-123 relies on. The effect on the enabled state is unspecified (it MAY be stale). This MUST be documented in the package README and in the doc comment of ScheduleStateTrait, together with the supported way: world.set(schedule.state, { disabled: [...] }) or enable/disable. Verify by reading both.
  • SCH-124 [manual]: The enabled check MUST NOT cost O(number of disabled names) per system per call. Reading the enabled state of one system MUST be O(1) (one trait read, one identity comparison, one lookup); rebuilding a cache costs O(number of disabled names) only once per replacement of disabled (SCH-123), not per system. Verify with a benchmark that disables many systems and runs many calls.

SCH-F05 Per-world isolation

  • SCH-060 [test]: One schedule used with two worlds MUST keep enabled/disabled state separately per world, in each world's own state trait.
  • SCH-061 [test]: One schedule used with two worlds MUST track the OnStart once-only state separately per world, in each world's own state trait.
  • SCH-062 [test]: Two schedules with different names used on the same world MUST NOT share enabled or OnStart state. Each createSchedule call MUST create its own state trait. Names of schedules MUST be unique per world. This is documented, not enforced (enforcing needs module-level state, SCH-064); behaviour with two same-named schedules on one world is unspecified.
  • SCH-063 [test]: Per-world schedule state MUST live in a koota world trait, added to the world lazily on first use through koota's public API, and exposed as schedule.state (SCH-151). Its fields MUST be exactly name (string, default: the schedule name, SCH-140), disabled (a list of system names), and onStartDone (boolean), all serializable. name is the stable identity of the state: consumers (ecs-serialize, devtools, tests) match a state to its schedule by name. The schedule MUST NOT read name back from the trait. Conditions and hooks MUST NOT be stored in it (conditions read world data, which is saved with the world). disabled needs a factory default (() => []), since koota trait schemas reject array literals. The schedule definition (phases, systems, order) MUST stay immutable after createSchedule. The re-entrancy marker (SCH-043) MUST NOT be part of the trait. It is kept in non-persistent per-schedule state (WeakMap<World, …> or equivalent). Because the state is a trait, it is saved and restored with the world (DND-F08). Verified through SCH-060, SCH-061, SCH-065, and SCH-043..045.
  • SCH-064 [test]: Importing the package MUST NOT have side effects, and creating or running schedules in one world MUST NOT change the behaviour of another. There MUST be no module-level mutable state: state traits and the re-entrancy WeakMap are created inside createSchedule.
  • SCH-065 [test]: After world.reset() (called while no run is executing on that world), the schedule state for that world MUST be back to defaults: all systems enabled, and OnStart runs again on its next execution. The trait is re-added lazily with the same name. This MUST NOT throw. A world.reset() from inside a running system is unspecified in v0 (see Trade-offs).
  • SCH-125 [test]: Names in disabled that match no system of the schedule MUST be ignored silently, in every run API and in isEnabled of other systems. They MUST NOT throw and MUST NOT disable anything. Tests set such names through world.set(schedule.state, { name: schedule.name, disabled: […], onStartDone }) (SCH-151).
  • SCH-151 [test]: schedule.state MUST be a readonly property returning the koota trait that holds the schedule's per-world state (SCH-063). It MUST have the same identity on every access and differ from the state of every other schedule (SCH-062). It MUST work with koota's public world API: world.has(schedule.state), world.get(schedule.state), world.set(schedule.state, …), world.add(schedule.state). The trait is added lazily, so world.has(schedule.state) is false until the schedule first uses that world. Once present, world.get(schedule.state)?.name MUST equal schedule.name, including after world.reset() and re-adding (SCH-065) and after a caller's world.add(schedule.state) (same defaults).

SCH-F06 Sets and plugins

  • SCH-126 [test]: defineSet(name, { before?, after?, runIf? }) MUST return a set with name, before (default []), after (default []), and optional runIf. before/after of a set contain sets. name is required: defineSet MUST throw if it is missing or empty, and the message MUST name defineSet and say that name is missing or empty.
  • SCH-127 [test]: defineSystem({ sets }) MUST put the system in every listed set. A system MAY be in several sets. Membership is declared only by the system.
  • SCH-128 [test]: A system's before/after MAY contain sets. A set target MUST expand to all members of that set (every member must run before/after this system).
  • SCH-129 [test]: A set's before/after MUST expand to the members of both sets: with A having before: [B], every member of A runs before every member of B.
  • SCH-130 [test]: Sets MUST NOT change phase membership. A set MAY span phases. Expanded edges that agree with phase order are accepted with no further effect. Expanded edges that contradict phase order MUST throw at build (SCH-016).
  • SCH-131 [test]: createSchedule MUST throw if the expanded edges contain a cycle. The message MUST name the systems in the cycle, and the sets when the edge came from a set. A system that lists its own set in before/after expands to a self-edge and MUST throw. Cycles between sets alone cannot be built: sets are immutable and can only reference sets that already exist.
  • SCH-132 [test]: A set's runIf MUST gate every member (SCH-118), including members in different phases.
  • SCH-133 [test]: createSchedule({ sets }) lists the sets. A set used by a system's sets, by a before/after entry, or by another set's before/after, that is neither in sets nor in a plugin's sets MUST cause a throw naming the referring item and the set.
  • SCH-134 [test]: An empty set (no member systems) MUST be allowed and be a no-op. Edges to or from it expand to nothing, and its runIf is never evaluated.
  • SCH-135 [test]: definePlugin({ name, phases?, sets?, systems? }) MUST return a plain readonly object with name and phases, sets, systems (default []). A plugin is data only: it has no install hook, and defining it MUST have no side effects. name is required: definePlugin MUST throw if it is missing or empty, and the message MUST name definePlugin and say that name is missing or empty. Plugin name is used in the duplicate messages of SCH-138.
  • SCH-136 [test]: createSchedule({ name, plugins?, phases?, sets?, systems? }) MUST flatten plugins in array order first, then the schedule's own phases, sets, and systems, separately per kind. All requirements about phases, systems, and sets apply to the flattened lists.
  • SCH-137 [test]: The flattened order MUST be the declaration order for all tie-breaks (SCH-012, SCH-014, SCH-102) and therefore deterministic. Listing the same plugins in another array order MUST give another, equally deterministic, order.
  • SCH-138 [test]: A duplicate phase, set, or system (same object, or same name for the same kind) among the flattened entries MUST cause a throw naming both sources (a plugin name or the schedule name). Set names MUST be unique per schedule.
  • SCH-139 [test]: Plugins MUST NOT depend on other plugins: definePlugin takes no plugins field and the app lists plugins in order. A system in one plugin MAY reference (via before/after, sets, phase) objects exported by another plugin when both are in the schedule. Missing ones fail through SCH-018, SCH-019, SCH-133.

SCH-F07 Introspection and hooks

  • SCH-140 [test]: createSchedule({ name }): name is required. createSchedule MUST throw if it is missing or empty, and the message MUST say that name is missing or empty. schedule.name MUST return it. It is stored in the name field of the schedule's state trait (SCH-063, SCH-151), the stable identity for ecs-serialize and devtools.
  • SCH-141 [test]: Every error the schedule throws, at build and in run APIs, MUST include the schedule name. The exceptions are an error thrown by a system, hook, or condition itself (SCH-037), which propagates unchanged, and the missing-or-empty name errors (SCH-140 and the define* functions), which have no schedule name to include.
  • SCH-142 [test]: schedule.systems MUST be a frozen array of all systems, manual phases included, in execution order, phase by phase (phases in phases order, systems in their within-phase order). It MUST be the same array on every access. Disabled systems are included.
  • SCH-143 [test]: schedule.systemsOf(phase) MUST return the systems of that phase in execution order as a readonly array, with the same identity on every call for that phase. A phase without systems returns an empty array. It MUST throw for a phase not in the schedule.
  • SCH-144 [test]: createSchedule({ hooks: { beforeSystem?, afterSystem?, onSkip? } }) MUST call hooks for each system considered in a run call. beforeSystem(ctx) is called right before the system's run, after enabled and conditions passed. afterSystem(ctx) is called after run returned successfully. onSkip(ctx, reason) is called when the system is skipped, with reason 'disabled' (enabled flag false) or 'condition' (a set or own condition returned false).
  • SCH-145 [test]: ctx MUST be { schedule, world, system, phase, dt } with the dt of the call. The schedule MUST NOT read a clock or add timing to ctx. Observers measure time themselves (devtools). Lifetime of ctx: SCH-150.
  • SCH-146 [test]: afterSystem MUST NOT be called when the system throws. An error thrown by a hook MUST propagate unchanged and like a system error (SCH-037, SCH-044).
  • SCH-147 [test]: Systems that are not considered fire no hook: systems of an OnStart phase that is skipped because it is done, and systems of manual phases during run.
  • SCH-148 [test]: Hooks are build-time only. There is no API to add or replace them after the build, and changing the options object after the build has no effect.
  • SCH-149 [manual]: With no hooks, a run call SHOULD NOT cost more per system than a null check, and with hooks it SHOULD NOT allocate a ctx per hook call (the reason for SCH-150). Verify together with SCH-117.
  • SCH-150 [test]: ctx MUST be a single reused object per schedule and world, not a fresh object per hook call. The same object MUST be passed to beforeSystem, afterSystem, and onSkip, for every system and every run call on that world. Its fields are overwritten before each hook call, so inside a hook they MUST match that system, phase, dt, schedule, and world. It is valid only during the hook call: observers MUST NOT retain it or read it after the hook returns (they copy the fields they need), and MUST NOT mutate it. Its contents after the hook returns are unspecified. The object is per world because SCH-045 allows nested calls of one schedule on different worlds, which must not overwrite each other's ctx. Tests: (a) the identity is the same across the three hooks and across consecutive calls on one world; (b) a snapshot of the fields taken inside each hook matches the expected system/phase/dt; © with a system of A on world 1 calling A on world 2, the objects of the two worlds differ and the outer ctx.world is still world 1 in the outer afterSystem.

API sketch

import type { Trait, World } from 'koota';

export interface Phase {
  readonly name: string;
  readonly dependsOn: readonly Phase[]; // runs after these
  readonly before: readonly Phase[]; // runs before these
  readonly manual: boolean; // explicit flag only; detached phases are also manual (SCH-104)
}

export const OnStart: Phase;
export const OnLoad: Phase;
export const PostLoad: Phase;
export const PreUpdate: Phase;
export const OnUpdate: Phase;
export const OnValidate: Phase;
export const PostUpdate: Phase;
export const PreStore: Phase;
export const OnStore: Phase;

// Throws if `name` is missing or empty (SCH-100).
export function definePhase(
  name: string,
  options?: { dependsOn?: readonly Phase[]; before?: readonly Phase[]; manual?: boolean },
): Phase;

export type Condition = (world: World) => boolean; // sync, side-effect free, returns a real boolean

// Named SystemSet to avoid shadowing the global Set.
export interface SystemSet {
  readonly name: string;
  readonly before: readonly SystemSet[];
  readonly after: readonly SystemSet[];
  readonly runIf?: Condition;
}

// Throws if `name` is missing or empty (SCH-126).
export function defineSet(
  name: string,
  options?: { before?: readonly SystemSet[]; after?: readonly SystemSet[]; runIf?: Condition },
): SystemSet;

export interface System {
  readonly name: string;
  readonly phase: Phase;
  readonly before: readonly (System | SystemSet)[];
  readonly after: readonly (System | SystemSet)[];
  readonly sets: readonly SystemSet[];
  readonly runIf?: Condition;
  readonly run: (world: World, dt: number) => void; // MUST be synchronous (SCH-116)
}

export interface SystemOptions {
  name: string;
  run: (world: World, dt: number) => void;
  phase?: Phase; // default OnUpdate
  before?: readonly (System | SystemSet)[];
  after?: readonly (System | SystemSet)[];
  sets?: readonly SystemSet[];
  runIf?: Condition;
}

// Throws if `name` is missing or empty (SCH-010).
export function defineSystem(options: SystemOptions): System;

// Plain frozen data. No install hook, no dependencies on other plugins (SCH-135, SCH-139).
export interface Plugin {
  readonly name: string;
  readonly phases: readonly Phase[];
  readonly sets: readonly SystemSet[];
  readonly systems: readonly System[];
}

// Throws if `name` is missing or empty (SCH-135).
export function definePlugin(options: {
  name: string;
  phases?: readonly Phase[];
  sets?: readonly SystemSet[];
  systems?: readonly System[];
}): Plugin;

export type SkipReason = 'disabled' | 'condition';

// Single object per schedule and world, reused for every hook call (SCH-150).
// Valid only during the hook call: observers MUST NOT retain, read after return, or mutate it.
export interface HookContext {
  readonly schedule: Schedule;
  readonly world: World;
  readonly system: System;
  readonly phase: Phase;
  readonly dt: number;
}

// State per world (SCH-063). `name` defaults to the schedule name and is its stable identity.
// `disabled` is the source of truth for the enabled state. Replace it with `world.set` or use
// `enable`/`disable`. Mutating the array in place is unsupported (SCH-123, SCH-152).
export type ScheduleStateTrait = Trait<{ name: string; disabled: () => string[]; onStartDone: boolean }>;

export interface ScheduleHooks {
  beforeSystem?(ctx: HookContext): void;
  afterSystem?(ctx: HookContext): void;
  onSkip?(ctx: HookContext, reason: SkipReason): void;
}

export interface ScheduleOptions {
  name: string; // required, unique per world (SCH-062, SCH-140)
  plugins?: readonly Plugin[]; // flattened first, in array order (SCH-136)
  phases?: readonly Phase[]; // custom phases, declaration order
  sets?: readonly SystemSet[];
  systems?: readonly System[]; // declaration order
  hooks?: ScheduleHooks; // build-time only (SCH-148)
}

// dt: seconds, finite and >= 0 (0 allowed). Run APIs throw, in this precedence (SCH-115):
// unknown phase / from after to / invalid manual endpoints, then invalid dt (SCH-041),
// then re-entrancy (SCH-043).
export interface Schedule {
  readonly name: string;
  readonly state: ScheduleStateTrait; // per-world state trait, same identity per access (SCH-063, SCH-151)
  readonly phases: readonly Phase[]; // execution order (SCH-102), builtin + custom, manual included
  readonly systems: readonly System[]; // frozen, execution order, all phases (SCH-142)
  systemsOf(phase: Phase): readonly System[]; // same identity per call (SCH-143)
  isManual(phase: Phase): boolean; // SCH-104
  run(world: World, dt: number): void; // automatic phases only
  runPhase(world: World, phase: Phase, dt: number): void;
  runPhases(world: World, from: Phase, to: Phase, dt: number): void; // inclusive, SCH-033/109..111
  enable(world: World, system: System): void; // applies immediately (SCH-056); throws on unknown system (SCH-055)
  disable(world: World, system: System): void; // same
  isEnabled(world: World, system: System): boolean; // flag only, not conditions (SCH-054); reflects world.set on the state (SCH-153)
}

// Throws on SCH-006/007/016-020/023/103/131/133/138/140 (140: missing or empty `name`).
export function createSchedule(options: ScheduleOptions): Schedule;

Phases, sets, and plugins are created from existing objects, so cycles between phases built only from dependsOn or between sets cannot be built. before on phases can still form cycles (with dependsOn or the builtin chain); they are checked at build (SCH-103). Edges are checked between systems after set expansion. System, phase, and set names MUST be unique (SCH-023, SCH-138), because disabled systems are stored by name.

Per-world state is one trait per schedule, exposed as the readonly schedule.state and carrying the schedule name in its name field (SCH-063, SCH-151).

// shape only
const state = trait({ name: scheduleName, disabled: () => [] as string[], onStartDone: false });

Driver examples (docs only, not part of the package). Clamping and time scale are the driver's job: the schedule neither clamps nor scales dt (SCH-031). It throws on invalid dt (SCH-041), and a throw would kill a rAF loop, so drivers clamp.

// Real-time: requestAnimationFrame
const MAX_DT = 0.1; // seconds; koota runtime guidance (its boids example uses 1 / 30)
let last: number | undefined; // first rAF timestamp, not performance.now()
const frame = (now: number) => {
  const dt = last === undefined ? 0 : Math.min(Math.max((now - last) / 1000, 0), MAX_DT);
  last = now;
  schedule.run(world, dt);
  requestAnimationFrame(frame);
};
requestAnimationFrame(frame);

// Real-time: R3F
useFrame((_, delta) => schedule.run(world, Math.min(delta, MAX_DT)));

// Turn-based (demo-dnd): a dedicated schedule, separate from any frame schedule.
// The chain StartOfTurn → Move → Act → Resolve → EndOfTurn has no edge to a builtin phase,
// so it is detached and manual (SCH-104): `run` never executes it, only runPhase/runPhases do.
const StartOfTurn = definePhase('StartOfTurn');
const Move = definePhase('Move', { dependsOn: [StartOfTurn] });
// … Act, Resolve, EndOfTurn chained the same way
const turn = createSchedule({ name: 'dnd/turn', phases: [StartOfTurn, Move /* … */], systems });
turn.runPhases(world, StartOfTurn, Move, 0); // then wait for player input
// on input:
turn.runPhases(world, Act, EndOfTurn, 0);
// turn.run(world, 0) is a no-op for these phases. A frame system of another schedule MAY call `turn` (SCH-045).

Notes for system authors

  • Entity iteration order is unspecified. miniplex iterates in reverse and shuffles on removal, and koota's query order may change. Systems MUST NOT rely on it.
  • Systems are synchronous (SCH-116). Use runIf for data-driven gating (turn state etc.), keep it side-effect free, and read world data only. Use enable/disable for explicit toggles.
  • Turn-based callers pass dt = 0 (SCH-042).
  • Schedule names are unique per world (SCH-062), e.g. dnd/turn. Package authors SHOULD prefix system, phase, and set names (e.g. prefab/spawn) to avoid SCH-138 collisions.
  • Extension packages (ecs-prefab, ecs-devtools) export a plugin or a plain System[]. They never create or mutate the app's schedule, and they depend on ecs-schedule as a peer dependency (package-rules).

Differences from flecs

  • Same-phase ties use declaration order (the array order), not entity id. flecs notes that id order is not guaranteed; this one is.
  • Phases are plain objects that dependsOn other phase objects, not entities with DependsOn pairs.
  • Phases can also use before, are placed next to their anchors (dependsOn: [OnUpdate] runs directly after OnUpdate, SCH-102), and phases not connected to the builtin chain, or marked manual, are skipped by run (SCH-104, SCH-107). flecs orders phases through DependsOn only. Custom phases never run before OnLoad just because they have no dependency.
  • The flecs phases PreFrame and PostFrame are not included.
  • Systems also have explicit before/after edges (from becsy). flecs only orders by phase and entity id.
  • Systems can be grouped in sets with set-level ordering and conditions, and shipped as plugins: plain data with no install hook or import-time side effects, unlike flecs modules.
  • A system also runs only if its runIf conditions hold (SCH-118), alongside enable/disable.
  • A schedule is built once and is immutable. All ordering errors are thrown at build, not found at run time.
  • Systems are plain functions. There is no system query, no automatic iteration, no pipeline query, and no per-system parameter or ecs_run.
  • dt is an explicit argument in seconds, validated (SCH-041). 0 is a valid value, not "measure the time since the last call" as in flecs progress(). There is no delta_time from a world or a tick source.
  • OnStart runs once per world on its first execution, from any run API (SCH-036). flecs runs it on the first progress().
  • enable/disable inside a running system apply immediately and affect later systems in the same call (SCH-056). flecs queues them until the next sync boundary.
  • Schedule state is a world trait, and disabled systems are stored by name (needs unique names, SCH-023).
  • Running a schedule on a world while it is already executing there throws (SCH-043).
  • Added runPhase/runPhases to step part of the pipeline, so the caller can pause (turn-based use).

Trade-offs and known gaps

  • No read/write-derived ordering (becsy): koota has no declarative component access, and JS is single-threaded. Order comes from phases, explicit edges, and sets.
  • No sync points: koota mutates immediately. So enable/disable apply immediately (SCH-056), unlike flecs and becsy, which defer them because of staging.
  • Fixed-step and intervals are out of scope. The design leaves room: dt passthrough, runPhases, manual phases, and hooks.
  • A world.reset() from inside a running system is unspecified in v0. State returns to defaults, but what the current call does with already-made decisions is not defined.
  • No per-system parameters or ecs_run. A system is a plain function: call system.run(world, dt) directly in tests.
  • No hot add/remove. The schedule is immutable, so rebuild it. Devtools use introspection (SCH-142, SCH-143).
  • disabled is a list of names. Renaming a system invalidates saved entries, which are ignored silently on load (SCH-125).
  • In-place mutation of the disabled array by external code is unsupported (SCH-152). The enabled-state cache is keyed on the identity of that array (SCH-123).
  • Re-entrancy is checked per schedule and world (SCH-043, SCH-045). A same-schedule nested call throws. A system may still step a different schedule, e.g. a turn schedule from a frame schedule.
  • Unique schedule names per world are documented, not enforced (SCH-062).
  • A throwing OnStart system re-runs the whole OnStart phase on the next call, including systems that already completed (SCH-112).
  • Floating phases linked to the builtin chain only through other phases, and never promoted by SCH-102 step 1, run after everything else, in declaration order (SCH-102 step 3).

Out of scope

  • Intervals, rates, tick sources, and fixed-step. A createFrameClock({ maxDt, timeScale }) helper (clamping and time scale) is a possible follow-up.
  • Multithreading, staging, and sync points (including read/write analysis of systems).
  • Per-system delta time.
  • Adding or removing systems after build, hot reload, and disabling a whole phase, set, or plugin (flecs has this for modules and phases; possible follow-up).
  • The flecs phases PreFrame and PostFrame.
  • Plugin dependencies and install hooks.
  • Multiple pipelines and custom pipeline queries.
  • A Time trait.
  • Serialization codecs for the schedule state trait. koota has no JSON serialization, so this is a later concern for ecs-serialize, which reaches the trait through schedule.state (SCH-151).
  • Adapters for requestAnimationFrame and useFrame. Only the driver examples above are documented.
  • System timings for ecs-devtools. The schedule only exposes introspection and hooks (F07); observers measure time themselves.