#Exploring Entity Component Systems in Elm
Entity-Component-System (or ECS) is a pattern for designing programs that is prevalent in the games industry. This pattern consists of three simple parts:
- Entity : A uniquely identifiable object that may contain any number of components
- Component : A property usually representing the raw data of one aspect of the object. (Position is a component, Velocity is a component, Strength is a component, etc...)
- System : A continuous process performing actions on every entity that possesses a component of the same aspect as that system
To understand this, let us try to make a simple example: Boxes that move in space:
#Example
Let us model boxes that move through space. Each box is a square of given size and has a position and velocity. (and let's add a color for good measure)
type alias Box = {
position : Vector,
velocity : Vector,
size : Float,
color : Color
}
where Vector
is the record type:
type alias Vector = {
x : Float,
y : Float
}
if we want to render a Box, we would simply do:
renderBox : Box -> Form
renderBox box =
move (box.position.x, box.position.y) <|
filled (box.color) (square box.size)
So, then, to display a red box on the screen, we just need to do:
box = {
position : Vector 0 0,
velocity : Vector 0 0,
size : 10,
color : rgb 255 0 0
}
main = collage 400 400 [renderBox box]
If we want to update the position of a box, this is very simple:
-- Assume delta time = 1
updatePosition : Box -> Box
updatePosition box =
{ box | position <- box.position `vAdd` box.velocity }
where vAdd
is defined as vector addition as follows:
vAdd : Vector -> Vector -> Vector
vAdd v w = Vector (v.x + w.x) (v.y + w.y)
The final step is that we may want to move this box with the keyboard arrows. For this, we may want to feed in the keyboard input to an updateVelocity function as follows:
updateVelocity : Vector -> Box -> Box
updateVelocity vector box =
{ box | velocity <- vector}
input : Signal Vector
input = foldp vAdd origin (toFloatVector <~ arrows)
update : Vector -> Box -> Box
update input box =
updateVelocity input <| updatePosition
where origin
is the vector at the origin and toFloatVector
helps us convert a vector of Int to a vector of Float:
origin = Vector 0 0
toFloatVector : {x : Int, y : Int} -> Vector
toFloatVector {x, y} = Vector (toFloat x) (toFloat y)
So, to put everything together we just do:
-- nice shorthand
render box = collage 400 400 [renderBox box]
main = render <~ foldp update box input
And, there, we have a box that moves whenever you press the arrow keys. Each time you press the arrow keys you change the box's velocity and then the game updates the position accordingly.
For reference, here is the full code:
import Color (..)
import Graphics.Collage (..)
import Graphics.Element (..)
import Signal (..)
import Keyboard (..)
-------------- Model ----------------
-- Two dimensional Vector type
type alias Vector = {
x : Float,
y : Float
}
origin : Vector
origin = Vector 0 0
-- Need this function to turn keyboard arrows into a Vector
toFloatVector : {x : Int, y : Int} -> Vector
toFloatVector {x, y} = Vector (toFloat x) (toFloat y)
-- Vector Addition
vAdd : Vector -> Vector -> Vector
vAdd v w = Vector (v.x + w.x) (v.y + w.y)
-- Box type to represent our box
type alias Box = {
position : Vector,
velocity : Vector,
size : Float,
color : Color
}
-- default box (red box at the origin)
box : Box
box = {
position = origin,
velocity = origin,
size = 10,
color = rgb 255 0 0 }
------- Update ------------
updatePosition : Box -> Box
updatePosition box =
{ box | position <- box.position `vAdd` box.velocity}
updateVelocity : Vector -> Box -> Box
updateVelocity vector box =
{ box | velocity <- vector }
update : Vector -> Box -> Box
update input box =
updateVelocity input <| updatePosition box
------- Render -------------
renderBox : Box -> Form
renderBox box =
move (box.position.x, box.position.y) <|
filled (box.color) (square box.size)
render : Box -> Element
render box = collage 400 400 [renderBox box]
------- Input --------------
input : Signal Vector
input = foldp vAdd origin (toFloatVector <~ arrows)
------- Main --------------
main : Signal Element
main = render <~ foldp update box input
#Analyzing the example
This code has several nice things going for it. First of all, the code is nicely seperated between the model code (our types and data), the update code (how to update the model given an input), the render code (how to render the model onto the screen), and the main part which glues everything together.
This separation is good in that one can easily reason about the program. It is also quite easy to debug because the only things in the entire code that involve signals are the input and the main. Everything else are just in terms of immutable data and pure functions.
Now, suppose that we want to extend this game. We want to add another box, that is blue, but we only control the red box. This sounds like a reasonable extension and shouldn't cost much but isn't obvious to implement.
A quick and dirty way would be to do the following changes:
blueBox = { box | color <- rgb 0 0 255 }
render : Box -> Element
render redBox = collage 400 400 [renderBox redBox, renderBox blueBox]
This works, but we can already smell something wrong with this approach. In some sense, the blue box does not participate in the system like the red box. The blue box is relegated to just some square that we render on the screen. The above change is completely equivalent to the following change:
render : Box -> Element
render redBox = collage 400 400 [renderBox redBox,
filled (rgb 0 0 255) (square 10)]
We suddenly lost the potential for the box to interact with the world. This may become clear if we decide to add gravity to the system and all boxes must be subject to gravity.
... uh ... oh ...
well, one way to do this is by adding an addition updateVelocityDueToGravity
function
gravity = 0.5 --Cuz why not
updateVelocityDueToGravity : Box -> Box
updateVelocityDueToGravity box =
{box | velocity <- box.velocity `vAdd` (Vector 0 -gravity) }
This means we'll have to modify update to:
updateBox : Vector -> Box -> Box
updateBox input box =
updateVelocityDueToGravity <|
updateVelocity input <|
updatePosition box
update : Vector -> List Box -> List Box
update input = map (updateBox input)
We modify render to:
render : List Box -> Element
render boxes =
collage 400 400 (map renderBox boxes)
We modify our base model to be:
redBox = { box | color <- rgb 255 0 0 }
blueBox = { box | color <- rgb 0 0 255,
position <- Vector 20 0 }
boxes = [redBox, blueBox]
And thus, modify main to be:
main : Signal Element
main = render <~ foldp update boxes input
And...we get gravity. So, if we only move sideways we see that the boxes fall down. It's not a realistic model of gravity because it is as if you reach terminal velocity instantly, but it push the boxes down as intended. But, something weird has happened... now both boxes are controlled by the keyboard... oops...
So, how do we fix that?
Well, we could have two separate update functions, one for blue squares and one for red squares. But that just feels weird. That's specializing too much. Because what if I decide to control green squares or yellow pentagons... That's not a good strategy.
A better strategy would be to extend the box type to have an
isControllable
flag.
so, that means that we change the model to :
type alias Box = {
position : Vector,
velocity : Vector,
size : Float,
color : Color,
isControllable : Bool
}
box = {
position = origin,
velocity = origin,
size = 20,
color = rgb 255 0 0,
isControllable = False
}
redBox =
{ box | color <- rgb 255 0 0,
isControllable <- True }
blueBox =
{ box | color <- rgb 0 0 255,
position <- Vector 20 0}
And, all we need to change is the one function in the code that uses the input to modify the model which is updateVelocity
(let's change its name to updateVelocityDueToInput
to clarify things ):
updateVelocityDueToInput : Vector -> Box -> Box
updateVelocityDueToInput input box =
if (box.isControllable == False) then box
else
{ box | velocity <- vector }
I guess that wasn't too bad. But it does mean that every box must have an isControllable attribute, which kinda feels like it should be there if you need it. The problem becomes if you start having many such flags.
But the real problem comes if you want things other than square boxes. What if you want circles...
The obvious step would be to do
type alias Ball = {
position : Vector,
velocity : Vector,
radius : Float,
color : Color
isControllable : Bool
}
ball = {
position = origin,
velocity = origin,
radius = 10,
color = rgb 255 0 0,
isControllable = False
}
renderBall ball =
move (ball.position.x, ball.position.y) <|
filled ball.color (circle ball.radius)
But, now, we'd need to update render to:
render = collage 400 400
((map renderBall balls) ++ (map renderBox boxes))
and, we'd need to modify a bunch of type signatures to make sure they also work with balls, like
updateVelocity :
Vector ->
{a | position : Vector, velocity : Vector} ->
{a | position : Vector, velocity : Vector}
and so on...
As, we can see, these changes are starting to become hairy and must be done for every additional type or functionality we add. Is there a simpler way?
#Component Entity System Instead of modeling each object as a separate type, we should model all objects as having the same type but having a varying number of components.
type Entity = Entity (List Component)
type Component =
Position Float Float |
Velocity Float Float |
Color Color |
Size Float |
Radius Float |
IsControllable
With these types, we could simply model our boxes and balls as:
box =
Entity [
Position 0 0,
Velocity 0 0,
Color (rgb 255 0 0),
Size 10
]
ball =
Entity [
Position 0 0,
Velocity 0 0,
Color (rgb 255 0 0),
Radius 10
]
if we want to make something controllable, all we need to do is make an addComponent
function and add the isControllable
component to the entity.
addComponent : Component -> Entity -> Entity
addComponent component (Entity components) =
Entity (component :: components)
controllableBox = addComponent isControllable box
This allows to also do the crazy trick of having a list of entities with dramatically different components
entities : List Entity
entities = [box, ball, controllableBox]
This means that we don't need to separate our entities and have code that can work on all entities. More code reuse, more generality, more awesome!
Now, let's try to implement the example with one controllable red box, one blue box, one green circle, and (to spice things up) one black circle that isn't affected by gravity.
import Color (..)
import Graphics.Collage (..)
import Graphics.Element (..)
import Signal (Signal, foldp, (<~))
import Keyboard (..)
import List (map, (::))
-- A type to capture the shape of an object.
-- Either a square or a circle
type Shape = Square | Circle
-- Easier to alias the input type.
-- This matches the output from `arrows`
type alias Input = { x : Int, y : Int }
-- The Entity type.
-- An entity is an object with a list of components.
type Entity = Entity (List Component)
-- All the different type of components we will used
-- together in one big union type.
-- If you want to make a new kind of component,
-- just add it here
type Component =
Position Float Float |
Velocity Float Float |
Scale Float |
Color Color |
Shape Shape |
Controllable |
Static
-- Red controllable box
redBox : Entity
redBox =
Entity [
Position 0 0,
Velocity 0 0,
Scale 10,
Color (rgb 255 0 0),
Shape Square,
Controllable
]
-- Blue box
blueBox : Entity
blueBox =
Entity [
Position -30 0,
Velocity 0 0,
Scale 10,
Color (rgb 0 0 255),
Shape Square
]
-- Green circle
greenCircle : Entity
greenCircle =
Entity [
Position 30 0,
Velocity 0 0,
Scale 10,
Color (rgb 0 255 0),
Shape Circle
]
-- Black circle (unaffected by gravity)
blackCircle : Entity
blackCircle =
Entity [
Position 80 0,
Velocity 0 0,
Scale 10,
Color (rgb 0 0 0),
Shape Circle,
Static
]
-- The list of all entities in the system
entities : List Entity
entities = [redBox, blueBox, greenCircle, blackCircle]
-- Some value for gravity
gravity : Float
gravity = -0.5
-- Action: Apply gravity on an object.
-- To apply gravity, an entity must have a velocity component and
-- not have a static component
applyGravity : Float -> Entity -> Entity
applyGravity gravity entity =
case (getVelocity entity, getStatic entity) of
(Just (Velocity x y), Nothing) ->
updateVelocity (Velocity x (y + gravity)) entity
_ -> entity
-- Action: Move an entity
-- To move an entity, there must be a position and velocity component
moveEntity : Entity -> Entity
moveEntity entity =
case (getPosition entity, getVelocity entity) of
(Just (Position x y), Just (Velocity vx vy)) ->
updatePosition (Position (x + vx) (y + vy)) entity
_ -> entity
-- Action: Apply input
-- To apply input to an entity, there must be a velocity and
-- controllable component
applyInput : Input -> Entity -> Entity
applyInput {x,y} entity =
case (getVelocity entity, getControllable entity) of
(Just (Velocity vx vy), Just Controllable) ->
updateVelocity (Velocity (vx + toFloat x) (vy + toFloat y)) entity
_ -> entity
-- Action: Render entity
-- To render an entity, there must be a position, scale,
-- shape, and color component
renderEntity : Entity -> Maybe Form
renderEntity entity =
case (getPosition entity,
getScale entity,
getShape entity,
getColor entity) of
(Just (Position x y),
Just (Scale scale),
Just (Shape shape),
Just (Color color)) ->
case shape of
Square ->
Just <| move (x,y) <| filled color (square scale)
Circle ->
Just <| move (x,y) <| filled color (circle scale)
_ -> Nothing
-- Function to render a list of entities
render : List Entity -> Element
render entities = collage 400 400
(filterJusts (map renderEntity entities))
-- The update function for a single entity
-- Move the entity, the apply input, then apply gravity
updateEntity : Input -> Entity -> Entity
updateEntity input entity =
applyGravity gravity <|
applyInput input <|
moveEntity entity
-- Function to update a list of entities given an input
update : Input -> List Entity -> List Entity
update input = map (updateEntity input)
-- Get input from Keyboard
input : Signal Input
input = accumulateInput arrows
-- The Main Function
-- Update and render all entities
main : Signal Element
main = render <~ foldp update entities input
#Analysis
Every object in the game is modeled as an Entity
. As we can see, the Entity
type is super simple:
type Entity = Entity (List Component)
An entity simply contains a list of components. Now, let's look at the Component
type:
type Component =
Position Float Float |
Velocity Float Float |
Scale Float |
Color Color |
Shape Shape |
Controllable |
Static
Every component in the system is included in this union type. This may seem like an odd choice but this implies that we can have a list of entities where each entity has wildly different components.
As we can see, the entities
object contains completely different entities. Some are controllable, some are unaffected by gravity, some are circles, other are squares... This is basically the closest thing there is in Elm to a heterogeneous list. Think of it like an explicit heterogeneous list.
After that we have "actions". Operations on entities. Each of these actions perform a distinct operation that depends on the type of components the entity has. The adequate pattern here is to make explicit by pattern matching only the cases you are interested in and use underscore to fail. This allows for the component type to be extendable ad infinitum and reduce boilerplate. DRY and composability FTW!
We can see that applyGravity
, moveEntity
, applyInput
, and renderEntity
are all actions although renderEntity
deserves special mention for being the only one not to return an entity. This means that rendering and update are still kept separate in this model.
All "actions" are performed in the updateEntity
function. updateEntity
is quite simple, it takes an input and an entity and returns an entity.
updateEntity : Input -> Entity -> Entity
updateEntity input entity =
applyGravity gravity <|
applyInput input <|
moveEntity entity
After this, we have the input, which is just accumulating the input from the keyboard arrows.
input : Signal Input
input = accumulateInput arrows
where accumulateInput
is defined as:
accumulateInput : Signal Input -> Signal Input
accumulateInput =
let add p q = { x = p.x + q.x, y = p.y + q.y}
origin = {x = 0, y = 0}
in foldp add origin
And then the main function is almost the same as before:
main : Signal Element
main = render <~ foldp update entities input
At first glance, it may not seem obvious how this code is better than the previous example. But, one can see that this code can accept additive changes without major changes to the codebase. In order to add an additional component to this code, all you'd need to do is add the component to the Component type and add whatever action you need to be performed on the entity.
Say you want to add mass, you just need to add mass to the component type
type Component =
Position Float Float |
Velocity Float Float |
Mass Float |
Scale Float |
Color Color |
Shape Shape |
Controllable |
Static
and perhaps you'll want to change the gravity code to take mass into account.
applyGravity : Float -> Entity -> Entity
applyGravity gravity entity =
case (getVelocity entity, getStatic entity, getMass entity) of
(Just (Velocity x y), Nothing, Mass mass) ->
updateVelocity (Velocity x (y + gravity / mass)) entity
_ -> entity
And, now that you require mass for things to move, you might want to add a mass component to any object you want to move.
redBoxWithMass = addComponent (Mass 10) redBox
blueBoxWithMass = addComponent (Mass 1) blueBox
blackCircleWithMass = addComponent (Mass 100) blackCircle
and change the entities to :
entities = [redBoxWithMass, blueBoxWithMass, greenCircle, blackCircleWithMass]
Now if you run the code, the boxes are affect by gravity at a different rate and suddenly the green circle is unaffected by gravity which may be preferred since now we have decided to tie mass to gravity. Note how while the black circle has mass, it still does not move. This is because it still has a Static component and that overrides having mass.
So, as we can see, this approach seems to be more reasonable to architect code as it lends itself nicely to updates and modification, requiring you to change only what conceptually needs to be changed.
But you might be wondering, "hey, where did all those updatePosition
, updateVelocity
functions come from?".
Well, this is the drawback to this method. Since you can't create a generic function to ask if a value has a type tag, the following boilerplate is required:
accumulateInput : Signal Input -> Signal Input
accumulateInput =
let add p q = {x = p.x + q.x, y = p.y + q.y}
origin = {x = 0, y = 0}
in foldp add origin
filterJusts : List (Maybe a) -> List a
filterJusts list =
case list of
[] -> []
x :: xs ->
case x of
Nothing -> filterJusts xs
Just just -> just :: filterJusts xs
addComponent : Component -> Entity -> Entity
addComponent component (Entity components) =
Entity (component :: components)
--- position component accessors
getPosition : Entity -> Maybe Component
getPosition (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Position _ _ -> Just x
_ -> getPosition (Entity xs)
filterPosition : Entity -> Entity
filterPosition (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Position _ _ -> filterPosition (Entity xs)
_ -> addComponent x (filterPosition (Entity xs))
updatePosition : Component -> Entity -> Entity
updatePosition component entity =
case component of
Position _ _ ->
case (getPosition entity) of
Nothing -> entity
_ -> addComponent component (filterPosition entity)
_ -> entity
--- velocity component accessors
getVelocity : Entity -> Maybe Component
getVelocity (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Velocity _ _ -> Just x
_ -> getVelocity (Entity xs)
filterVelocity : Entity -> Entity
filterVelocity (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Velocity _ _ -> filterVelocity (Entity xs)
_ -> addComponent x (filterVelocity (Entity xs))
updateVelocity : Component -> Entity -> Entity
updateVelocity component entity =
case component of
Velocity _ _ ->
case (getVelocity entity) of
Nothing -> entity
_ -> addComponent component (filterVelocity entity)
_ -> entity
--- scale component accessors
getScale : Entity -> Maybe Component
getScale (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Scale _ -> Just x
_ -> getScale (Entity xs)
filterScale : Entity -> Entity
filterScale (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Scale _ -> filterScale (Entity xs)
_ -> addComponent x (filterScale (Entity xs))
updateScale : Component -> Entity -> Entity
updateScale component entity =
case component of
Scale _ ->
case (getScale entity) of
Nothing -> entity
_ -> addComponent component (filterScale entity)
_ -> entity
--- color component accessors
getColor : Entity -> Maybe Component
getColor (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Color _ -> Just x
_ -> getColor (Entity xs)
filterColor : Entity -> Entity
filterColor (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Color _ -> filterColor (Entity xs)
_ -> addComponent x (filterColor (Entity xs))
updateColor : Component -> Entity -> Entity
updateColor component entity =
case component of
Color _ ->
case (getColor entity) of
Nothing -> entity
_ -> addComponent component (filterColor entity)
_ -> entity
--- shape component accessors
getShape : Entity -> Maybe Component
getShape (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Shape _ -> Just x
_ -> getShape (Entity xs)
filterShape : Entity -> Entity
filterShape (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Shape _ -> filterShape (Entity xs)
_ -> addComponent x (filterShape (Entity xs))
updateShape : Component -> Entity -> Entity
updateShape component entity =
case component of
Shape _ ->
case (getShape entity) of
Nothing -> entity
_ -> addComponent component (filterShape entity)
_ -> entity
--- controllable component accessors
getControllable : Entity -> Maybe Component
getControllable (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Controllable -> Just x
_ -> getControllable (Entity xs)
filterControllable : Entity -> Entity
filterControllable (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Controllable -> filterScale (Entity xs)
_ -> addComponent x (filterControllable (Entity xs))
updateControllable : Component -> Entity -> Entity
updateControllable component entity =
case component of
Controllable ->
case (getControllable entity) of
Nothing -> entity
_ -> addComponent component (filterControllable entity)
_ -> entity
--- static component accessors
getStatic : Entity -> Maybe Component
getStatic (Entity components) =
case components of
[] -> Nothing
x :: xs ->
case x of
Static -> Just x
_ -> getStatic (Entity xs)
filterStatic : Entity -> Entity
filterStatic (Entity components) =
case components of
[] -> Entity (components)
x :: xs ->
case x of
Static -> filterStatic (Entity xs)
_ -> addComponent x (filterStatic (Entity xs))
updateStatic : Component -> Entity -> Entity
updateStatic component entity =
case component of
Static ->
case (getStatic entity) of
Nothing -> entity
_ -> addComponent component (filterStatic entity)
_ -> entity
As you can see, it is a bit gnarly. But there are two redeeming things to note:
- You only need to write a get, filter, and update function per component. This means that whenever you create a new component (chances are you won't create thousands), you create these three functions alongside. This is a somewhat tractable process.
- If you notice, all the get functions and all the filter functions and all the update functions look almost alike except from a few details that depend on the name of the component and the number of parameters. With this knowledge, one can imagine that it wouldn't be too hard to generate this code automatically just by looking at the component type. This does mean that there would be a step prior to compilation but it would be an incredible time saver (and sanity saver).
All this could be avoided if Elm had a way to ask if an object has a given tag.
You could rewrite getPosition
, filterPosition
, and updatePosition
as follows
getPosition : Entity -> Maybe Component
getPosition (Entity components) =
head <| get Position components
filterPosition : Entity -> Entity
filterPosition (Entity components) =
Entity (filter Position components)
updatePosition : Component -> Entity -> Entity
updatePosition component (Entity components) =
update Position components
where the get
, filter
, and update
functions would be implemented based on a testing function hasTag
{- Ignore the details of the syntax, just read it as:
`hasTag` is a function that takes a tag from a
union type and a union type and returns a Bool.
-}
hasTag : (Tag : unionType) -> unionType -> Bool
which can be used as follows
type UnionType = A Int | B String
type UnionType2 = C Int
a = A 4
b = B "Hello"
c = C 8
test = hasTag A a -- True
test2 = hasTag A b -- False
test3 = hasTag B b -- True
test4 = hasTag A c -- Compile-time error. Type mismatch
#Conclusion
Entity Component Systems are promising. They have a really clean way of separating code. Basically, it is possible to write code that can be easily extended with very minimal changes. This pattern is not at odds with the best practices in Elm that encourage the signal code and the pure code to be separate and the signal code to be minimized.
The only issue is that of boilerplate. One can use Strings instead of tags but Strings lead to brittle code. Elm's type system should be taken advantage of fully.
I will continue to explore this pattern to see how it can be improved in Elm and perhaps reduce the boilerplate which is conceptually unnecessary but required for the code to work.
Another area worth exploring is how rendering is such a special case in the code. Should rendering be a special case? Could this be all wrapped in some monadic pattern? Can the renderer be modeled as a component?
Great article.
How have you implemented hasTag function? Declaration
hasTag : (Tag : unionType) -> unionType -> Bool
does not even compile. Am I missing something here?