15 KiB
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
sparsearray 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:
Worldmust track the current version per ID slot (abyte[]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
foreachcall 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 toSystemGroupwithout breaking existing code.
Consequences:
- System authors must be mindful of registration order.
SystemGroupis 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
refhave no built-in change detection. Auto-tracking within known scopes eliminates the most common source of forgottenMarkModifiedcalls. - The
RefN/ValNiterator property pattern lets the user opt in to tracking per-component:RefNtracks,ValNdoes 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
refreturns, 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. ValNaccessors 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>, andRefNiterator 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
foreachloops 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.
FlushPendingMutationsbetween 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,AddTofor lifecycle management. - First-party support for
IDisposablesubscription 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.
ICommandinterface on a struct causes boxing if passed asICommand. Mitigation:CommandQueue.Enqueue<T>(T command) where T : struct, ICommanduses 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
1is permanently reserved. DestroyEntityon 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
Worldmethods 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 structimprovements, 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
refreturns,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.
MessagePackAnalyzerprovides 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
privateorinternaltypes. - The
MessagePackAnalyzerpackage 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, notWorld. This keepsWorldlean and means interrupts are a no-op when noSystemGroupis managing the world. - Resolution is done via a regular
ICommandwith a defaultExecuteimplementation. The handler'sTryResolvemethod 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
Worldwithout 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.