Skip to content

t1k:unity:dots-core:architecture

FieldValue
Moduledots-core
Version2.3.2
Efforthigh
Tools

Keywords: architecture, design patterns, DOTS, ECS

/t1k:unity:dots-core:architecture

DOTS Architecture — Component & System Design

Section titled “DOTS Architecture — Component & System Design”

Decision framework for Unity DOTS ECS architectural choices. Guides DOTS vs MonoBehaviour tier selection, component/system creation, SOLID principles for ECS, and anti-patterns that hurt reusability.

Library scope: gameplay mechanics ONLY. The DOTS library handles combat, AI, navigation, inventory, progression, puzzles, and game flow — pure gameplay data and logic. Rendering, UI, audio, and platform-specific code do NOT belong in DOTS packages. Use MonoBehaviour/UI Toolkit (Tier 2/3) for non-gameplay concerns — they are better suited and avoid unnecessary DOTS complexity.

Related skills: dots-ecs (API reference) · dots-rpg (domain components) · dots-jobs-burst (performance) · dots-performance (profiling, chunk efficiency, parallelism)

  • Deciding whether to create a new component or extend an existing one
  • Deciding whether to create a new system or add logic to an existing one
  • Reviewing component granularity (too fat? too thin?)
  • Reviewing system responsibility (doing too much?)
  • Planning module boundaries for a reusable DOTS package
  • Refactoring ECS code for reusability across projects
  • Checking for demo-specific logic in reusable packages

See component-decisions.md

See system-decisions.md

See solid-ecs.md

See reusability-checklist.md

See atomicity-guide.md

See refactor-rename-checklist.md

Addressables-First Pattern (library v2.0.0+)

Section titled “Addressables-First Pattern (library v2.0.0+)”

