oecs-sharp/docs/architecture.md

11 KiB
Raw Blame History

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.

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.