12 KiB
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
sparsearray 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:
Worldmust track the current version per ID slot (abyte[]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
ForEachcall 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 toSystemGroupwithout breaking existing code.
Consequences:
- System authors must be mindful of registration order.
SystemGroupis 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
refhave no built-in change detection. Wrapping them in a property-change-notifying container would breakrefsemantics 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
refreturns, 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.
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,AddTofor lifecycle management. - First-party support for
IDisposablesubscription 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.
ICommandinterface on a struct causes boxing if passed asICommand. Mitigation:CommandQueue.Enqueue<T>(T command) where T : struct, ICommanduses 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
1is permanently reserved. DestroyEntityon 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
Worldmethods 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 structimprovements, 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
refreturns,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.
MessagePackAnalyzerprovides 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
privateorinternaltypes. - The
MessagePackAnalyzerpackage is a compile-time dependency. [Key]indices should be sequential starting from 0 to avoid null placeholders in the binary output.