oecs-sharp/docs/architecture.md

15 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. 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<T1..T6>().

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<T>, GetSingleton<T>, and RefN iterator properties are automatically tracked and marked as modified when the scope ends. Outside a batching scope, explicit world.MarkModified<T>(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<T>, GetSingleton<T>, 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.

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.

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<T>(interrupt) and block the next tick, not the current one. The interrupt is stored in an InterruptStore owned by SystemGroup. A matching IInterruptHandlerCommand<T> (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<T> types must be [MessagePackObject] structs (like all commands) to enable serialization for replay.