See addressables-pattern.mdAssetReferenceGameObject authoring → AssetReferenceBakerExtensions.BakeAssetReference → runtime Entity prefab. Enforcement test bans Resources.Load( / AssetDatabase.LoadAssetAtPath / non-ECS Object.Instantiate(GameObject) in Runtime asmdefs. Consumer projects MUST ship AddressablesBuildPlayerProcessor + AddressablesEnforcement.Allowlist.

3-Tier Architecture (DOTS vs MonoBehaviour)

Section titled “3-Tier Architecture (DOTS vs MonoBehaviour)”

See dots-vs-mono-tiers.md for full decision framework.

TierWhenEntity CountBurst?Example
1: Pure DOTSPer-entity gameplay10-10KYesCombat, Nav, AI, Stats, Spawning
2: DOTS Data + MB BridgeSingleton → managed API1NoCamera (Cinemachine), Audio, VFX, UI bindings
3: Pure MonoBehaviourNo ECS data0NoScene loading, Save/Load, Localization

Quick rule: 10+ entities → Tier 1. Singleton + managed API → Tier 2. No ECS → Tier 3.

Bridge Helpers — Use These for ALL ECS-Reading MonoBehaviours

Section titled “Bridge Helpers — Use These for ALL ECS-Reading MonoBehaviours”

ECSMonoBehaviourBase is the canonical base class for any runtime MonoBehaviour that reads ECS singletons — not just UI. Audit (2026-04-26) found the World.DefaultGameObjectInjectionWorld != null && IsCreated guard duplicated in 28 demo files (input handlers, camera controllers, bootstraps). All should inherit ECSMonoBehaviourBase and override OnECSUpdate(EntityManager em) instead of re-implementing the world-ready guard.

Sibling helper: SubsceneBypassBootstrap<TLevelData> — for JSON-driven config bootstraps that need to skip SubScene baking. Override OnConfigure(EntityManager em, TLevelData data); base class handles JSON parse + world-readiness guard.

Rule: any non-DOTS MonoBehaviour reading ECS state in Update() / LateUpdate() MUST inherit one of these two helpers. No raw World.DefaultGameObjectInjectionWorld checks in new code.

  1. Components = Data, Systems = Logic — never mix
  2. One system, one job — each system does exactly one transformation
  3. Tag > Enum dispatch — prefer tag components over byte/enum branching for OCP
  4. Config > Constants — tuning values belong in component data, not GameplayConstants
  5. Buffer priority — when filling fixed-capacity buffers, always prioritize important entries
  6. Bridge pattern — isolate external dependencies behind a bridge system (read abstraction -> write concrete)
  7. Atomicity — components and systems must be atomic (indivisible, single-concern). See atomicity-guide.md
    • Example: A large component with 12+ fields mixing concerns -> split into 3 focused components by domain
    • Example: A monolithic reset system -> per-module resets (Combat/AI/Inventory) + shared signal tag bridge

When building systems that need to work in both 2D and 3D:

ApproachVerdictWhy
Position2D componentWRONGDuplicates Position, forces system branching
#if DOTS_2DWRONGForks library, doubles maintenance
ISystem<T2D, T3D> genericsWRONGOver-engineering, Burst unfriendly
float3 everywhere (z=0 for 2D)CORRECTAll math works identically; no branching
Conditional via dataCORRECTSystems auto-skip via [RequireMatchingQueriesForUpdate]
Enum fields for shape variantsCORRECTAoEShape.Circle/Cone/Line — single system handles all
Bool flags for behaviorCORRECTParabolicArc.IsFlat — linear vs parabolic in one system

Key pattern: Configuration authoring sets different data -> same systems produce different behavior.

-> See dots-performance skill for chunk efficiency calculator, system parallelism rules, and profiling workflows.

Key performance principles for architectural decisions:

  • Smaller components = more entities per 16KB chunk = better cache
  • IEnableableComponent over add/remove for frequently changing state (no archetype move)
  • ISystem + [BurstCompile] always — never SystemBase for new code (5x faster)
  • ScheduleParallel for >100 entities, SystemAPI.Query for <100, Run for <10
  • Split systems when they process 2+ independent data sets (enables parallelism)
Anti-PatternFix
System reads enum and branches to 3+ behaviorsSplit into separate systems with tag queries
Component has 10+ fields, most unused per entitySplit into focused components
System does perception AND state managementExtract into separate systems
Demo tuning values in GameplayConstantsMove to per-entity component config
Filling buffer without priority orderingAlways prioritize important entries (enemies > allies)
Main-thread SystemAPI.Get* in nested loopsJobify with ComponentLookup<T>
using 5+ module namespaces in one systemSystem has too many responsibilities — split it
SystemBase for any new systemUse ISystem struct — 5x faster, full Burst support
Missing [BurstCompile] on system methodsSilent Mono fallback — 10-100x slower
Managed calls in [BurstCompile] codeSilent ISystem failure — system never registers, no error
Speed zone writing to MoveSpeed.ValueSpeed zones are transient — store multiplier in WaypointFollower.SpeedMultiplier, apply locally. Mutating the canonical stat component corrupts StatSyncSystem’s derived pipeline
Duplicating utility loops (FindWalletIndex, FindDefinition)Extract to static [BurstCompile] utility class in {Domain}/Utilities/ folder. Pattern: CurrencyWalletUtility, QuestUtility, AIHelpers
Game-specific OR over-specific instance names in library packagesNEVER use game/demo names (ColorFit, BackpackCrawler) in Packages/ code. Beyond demo names, also name the generic ROLE, not a concrete instance: ProjectileRegistryTag not ArrowRegistryTag (when a generic ProjectileData/ProjectileCollisionSystem already exists, Arrow is an inconsistent island); QueuePuzzle not ColorFitGameState. Mage/Knight/Sword/Arrow belong in enum values or demo content, never in a type name. Ask: “Would this name make sense in a different game?” See library-quality-mandate-unity.md § “Naming charter” (SSOT).
Skipping Play mode validation after implementationALWAYS run Play mode test (enter Play via MCP or dots-validator) after implementing new systems. EditMode tests only verify logic — Play mode catches baking failures, SubScene issues, system ordering bugs, and rendering problems
Renaming/deleting a public library symbol without grepping consumersRun grep -rn "OldName" Packages/ Assets/Demos/ BEFORE the deletion. Migrate every caller in the SAME commit. See refactor-rename-checklist.md
Moving a Runtime helper to a Tests asmdef without checking Runtime consumersIf consumer Runtime code (e.g. Assets/Demos/**/Runtime/**) extends or references the type, the move breaks the consumer. Check consumers and either keep in Runtime, or migrate the consumer in the same commit
Foundation asmdef references a mode/genre asmdef (e.g. DOTSCore.UIDOTSGamemodes.Survivor)Layering inversion — dependencies must flow DOWN only. Route the shared data through the infrastructure layer (dots-bridges) or a foundation-owned event type instead. dots-core → dots-bridges → mode-package is the only clean DAG. See Gotchas → “Package layering”.

Refactor / Rename a Library Symbol — Pre-Delete Checklist

Section titled “Refactor / Rename a Library Symbol — Pre-Delete Checklist”

Before deleting OR renaming OR relocating ANY public type, method, namespace, or asmdef in a consumed DOTS library package (e.g., Packages/unity-dots-library/):

  1. Grep both halves of the workspace: grep -rn "SymbolName" Packages/ Assets/ --include="*.cs"
  2. Migrate every caller in the SAME commit as the rename — never as a follow-up
  3. Check asmdef constraints: if you move a type from a Runtime asmdef to a Tests asmdef (with UNITY_INCLUDE_TESTS define), every Runtime consumer breaks at runtime, not at compile time in the test runner
  4. Check namespace continuity: if the file moves but the namespace stays, callers might still resolve — but if BOTH change, every using X.Y; is now broken
  5. Run read_console via Unity MCP before pushing the rename commit — zero new errors required

See refactor-rename-checklist.md for the full checklist (file moves, partial classes, attribute duplication on partials, asmdef visibility, submodule pointer drift downstream).

  • System update order is set per-world, not per-group — moving a system between groups changes its phase silently if the group’s update order isn’t documented.

  • SystemBase vs ISystem performance differs in Burst — ISystem is fully Burst-compilable; SystemBase has managed glue. Use ISystem when latency matters.

  • EntityCommandBuffer playback at the END of system update — not where it’s recorded — race conditions appear if you assume immediate effect.

  • World destruction order matters — destroying child worlds before parent leaks system instances.

  • Bake-time GetEntity(prefab) vs runtime AssetReferenceGameObject — pick the right tier. Both produce an Entity you can Instantiate, but the failure modes differ sharply. Decision tree:

    QuestionIf YES → bake-timeIf NO → addressable
    Is the prefab guaranteed to exist when the SubScene opens? (always in build, no DLC, no late content)baker.GetEntity(prefab, TransformUsageFlags.Dynamic)✗ switch to addressable
    Loaded exactly once per session (boot-time content)?✓ bake-time✗ switch
    Total prefab memory < 50 MB for the owning SubScene?✓ bake-time✗ addressable streams + unloads
    Need to instantiate from systems running BEFORE the owning SubScene finishes streaming?✗ — bake-time Entity doesn’t exist yet✓ addressable + per-system RequireForUpdate<MyPrefabSingleton>()
    Need to swap variants at runtime (different model per region/event)?✗ — bake-time bakes ONE variant✓ addressable keyed by region/event
    Cross-SubScene reference (system in SubScene A spawns SubScene B prefab)?✗ — GetEntity(prefab) from baker in A only sees A’s content✓ addressable + AssetReferenceBakerExtensions.BakeAssetReference

    Bake-time tell: consumers RequireForUpdate<MyPrefabRef>() where MyPrefabRef : IComponentData { public Entity Value; } is baked once via AddComponent(new MyPrefabRef { Value = baker.GetEntity(prefab, TransformUsageFlags.Dynamic) }). No runtime indirection. Addressable tell: prefab lives behind [SerializeField] AssetReferenceGameObject prefab; in authoring; baker calls AssetReferenceBakerExtensions.BakeAssetReference(this, prefab) → produces a runtime BakedAssetReference<Entity> systems resolve via ResolveBakedEntityRequired(...) after scene load. Anti-pattern (silent failure): Resources.Load<GameObject>(...) in Runtime asmdef — banned post library v2.0.0 (gate: DOTSCore.Tests.Addressables.AddressablesEnforcementTests). If you must support both modes, gate behind a UseAddressables baker config; never sprinkle #if between the two patterns.

    Field reports: see references/incidents.md.

  • Package layering — foundation never references a mode/genre layer. The library DAG is dots-core (foundation) → dots-bridges (infrastructure) → mode/genre packages (dots-gamemodes, dots-puzzlemodes). Dependencies flow DOWN only. A foundation type reaching UP into a mode package is an inversion — even if it compiles, because Unity asmdefs only catch literal cycles, not conceptual layering. Real incident (W6.3): DOTSCore.UI.Combat.FloatingDamageNumberRenderer once drained DOTSGamemodes.Survivor.DamageNumberEvent, making the DOTSCore.UI asmdef reference DOTSGamemodes.Runtime (foundation → mode = wrong direction). Fix: define the shared contract in the infrastructure layer and have both sides depend on it. The renderer was re-targeted to drain DOTSBridges.UI.FloatingText.FloatingTextRequest from the FloatingTextHub singleton, so DOTSCore.UI now references dots-bridges (down) instead of dots-gamemodes (up). Detection heuristic: a using in a DOTSCore.* / DOTSAI.* file naming a DOTSGamemodes.* / DOTSPuzzleModes.* namespace is almost always an inversion — route the data through dots-bridges or a dots-core event type instead.

→ Archetype layout, chunk fill rate, structural-change cost model: references/archetype-design.md → System group hierarchy, UpdateInGroup, ECB system selection: references/system-group-ordering.md → Baker patterns: TransformUsageFlags, singletons, buffers, DependsOn: references/baker-patterns.md → World bootstrap, VContainer DI bridge, ManagedEventQueue: references/world-bootstrap-di.md