oecs-sharp/docs/architecture.md

388 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.