Files
ComfyUI_frontend/docs/architecture/appendix-ecs-pattern-survey.md
Alexander Brown 162908a421 docs: ECS pattern survey appendix + PromotionStore cleanup (#12580)
## Summary

Add the ECS pattern survey appendix to ADR 0008's companion-documents
table, and drop stale `PromotionStore` references across architecture
docs to reflect the ADR 0009 removal.

## Changes

- **What**:
- New `docs/architecture/appendix-ecs-pattern-survey.md` — surveys
bitECS, miniplex, koota, ECSY, Bevy, and Thyseus: patterns adopted,
departed from, and when to revisit.
  - ADR 0008 companion table gains a row pointing at the new appendix.
- `docs/adr/0009-…/before-after-flows.md`,
`docs/architecture/ecs-target-architecture.md`,
`docs/architecture/entity-problems.md` — drop references to
`PromotionStore` / `usePromotionStore` (the legacy three-layer mechanism
is gone; promoted value widgets are now linked `SubgraphInput`s).
- `docs/architecture/subgraph-boundaries-and-promotion.md` — reframes
its "current mechanism" section as historical context with an explicit
"removed by ADR 0009" callout.
- **Breaking**: None — docs-only.

## Review Focus

Wording in the historical-context callout on
`subgraph-boundaries-and-promotion.md`. Everything else is a small
cleanup or a new standalone document.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-02 00:25:34 -07:00

20 KiB

Appendix: ECS Pattern Survey

A survey of mainstream Entity Component System libraries — bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation analysis that shipped slice 1 of ADR 0008. This appendix records which structural patterns our src/world/ substrate adopts, which it deliberately departs from, and where the trade-offs are load-bearing rather than incidental. Thyseus is called out specifically because it is the most Bevy-shaped of the TypeScript ECSs surveyed — its Commands parameter is the closest external analog to the command layer ADR 0003 / ADR 0008 are converging on, so it gets dedicated treatment in §2.5 and §3.5.

The in-code anchors for the load-bearing constraints discussed below are the doc-comments in src/world/world.ts (storage strategy) and src/world/entityIds.ts (identity contract) — see §3 below.


1. Survey Comparison

Six libraries were sampled for structural patterns: where component definitions live relative to the substrate, how components are declared, how entities are identified, and roughly how large the substrate's public surface is. Sources: the linked READMEs and docs.

Library Component placement Component definition style Entity ID type Approx. # core exports
bitECS Outside the substrate; user's choice plain arrays / objects number (unbranded) ~12
miniplex Colocated with the Entity type properties on a TS type plain object ref ~5
koota Colocated with the consumer trait({...}) factory numeric .id() ~15 (core) + ~8 (react)
ECSY User's choice class extends Component Entity object ~10
Thyseus Colocated with the consumer plain ES6 class (instances stored as values) numeric (via handle) ~25 (World/Schedule/Query/Commands/filters/Resource/Event)
Bevy (Rust, for shape) Plugin-owned (industry std) #[derive(Component)] struct Entity(u64) n/a

Two structural patterns are unanimous across the surveyed libraries:

  1. Component definitions live with the code that owns the data, not inside the substrate package. Whether by explicit recommendation (Bevy plugins, koota's colocation guidance, Thyseus's import { Position, Velocity } from './components' convention) or by default (bitECS, miniplex), no surveyed substrate ships pre-defined component types.
  2. Substrate surface area is small — bitECS at ~12 exports, koota at ~15, miniplex at ~5. ECSY and Thyseus are the outliers: ECSY exposes a wider class hierarchy, and Thyseus exposes a broader Bevy-shaped surface (Commands, Schedules, Resources, Events, filter combinators) because it commits to a full system-execution runtime, not just storage.

Our slice-1 end state — five source files under src/world/, ~14 exported names total — sits squarely in this band.


2. Patterns We Adopt

2.1 Substrate is deep; components live in domain code

The mainstream convention is that the ECS substrate exposes only the machinery — entities, component keys, a World — and component definitions live next to the system, store, or feature module that owns the data. This is the Bevy / miniplex / koota convention by design and the bitECS / ECSY convention by default.

Our substrate follows the same shape: src/world/ contains entity-ID brands, the ComponentKey definition primitive, and the World interface, but no domain-specific component types. Slice 1 places WidgetValueComponent and WidgetContainerComponent in src/stores/widgetComponents.ts, next to widgetValueStore.ts — the module that already owns widget value state.

This keeps the substrate / domain seam crisp: the World knows how to store and look up arbitrary components keyed by entity ID; the domain layer knows what a "widget value" is. It also aligns with the AGENTS.md DDD guidance to group code by bounded context. Future components follow the same rule — PositionComponent, when it lands, will live with the layout domain rather than inside the substrate.

2.2 Small public API

The substrate exports ~14 names — comparable to bitECS (~12) and koota (~15), much smaller than ECSY's class hierarchy. This is a deliberate target: every exported name is a contract a contributor must understand before extending the World, and every export is a potential migration cost when the substrate evolves.

The Brand / EntityId / ComponentKey / World / worldInstance split keeps each module single-purpose. Brand<T,Tag> is 5 LOC and shared across all branded ID kinds. ComponentKey<TData,TEntity> carries a two-parameter phantom that enables cross-kind compile-time checking. asGraphId is a single named boundary cast. The two explicit factories nodeEntityId / widgetEntityId are kept rather than collapsed into a parameterized helper because slice 2/3/4 will add factories with different parameter tuples (rerouteEntityId, linkEntityId, slotEntityId); the explicit-factory pattern scales linearly with new entity kinds without growing the helper's signature.

2.3 Reactive bridging via existing storage proxy

bitECS, koota, and miniplex bolt on a separate onChange event bus when a consumer wants reactive notifications. koota's React layer (useTrait(entity, ComponentKey)) is the closest analog to what useUpstreamValue and future composables want.

Because our World stores values inside Vue's reactive(Map<EntityId, ...>), a plain computed(() => world.getComponent(id, key)) already provides fine-grained per-(entity, component) tracking — no separate event bus is needed. This is a real Vue-specific advantage. The Vue tracker and the ECS storage are the same mechanism, so reactivity falls out of the storage choice rather than being layered on top.

2.4 Brand-typed entity IDs

No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded number, miniplex uses plain object references, koota uses a numeric .id(), and Thyseus hands back a numeric handle wrapped in Commands APIs. Our Brand<T, Tag> over each entity kind enables the type-level cross-kind isolation assertion in world.test.ts and documents slice-2/3/4 entity kinds at compile time.

This is a deliberate departure rather than an accident. It earns its keep once Position lands on NodeEntityId | RerouteEntityId (slice 2) and Connectivity lands on SlotEntityId (slice 4); without brands, those component-key declarations would accept any numeric ID and silently allow cross-kind misuse.

2.5 Commands pattern (Thyseus / Bevy) — direction we are converging on

Thyseus mutates the World exclusively through a Commands system parameter:

export function spawnEntities(commands: Commands) {
  commands.spawn().add(new Position()).add(new Velocity(1, 2))
}

commands.spawn(), .add(component), and .remove(component) enqueue deferred mutations against a command buffer; the World applies them at defined sync points in the schedule. This is the same shape Bevy uses and is the closest direct external analog to the mutation layer ADR 0003 and the World API and Command Layer describe for this codebase.

We deliberately match the shape of this pattern: external callers submit commands; only the executor calls the World's imperative setComponent / deleteEntity. ADR 0008 §"Relationship to ADR 0003" spells this out, and the parallel with Thyseus is intentional — when we extend slice 1 with a command executor, the public seam will look much more like Thyseus's Commands than like koota's entity.set(...) or bitECS's addComponent(world, ...).

What we deliberately do not copy from Thyseus's commands surface, yet:

  • Deferred buffering with schedule sync points. Thyseus batches commands and flushes them at well-defined frame phases for archetype efficiency. Our command executor stays synchronous in slice 1 because Vue reactivity wants writes to be observable in the same microtask, and we have no archetype churn cost to amortize.
  • Auto-injected Commands parameter. Thyseus's runtime inspects system signatures and injects Commands, Query<...>, Res<...>, etc. We do not have a system-runner yet (see §3.5), so commands today are called through a plain executor module rather than constructor injection.

The point of calling Thyseus out separately is that when ADR 0008 lands its command executor slice, "what does this look like in Thyseus?" is a load-bearing comparison point — not a curiosity. Diverging from the Bevy/Thyseus shape there should require an explicit justification, not silent drift.


3. Patterns We Explicitly Do NOT Adopt

Each of the following is a real industry idiom we considered and rejected on load-bearing grounds. None of these are pure performance trade-offs.

3.1 Replace-on-write usage idioms

koota's entity.set(Position, {...}) and miniplex's world.add(entity) replace component values with new objects on each write. Adopting either would break BaseWidget._state shared reactive identity — the contract that lets DOM widget overrides, useProcessedWidgets memoization, and the 40+ extension ecosystem all read the same proxy. Our setComponent(id, key, ref) stores by reference and the inner reactive(Map) keeps a stable cached proxy per entity-component pair: every getComponent returns the same proxy, regardless of how many writes intervene. widgetValueStore.registerWidget returns that proxy (not the caller's input ref), so BaseWidget._state and every other reader observe the same object. Replace-on-write idioms would swap the cached proxy on each write and break that stability — the reactive-identity test in widgetValueStore.test.ts locks in the contract.

3.2 SoA / archetype storage

bitECS, koota, miniplex, and Thyseus use sparse-set / archetype storage internally for cache locality — Thyseus is explicitly archetypal and sells "lean memory use and cache-friendly iteration" as a headline feature. Our reactive(Map<EntityId, unknown>) is closer to ECSY's AoS — slower iteration but integrates natively with Vue's tracking.

The surface trade-off is performance; the deeper trade-off is identity. SoA storage spreads each component's fields across parallel typed arrays, so the per-entity "row object" is reconstructed on read. A future migration to SoA would lose the proxy on the row object — and with it the shared-reactive-identity contract that BaseWidget._state and the widgetValueStore facade rely on. This is a load-bearing constraint, not just a perf optimization decision.

The contract is pinned in the doc-comment at the top of src/world/world.ts — copied here for proximity:

/**
 * `setComponent` stores values by reference (no clone). The inner
 * `reactive(Map)` produces a single cached Vue proxy per entity-component
 * pair: every `getComponent` call returns the same proxy, and mutations
 * through it propagate to all readers. Note that the proxy is NOT `===`
 * to the raw object passed to `setComponent` — read through `getComponent`
 * (or a `registerWidget`-style helper that does so internally) and treat
 * that proxy as canonical.
 *
 * `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
 * invariant. Replace-on-write idioms (koota's `entity.set(...)`,
 * miniplex's `world.add(entity)`) would swap the cached proxy on each
 * write and break the contract; revisiting either consumer is required
 * before changing storage semantics.
 */

3.3 Auto-generated opaque entity IDs

bitECS and koota assume IDs are opaque numbers — lastId++, with no external structure. miniplex uses plain object references with the same property.

Our widgetEntityId(rootGraphId, nodeId, name) is deterministic and content-addressed. Consumers consistently pass rootGraph.id, so a widget viewed at different subgraph depths shares identity with itself. Migrating to opaque numeric IDs would break cross-subgraph value sharing — the same widget at depth 0 and depth 2 would receive different IDs and diverge.

The contract is pinned in the doc-comment at the top of src/world/entityIds.ts:

/**
 * Entity IDs are deterministic, content-addressed, and string-prefix
 * encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
 *
 * `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
 * consumers consistently pass `rootGraph.id` so widgets viewed at
 * different subgraph depths share identity. Migrating to numeric IDs
 * would break cross-subgraph value sharing. See ADR 0008 and
 * widgetValueStore for the canonical keying contract.
 */

3.4 Substrate-side parent/child relations

Bevy ships Parent / Children components at the substrate layer; Flecs ships first-class relations. These are useful when many subsystems need hierarchical traversal at storage-near speeds.

We treat hierarchical traversal as a domain-layer concern instead. The only structural relation slice 1 needs is node → widgets forward lookup, expressed as a domain component (WidgetContainer.widgetIds in src/stores/widgetComponents.ts) and surfaced through getNodeWidgets() on the widget value store. Reverse widget → node lookup is not modeled in the World at all today — existing call sites already hold a widget object and read widget.node directly via the BaseWidget back-reference, so no substrate-side parent component earns its keep yet. We may revisit this if multiple slices need a shared traversal API; until then, keeping hierarchy domain-local preserves the substrate's "no domain knowledge" property.

3.5 Thyseus-style system runner, schedules, and worker threads

Thyseus ships a full execution runtime alongside its storage:

  • System functions as units of work, written as plain functions whose parameters (Commands, Query<[Position, Velocity]>, Res<Time>, Maybe<Velocity>, With<Active>, Without<Frozen>) describe the data they read and write.
  • Schedules (class SetupSchedule extends Schedule {}, world.runSchedule(SetupSchedule)) name groups of systems and control ordering / frequency, including fixed-update patterns.
  • Boilerplate-free worker threads for running disjoint systems in parallel without eval().
  • Builder World assembled imperatively (new World().addSystems(SetupSchedule, spawnEntities).prepare()).

We deliberately do not adopt any of this in slice 1. The reasons:

  1. Vue already owns scheduling. Reactivity-driven recomputation, watch, and component render passes are how work runs in this codebase. Inserting a parallel system scheduler would mean every piece of work has two possible execution contexts, and consumers would have to know which one applies. ADR 0008's planned executor is a thin command-application layer, not a fixed-step ECS schedule.
  2. No parallelism budget to spend. Worker-thread parallelism pays off when systems are CPU-bound and clearly data-disjoint. ComfyUI frontend's hot paths are render and DOM-bound; the cost of marshaling state across threads would dwarf any gain at our entity counts.
  3. Constructor-style parameter injection has a real DX cost. Thyseus's Query<[Position, Velocity]> injection requires the runtime to introspect and resolve types at registration time. That couples every system to the runner. The plain-function + world.getComponent shape we use today stays trivially testable without a World fixture.

Revisitable if (a) we end up running solver-style passes that are clearly CPU-bound and disjoint, or (b) the command executor grows enough phase ordering that an explicit schedule abstraction earns its keep over ad-hoc call sites. Until then, "Thyseus has a scheduler so we should too" is not a sufficient argument — the slice-1 substrate intentionally stops at storage + identity.


4. When to Revisit

The choices in §3 are deliberate but not eternal. Each has a revisit threshold.

SoA / archetype storage. The break-even point against reactive(Map) iteration is roughly >10k entities per component in steady-state hot paths. ComfyUI's projected widget count through slice 4 stays well under that. The watch signal is whether a render-loop or solver-loop pass demonstrably dominates frame time on entitiesWith(WidgetValueComponent) or any successor query — not just micro-benchmarks of Map.get.

If we cross that threshold, the migration is non-trivial: SoA loses the proxy on the row object (see §3.2), so a SoA World must either reconstruct proxies on read (defeating the perf gain) or move shared-identity reads back to a domain-side cache. ADR 0008's "Render-Loop Performance Implications and Mitigations" section already enumerates the planned mitigations (frame-stable query caches, archetype buckets, profiling-gated storage upgrades behind the World API).

Replace-on-write idioms. Revisitable only if the 40+ extension ecosystem moves off BaseWidget._state shared identity entirely — a separate, larger slice with explicit cost analysis (re-entry, DOM widget options.getValue overrides, linkedWidgets fan-out, useProcessedWidgets memoization invalidation), out of scope for the current ADR 0008 implementation.

Opaque entity IDs. Revisitable only if the cross-subgraph identity contract is dropped. Today widget value sharing across subgraph depths depends on it; slice 2 may extend the same contract to nodeEntityId for spatial reads. Until the product requirement changes, opaque IDs would be a regression.

Substrate-side parent/child relations. Revisitable when ≥2 subsystems need parent traversal. At one consumer it stays domain-local.

Thyseus-style system runner / schedule / worker threads. Revisitable only when the command executor grows multiple explicit phases that have to be ordered against each other, or when a profiled CPU-bound, clearly data-disjoint pass shows worker-thread parallelism would pay for the marshaling cost. Until both of those conditions land in a real ticket, keep the substrate at storage + identity and let Vue own scheduling.


5. Cross-References