-
Notifications
You must be signed in to change notification settings - Fork 11
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 -
Entity
is a compound of one or more components:entity
can't exist without components -
Entity
is just aninteger
representation -
Entity
exists only in the context of theworld
it was created in; in order to save anentity
for "later"EntityReference
should be used. -
Entity
does not contain a reference to theworld
it 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
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 theEntity
, introduce methods to get [by ref] desired components (e.g.PlayerInteractionEntity
- It's a bad practice to store
Entities
outside 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
MonoBehaviors
directly: we follow a hybridECS
approach so it is allowed to store Unity objects inECS
and access them from systems - Adding existing classes whose lifecycle is handled outside of ECS as components, e.g.
ISceneFacade
andProtobuf
instances - 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
:
- 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, theentity
is 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
ECS
systems 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 globalWorld
and/orPlayer 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 injectWorld
andEntity
which breakconstructor
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))); }; } ```
- In
-
World: corresponds to everything else that exists per scene basis
You may have one
Global
and oneWorld
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 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
andGlobalWorldFactory
- is dependent on
- It's highly encouraged to break
Static
andDynamicWorld
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
andDiagnosticsContainer
are created in theStatic
container and responsible for building up the logic of utilizingProtobuf
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
orDynamicWorldContainer
. 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.
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
UniTask
themselves 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
Result
signature - 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