2026-07-18 19:03:37 +08:00
|
|
|
|
# Architecture Decisions: OECS
|
|
|
|
|
|
|
|
|
|
|
|
This document records the key architectural decisions made during the design of
|
|
|
|
|
|
OECS, with the rationale behind each one. It serves as a reference for
|
|
|
|
|
|
contributors and a guardrail against accidental complexity.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-001: Sparse Sets over Archetypes
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** The ECS needs to store components and support iteration. The two
|
|
|
|
|
|
dominant patterns are archetypes (entities grouped by exact component
|
|
|
|
|
|
signature) and sparse sets (one dense array per component type).
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Use sparse sets.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- The design prioritizes cheap add/remove operations because reactivity is a
|
|
|
|
|
|
first-class concern. Sparse sets have O(1) add/remove via swap-remove.
|
|
|
|
|
|
- Archetypes require moving an entity to a new archetype when its component
|
|
|
|
|
|
signature changes. This complicates change tracking — you must detect the
|
|
|
|
|
|
move and emit events.
|
|
|
|
|
|
- Sparse sets have worse cache locality for multi-component queries (you probe
|
|
|
|
|
|
multiple arrays), but for an observable-first ECS targeting UI workflows,
|
|
|
|
|
|
query throughput is not the bottleneck.
|
|
|
|
|
|
- Sparse sets are simpler to implement and reason about.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Multi-component queries probe N sparse sets. The smallest set drives
|
|
|
|
|
|
iteration to minimize probes.
|
|
|
|
|
|
- Memory overhead: one `sparse` array per component type, sized to max entity
|
|
|
|
|
|
ID. Acceptable for the expected entity counts (tens of thousands, not
|
|
|
|
|
|
millions).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-002: 32-bit Entity with Version Bits
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Entities need to be cheap to copy, comparable, and safe against
|
|
|
|
|
|
use-after-free (accessing a recycled entity ID).
|
|
|
|
|
|
|
2026-07-18 21:42:08 +08:00
|
|
|
|
**Decision:** `readonly struct Entity` wrapping a `uint`. Lower 24 bits are the
|
|
|
|
|
|
ID, upper 8 bits are the version.
|
2026-07-18 19:03:37 +08:00
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- 24-bit ID space supports ~16.7 million simultaneously alive entities. More
|
|
|
|
|
|
than enough for observable-first use cases (UI entities, game objects in a
|
|
|
|
|
|
typical scene).
|
|
|
|
|
|
- 8-bit version allows 256 generations before an ID wraps. With a free list,
|
|
|
|
|
|
hot IDs are recycled quickly, making version collisions unlikely.
|
|
|
|
|
|
- 32-bit struct is 4 bytes — fits in a register, zero heap allocations.
|
|
|
|
|
|
- `Entity.Null` = `0` (ID=0, version=0). ID 0 is never allocated, so this is
|
|
|
|
|
|
a natural sentinel.
|
|
|
|
|
|
|
|
|
|
|
|
**Alternatives considered:**
|
|
|
|
|
|
|
|
|
|
|
|
- `ulong` (64-bit): More headroom but 2× memory in every array and struct that
|
|
|
|
|
|
references an entity. Overkill for the target scale.
|
|
|
|
|
|
- `Guid`: Heap-allocated when boxed, 16 bytes, not comparable by ref. Rejected
|
|
|
|
|
|
for performance and ergonomics.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- `World` must track the current version per ID slot (a `byte[]` parallel to
|
|
|
|
|
|
the free list).
|
|
|
|
|
|
- Entity equality checks both ID and version.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-003: Explicit Query Iteration over Auto-Injection
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Systems need to iterate entities matching a component signature.
|
|
|
|
|
|
Two API styles exist: auto-injection (the framework calls the system with the
|
|
|
|
|
|
right components) and explicit iteration (the system calls `ForEach`).
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Use explicit iteration via `world.ForEach(query, action)`.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- Auto-injection hides the iteration cost. A system that looks like a simple
|
|
|
|
|
|
method is actually O(N) — this is surprising.
|
|
|
|
|
|
- Explicit iteration makes the performance model visible. The system author
|
|
|
|
|
|
sees the `ForEach` call and understands they're iterating.
|
|
|
|
|
|
- Auto-injection requires either code generation or reflection to match
|
|
|
|
|
|
parameters to component types. Explicit iteration uses generics, which are
|
|
|
|
|
|
resolved at compile time.
|
|
|
|
|
|
- Explicit iteration is more flexible: a system can run multiple queries, or
|
|
|
|
|
|
conditionally skip iteration.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Slightly more verbose system code. Acceptable tradeoff for clarity.
|
|
|
|
|
|
- No source generators or reflection needed for system dispatch.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-004: Registration Order for System Execution
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Systems need a defined execution order. Options include
|
|
|
|
|
|
registration order, explicit dependency declarations, and stage-based
|
|
|
|
|
|
grouping.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Registration order determines execution order. No dependency
|
|
|
|
|
|
graph.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- Registration order is the simplest model that works. It's predictable and
|
|
|
|
|
|
requires no additional API surface.
|
|
|
|
|
|
- For the target use case (observable ECS for UI-heavy applications), the
|
|
|
|
|
|
number of systems is typically small (< 50). Manual ordering is manageable
|
|
|
|
|
|
at this scale.
|
|
|
|
|
|
- Dependency graphs add complexity (cycle detection, topological sort) without
|
|
|
|
|
|
proportional benefit at this scale.
|
|
|
|
|
|
- If needed later, `Before()`/`After()` constraints can be added to
|
|
|
|
|
|
`SystemGroup` without breaking existing code.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- System authors must be mindful of registration order.
|
|
|
|
|
|
- `SystemGroup` is a simple ordered list, not a graph.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-005: Manual Marking for Component Modifications
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** The reactivity system needs to know when a component value changes
|
|
|
|
|
|
so it can notify observers. Structural changes (add/remove) are detectable, but
|
|
|
|
|
|
in-place mutations via `ref T` are not.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Require explicit `world.MarkModified<T>(entity)` calls after
|
|
|
|
|
|
mutating a component. Provide a debug-mode warning when a `ref T` is obtained
|
|
|
|
|
|
but never marked.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- C# structs returned by `ref` have no built-in change detection. Wrapping them
|
|
|
|
|
|
in a property-change-notifying container would break `ref` semantics and add
|
|
|
|
|
|
overhead.
|
|
|
|
|
|
- Auto-detection via `IEquatable<T>` comparison is possible but expensive:
|
|
|
|
|
|
every component would be compared every tick, even if unchanged.
|
|
|
|
|
|
- Manual marking puts the cost on the author, where it belongs. The debug
|
|
|
|
|
|
warning catches the most common mistake (forgetting to mark).
|
|
|
|
|
|
|
|
|
|
|
|
**Alternatives considered:**
|
|
|
|
|
|
|
|
|
|
|
|
- **Dirty flag on every component:** Requires a wrapper struct, breaks `ref`
|
|
|
|
|
|
returns, adds per-component memory overhead.
|
|
|
|
|
|
- **Hash-based change detection:** Compute hash on write, compare on post.
|
|
|
|
|
|
Expensive for large components, false positives on hash collisions.
|
|
|
|
|
|
- **Copy-on-write:** Store previous value, compare on post. Doubles memory for
|
|
|
|
|
|
all components.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- System authors must remember to call `MarkModified`. The debug warning
|
|
|
|
|
|
mitigates this.
|
|
|
|
|
|
- No per-component memory or CPU overhead for change detection.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-006: Deferred Change Posting
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Changes made during a system's `Run` need to be communicated to
|
|
|
|
|
|
observers. Posting immediately would interleave observer callbacks with system
|
|
|
|
|
|
logic, leading to reentrancy bugs.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Accumulate changes during `Run`, post them after `Run` completes.
|
|
|
|
|
|
Post once more after the full tick.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- Prevents observers from seeing partially-updated state mid-system.
|
|
|
|
|
|
- Allows batching: multiple changes to the same entity/component are collapsed
|
|
|
|
|
|
into one notification.
|
|
|
|
|
|
- Matches the mental model of "the tick is the atomic unit of work."
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Observers always see state after a complete system or tick, never during.
|
|
|
|
|
|
- If an observer needs to react mid-tick, they must split their logic into
|
|
|
|
|
|
multiple systems.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-007: R3 for Reactivity
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** The ECS needs a reactive programming library for observable
|
|
|
|
|
|
queries and change subscriptions.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Use [R3](https://github.com/Cysharp/R3).
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- R3 is the de facto standard for reactive programming in modern .NET (the
|
|
|
|
|
|
successor to UniRx).
|
|
|
|
|
|
- It's actively maintained by Cysharp (same author as MessagePack-CSharp).
|
|
|
|
|
|
- Zero-allocation observables, `IObservable<T>` compatible, `AddTo` for
|
|
|
|
|
|
lifecycle management.
|
|
|
|
|
|
- First-party support for `IDisposable` subscription handles — natural fit for
|
|
|
|
|
|
UI lifecycle binding.
|
|
|
|
|
|
|
|
|
|
|
|
**Alternatives considered:**
|
|
|
|
|
|
|
|
|
|
|
|
- **System.Reactive (Rx.NET):** Heavier, more allocation-heavy, less
|
|
|
|
|
|
game-dev-friendly.
|
|
|
|
|
|
- **Custom event system:** Reinventing the wheel. R3 provides operators
|
|
|
|
|
|
(Where, Select, Throttle) for free.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Dependency on R3. Acceptable — it's the same ecosystem as MessagePack.
|
|
|
|
|
|
- Subscribers use standard Rx patterns (`Subscribe`, `AddTo`).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-008: Commands as Serializable Structs
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** The design calls for a command queue where commands are
|
|
|
|
|
|
serializable and executed deferred.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Commands are `[MessagePackObject]` structs implementing
|
|
|
|
|
|
`ICommand`. They live in a `CommandQueue`, not in ECS sparse sets.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- Structs avoid heap allocations per command.
|
|
|
|
|
|
- MessagePack serialization enables networking, replay, and save/load of
|
|
|
|
|
|
command streams.
|
|
|
|
|
|
- Separation from ECS state: commands are transient actions, not persistent
|
|
|
|
|
|
data. Mixing them into sparse sets would blur this distinction.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Commands cannot be queried like components. This is intentional.
|
|
|
|
|
|
- `ICommand` interface on a struct causes boxing if passed as `ICommand`.
|
|
|
|
|
|
Mitigation: `CommandQueue.Enqueue<T>(T command) where T : struct, ICommand`
|
|
|
|
|
|
uses constrained generics to avoid boxing. Internal storage still boxes
|
|
|
|
|
|
(heterogeneous queue), but the enqueue path is allocation-free.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-009: Singleton as Reserved Entity
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Singletons (global state like `Time`, `Config`, `InputState`) need
|
|
|
|
|
|
a home in the ECS.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Reserve entity ID `1` as the singleton entity. Provide
|
|
|
|
|
|
convenience accessors (`SetSingleton<T>`, `GetSingleton<T>`). Exclude from
|
|
|
|
|
|
normal queries.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- Reuses existing component storage — no separate dictionary or global
|
|
|
|
|
|
variables.
|
|
|
|
|
|
- Query exclusion prevents accidental iteration over singleton data in entity
|
|
|
|
|
|
queries.
|
|
|
|
|
|
- Simpler than a separate "resource" system (as in Bevy). One concept (entity +
|
|
|
|
|
|
components) covers both entities and singletons.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Entity ID `1` is permanently reserved.
|
|
|
|
|
|
- `DestroyEntity` on the singleton entity is a no-op or throws.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-010: Single-Threaded by Default
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Should systems run in parallel? Should component access be
|
|
|
|
|
|
thread-safe?
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Single-threaded execution. No locks, no `ConcurrentDictionary`,
|
|
|
|
|
|
no parallel scheduling.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- The target use case (observable ECS for UI) is inherently single-threaded.
|
|
|
|
|
|
UI frameworks require main-thread access.
|
|
|
|
|
|
- Parallel system execution adds significant complexity: dependency analysis,
|
|
|
|
|
|
component access arbitration, synchronization.
|
|
|
|
|
|
- If needed later, systems can declare read/write access sets for automatic
|
|
|
|
|
|
parallel scheduling. This is an additive change.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- All systems run sequentially on the calling thread.
|
|
|
|
|
|
- No thread-safety guarantees. Calling `World` methods from multiple threads is
|
|
|
|
|
|
undefined behavior.
|
|
|
|
|
|
- Simpler implementation, easier debugging.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-011: .NET 8 Target
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** The library needs a target framework.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Target `net8.0` (LTS).
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- .NET 8 is the current LTS release with support through November 2026.
|
|
|
|
|
|
- `ref struct` improvements, generic math, and performance enhancements over
|
|
|
|
|
|
.NET 6/7.
|
|
|
|
|
|
- R3 and MessagePack both support .NET 8.
|
|
|
|
|
|
- No need for .NET 9 preview features.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Consumers must be on .NET 8 or later.
|
|
|
|
|
|
- Can use `ref` returns, `readonly struct`, and other modern C# features.
|
2026-07-18 19:07:22 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ADR-012: Public Types Required for MessagePack Serialization
|
|
|
|
|
|
|
|
|
|
|
|
**Status:** Accepted
|
|
|
|
|
|
|
|
|
|
|
|
**Context:** Component types must be serializable by MessagePack-CSharp. The
|
|
|
|
|
|
library imposes constraints on type design.
|
|
|
|
|
|
|
|
|
|
|
|
**Decision:** Component types must be `public` and annotated with
|
|
|
|
|
|
`[MessagePackObject]` and `[Key]` attributes. The `MessagePackAnalyzer` NuGet
|
|
|
|
|
|
package is included for compile-time validation.
|
|
|
|
|
|
|
|
|
|
|
|
**Rationale:**
|
|
|
|
|
|
|
|
|
|
|
|
- MessagePack-CSharp requires public types for its dynamic formatter generation
|
|
|
|
|
|
and source-generated formatters.
|
|
|
|
|
|
- `MessagePackAnalyzer` provides AOT-safe source-generated formatters (critical
|
|
|
|
|
|
for Unity IL2CPP) and catches misconfigured types at compile time.
|
|
|
|
|
|
- Indexed integer keys (`[Key(0)]`) produce the fastest and most compact
|
|
|
|
|
|
serialization, which aligns with the ECS performance goal.
|
|
|
|
|
|
|
|
|
|
|
|
**Consequences:**
|
|
|
|
|
|
|
|
|
|
|
|
- Component authors cannot use `private` or `internal` types.
|
|
|
|
|
|
- The `MessagePackAnalyzer` package is a compile-time dependency.
|
|
|
|
|
|
- `[Key]` indices should be sequential starting from 0 to avoid null
|
|
|
|
|
|
placeholders in the binary output.
|