-
Couldn't load subscription status.
- Fork 14
Architecture Overview
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.
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 -
Entityis a compound of one or more components:entitycan't exist without components -
Entityis just anintegerrepresentation -
Entityexists only in the context of theworldit was created in; in order to save anentityfor "later"EntityReferenceshould be used. -
Entitydoes not contain a reference to theworldit was created in
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
SingleInstanceEntitystructure: 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
Worldand theEntity, introduce methods to get [by ref] desired components (e.g.PlayerInteractionEntity
- It's a bad practice to store
Entitiesoutside of ECS
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
MonoBehaviorsdirectly: we follow a hybridECSapproach so it is allowed to store Unity objects inECSand access them from systems - Adding existing classes whose lifecycle is handled outside of ECS as components, e.g.
ISceneFacadeandProtobufinstances - Referencing a component that can be changed outside of the current
World
Refer to Design guidelines for development practices.
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.
We are using a separate library that automates systems creation and their dependencies resolution.
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 is an architectural pattern that does not benefit much from the traditional OOP principles.
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
It's not prohibited for systems to have a common behavior in a base class
Both components and systems can be generic. There are no limitations. Though you can't specify a dependency on an open generic type.
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.
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 (Asset Promises page):
- It is represented by
AssetPromise<TAsset, TLoadingIntention> - Each
Promiseis an entity -
Promiseis polled by a system each frame to check if it is resolved - Once
Promiseis consumed by the system that originated it, theentityis destroyed
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
ECSsystems based on shared dependencies, settings provided byAddressables, 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.
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.
DCLGlobalPluginBase<TSettings>provides a common initialization scheme for plugins with dependency on the globalWorldand/orPlayer Entity(or any other entities that can exist in the world):- In
InitializeAsyncInternalassets can be loaded asynchronously, and all the dependencies that do not rely on the world can be set up immediately -
InitializeAsyncInternalreturns 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
Controllersto injectWorldandEntitywhich breakconstructorconceptually
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))); }; } ``` - In
-
World: corresponds to everything else that exists per scene basis
You may have one
Globaland oneWorldplugin which correspond to a single feature (logical) scope if such necessity arises. E.g.Interaction Componentsexist 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 are final classes that produce dependencies in a given context:
- Created in a
staticmanner -
StaticContaineris 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
RealmControllerandGlobalWorldFactory
- is dependent on
- It's highly encouraged to break
StaticandDynamicWorldcontainers into smaller encapsulated pieces as the number of dependencies and contexts grows to prevent creating a god-class from the container.- e.g.
ComponentsContainerandDiagnosticsContainerare created in theStaticcontainer and responsible for building up the logic of utilizingProtobufand 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
StaticorDynamicWorldContainer. All of them should live in the same assemblies: don't introduce an assembly per container, refer to Assemblies Structure for more details.
- e.g.
Despite the Singleton pattern generally being considered an anti-pattern, it can still be applied with certain restrictions and under certain circumstances to simplify dependency management:
- The class should be non-abstract: the singleton itself should not represent an abstraction such as an interface or a base class. The singleton can participate in the inheritance chain, but only the final type can be a singleton. Thus, we guarantee a singleton is not a replacement for a proper dependency management;
- The class shouldn't depend on other abstractions;
- The class can depend on other singletons
- The class can rely on the existing static APIs (e.g. Unity API);
- The class should represent a piece of functionality that exists solely in a single instance, and is globally accessible by nature:
- For example, if the class contains logic related to the specific scene/world, it can't be a singleton
- On the contrary, if the class represents logic related to
Inputor the Global World it can be a singleton
- The class must be used statically, its instance should not be passed around: thus, the developer guarantees that they understand it's publicly accessible;
- Using a singleton should be justified: it should lead to the significant simplification of dependency management;
- The class should respect one of the following life cycles:
- Is always available: constructed via implicit instantiation or manually during containers' initialization;
- Constructed for the user session: in this case, the singleton must be
Reseton logout/re-login, and strictly can't be accessible before the session is initialized;
- The singleton must be reset between Unit Tests to prevent state leakage.
To facilitate the usage of singletons and following the requirements the CodeLess project was created.
First, always follow the YAGNI principle when developing a certain feature: don't introduce unneeded abstractions/interfaces if the application is not justified by design requirements and can't be easily foreseen for the nearest future.
Several tips can help with a proper decision:
- If a class has exactly one reasonable implementation and you don’t foresee needing a variant, skip the interface;
- If the interface you are going to introduce is fully symmetric;
- No Testing/Mocking expected for the given scope
- When the concrete class is trivial to instantiate or wrap without mocking, there's no mocking gain from an abstraction
- View layer is usually not testable and doesn't require any abstractions
- Simple Data Structures: Plain Objects that only carry data and have no behavior don’t need interfaces—they don’t encapsulate logic worth abstracting.
- Performance-Critical Paths: Virtual dispatch or interface calls incur a tiny cost. In hot loops or latency-sensitive code, concrete calls are marginally faster—avoid abstraction here.
- Utility classes and extensions for which dependencies can be passed as arguments, in case the number of arguments is reasonable. And the class itself can be simply static.
If some aspects of the aforementioned are not true and you still need an abstraction, consider using the CodeLess project to generate interfaces for you, reducing the amount of boilerplate code:
- The interface is fully symmetric to the class implementation: the majority of open and internal members should be exposed via an interface. The minority can be ignored by
IgnoreAutoInterfaceMemberattribute; - The interface is independent: there is no necessity to inherit from other interfaces, and segregate into smaller ones
- The class, an interface will be generated from, implements only that interface,
IDisposablecan be an exception - The generated interface must be obvious to a developer (as it's not part of the source code base)
The ContextualAsset system is a set of utility types designed to load and unload assets only when needed, freeing memory when the asset is not actively used. This approach greatly reduces RAM consumption, particularly for UI-heavy scenarios where certain resources are only relevant in specific contexts.
It consists of:
- ContextualAsset – Generic wrapper for deferred, context-based asset loading/unloading.
- LocalizedContextualAsset – Specialization for localized content (e.g., different languages or regions).
- ContextualImage – UI component specifically for on-demand image loading. It wraps over ContextualAsset
-
Reduced RAM Usage Assets are only loaded when the relevant context is active (e.g., a specific screen, mode, or scene). Once the context changes, the assets can be released, preventing unnecessary memory retention. Example: Instead of keeping all loading screen images in memory for the entire session, load only the one needed for the current screen, and unload it after use.
-
Performance-Friendly By controlling load/unload points, you can avoid:
- Large memory spikes from many assets being loaded at once.
- Performance drops caused by excessive simultaneous asset streaming.
- Context Management The contextual system ties asset lifetime to a scope where it should be used:
- When entering a scope: load an asset asynchronously.
- When leaving a scope: release the asset. This makes it easy to add memory-efficient asset usage with minimal logic.
- Integration With UI ContextualImage integrates directly with Unity’s UI system, automatically replacing Image.sprite only when needed, and freeing it when not.
UI Screens with Rarely Used Assets:
- Preview Image in auth screen.
- Loading Screen Tips (text + image pairs).
- ScreenShot Camera Grid overlay.
- Works with Addressables.
- Load is asynchronous by default.
- Unload is deterministic when the context ends. But be careful: Addressables won't release asset until next Unity's cleanup cycle. If you need to release it manually use
Resources.UnloadUnusedAssets
Based on internal testing, this system saves significant RAM by unloading unneeded assets between screens. Measurements are attached in this PR https://github.com/decentraland/unity-explorer/pull/5058
A union type (also known as a sum type or tagged union) allows a variable to hold data of one of several distinct types, each with its own associated values and structure. At runtime, the type of the value is tracked along with the value itself.
This is especially useful when modeling data that can take on several shapes — such as results that are either success or error, or input events from APIs.
Many modern languages have first-class support for union types:
Rust: Uses enum to define tagged unions. Each variant can have its own data.
enum Address {
House { street: String, number: u32 },
Apartment { street: String, unit: u8 },
}
TypeScript: Supports union types directly via the | operator.
type Address = HouseAddress | ApartmentAddress;
Swift: Uses enum with associated values.
enum Address {
case house(street: String, number: Int)
case apartment(street: String, unit: Int)
}
These languages use union types to simplify logic, reduce errors, and enable exhaustive pattern matching.
C# lacks native support for tagged unions. Instead, developers often use:
- Class hierarchies and polymorphism — verbose and GC-heavy
- Libraries like OneOf — easier, but still relies on runtime boxing
- Manual switch statements on enums — error-prone and not type-safe
REnum is a Roslyn Source Generator that brings Rust-style enums to C#, enabling:
- Zero-cost union types using value types (structs)
- Pattern matching using .Match()
- Exhaustive and safe access to union cases using .IsXXX(out T)
It generates all required code at compile time, ensuring no runtime overhead, no boxing, and no GC pressure, which is ideal for performance-sensitive apps like games.
Type declaration:
[REnum]
[REnumField(typeof(HouseAddress))]
[REnumField(typeof(ApartmentAddress))]
public partial struct Address {}
Pattern matching:
string result = address.Match(
"Address: ",
static (prefix, house) => $"{prefix}{house.Street}",
static (prefix, apt) => $"{prefix}{apt.Street}"
);
Safe access:
if (address.IsHouseAddress(out var house)) {
// use house variable here
}
To see more examples visit https://github.com/NickKhalow/REnum
In some sensitive flows, especially with deep hierarchy, you may want to avoid handling exceptions and rely on the Result reported from the functions being called.
public readonly struct Result
{
public readonly bool Success;
public readonly string? ErrorMessage;
private Result(bool success, string? errorMessage)
{
this.Success = success;
this.ErrorMessage = errorMessage;
}
public static Result SuccessResult() =>
new (true, null);
public static Result ErrorResult(string errorMessage) =>
new (false, errorMessage);
public static Result CancelledResult() =>
new (false, nameof(OperationCanceledException));
}
Several guidelines should be respected if a method follows the given principle with a return value like UniTask<Result>:
- The method should guarantee it does not throw any exceptions as a managed way of handling the flow
- The method itself can call other APIs that can throw exceptions (as generally it's impossible to compile them out in C# and avoid, especially considering how Unity APIs and
UniTaskthemselves are designed)
E.g. it can be achieved like this:
public UniTask<Result> ExecuteAsync(TeleportParams teleportParams, CancellationToken ct) =>
InternalExecuteAsync(teleportParams, ct).SuppressToResultAsync(ReportCategory.SCENE_LOADING, createError);
/// <summary>
/// This function is free to throw exceptions
/// </summary>
protected abstract UniTask InternalExecuteAsync(TeleportParams teleportParams, CancellationToken ct);
...
public static async UniTask<Result> SuppressToResultAsync(this UniTask coreOp, ReportData? reportData = null, Func<Exception, Result>? exceptionToResult = null)
{
try
{
await coreOp;
return Result.SuccessResult();
}
catch (OperationCanceledException) { return Result.CancelledResult(); }
catch (Exception e)
{
ReportException(e);
return exceptionToResult?.Invoke(e) ?? Result.ErrorResult(e.Message);
}
void ReportException(Exception e)
{
if (reportData != null)
ReportHub.LogException(e, reportData.Value);
}
}
- Watch out for cancellation token handling: the method should handle it gracefully without calling
ThrowIfCancellationRequested();.
if (ct.IsCancellationRequested)
return Result.CancelledResult();
- When an exception-free method is called, it's expected that it won't throw any exceptions. The method should only handle its own exceptions to comply with the
Resultsignature - Try to propagate the exceptions-free flow to the upper-layer methods while it's reasonable, don't mix/alternate the hierarchy with both throwing and non-throwing notations