337 lines
11 KiB
Markdown
337 lines
11 KiB
Markdown
# 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).
|
||
|
||
**Decision:** `readonly struct Entity` wrapping a `uint`. Upper 24 bits are the
|
||
ID, lower 8 bits are the version.
|
||
|
||
**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.
|