Here is the architecture of my TodoMVC-ecs project which is the classic TodoMVC app implemented using ECS, a gaming architecture.
I chose to use the javascript Jecs library - pictured here.
Is the Entity Component System any good for building traditional GUIs?
It turns out that the answer is yes! Whilst ECS is most commonly used in building games, it can also be used for building a traditional web "form" style application like TodoMVC. However you will need to radically rethink how models, their data and behaviour is organised.
This is arguably a refreshing, mind-blowing lesson in GUI programming! 🤯😉
The ECS (Entity Component System) architecture is the new hotness in game developer circles. Its a way of architecting your software that rejects Models as classes in favour of a more deconstructed, data driven approach. The results, in gaming apps at least, are massive reductions in complexity and huge increases in performance and maintainability.
When I read about ECS it got me thinking - why not use it for more traditional software, not just games?
You can read about Entity Systems in this Wikipedia article. The basic idea is to take the Object (that contains identity, data and behaviour) and 'deconstruct it'.
A traditional class Model of a Todo
item would look like:
class TodoItem {
constructor(title, completed, id) {
this.title = title
this.completed = completed
this.id = id == undefined ? util.uuid() : id
}
report() {
let is_or_is_not = this.completed ? 'is' : 'is not'
console.log(`Todo item ${this.title} ${is_or_is_not} completed`)
}
}
let todos = []
todos.push(new TodoItem('make lunch', true))
todos.push(new TodoItem('wash dishes', false))
todos.forEach(function (todo) {
todo.report()
})
which would generate:
$ node example.js
Todo item make lunch has id 1 and is completed
Todo item wash dishes has id 2 and is not completed
wheras the ECS approach would look like this:
const engine = new Engine();
engine.entity(util.uuid()).setComponent('data', {title:'make lunch', completed:true})
engine.entity(util.uuid()).setComponent('data', {title:'wash dishes', completed:false})
engine.system('report', ['data'], (entity, { data }) => {
let is_or_is_not = data.completed ? 'is' : 'is not'
console.log(`Todo item ${data.title} has id ${entity.name} and ${is_or_is_not} completed`)
});
engine.tick()
Let's break this down:
engine.entity()
entity.setComponent('data', {title, completed, id})
. The name of this component happens to be 'data'
because I chose that name, but I could have named it something like 'todo-data'
engine.system
- the code inside will run for each entity that has the 'data'
component.Notice there is no explicit looping. The System loops for us. All entities who have the 'data' component will be looped through. You can add additional components to the list, which will mean the system will loop through all components who have all those components (an and
selector).
You can have multiple systems, they will run in the order declared. Each time engine.tick()
is run, all Systems will be run.
Notice there is no need to store a master list of todos
- the ECS holds all entities for us. If we did want to generate a list of todos, its actually a bit tricky. See the next section Gathering results whilst looping.
You now have the freedom to attach any Component to any Entity. But why bother?
You would think that a pure data Component like Position {x:0, y:0}
is always closely tied with a specific Entity - so why break them apart? For example a Ball
has a Position, a Person
does not! What a Person
entity surely needs instead is a Name {firstname:'John', surname:'Smith'}
etc.
Well, it turns out that the opposite is often true - entities like Ball
, Bullet
, Car
all need Position
. And in more business oriented applications, Person
, Employee
, Manager
all need a Name
.
Thinking further along these lines, another useful component might be Address
- many Entities would need that. Thus having a single declaration of Name
and Address
etc. which can be re-used many times saves having duplicate declarations and associated errors.
If you think that using class inheritance can achieve the goal of declaring Address
only once - you would be right. However most languages support only single inheritance, so good luck with forcing disparate inheritance trees to inherit from a common Address class - that's a bit awkward.
With languages that support multiple inheritance (e.g. Python), you will have more luck - in fact multiple inheritance comes closest to matching the idea of ECS, where you can arbitrarily compose a model entity from as many Component data aspects as you like.
You could also solve the problem through composition - a class could reference a common instance of Address
and any other common classes it needs. This would also achieve the goal of declaring Address
only once and providing 'mix and match' composability of Models, that ECS has.
This is how we arbitrarily attach data Components to entities in ECS:
let e = engine.entity('person 1')
e.setComponent('Name', {firstname:'John', surname:'Smith'})
e.setComponent('Address', {number: 12, street: 'Bounty Drive', state: 'WA'})
We have created an entity person 1
and attached both a Name
and an Address
Component. Running
console.log(
e.name,
JSON.stringify(e.getComponent('Name')),
JSON.stringify(e.getComponent('Address'))
)
results in:
person 1 {"firstname":"John","surname":"Smith"} {"number":12,"street":"Bounty Drive","state":"WA"}
You can attach even Components that have no actual data. Sometimes this technique is useful to mark entities e.g. a dirty
Component flag for rendering purposes, or a delete
Component flag etc. so that a System selector matches on this condition (of having this flag) and triggers that particular System to run. This technique is further discussed in the section entitled Components as Flags, later.
Here is an example of adding various 'Flag' Components to an entity - of course you would name the components better e.g. in TodoMVC-ECS editingmode
is such a Component used as a flag. The presence of the flag Component on an entity will trigger a particular System. Notice that in this example the Component data is either an empty object {}
or null
. Typically (though your situation may be different) its the presence of the Component attached to the entity that matters, not the Component data.
e.setComponent('Flag', {})
e.setComponent('Flag2', null)
This freedom to decouple data from entities is very powerful, especially when we take the next and final step in understanding ECS - Systems.
Systems are where most of the application behaviour happens, including rendering/updating the DOM.
The idea is to have lots of Systems, one after each other, each doing a bit of work that can be reasoned about simply.
TodoMVC-ECS Architecture - Partial 'Literate Code Map' Diagram
Systems are code blocks which run across subsets of entities which have all the components listed in each System's declaration e.g. ['data', 'dirty']
means all entities that possess both these components will be processed.
Here is an example of a two Systems, which run one after the other:
// process all entities with data component
engine.system('process-all', ['data'], (entity, { data }) => {
console.log(`Found entity ${entity} with data ${data}`)
});
// all entities with data component and dirty component
engine.system('find-dirty', ['data', 'dirty'], (entity, { data, dirty }) => {
console.log(`Found dirty entity ${entity}`)
});
I like to think of the list of Components declared on each System as like a CSS selector, except it applies to ECS entities. The system will loop through all entities who possess all the Components listed (an and
concept).
System blocks run in the order declared, one after the other, each time engine.tick()
is called. Each System will iterate as needed, then exit and the next System will run.
Systems form a multi-stage processing pipeline. A System often builds on the work of the previous System - this is a strength of this ECS architecture, the ability to break down work into easy to understand stages.
See Systems in this Todo app, below, to see a table Systems I came up with to implement TodoMVC and what they do. Its suprising to discover that one can build an app in this way.
As you have probably noticed, looping is central to ECS. I guess it comes from the gaming heritage, where there are many game objects to process.
If you have an explicit for loop in your code you are probably doing ECS wrong! Use a System instead.
Systems magically iterate across entites that match certain Components e.g. all entities who have a Speed component and a Position component. Its like having a CSS selector and running a code block for all matches.
If you don't want to loop through anything, and just have to do some miscellaneous work at any stage, simply insert a System that matches on a single component.
engine.entity('single-step').setComponent('housekeeping', {})
...
engine.system('reset-todos', ['housekeeping'], (entity, { housekeeping }) => {
// this code will run once per tick
})
Read more about this technique below, in the section The housekeeping Component trick
When there is any change to the application, you need to trigger engine.tick()
again, in order to re-render the GUI. The tick function simply re-runs all Systems again.
Remember to boot your app with a lone call to engine.tick()
at the bottom of the app javascript file.
Game based ECS systems typically run tick
in a loop at 60fps which is not appropriate for our form based GUI, but by all means, enable the automatic looping feature of your ECS if you feel that strategically calling tick is too much to think about!
You may also have a use for the handy 'tick:after'
and 'tick:after'
events to run code before and after each tick runs all the Systems.
engine.on('tick:after', (engine) => {
console.log('-'.repeat(40))
})
I ended up creating a couple of tricks to get ECS to do what I wanted in certain cases.
Generating a list of todos is pretty easy - just declare a System which matches a Component that all todo entities have, and push each entity onto a list.
let todos = []
engine.system('gather-todos-for-save', ['data'], (entity, { data }) => {
todos_data.push(data)
});
You can gather anything you want in this way, e.g.
push(data)
push(data.title)
push(entity)
The only problem is that next time you tick(), todos
is not cleared and your list will double, triple, quadriple etc. The easiest way to solve this is to utilise the 'tick:before'
event and clear todos
before each tick run:
engine.on('tick:before', (engine) => {
todos = [] // reset
})
I use the above technique to generate the JSON of all the todo data to persist in the browser storage. Try the live demo and hit refresh - the same todo items survive, because of this persistence.
If you want to reset some variables or do some processing in the middle of the pipeline e.g.
System1
System2
<-- reset a variable here
System3
then you will need to create a System whose codeblock runs once, and define it at the appropriate point in your code - after System2 and before System3.
To create a System whose codeblock runs once, use The housekeeping Component trick, below, which defines a special Component and Entity pair, then create a System matching on them.
If you need to initialise variables in the middle of a system run (remember the order that systems run in is critical, with one system often feeding results into another system) then reseting at the start or beginning of a tick won't work. We need to be able to define a System which does the reset or housekeeping tasks, which we can place anywhere in our list of Systems. And we don't want that System to loop, we want it to run once. Which means it must match only one entity.
The solution is to define a special Component and Entity pair
engine.entity('single-step').setComponent('housekeeping', {})
then whever you need to run some code, insert a System like this:
engine.system('reset-todos', ['housekeeping'], (entity, { _ }) => {
this.todos = []
})
The entity name 'single-step'
is arbitrary and can be anything. The Component name 'housekeeping'
is also arbitrary, but is referred to in Systems, as you can see in the above example.
You can use this technique to insert as many sytems like this as you like, at the appropriate places in your code. I'm not sure if it is kosher ECS usage, but its a technique I found I needed.
Thus the full code for gathering a list of todos, using the above housekeeping system technique, is
let todos_data = []
engine.system('reset-todo-list', ['housekeeping'], (entity, { housekeeping }) => {
todos_data = []
});
engine.system('gather-todos-for-save', ['data'], (entity, { data }) => {
todos_data.push(data) // or push data.title, or push the entity, or whatever you need
});
engine.system('report-final-result', ['housekeeping'], (entity, { housekeeping }) => {
console.log('final list of todos', todos_data)
});
engine.tick()
Systems 'doing stuff' typically means updating component or other data and rendering based on component data. Interestingly, it can even mean adding and removing components from entities, which may cause other Systems to run, which hadn't previously been running because entities with the right combination of components that the Systems were looking for did not exist.
For example, my 'think-todoitem'
System decides whether a todo entity needs the GUI DOM <li>
created or updated, adding either the component 'insert'
or 'update'
to each entity. Note that the 'insert' and 'update' components have empty data {}
attached thus the component acts like a flag.
I use this trick extensively in this Todo implementation.
When I asked an ECS expert about this approach, he said:
Attaching and detaching components in order to activate a system is absolutely the way to go. In fact, it is one of the biggest advantages of using ECS, since, in this way, you are changing the behaviour by taking advantage of the composition.
Just because we are deconstructing classes into an ECS approach doesn't mean we can't use classes to help us. Systems still work when created inside classes - they are registered with the engine and will run ok.
You might want to use a class to group variables that are private to specific Systems together, so they do not pollute the outer namespaces e.g. this.todos_data
in the example below. Classes are also handy places to define functions that are private to specific Systems, too e.g. the save()
method in the example below.
class Persistence {
constructor() {
this.todos_data = [] // gather info here
engine.system('reset-save', ['housekeeping'], (entity, { _ }) => {
this.todos_data = []
});
engine.system('gather-todos-for-save', ['data'], (entity, { data }) => {
this.todos_data.push(data)
});
engine.system('save', ['housekeeping'], (entity, { housekeeping }) => {
this.save()
});
save() {
util.store('todos-oo', this.todos_data)
console.log('saved', JSON.stringify(this.todos_data))
}
}
new Persistence()
This use of Systems inside Classes is arguably nicer and more encapsulated.
The only thing to watch out for is that you need to instantiate the classes in the correct order, so that their Systems are created (via their constructors) at the appropriate point in the 'pipeline' of Systems.
Whilst there are GUI events from the DOM, notice that there are no 'internal' events in this implementation. This is a very real benefit, as event flow can be hard to follow.
The efficiency of the ECS approach is not as good as an event based approach however, because we are re-rendering more than we need to. Internal events give us a finer grained control over what needs to be updated in the GUI. See my TodoMVC-OO implementation to see how efficiently event notifications from the model to controllers can work.
Adding a dirty flag to entities that need updating can fix this brute force re-rendering inefficiency. I'd recommend a Component called 'dirty'
which can be attached to entities. Then refine your Systems to only match on todo data that is dirty e.g.
engine.system('dirty-todoitems', ['data', 'dirty'], (entity, {data, _}) => {
// do something with entity and data
// ...
entity.deleteComponent('dirty') // important! remove dirty flag
}
Notice that the last line of this System removes the dirty flag/component. When you change the data of the entity, you would add the 'dirty'
component e.g.
entity.getComponent('data').completed = false
entity.setComponent('dirty', {})
Systems are where most of the behaviour lives. The idea is that each System is a loop which 'queries' our little database of entities and components and does something with the matching entities and components. I came up with the following Systems, which run in the following order, top to bottom, pipeline:
System name | Documentation |
---|---|
mark-all-todos-as-complete | Loops through all todo entities and sets data.complete = true |
destroy-completed-todos | Loops through all todo entities and attaches the 'destroy' component if it is complete (see next system which does the actual deletion) |
controller-destroy | Loops through all todo entities that have the 'destroy' component and deletes the GUI and the entity |
housekeeping-resets | Runs once (matches the single 'housekeeping' component), reset various variables incl todoCount . See The housekeeping Component trick above. |
counting | Loops through all todo entities and counts the total number todoCount and number completed. |
editing-mode-on | Any entity with the 'editingmode' component will have the GUI element for it go into editing mode. The 'editingmode' component is removed from the entity. |
editing-mode-off | Any entity with the 'editingmode-off' component will have the GUI element for it go out of editing mode and the new todo item title saved in the entitie's data.title component. The 'editingmode-off' component is removed from the entity. |
think-todoitem | Decides whether a todo entity needs the GUI DOM <li> created or updated, adding either the component 'insert' or 'update' to each entity. Note that the 'insert' and 'update' components have empty data {} attached thus the component acts like a flag. |
controller-update-todoitem | For any entity that has the 'update' component, updates the GUI with the entity's data. The 'update' component is then removed from the entity. |
controller-insert-todoitem | For any entity that has the 'insert' component, created the GUI with the entity's data, and binds the GUI event handlers to javascript functions in our app. The 'insert' component is then removed from the entity. |
apply-filter | Loops through all todo entities and hides or shows the corresponding GUI element depending on the state of the app.filter flag. |
reset-gather-for-save | Runs once (matches the single 'housekeeping' component), reset the todos_data list variable ready for the next System. See The housekeeping Component trick, above. |
gather-todos-for-save | For each entity, append its data to the todos_data list variable. |
save | Saves the todos_data list variable data into browser storage. |
That's a lot of Systems! But each System runs after the other, and each one is easy to reason about. That's the benefit of ECS - its more 'flat' I suppose.
Remember, the systems will only run when you call engine.tick()
, so don't forget to do that. When you want all the Systems to run again, simply call engine.tick()
again.
Entity Component System frameworks are actually relatively simple. They offer ways of:
Entities
,Components
(data objects) to those entity instances,Systems
- which are code blocks which run across subsets of matched Components.tick
functionFor this project I chose to use the javascript Jecs library.
Jecs ESC Framework for Javascript - UML diagram.
The single file jecs.js
can be copied into your project and with the usual <script src="jecs.js"></script>
you are all set to go. Or you can npm install jecs
and require it in your node projects.
For my Python ECS projects I use Esper which is a lightweight Entity System for Python, with a focus on performance.
This approach to wiring up GUI's has been most refreshing. I find the ECS approach fascinating and will be looking for ways to use decoupled Systems in my future projects.
Running demo here, fully implements the TodoMVC specification.
Study the final code in this repository, specifically app.js.
TodoMVC-OO GitHub Repo - another of my TodoMVC implementations. The classic Javascript TodoMVC app implemented without a framework, using plain Object Oriented programming.
Official TodoMVC project with other TodoMVC implementations (e.g. Vue, Angular, React etc.)
Created by Andy Bulka
Note: This project is not not officially part of the TodoMVC project - as it does it meet the criterion of "having a community" around it.
List of repository modules/files being visualised in the above diagram:
js/app.js
js/util.js