# 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`. Lower 24 bits are the ID, upper 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.Select()`. **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: Auto-Tracking for Component Modifications in Batching Scopes **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:** Inside a batching scope (system run, command drain, foreach iteration), component accesses via `GetComponent`, `GetSingleton`, and `RefN` iterator properties are automatically tracked and marked as modified when the scope ends. Outside a batching scope, explicit `world.MarkModified(entity)` is still required. Batching scopes nest: `SystemGroup` wraps the entire tick, each system's `Run` extension adds a nested scope, and each `foreach` iteration adds another. Only the outermost scope flush triggers auto-marking. **Rationale:** - C# structs returned by `ref` have no built-in change detection. Auto-tracking within known scopes eliminates the most common source of forgotten `MarkModified` calls. - The `RefN`/`ValN` iterator property pattern lets the user opt in to tracking per-component: `RefN` tracks, `ValN` does not. This avoids false positives from read-only iterations. - Outside batching scopes, manual marking is still required — but these are rare (one-off mutations outside systems). **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 rarely need to call `MarkModified` — only for mutations outside batching scopes. - `ValN` accessors on iterators are the safe default for read-only access. - No per-component memory or CPU overhead for change detection. --- ## ADR-006: Deferred Mutation Batching and 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. Structural mutations (add/remove component, destroy entity) during iteration also need to be deferred to avoid invalidating iterators. **Decision:** Use nested batching scopes (`BeginBatching`/`EndBatching`). `SystemGroup` wraps the entire tick in a batching scope. Each system's `Run` extension, each command drain, and each `foreach` iteration add nested scopes. Within a batching scope: - Structural mutations are buffered and applied when the outermost scope ends. - Component accesses via `GetComponent`, `GetSingleton`, and `RefN` iterator properties are tracked for auto-dirty-marking. Pending mutations are flushed between systems so each system sees the prior system's changes. Changes are posted after each system and 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." - Nesting means `foreach` loops inside systems are safe — adding/removing components mid-iteration is deferred. **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. - `FlushPendingMutations` between systems ensures chained work is visible. --- ## 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:** Each singleton component type gets its own dedicated entity (allocated via `EntityAllocator`). Provide convenience accessors (`SetSingleton`, `GetSingleton`). Exclude all singleton entities 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. - Each singleton type having its own entity means `DestroyEntity` on a singleton entity is a normal operation, and singletons can have multiple component types attached without conflict. **Consequences:** - A `Dictionary` maps each singleton component type to its backing entity. - `IsSingletonEntity(Entity)` is checked during query iteration to exclude singleton entities. --- ## 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. --- ## 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. --- ## ADR-013: Interrupts for System-Blocking Prompts **Status:** Accepted **Context:** Systems sometimes need to pause execution until an external response arrives (e.g., a UI confirmation dialog). The pause must be serialization-safe (the world is fully consistent when blocked) and must not interrupt mid-tick (which would leave partial state). **Decision:** Add an interrupt system. Interrupts are issued via `world.Interrupt(interrupt)` and block the **next** tick, not the current one. The interrupt is stored in an `InterruptStore` owned by `SystemGroup`. A matching `IInterruptHandlerCommand` (a regular `ICommand`) resolves the interrupt when executed. **Rationale:** - Deferring the block to the next tick avoids mid-tick state inconsistency. The current tick completes normally, all mutations are flushed, and the world is serializable before the block takes effect. - The interrupt store lives in `SystemGroup`, not `World`. This keeps `World` lean and means interrupts are a no-op when no `SystemGroup` is managing the world. - Resolution is done via a regular `ICommand` with a default `Execute` implementation. The handler's `TryResolve` method receives the interrupt data and can inspect it before accepting or rejecting the resolution. - One interrupt per type ensures no ambiguity about which handler resolves which interrupt. **Alternatives considered:** - **Mid-tick blocking:** Would leave partial system state, making serialization and recovery unsafe. - **Storing interrupts in World:** Adds complexity to `World` without benefit — interrupts are a tick-level concern, not a component-level one. **Consequences:** - Systems that issue interrupts must be prepared for the next tick to be skipped entirely. - Interrupts are transient — they are not serialized and do not survive save/load. - `IInterruptHandlerCommand` types must be `[MessagePackObject]` structs (like all commands) to enable serialization for replay.