Entity Component Systems: Tutorial 4
By the end part 3, we had set up our Entity-Component System (ECS) proof-of-concept -- Tiny Tanks -- that demonstrates generalised entity-component population and initialisation phases. It uses complex (object-based) and (de)activatable components.
In terms of game logic updates, our ECS is still tightly coupled and application-specific. In part 4, we will refactor again: touching a minimal amount of code, we'll make our ECS less application-specific.
We'll do this using systems, the third part of the ECS triad.
Technical overview
We will continue to use our specialised componentsByIndex
concrete data and type-information array to generalise the update
system.
We will also begin the use of systems. These are, in essence, the update*
functions we have been using so far. However, they will be more constrained in the way they operate, and need to satisfy system dependencies in terms of components, in order to work. More on this shortly.
Writing the Code
Download the project from github
and using a diff tool to compare part3.js
against part4.js
.
I recommend using kdiff3 if you don't have a favourite diff tool -- it allows 3-way diffs meaning you could compare the last 3 parts, and see their evolution side by side.
Generalising the update loop
Let's next look at our update loop from part 3, and evaluate what it's doing.
//Our ECS function.
function processComponents()
{
//process each entity in our game
for (let e = 0; e < ENTITIES_COUNT; e++)
{
//any moving entity -- tank or bullet.
if (transforms[e].isActive)
{
if (motions[e].isActive)
{
updateTransform(e);
}
//else it is a non-moving entity.
}
if (turrets[e].isActive)
{
updateTurret(e);
}
}
}
This code is still rather specific to Tiny Tanks. Let's change that.
If we ignore the if
statements for simplicity, all we're really doing is
function processComponents()
{
for (let e = 0; e < ENTITIES_COUNT; e++)
{
for (let update of updaters)
{
update(e);
}
}
}
OK, but it's better if we do this:
function processComponents()
{
for (let update of updaters)
{
for (let e = 0; e < ENTITIES_COUNT; e++)
{
update(e);
}
}
}
Did you notice what changed? The loops. We no longer loop primarily by entity e
; instead we now process primarily by updaters
. In the original version, every time we ticked the simulation over (pressed spacebar) we processed as follows:
IF TANK OR IF BULLET
entity[0] entity[0]
-move -move
-shoot
entity[1] entity[1]
-move -move
-shoot
entity[2] entity[2]
-move -move
-shoot
.
.
.
Whereas with the new version we'd be processing in this order:
All entities that can move
-move: [0], [1], [2], [3] etc.
All entities that can shoot
-shoot: [0], [1], [2], [3] etc.
.
.
.
So we now have phased processing, and that's precisely what we need in a game engine: A defined order in which things occur makes reasoning about the simulation much simpler.
System Dependencies 1: What they are
But we've skipped something very important. Remember those if
s we just conveniently ignored? In part 3's processComponents()
, we were checking .isActive
on numerous components -- for our tanks (1st if
block), then for our bullets (2nd if
block).
Why do we do need those if
s?
It is because we need to know that the types of components used in each update()
, are present / active for the entity [e]
. These if
s speak of data dependencies. For example,
function updateTransform(e)
{
let transform = transforms[e];
let motion = motions[e];
transform.x += motion.dx;
transform.y += motion.dy;
}
This updater function needs at least to have a transform
and a motion
component. If we found an entity that had an active transform
, but an inactive motion
, then this logic could not work. For example, a tree just sits at a position, but doesn't move. By not giving trees an active motion
component, we are saying "trees can't move".
Assuming trees existed in our game, how could we run updateTransform
(literally, move) for bullets but not for trees?
Well.. we'd need to check our component dependencies. That is, for each system we try to run, what components does it require to do its work? Does this entity [e]
have those components active?
Imagine a tank whose turret has been destroyed. It no longer matches its archetype of TANK
. It can still move, but it can longer turn its turret or shoot. But that tank won't stop doing updateTransform
-- after all, it still has its transform
and motion
properties active. It can still move, even if it can't shoot.
So this leaves us with the question, how do we integrate those if
s into our newly generalised code structure?
System dependencies 2: How to implement dependency in a generalised manner
We'll need to modify our proposed update
loop a little more:
//Our ECS function.
function processComponents()
{
for (let system of systems)
{
for (let e = 0; e < ENTITIES_COUNT; e++)
{
if (systemDependenciesMetByEntity(system, e))
{
system.update(e);
}
}
}
}
The function used to check the dependencies is simple: provided that no component we depend upon is inactive, we'll return true
:
function systemDependenciesMetByEntity(system, e)
{
let depsMet = true; //assume true until proven false
for (let c of system.componentDependencies)
{
depsMet = depsMet && componentsByIndex[c].array[e].isActive;
}
return depsMet;
}
...It just keeps folding the boolean result into itself until it's done. If at any stage it hits false
, the result will stay false
due to the use of &&
(logical AND).
So before we run the system's update
function, we check whether it has the dependencies that its update
function needs to do its work. This ensures that bullets don't, for example, try to shoot more bullets, acting as though they were a tank turret!
But how do we know what these dependencies are, that we need to check against?
System dependencies 3: Finding what they are, and specifying them for use
Dependencies are specific to each system. To find what they are, we look into each system function to see what it uses in the way of components, for example, in our existing updateTurret
system:
function updateTurret(e)
{
let turret = turrets[e]; // <--
turret.angle += turret.angleDelta;
turret.reloadCountdown--;
//shoot if we can
if (turret.reloadCountdown == 0)
{
let tankTransform = transforms[e]; // <--
let tankMotion = motions[e]; // <--
//spawn new bullet
console.log(e, 'says bang!');
...
}
}
We're looking for each reference to a component array where we access element [e]
(the current entity being processed).
In this way, we see what is needed on the current tank, in order to update its turret
. That is, we need turrets[e]
, transforms[e]
and motions[e]
. (I've marked each with a <--
comment.)
(Did you notice that we also access transforms
and motions
at [TANKS_COUNT+e]
? However, these are for the bullet being fired, and not for the turret/tank we are currently processing in order to fire that bullet. I did this to make the series easier to follow up until now; we'd not work this way in a fully-implemented ECS. We'll de-hack this in a later part.)
Now we know the dependencies for this system function, so how do we specify them in code?
//Systems are listed in the order in which they will run.
const systems =
[
...
{update: updateTransform, componentDependencies: [COMPONENT.TRANSFORM, COMPONENT.MOTION]},
{update: updateTurret , componentDependencies: [COMPONENT.TRANSFORM, COMPONENT.MOTION, COMPONENT.TURRET]}, // <-- that's our guy
...
];
With this in place, systems called in processComponents
should be able to do their work according to the kind of entity that [e]
is. Perfect.
Fixing updateTurret
There is one last thing to do.
You'll have noticed that updateTurret
contains some code which should not be there, specifically, updating the tank's speed according to the speed of its tracks. Let's fix this architectural mistake by splitting the function in two.
function updateMotionFromTracks(e)
{
let tankMotion = motions[e];
tankMotion.dy = trackLefts[e].speed + trackRights[e].speed;
}
and
function updateTurret(e)
{
let turret = turrets[e];
turret.angle += turret.angleDelta;
turret.reloadCountdown--;
//shoot if we can
if (turret.reloadCountdown == 0)
{
let tankTransform = transforms[e];
let tankMotion = motions[e];
//spawn new bullet
console.log(e, 'says bang!');
...
}
}
And lastly, let's ensure both of these are run as independent systems (marked by <--
):
//Systems are listed in the order in which they will run.
const systems =
[
{update: updateMotionFromTracks, componentDependencies: [COMPONENT.MOTION, COMPONENT.TRACK_LEFT, COMPONENT.TRACK_RIGHT]}, // <-- newly added
{update: updateTransform, componentDependencies: [COMPONENT.TRANSFORM, COMPONENT.MOTION]},
{update: updateTurret , componentDependencies: [COMPONENT.TRANSFORM, COMPONENT.MOTION, COMPONENT.TURRET]}, // <-- existing
];
Result
At this stage you can run the app and everything should be working exactly as before.
As this was a pure refactoring exercise, our output is indistinguishable from that of parts 2 and 3.
The final code can be found on github.
Conclusion
We've seen how to generalise our whole ECS, from initialisation through to game logic updates.
It's worth noting that there are two types of dependencies in our ECS: System dependencies are not the same as entity archetype dependencies, even though they both talk about components. Archetypes just say, "an ideal tank consists of a transform, a motion, a turret, two tracks, and a hull, so we'll initialise it like that". Whereas systems say, "I have some work to do, and I need to know that this entity -- regardless of its original archetype, and in its present state, whatever that may be -- still has the capabilities to do this work".
In the next part, we will add some new components, and generalise our rendering system.