# 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(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` 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` 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 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`, `GetSingleton`). 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.