Eternity Works runs on a form of Entity-Component-System, which will be referred to as an ECS from now on. ECS can mean many things to different engines, but in Eternity, it refers to a framework where components are containers of data:
[IsComponent]
struct SineMovement
{
float angle;
float angleIncrement;
float amplitude;
}
Note that all components must have [IsComponent]
above the struct name!
Meanwhile, systems are functions that take in components as ref or in parameters. They are ran once for every entity in the world that has all components required to call the function
[IsGameSystem]
public static void UpdateSineMovement(ref Transform transform, ref SineMovement data)
{
transform.localPosition.Y += Mathf.Sin(data.angle) * data.amplitude;
data.angle += Time.deltaTime * data.amplitude * Mathf.DegreeRadian;
if (data.angle >= 360f * Mathf.DegreeRadian)
{
data.angle = 0f;
}
}
Just like components, system functions must have [IsGameSystem]
decorating it. Additionally, a system can take in an Entity
as a parameter, which lets the function know which entity it is currently operating on.
Additionally, you can specify a system without any components as well:
[IsGameSystem]
public static void GlobalSystem()
{
globalCounter += Time.deltaTime;
}
This will run once per updating world per frame. Such 'global systems' cannot take in Entity
as a parameter, but can take in a uint worldID
to be informed of which world it is currently operating on.
By default, there is no guarantee of which systems will run in what order. There will be cases in which you want one system to run after another. In that case, decorate the system with the [BeforeSystem(other system name)]
or [AfterSystem(other system name)]
attribute annotations.
You can stack as many of these as you want. However, if a system declares to run after a system that in turn declares itself to run after the first system (forming a 'cyclic dependency'), neither of the systems will run.
Currently, you must write the system's full name, which normally comprises of container namespace name + container data type name + system function name
. Work is being done to provide a nicer and cleaner interface!
The last main part of the ECS is known as Entities. An Entity is essentially a container of any number of components, but with a maximum of one of each component type only. You can manipulate entities with code like follows:
public static Entity SpawnEntityAtPosition(uint worldID, Vector3 position)
{
//create a new entity with the given worldID
Entity entity = Entities.New(worldID);
//add a component of type 'transform' to the entity, while specifying it's position to the input argument
entity.AddComponent(new Transform()
{
localPosition = position
});
//return our newly created entity, yippee!!
return entity;
}
Each entity has both an ID, which is a uint, and a persistent ID, which is a traditional UUID. The entity's ID may change in between executions of the program, and may be reused by other entities when the entity is destroyed. However, if the entity was created by the Eternity Editor, its persistent ID is guaranteed to be the same.
Worlds
On top of the core parts of the ECS, there exists additional constructs to aid in designing complex games. Chief amongst which are Worlds. Worlds are groups of entities bound by a world ID, which is a uint. An Entity can only belong in one world at a time.
By default, two worlds are created by the engine: A special 'static' world which will always have an ID of 0, and the regular game world, with an ID of 1. The static game world will never have any systems executing on entities and their components within it, and is used as a storage of inactive entities (such as Prefabs). Meanwhile, the regular game world will always be updated unless the engine is overriden, and also rendered to screen.
Phases
Phases are ways to group categories of systems together. A system by default is allocated to the default phase (Phase.defaultPhase). However, one can manually apply a phase to a system as follows:
[ExecPhase(phase name)]
[IsGameSystem]
public static void SystemName(components)...
Eternity Works provides some basic phases for you to utilise:
- Default Phase (Phase.defaultPhase): Always runs first, most systems that do basic update functionality that modify components should go here.
- Fixed Update Phase (Phase.fixedUpdate): Runs after the default phase, and is guaranteed to run 50 times a second regardless of frames per second, as compared to default and draw phases, which run as many times as possible. Systems that interact with the physics engine normally use the Fixed Update phase.
- Draw Phase (Phase.drawPhase): Runs after the fixed update phase. Systems that interact with the Renderer should be situated in the draw phase. Ideally, systems in the draw phase should not have any ref component parameters, only in parameters.
System Guarantees (Advanced)
Multiple systems can technically run at the same time to boost performance of games that have many entities. This is subject to some rules:
- Two systems cannot modify the same component at the same time. A system is said to be able to modify a component if it refers to the component as a ref parameter and NOT an in parameter
- Two system types cannot run at the same time if one declares a dependency (ie: BeforeSystem/AfterSystem) on the other
- A system declared
[BlocksOtherSystems]
will never run together with any other system
The reason for this is to prevent a data race, which is a phenomenon where two systems modify the same data or components, which would result in (sometimes fatal) memory corruption.
Despite Eternity Works' best attempts at safeguarding you from this, there are some scenarios where it could happen despite the system guarantee of what it would and would not modify. A common example would be the following:
[IsGameSystem]
public static void IllegalSystem(Entity entity, ref PhysicsController2D physics)
{
entity.GetComponent<Transform>().localPosition.z = 1.0f;
}
Here, the entity's transform component is manipulated by directly getting it from the entity via GetComponent
, despite the system declaring that it will only manipulate the PhysicsController2D
component.
Another example of an illegal system would be:
[IsGameSystem]
public static void IllegalSystem2(Entity entity, ref Transform transform)
{
Entity child0 = transform.children[0];
child0.GetComponent<Transform>().localPosition.Z = 1.0f;
}
Despite the system declaring that it will modify Transform
, and doing just that, this is illegal as it is not guaranteed that the system is allowed to operate on child0's transform, only entity's transform.
However, it is worth noting that the second scenario, where one would like to modify another entity's components, is surprisingly common. There is two solutions to this:
1. Declare the system as a [BlocksOtherSystems]
, which will allow it to modify whatever it wants, perhaps at the cost of performance due to stopping the rest of the systems while it runs
2. Send a message through a mailbox!
Mailbox
Mailboxes are another helper concept to aid the ECS. Each entity has a mailbox, which can contain messages of different types.
To declare a new message type, use the following:
//specifies which phase will have systems that send, process and receive this message type
[IsMessage(Phase.defaultPhase)]
public struct ExampleMessage
{
public Vector2 newVelocity;
}
Messages can be sent using the following syntax:
[IsGameSystem]
[BeforeSystem("MyGameNamespace.ExampleMessage Sender")]
public static void VelocitySender(Entity entity, ref PhysicsController2D controller)
{
Mailbox<ExampleMessage>.Write(new ExampleMessage(arguments), target entity);
}
Or, alternatively, if you want to send a 'global message' that targets no entities:
Mailbox<ExampleGlobalMessage>.WriteGlobal(new ExampleGlobalMessage());
Entity-bound messages should be read in a system:
[IsGameSystem]
[AfterSystem("MyGameNamespace.ExampleMessage Sender")]
public static void VelocitySetter(Entity entity, ref PhysicsController2D controller)
{
foreach (var msg in Mailbox<ExampleMessage>.Read(entity))
{
controller.velocity = msg.newVelocity;
}
}
While global messages should be read in a global system.
Notice that both VelocitySender
and VelocitySetter
have a BeforeSystem and AfterSystem pair: The former being before MyGameNamespace.ExampleMessage Sender
, and the latter being after it. When a message is declared, a system is automatically generated that flags it as sent.
This system can be thought of as a 'barrier,' such that all message-senders must declare to run before it, and all message receivers must declare to run after it. This system will always be named message struct namespace name + message struct name + " Sender"
. Reading a message type before this system runs, or sending after it runs will result in an error.