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(explicitbefore/afteredges, cycles reported at build). - koota:
Worldtype andcreateWorld(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 byworld.reset(). Trait schemas reject object/array literals (validateSchema), so array defaults need a factory (() => []). - koota runtime guidance:
.github/skills/koota/references/runtime.md(deltacapped at 0.1 s) andreference/koota/examples/boids/src/sim/systems/update-time.ts(capped at1 / 30). koota traits carry no name in the public API, so the schedulenameis 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 independsOn. - 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-005Retired: replaced by SCH-101/SCH-102. Custom phases no longer run beforeOnLoadjust because theirdependsOnis empty; that caused turn pipelines to run on every fullrunand left non-root custom phases unplaceable.- SCH-006 [test]:
createScheduleMUST throw if a phase'sdependsOnorbeforecontains a phase that is neither builtin nor inphases. The message MUST name both phases. - SCH-007 [test]:
createScheduleMUST throw if the same phase appears twice inphases, or ifphasescontains a builtin phase. - SCH-008 [test]:
schedule.phasesMUST 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 (Xis a custom phase, builtins elided): XwithdependsOn: [OnUpdate]→[…, OnUpdate, X, OnValidate, …].Xwithbefore: [OnValidate]→ the same position.Xwithbefore: [OnStart]→[X, OnStart, …].XwithdependsOn: [OnStore]→[…, OnStore, X].XwithdependsOn: [OnUpdate], before: [OnStore]→ directly afterOnUpdate.A,BwithdependsOn: [OnUpdate], declaredA, B→[…, OnUpdate, A, B, OnValidate, …].AandBwithdependsOn: [OnUpdate]andCwithdependsOn: [A]: declaredA, B, C→[OnUpdate, A, B, C, OnValidate]; declaredA, C, B→[OnUpdate, A, C, B, OnValidate].YwithdependsOn: [OnUpdate]andXwithbefore: [Y]→[OnUpdate, X, Y, OnValidate].- A detached chain
T1 → T2 → T3→[…, OnStore, T1, T2, T3]. SCH-009Retired: replaced by SCH-102 and SCH-107 (see SCH-005).- SCH-100 [test]:
definePhase(name, { dependsOn?, before?, manual? })MUST return a phase withname,dependsOn(default[]),before(default[]), andmanual(defaultfalse).dependsOnlists phases it runs after (flecs name),beforelists phases it runs before. Both MAY reference builtin or custom phases. Builtin phases are never manual.nameis required:definePhaseMUST throw if it is missing or empty, and the message MUST namedefinePhaseand say thatnameis missing or empty. - SCH-101 [test]:
phasesMUST satisfy every edge of the phase graph: the builtin chainOnStart → OnLoad → PostLoad → PreUpdate → OnUpdate → OnValidate → PostUpdate → PreStore → OnStore, eachdependsOnedge (dependency → phase), and eachbeforeedge (phase → target). - SCH-102 [test]:
phasesMUST be produced by this deterministic procedure. A custom phase is rooted if a builtin phase reaches it throughdependsOn/beforeedges (e.g.dependsOn: [OnUpdate], ordependsOna 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
Swhose 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.phasedefaults toOnUpdate.nameis required:defineSystemMUST throw if it is missing or empty, and the message MUST namedefineSystemand say thatnameis missing or empty. - SCH-011 [test]: A system's
runMUST be called asrun(world, dt)with the world anddtpassed to the run API. - SCH-012 [test]: Systems in the same phase with no
before/afteredges MUST run in declaration order. - SCH-013 [test]: A system with
after: [a]MUST run aftera, and a system withbefore: [b]MUST run beforeb, 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/afteredge between systems in different phases that agrees with phase order MUST be accepted and have no further effect. - SCH-016 [test]: A
before/afteredge between systems in different phases that contradicts phase order MUST throw at build. This includes edges expanded from sets (SCH-130). - SCH-017 [test]:
createScheduleMUST throw if thebefore/afteredges contain a cycle (including a system referencing itself). The error message MUST name the systems in the cycle. - SCH-018 [test]:
createScheduleMUST throw if abefore/afterentry refers to a system that is not insystems. The message MUST name the referring system and the unknown one. - SCH-019 [test]:
createScheduleMUST throw if a system'sphaseis neither builtin nor inphases. - SCH-020 [test]:
createScheduleMUST throw if the same system object appears twice insystems. The message MUST name the duplicate. - SCH-023 [test]: System names MUST be unique within a schedule.
createScheduleMUST throw if two entries ofsystemshave the samename, and the message MUST name the duplicate. Identity stays by object:before/aftertakeSystemobjects, and an entry that is not the same object as one insystemsis rejected by SCH-018 even if its name matches. - SCH-021 [test]: Systems, phases, and edges MUST be validated only in
createSchedule, except the requirednameof eachdefine*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 whichdefineSystem/definePhase/defineSetwere called, and MUST be identical for everycreateSchedulecall with the same arrays. - SCH-106 [test]:
createScheduleMUST 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, andschedule.systemsOf(phase)keep the same identity across accesses and runs, and mutating the arrays passed tocreateScheduleafter 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 inphasesorder, and return after the last one finished (synchronous). Manual phases are skipped (SCH-107). - SCH-031 [test]:
dtis in seconds. A validdt(SCH-041) MUST be passed unchanged to every system. The schedule MUST NOT read a clock, and MUST NOT require aTimetrait. - 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)withfromandtoboth automatic MUST run the automatic phases fromfromtotoinclusive, inphasesorder. This includes phases between them that neither depend onfromnor onto. Manual phases positioned between them MUST NOT run. - SCH-034 [test]:
runPhaseandrunPhasesMUST throw if a phase is not in the schedule, andrunPhasesMUST throw iffromcomes aftertoinphasesorder. - 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]:
OnStartsystems MUST run only the first timeOnStartis executed for a given world, by any ofrun,runPhase, orrunPhases. Later executions ofOnStartMUST 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 changingdtMUST execute a full pass each call and pass each call'sdtto the systems. - SCH-040 [test]: Turn-based use: after
runPhases(world, a, b, dt)returns, no system outsidea..bMUST have run, so the caller can wait for input before the next call. - SCH-041 [test]:
run,runPhase, andrunPhasesMUST throw ifdtisNaN, not finite (±Infinity), or negative. The check MUST happen before any system runs and before any schedule state changes (e.g.OnStartstays not done). - SCH-042 [test]:
dtof0MUST be accepted and passed to the systems as0. It MUST NOT be replaced with a measured time (turn-based callers pass0). - SCH-043 [test]: Calling
run,runPhase, orrunPhaseson 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
AMAY call scheduleBon the same world, and a system of scheduleArunning on world 1 MAY callAon world 2. - SCH-107 [test]:
runMUST NOT execute any system of a manual phase (SCH-104), whether enabled or not. Manual phases run only throughrunPhase(SCH-108) andrunPhases(SCH-109). A schedule whose only systems are in detached phases MUST run nothing onrun. - 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)withfromandtoboth manual and in the same component (SCH-104) MUST run the manual phases of that component fromfromtotoinclusive, inphasesorder (for a detached component, its own topological order). Automatic phases and manual phases of other components MUST NOT run. - SCH-110 [test]:
runPhasesMUST throw iffromandtoare both manual but in different components. The message MUST name both phases. No system runs and no state changes. - SCH-111 [test]:
runPhasesMUST throw if exactly one offromandtois 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]:
onStartDoneMUST be set only after allOnStartsystems have completed without throwing, at the end of theOnStartphase (not at the end of the call). If anOnStartsystem throws,onStartDonestaysfalse, and the next call whose range includesOnStartruns the whole phase again, including systems that completed before the throw. - SCH-113 [test]: An
OnStartphase 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]:
OnStartMUST run only when a run call's range includes it. A call that excludes it (runPhaseon another phase, orrunPhasesbetween manual phases) MUST NOT runOnStartor mark it done. - SCH-115 [test]: When several errors apply to one call, the schedule MUST throw the first of: (1) unknown phase,
fromafterto, or invalid manual endpoints (SCH-034, SCH-110, SCH-111); (2) invaliddt(SCH-041); (3) re-entrancy (SCH-043). - SCH-116 [test]: A system's
runMUST be synchronous. If it returns a thenable (a value with athenfunction), 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
runcalls.
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/afterconstraints 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, andisEnabledMUST throw for a system that is not in the schedule.enableanddisableMUST be idempotent. - SCH-056 [test]:
enableanddisableMUST 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
runIfof each of its sets returnstrueAND its ownrunIfreturnstrue. 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'ssetsarray, 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 callrun. - SCH-120 [test]:
runIfMUST be called synchronously asrunIf(world)with only the world. - SCH-121 [test]: A condition that returns a non-boolean (e.g. a
Promiseorundefined) 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
runPhaseandrunPhasestoo, including manual phases. - SCH-123 [test]: The
disabledlist ofschedule.state(SCH-063) is the source of truth for the enabled state. The schedule MAY cache a lookup derived from it (e.g. aSetof names) per world, never in module-level state (SCH-064). Such a cache MUST be invalidated when thedisabledvalue of the state trait is replaced (its identity changes, asworld.set(schedule.state, { disabled: [...] })does), and afterworld.reset()(the state is re-added with a freshdisabled, SCH-065).enableanddisableMUST replacedisabledwith a new array and MUST NOT mutate it in place, so the same invalidation rule covers them. Tests: warm the cache withisEnabled/run, then (a)disable/enable, (b)world.setwith a newdisabled, ©world.reset(), and check each time that the result matches the trait. - SCH-153 [test]:
isEnabledand every run API MUST reflectworld.set(schedule.state, { disabled: [...] })made between calls, with noenable/disablecall in between and no stale result, whether or not earlier calls warmed a cache. This includes aworld.setmade from inside a running system, which affects the systems after it in the same call (SCH-056). Tests: (a)isEnabledistrue, setdisabledto the system's name,isEnabledisfalse; (b) run, setdisabled, run again: the system is skipped on the second run; © setdisabledback to[]: enabled again; (d) setdisabledin a system: a later system of the same call is skipped. - SCH-152 [manual]: Mutating the
disabledarray 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 ofScheduleStateTrait, together with the supported way:world.set(schedule.state, { disabled: [...] })orenable/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
OnStartonce-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 orOnStartstate. EachcreateSchedulecall 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 exactlyname(string, default: the schedulename, SCH-140),disabled(a list of system names), andonStartDone(boolean), all serializable.nameis the stable identity of the state: consumers (ecs-serialize, devtools, tests) match a state to its schedule byname. The schedule MUST NOT readnameback from the trait. Conditions and hooks MUST NOT be stored in it (conditions read world data, which is saved with the world).disabledneeds a factory default (() => []), since koota trait schemas reject array literals. The schedule definition (phases, systems, order) MUST stay immutable aftercreateSchedule. 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
WeakMapare created insidecreateSchedule. - 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, andOnStartruns again on its next execution. The trait is re-added lazily with the samename. This MUST NOT throw. Aworld.reset()from inside a running system is unspecified in v0 (see Trade-offs). - SCH-125 [test]: Names in
disabledthat match no system of the schedule MUST be ignored silently, in every run API and inisEnabledof other systems. They MUST NOT throw and MUST NOT disable anything. Tests set such names throughworld.set(schedule.state, { name: schedule.name, disabled: […], onStartDone })(SCH-151). - SCH-151 [test]:
schedule.stateMUST 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 thestateof 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, soworld.has(schedule.state)isfalseuntil the schedule first uses that world. Once present,world.get(schedule.state)?.nameMUST equalschedule.name, including afterworld.reset()and re-adding (SCH-065) and after a caller'sworld.add(schedule.state)(same defaults).
SCH-F06 Sets and plugins¶
- SCH-126 [test]:
defineSet(name, { before?, after?, runIf? })MUST return a set withname,before(default[]),after(default[]), and optionalrunIf.before/afterof a set contain sets.nameis required:defineSetMUST throw if it is missing or empty, and the message MUST namedefineSetand say thatnameis 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/afterMAY 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/afterMUST expand to the members of both sets: withAhavingbefore: [B], every member ofAruns before every member ofB. - 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]:
createScheduleMUST 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 inbefore/afterexpands 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
runIfMUST gate every member (SCH-118), including members in different phases. - SCH-133 [test]:
createSchedule({ sets })lists the sets. A set used by a system'ssets, by abefore/afterentry, or by another set'sbefore/after, that is neither insetsnor in a plugin'ssetsMUST 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
runIfis never evaluated. - SCH-135 [test]:
definePlugin({ name, phases?, sets?, systems? })MUST return a plain readonly object withnameandphases,sets,systems(default[]). A plugin is data only: it has no install hook, and defining it MUST have no side effects.nameis required:definePluginMUST throw if it is missing or empty, and the message MUST namedefinePluginand say thatnameis missing or empty. Pluginnameis 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 ownphases,sets, andsystems, separately per kind. All requirements aboutphases,systems, andsetsapply 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
namefor 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:
definePlugintakes nopluginsfield and the app lists plugins in order. A system in one plugin MAY reference (viabefore/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 }):nameis required.createScheduleMUST throw if it is missing or empty, and the message MUST say thatnameis missing or empty.schedule.nameMUST return it. It is stored in thenamefield of the schedule's state trait (SCH-063, SCH-151), the stable identity forecs-serializeand 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-emptynameerrors (SCH-140 and thedefine*functions), which have no schedule name to include. - SCH-142 [test]:
schedule.systemsMUST be a frozen array of all systems, manual phases included, in execution order, phase by phase (phases inphasesorder, 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'srun, after enabled and conditions passed.afterSystem(ctx)is called afterrunreturned successfully.onSkip(ctx, reason)is called when the system is skipped, with reason'disabled'(enabled flag false) or'condition'(a set or own condition returnedfalse). - SCH-145 [test]:
ctxMUST be{ schedule, world, system, phase, dt }with thedtof the call. The schedule MUST NOT read a clock or add timing toctx. Observers measure time themselves (devtools). Lifetime ofctx: SCH-150. - SCH-146 [test]:
afterSystemMUST 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
OnStartphase that is skipped because it is done, and systems of manual phases duringrun. - 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
ctxper hook call (the reason for SCH-150). Verify together with SCH-117. - SCH-150 [test]:
ctxMUST be a single reused object per schedule and world, not a fresh object per hook call. The same object MUST be passed tobeforeSystem,afterSystem, andonSkip, 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'sctx. 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 ofAon world 1 callingAon world 2, the objects of the two worlds differ and the outerctx.worldis still world 1 in the outerafterSystem.
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
runIffor data-driven gating (turn state etc.), keep it side-effect free, and read world data only. Useenable/disablefor 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 plainSystem[]. They never create or mutate the app's schedule, and they depend onecs-scheduleas 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
dependsOnother phase objects, not entities withDependsOnpairs. - Phases can also use
before, are placed next to their anchors (dependsOn: [OnUpdate]runs directly afterOnUpdate, SCH-102), and phases not connected to the builtin chain, or markedmanual, are skipped byrun(SCH-104, SCH-107). flecs orders phases throughDependsOnonly. Custom phases never run beforeOnLoadjust because they have no dependency. - The flecs phases
PreFrameandPostFrameare not included. - Systems also have explicit
before/afteredges (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
runIfconditions hold (SCH-118), alongsideenable/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. dtis an explicit argument in seconds, validated (SCH-041).0is a valid value, not "measure the time since the last call" as in flecsprogress(). There is nodelta_timefrom a world or a tick source.OnStartruns once per world on its first execution, from any run API (SCH-036). flecs runs it on the firstprogress().enable/disableinside 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/runPhasesto 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/disableapply 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:
dtpassthrough,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: callsystem.run(world, dt)directly in tests. - No hot add/remove. The schedule is immutable, so rebuild it. Devtools use introspection (SCH-142, SCH-143).
disabledis a list of names. Renaming a system invalidates saved entries, which are ignored silently on load (SCH-125).- In-place mutation of the
disabledarray 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
OnStartsystem re-runs the wholeOnStartphase 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
PreFrameandPostFrame. - Plugin dependencies and install hooks.
- Multiple pipelines and custom pipeline queries.
- A
Timetrait. - 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 throughschedule.state(SCH-151). - Adapters for
requestAnimationFrameanduseFrame. Only the driver examples above are documented. - System timings for
ecs-devtools. The schedule only exposes introspection and hooks (F07); observers measure time themselves.