438 lines
15 KiB
Markdown
438 lines
15 KiB
Markdown
# 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](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<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.
|