Skip to content

Instantly share code, notes, and snippets.

@mzaks
Last active November 26, 2016 19:17
Show Gist options
  • Save mzaks/a6ac9829cf09a65d7328d7194df29e20 to your computer and use it in GitHub Desktop.
Save mzaks/a6ac9829cf09a65d7328d7194df29e20 to your computer and use it in GitHub Desktop.
ECS-Lang walk through

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 Mywill 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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment