Skip to content

Architecture Overview

Mikhail Agapov edited this page Feb 14, 2024 · 12 revisions

ECS

The common ECS concepts are very well described in the Unity Entities documentation. Here, the description of the concepts tailored to the project's needs is given.

Entities

An entity represents an instance of something discrete:

  • everything in ECS is represented by entities: there are no other means to express one's intentions
  • Entity is a compound of one or more components: entity can't exist without components
  • Entity is just an integer representation
  • Entity exists only in the context of the world it was created in; in order to save an entity for "later" EntityReference should be used.
  • Entity does not contain a reference to the world it was created in

Single-instance entities

Design-wise there could be entities that should exist in a single copy in the World. Unlike real singletons, each entity belongs to the World so it can't be static or accessed in a static manner.

The main benefit of using a single-instance entity is avoiding querying each frame since you can poll the entity directly and ensuring it exists in a single instance (instead of tacitly assuming a query is executed only once)

You may consider the following approaches to single-instance entities:

  • Use SingleInstanceEntity structure: it is cached (by a query) once and then stored/cached in the system. Then the Component is accessed via Extensions
  • Introduce your own structure that encapsulates the World and the Entity, introduce methods to get [by ref] desired components (e.g. PlayerInteractionEntity

No Goes

  • It's a bad practice to store Entities outside of ECS

Components

Components contain data that systems can read or write.

In ECS components may contain data only and must not contain any logic executed on components. For simplification components apart from the data itself may execute the following responsibilities:

  • Encapsulate a pool solely dedicated to this component type or its fields.
  • Provide a static factory method
  • Provide a non-empty constructor

In Arch both classes (reference types) and structures (value types) can be added as components.

In order to reduce memory footprint and simplify lifecycle management it's preferred to use structures wherever possible. However, there are several cases when the usage of classes is favorable:

  • Adding MonoBehaviors directly: we follow a hybrid ECS approach so it is allowed to store Unity objects in ECS and access them from systems
  • Adding existing classes whose lifecycle is handled outside of ECS as components, e.g. ISceneFacade and Protobuf instances
  • Referencing a component that can be changed outside of the current World

Refer to Design guidelines for development practices.

Systems

A system provides the logic that transforms component data from its current state to its next state. For example, a system might update the positions of all moving entities by their velocity multiplied by the time interval since the previous update.

A system runs on the main thread once per frame. Systems are organized into a hierarchy of system groups that you can use to organize the order that systems should update in.

Systems in their group of responsibility (feature) rely on data produced in a certain order: by design, systems should have dependencies on each other in the form of components (data) produced and modified by them. This is the only allowed and needed type of communication between different systems.

Refer to Design guidelines for development practices.

Creation and Update Order

We are using a separate library that automates systems creation and their dependencies resolution.

Worlds

A world is a collection of systems and entities. All worlds:

  • are fully independent of each other, can't reference entities from other worlds
  • can be disposed in separation
  • may have a unique set of systems

Currently, we have the following worlds:

  • The global world. Exists in a single entity, instantiated in the very beginning:
    • Handles realm and scenes lifecycle
    • Handles Player and Camera entities
    • Handles Avatars received from comms
  • Scene world. Each JavaScript scene is reflected onto an individual ECS World:
    • Peer communication and dependencies between worlds are strictly forbidden

ECS and OOP

ECS is an architectural pattern that does not benefit much from the traditional OOP principles.

Abstraction

You can't operate with systems and components in an abstract way: neither instantiate systems and then reference them anyhow nor query components by a base type or an interface.

The following use cases can be considered for interfaces:

  • generic constraints

Polymorphism and Inheritance

It's not prohibited for systems to have a common behavior in a base class

Generics

Both components and systems can be generic. There are no limitations. Though you can't specify a dependency on an open generic type.

Events/Callbacks

The callbacks mechanism is in contradiction with ECS: you should never create, subscribe to or store any delegate-like data.

It is also forbidden to propagate data from Unity objects to Systems via events.

The main reason for that is the lifecycle of the subscriptions: they can be invoked at any moment, while Systems should execute logic in their Update function only.

ECS and asynchronous programming

async/await is a nice pattern, however, it does not fit ECS at all: still, it is possible to marry it with systems, though it's very hard to maintain. There is only one implementation of gluing two worlds together: LoadSystemBase<TAsset, TIntention>.

We should be very cautious in trying to implement more of them as the logic to keep everything intact is perplexed and hardly approachable.

In order to benefit from an asynchronous resolution there is a concept of Promises:

  • It is represented by AssetPromise<TAsset, TLoadingIntention>
  • Each Promise is an entity
  • Promise is polled by a system each frame to check if it is resolved
  • Once Promise is consumed by the system that originated it, the entity is destroyed

Dependencies management

Plugins System

Each IDCLPlugin encapsulates its own unique dependencies that are not shared with other plugins (if you need a shared dependency you should introduce it in a container). The responsibility of the plugin are:

  • instantiate any number of dependencies needed in a given scope
  • instantiate any number of ECS systems based on shared dependencies, settings provided by Addressables, and scoped dependencies:
    • Inject into the real world scene
    • Inject a subset of systems (as needed) into an empty scene (e.g. Textures Loading is not needed for Empty Scenes but Transform System are)

Plugins are produced within Containers and initialized from DynamicSceneLoader or StaticSceneLauncher.

Plugin Settings

Each plugin may have as many settings as needed, these settings are not shared between plugins and exist in the corresponding scope.

IDCLPluginSettings is a contract that should be implemented by all types of setting, every type should be annotated with Serializable attribute. Each type of IDCLPluginSettings represents a set of dependencies that belong exclusively to the context of the plugin. They may include:

  • Pure configuration values such as int, float, string, etc. All fields/properties should be serializable by Unity.
  • Addressable references to assets: AssetReferenceT<T>. This way main assets are referenced: Scriptable Objects, Material, Texture2D, etc.
  • Components referenced on prefabs: ComponentReference<TComponent>. This way prefabs are referenced.

IAssetsProvisioner is responsible to create an acquired instance (ProvidedAsset<T> or ProvidedInstance<T>) from the reference. They provide a capability of being disposed of so the underlying reference counting mechanism is properly triggered.

It's strictly discouraged to reference assets directly (and, thus, create a strong reference): the idea is to keep the system fully dependent on Addressables, disconnect from the source assets come from, and prevent a widely known issue of asset duplication in memory.

❗ There is no assumption made about where dependencies may come from: in the future we may consider distributing different versions of bundles from a remote server, thus, disconnecting binary distribution from upgradable/adjustable data.

All IDCLPluginSettings are stored in a single Scriptable Object PluginSettingsContainer, this capability is provided by reference serialization: [SerializeReference] internal List<IDCLPluginSettings> settings;. The capability of adding them is provided by a custom editor script. Implementations must be [Serializable] so Unity is capable of storing them.

if no settings are required NoExposedPluginSettings can be used to prevent contamination with empty classes.

There are two scopes with plugins:

  • Global: corresponds to anything that exists in a single instance in a global world. E.g. camera, characters, comms, etc. Global plugins are not created for static scenes and testing purposes.

    Screenshot 2023-08-14 164048

    DCLGlobalPluginBase<TSettings> provides a common initialization scheme for plugins with dependency on the global World and/or Player Entity (or any other entities that can exist in the world):

    • In InitializeAsyncInternal assets can be loaded asynchronously, and all the dependencies that do not rely on the world can be set up immediately
    • InitializeAsyncInternal returns a special continuation delegate that will be executed when the global world is injected. It's assumed that it's possible to make all the required closures to shorten the code as much as possible and avoid introducing boilerplate fields explicitly.
    • it allows avoiding extra methods in Controllers to inject World and Entity which break constructor conceptually
    protected override async UniTask<ContinueInitialization?> InitializeAsyncInternal(MinimapSettings settings, CancellationToken ct)
       {
           MinimapView? prefab = (await assetsProvisioner.ProvideMainAssetAsync(settings.MinimapPrefab, ct: ct)).Value.GetComponent<MinimapView>();
    
           return (ref ArchSystemsWorldBuilder<Arch.Core.World> world, in GlobalPluginArguments _) =>
           {
               mvcManager.RegisterController(new MinimapController(MinimapController.CreateLazily(prefab, null),
                   mapRendererContainer.MapRenderer, mvcManager, placesAPIService, TrackPlayerPositionSystem.InjectToWorld(ref world)));
           };
       }
     ```
    
    
  • World: corresponds to everything else that exists per scene basis

    image

You may have one Global and one World plugin which correspond to a single feature (logical) scope if such necessity arises. E.g. Interaction Components exist in two plugins as some systems should run in the Global world while others in the Scene World.

⚠️ It's mandatory for each plugin to initialize successfully; it's considered that no plugins are optional; thus, upon failure the client won't be launched and the related error will be logged.

Containers

Containers are final classes that produce dependencies in a given context:

  • Created in a static manner
  • StaticContainer is the first class to create:
    • It produces common dependencies needed for other containers and plugins
    • It produces world plugins.
  • DynamicWorldContainer
    • is dependent on StaticContainer
    • produces global plugins
    • creates RealmController and GlobalWorldFactory
  • It's highly encouraged to break Static and DynamicWorld containers into smaller encapsulated pieces as the number of dependencies and contexts grows to prevent creating a god-class from the container.
    • e.g. ComponentsContainer and DiagnosticsContainer are created in the Static container and responsible for building up the logic of utilizing Protobuf and other components such as pooling, serialization/deserialization, and special disposal behavior:
      • Each substituent is responsible for creating and holding its own context so it naturally follows the Single Responsibility principle
    • it's possible to introduce as many containers as needed and reference them from Static or DynamicWorldContainer. All of them should live in the same assemblies: don't introduce an assembly per container, refer to Assemblies Structure for more details.