oecs-sharp/docs/architecture.md

444 lines
16 KiB
Markdown
Raw Permalink Normal View 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](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:** Each singleton component type gets its own dedicated entity
(allocated via `EntityAllocator`). Provide convenience accessors
(`SetSingleton<T>`, `GetSingleton<T>`). 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<Type, Entity>` 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<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.