Language syntax and semantics walk through. [LAST_UPDATE 26th November 2016]
This language is higher order language which, defines the data and the transformations for an entity component system architecture library. The concepts are based on Entitas, but it should be posible to implement code generators for other ECS implementations.
All definitions can be split in to multiple ecs
files.
This is why all definitions are optional. The order is however mandatory
platform entitas_csharp_light
The first statement which an ecs
file might start with is platform
.
By defining the platform you define which code generator should be used.
It is posible to generate for multiple platforms.
platform entitas_csharp_light swift
Defining multiple platforms means multple code generators will be executed. The editor shows error if there is no code generator for specified platform.
namespace my.game
Namespace is a language specific feature. Many programming language support or even force programmers to define custom namespaces/packages/modules for there code. There should be only one namespace statement present in the whole project.
ctx Core, Meta
User might want to define multiple context names. Generally an entity can contain any defined component. However if the user defines multiple contex names, an entity coming from a certain context can only contain components which are valid in the given context. The detailed explanation will follow in the component section.
alias long = "long"
Next comes a list of type aliases. As we want to support multiple platforms type aliases are esential to keep the logical structure platform independent. Left side of the equasion is the symbol we will use in our language. On the right side is the platform equivalent. It is defined as a string becuase it should support complex type representations like generics:
alias i64 = "long"
alias ConsumptionHistory = "System.Collections.Generic.List<ConsumptionEntry>"
As we already mentioned we can define multiple platforms. And the notation showed previously can not support it. The notation is a short notation and meant only to be used with a single platform defined. If we define multiple platform this notation will result in an error. For multiple platform we should use following notation
alias i64 {
entitas_csharp : "long"
swift : "Int64"
}
There is a way to add information about the size of the type (in bytes). This is usefull for code generation inorder to achieve better memory alignment.
alias i64 = "long" 8
All the statements described above can be seen as meta information for the generator. In following we will describe the definition of application data.
Data in ECS is described by components. ECS-Lang uses comp
keyword to describe components:
comp Position {
x : int
y : int
}
Above we see a description of a Position
component. It has two values x
and y
of type int
. It is important to realize that the type has to be described as an alias
. This way we can generate component struct or class for different platforms.
In current case a component has two fields. However often there are components which has only one field. In this case we can use the short notation
comp Weight : int
This way we know that the component has only one field of the type int
. It is up to the code generator implementation to decide how to translate this fact. In entitas_csharp
we generate a default property called value
.
Another common pattern for ECS is to introduce flag components. Those components have no fields and are normally used for querying.
comp Movable
The prefix used for a flag component accessor is normally is
e.isMovalbe
Some times it is desired to define custom prefix. This can be done like this:
comp Destroy / "flag"
and results in following accessor
e.flagDestroy
In Entitas we have a concept of single entity. Let's have a look at an example:
comp unique Time : i64
Above we describe that we have a component Time
with a single value of type i64 (64bit integer), which has to be unique
.
What does it mean?
It means that if you have multiple entities with Time
component in your applciation, something went terribly wrong, because there should be only single entity with component Time
. In Entitas we have runtime check, which result in exceptions when you query for single entity and find more than one entity. The goal of ECS-Lang is to make unqiue
component even more robust.
There are cases where a component captures a type which is used only in context of one component.
alias ConsumptionHistory = "System.Collections.Generic.List<ConsumptionEntry>"
comp unique ConsumptionHistory : ConsumptionHistory
In such case we can use another short notation
alias comp unique ConsumptionHistory = "System.Collections.Generic.List<ConsumptionEntry>"
It implies that we have type alias ConsumptionHistory
and we have a unique
component ConsumptionHistory
all in just one statement.
This technique also works for multi platform component definition:
alias comp unique ConsumptionHistory {
entitas_csharp : "System.Collections.Generic.List<ConsumptionEntry>" 8
swift : "[ConsumptionEntry]" 16*
}
In this case we define how ConsumptionHistory
type is implemented on different platforms. We also added the meta information about type size. In case of Entitas-CSharp we have a pointer which will result in 8bytes. In Swift however ConsumptionEntry
is a struct holding two 8 byte values and as it is wrapped in an array, it will result in multiple of 16bytes (this is what *
stands for). If all this memory alignment business confuses you, don't worry. It is an advanced topic and is optional. You can skip the definition of type sizes and the code generators will still do there magic, just not in the most optimal way.
As mentioned above a user can define multiple context names. In this case we have to idetify which context given component belongs to.
alias comp[Meta] unique ConsumptionHistory = "System.Collections.Generic.List<ConsumptionEntry>"
comp[Core, Meta] unique Time : i64
There are usecase where an index other all entites value component has to be build. Imagine we have a name component and we would like to get an entity with name "Max".
comp Name : string asIndexKey
Here we defien a component Name
which has value of type string and this value will be a key of an index (string)->Entity
In some cases we might want to have an index where we a key mapping to a set of entities (string)->[Entity]
In this case we use asMultiIndexKey
comp Name : string asMultiIndexKey
TODO: describe procedures
Last but not least is the definition of systems. Systems in ECS are responsible for state creation and transformation. In ECS-Lang we only define the contract and dependencies of the transformation. The logic itself will be writen in the platform specific language.
Here is a simple example:
sys Move {
group moveables {
matcher : allOf(Position Velocity)
api : Position(get replace) Velocity(get)
}
}
Above we see a Move
system, which supose to transforms a group of moveable entites. An entity is movable when it matches with allOf(Position Velocity)
. This means an entity has Position
and Velocity
component. In order to transform a movable entity, we should be able to get
Position
and Velocity
and replace
the Position
component in the entity. The contract defines how the system has to be generated. It implies that the API of the entity inside of the system is very narrow. It also provides us posibilities for analysis. For example if we would change the api
definition like following
api : Position(get replace) Velocity(get) Weight(get)
We should show a warning as the matcher does not imply that an entity inside of the moveable
group has a Weight
component. The IDE should suggest to either add a has
API accessor or to change the matcher
.
Add API accessor:
api : Position(get replace) Velocity(get) Weight(has get)
Change the Matcher:
matcher : allOf(Position Velocity Weight)
There are following keywords defining different API accessors:
(add
, get
, has
, replace
, remove
)
If we avoid the explicit enumaraton of API accessors, all of them will be included. This implies that the problem we ecountered before with the Weight
component, could be solved as following:
api : Position(get replace) Velocity(get) Weight
However it is recomendet to keep the API as narrow as posible. That sad, every rule has an exception - when we want to destroy the entity.
sys CleanupVelocity {
group moveables {
matcher : allOf(Velocity)
api : Velocity destroy
}
}
The matcher
can be defined as:
matcher : allOf(A B) anyOf(C D) noneOf(E)
This implies that an entity must have A
and B
component, C
or D
component and should not have E
component.
Let's see another simple system example
sys init TickUpdate {
unique : Pause(get)
unique : Tick(get replace)
}
Here we see one familiar and one new keyword. unique
is familar but we know it in the context of component definition. In this context it means that the system want to access this single component instance. What we see here is also a short notation for:
sys init TickUpdate {
unique pause {
matcher : allOf(Pause)
api : Pause(get)
}
unique tick {
matcher : allOf(Tick)
api : Tick(get replace)
}
}
The tick update system is reading if there is pause component present and it reads and replaces the tick component.
The init
keyword means that as a user, we want to be able to execute code when the system will be initialised.
I just mentioned that
unique : Pause(get)
Is short notation for
unique pause {
matcher : allOf(Pause)
api : Pause(get)
}
It is only partially correct. The first notation tells, that we are interested in the component Pause
. When we generate the accessor we can directly generate a getter for the component and avoid dealing with the entity holding this component. When we have the extended notation we can do something like
unique pause {
matcher : allOf(Pause SessionId)
api : SessionId(get)
}
Here we know that there will be only one entity present which has Pause and SessionId on it and we are interested in reading the SessionId. This is why we would recomend to generate a getter which will return an entity which complies to the described API. So the extended notation is much more powerfull.
In Entitas we have a concept of a reactive system. Reactive system is a system which is executed only on a certain state change. For example when a Position
component was added to an entity. The entities wich triggered the system will be passed to it as an input.
sys init ProduceElixir {
input {
trigger : enteredGroup(Tick)
api : Tick(get)
}
unique : Elixir(get replace)
}
Above we see an example of such simple reactive system. The system reads as following. Every time a tick will be added or replaced, we get the tick and elixir and replace the elixir.
Now lets have a more thorough walk through.
To turn a system into reactive system we have to provide the input
.
We see in this example that the input
contains trigger
and api
. The api
has the same semantics as the one in the group
It tells what kind of API the entity in the input should have.
trigger
is a bit more interesting. We say that we should trigger when an entity entered a group with Tick
component. When I explained the system a couple of senteses before, I spoke about replacing component, what does it has to do with entering a group?
Replacing a component means that we remove the component with old value from the entity. In this case an entity leaves the groups which consist of entites which has to have the named component. And than we add the component with the new value to the entity. And this results in the entity entering this group again. So triggering on enteredGroup
works when we add or replace a component.
Why don't we call it addedComponent
? We actually call it like this in Entitas-CSharp, however I experienced that it doesn't really reflect the concept behind the scenes and leads to confusions. Let's say we have a following trigger:
trigger : enteredGroup(Position Weight Velocity)
It means that in order to enter the group, the entity has to have all this components. If I replace one of the listed components, the entity will reenter the group. If we would write addedComponent(Position Weight Velocity)
, it would be confusing to know which one has to be added to actually trigger the system. I believe that by writing enteredGroup
it is distinct what has to happen in order for system to trigger.
There are three keywords that we can use as trigger idetifier:
enteredGroup
, leftGroup
and enteredOrLeftGroup
We can also have multiple triggers:
trigger : enteredGroup(A) enteredGroup(B) leftGroup(C D) enteredOrLeftGroup(E)
In this case the system will trigger on any of those events and the input will consist of a merged set of the triggering entities. In Entitas-CSharp we call such systems MultiReactive
A reactive system is a system and there for it is called only once per frame (game loop iteration). This means that it aggreagated all the changes which happened since it was last executed. What happens however if we asked for:
trigger : enteredGroup(A)
And an entity entered group A but than left it again before the reactive system was called. Will the system trigger and will the entity be passed into it as an input. In both cases the answer is yes. It will trigger and it will get the triggering entity in the input even though it doesn't have the component any more. However we have another two key words which can flip this rule.
sys ConsumeElixirCleanup {
input {
trigger : enteredGroup(ConsumeElixir)
ensure : ConsumeElixir
api : ConsumeElixir(get) destroy
}
}
In this case we use ensure
keyword to identify the component which has to be present in the input entity. If it does not, the entity will be filtered out. If the set of input entities is empty, the system will not be triggered.
sys CleanupConsumptionHistory {
input {
trigger : leftGroup(Pause)
exclude : Pause
api : Pause
}
unique : ConsumptionHistory(has get)
unique : Tick(get)
}
In this example we ask for the opposite. We ask to exclude
input entities which have the Pause
component.
There are last two things that we can do with systems that I want to describe. We can create a component which will hold a reference to the system:
sys comp My {
unique : Foo
}
This will generate a component which value field will be of type MySystem
. When the system My
will be initialised it will also automaticly create an entity with MySystemComponent
If we want to make sure that MySystemComponent
is a unique component we can add the unique
keyword
sys comp unique My {
unique : Foo
}
The second thing I wanted to mention is creation of entittie inside of the system
sys Replay {
input unique {
trigger : enteredGroup(JumpInTime)
ensure : JumpInTime
api : JumpInTime(get)
}
unique : ConsumptionHistory(has get)
unique : LogicSystems(get)
unique : Tick(replace)
unique : JumpInTime(get)
create consumeElixir : ConsumeElixir
}
TODO: explain interplay with different context names
And we are almost done. Last concept I want to introduce (and don't worry it will be a short one) is a chain of systems. Chain of systems are systems which aggregate other systems.
chain comp unique LogicSystems {
TickUpdate
ProduceElixir
ConsumeElixir
PersistConsumeElixir
ConsumeElixirCleanup
}
This will just generate a class which will aggregate the listed systems. As we can see we can also tell that we should generate a component which will hold reference to an instance of this class.
TODO: explain precondition
Congratulations!!!
If you made it till here you are my hero. This is the end of current features of the ECS-Lang.
As a bonus here is an example a full example of Reactvie-UI sample Application
I am very currious for your feedback in the comments
platform entitas_csharp
namespace my.game
alias long = "long"
alias float = "float"
alias int = "int"
alias comp unique ConsumtionHistory = "System.Collections.Generic.List<ConsumtionEntry>"
alias comp unique TickListener = "TickListener"
alias comp unique PauseListener = "PauseListener"
alias comp unique ElixirListener = "ElixirListener"
comp unique Tick {
currentTick : long
}
comp unique Elixir : float
comp ConsumeElixir : int
comp unique Pause
comp unique JumpInTime : long
sys init TickUpdate {
unique : Pause(get)
unique : Tick(get replace)
}
sys init ProduceElixir {
input {
trigger : enteredGroup(Tick)
api : Tick(get)
}
unique : Elixir(get replace)
}
sys ConsumeElixir {
input {
trigger : enteredGroup(ConsumeElixir)
ensure : ConsumeElixir
api : ConsumeElixir(get)
}
unique : Elixir(get replace)
}
sys PersistConsumeElixir {
input {
trigger : enteredGroup(ConsumeElixir)
ensure : ConsumeElixir
api : ConsumeElixir(get)
}
unique : Pause(get)
unique : Tick(get)
unique : ConsumtionHistory(has get replace)
}
sys ConsumeElixirCleanup {
input {
trigger : enteredGroup(ConsumeElixir)
ensure : ConsumeElixir
api : ConsumeElixir(get) destroy
}
}
sys Replay {
input unique {
trigger : enteredGroup(JumpInTime)
ensure : JumpInTime
api : JumpInTime(get)
}
unique : ConsumtionHistory(has get)
unique : LogicSystems(get)
unique : Tick(replace)
unique : JumpInTime(get)
create consumeElixir : ConsumeElixir
}
sys CleanupConsumtionHistory {
input {
trigger : leftGroup(Pause)
exclude : Pause
api : Pause
}
unique : ConsumtionHistory(has get)
unique : Tick(get)
}
sys NotifyTickListeners {
input {
trigger : enteredOrLeftGroup(Tick)
api : Tick(get)
}
group listeners {
matcher : allOf(TickListener)
api : TickListener(get)
}
}
sys NotifyPauseListeners {
input {
trigger : enteredOrLeftGroup(Pause)
api : Pause(get)
}
group listeners {
matcher : allOf(PauseListener)
api : PauseListener(get)
}
}
sys NotifyElixirListeners {
input {
trigger : enteredOrLeftGroup(Elixir)
api : Elixir(get has)
}
group listeners {
matcher : allOf(ElixirListener)
api : ElixirListener(get)
}
}
chain Root {
Replay
CleanupConsumtionHistory
NotifyElixirListeners
NotifyPauseListeners
NotifyTickListeners
LogicSystems
}
chain comp unique LogicSystems {
TickUpdate
ProduceElixir
ConsumeElixir
PersistConsumeElixir
ConsumeElixirCleanup
}