View Source Tutorial Project
Now that we have an Elixir project with Phoenix, we can get started on building a game with Entity-Component-System architecture. In our game, each player will control a ship, which can sail around the map, and will attack enemies if they come too close.
Note: This guide will get you up-and-running with a working game, but it is intentionally generic. Feel free to experiment with altering details from this implementation to customize your own game.
defining-component-types
Defining Component Types
First let's consider the basic properties of a ship:
- Hull Points: How much damage can it take before it is destroyed
- Armor Rating: How much is each incoming attack reduced by the ship's defenses
- Attack Damage: How much damage does its weapon deal to enemies
- Attack Range: How close must enemies get before the weapon can attack
- Attack Speed: How much time must you wait in-between attacks
- X Position: The horizontal position of the ship
- Y Position: The vertical position of the ship
- X Velocity: The speed at which the ship is moving, horizontally
- Y Velocity: The speed at which the ship is moving, vertically
Let's start by creating integer
component types for each one of these, except AttackSpeed, which will use float
:
$ mix ecsx.gen.component HullPoints integer
$ mix ecsx.gen.component ArmorRating integer
$ mix ecsx.gen.component AttackDamage integer
$ mix ecsx.gen.component AttackRange integer
$ mix ecsx.gen.component XPosition integer
$ mix ecsx.gen.component YPosition integer
$ mix ecsx.gen.component XVelocity integer
$ mix ecsx.gen.component YVelocity integer
$ mix ecsx.gen.component AttackSpeed float
For now, this is all we need to do. The ECSx generator has automatically set you up with modules for each component type, complete with a simple interface for handling the components. We'll see this in action soon.
our-first-system
Our First System
Having set up the component types which will model our game data, let's think about the Systems which will organize game logic. What makes our game work?
- Ships change position based on velocity
- Ships target other ships for attack when they are within range
- Ships with valid targets should attack the target, reducing its hull points
- Ships with zero or less hull points are destroyed
- Players change the velocity of their ship using an input device
- Players can see a display of the area around their ship
Let's start with changing position based on velocity. We'll call it Driver
:
$ mix ecsx.gen.system Driver
Head over to the generated file lib/my_app/systems/driver.ex
and we'll add some code:
defmodule MyApp.Systems.Driver do
...
use ECSx.System
alias MyApp.Components.XPosition
alias MyApp.Components.YPosition
alias MyApp.Components.XVelocity
alias MyApp.Components.YVelocity
def run do
for {entity, x_velocity} <- XVelocity.get_all() do
x_position = XPosition.get_one(entity)
new_x_position = x_position + x_velocity
# By default, an entity can only have one component of each type.
# Adding a second will overwrite the first.
XPosition.add(entity, new_x_position)
end
# Once the x-values are updated, do the same for the y-values
for {entity, y_velocity} <- YVelocity.get_all() do
y_position = YPosition.get_one(entity)
new_y_position = y_position + y_velocity
YPosition.add(entity, new_y_position)
end
# run/0 should always return :ok
:ok
end
end
Now whenever a ship gains velocity, this system will update the position accordingly over time. Keep in mind that the velocity is relative to the server's tick rate, which by default is 20. This means the unit of measurement is "game units per 1/20th of a second".
For example, if you want the speed to move from XPosition 0 to XPosition 100 in one second, you divide the distance 100 by the tick rate 20, to see that an XVelocity of 5 is appropriate. The tick rate can be changed in lib/my_app/manager.ex
.
targeting-attacking
Targeting & Attacking
Next let's move on to a more complicated part of the game - attacking. We'll start by considering the conditions which must be met in order to attack a given target:
- Target must be a ship
- Target must be within your ship's attack range
- You must not have attacked too recently (based on attack speed)
For each of these conditions, we want to use the presence or absence of a component as the signal to a system that action is to be taken. For example, in the Driver system, these were the Velocity components - for each Velocity component, we made a Position update.
First, for determining whether a given entity is a ship, we will simply use the existing HullPoints component, because only ships will have HullPoints.
Second, for confirming the attack range, we'll make a new component type SeekingTarget which will signal to a Targeting system that a ship's proximity to other ships must be continuously calculated until a valid target is found. Then another new component type AttackTarget will replace SeekingTarget, signaling to the Targeting system that we no longer need to check for new targets. Instead, an Attacking system will detect the AttackTarget and handle the final step of the attacking process.
The final attack requirement is that after a successful attack, the ship's weapon must wait for a cooldown period, based on the attack speed. To model this cooldown period, we will create an AttackCooldown component type, which will store the time at which the cooldown expires.
With this plan in place, let's go ahead and create the component types, starting with SeekingTarget. Since the presence of this component alone fulfills its purpose, without the need to store additional data, this is the appropriate use-case for a Tag
:
$ mix ecsx.gen.tag SeekingTarget
Once a target is found, the AttackTarget
component will be needed, and this time a Tag
will not be enough, because we need to store the ID of the target. Likewise with AttackCooldown
, which must store the timestamp of the cooldown's expiration.
$ mix ecsx.gen.component AttackTarget binary
$ mix ecsx.gen.component AttackCooldown datetime
Note: In our case, we're using binary IDs to represent Entities, and Elixir
DateTime
structs for cooldown expirations. If you're planning on using different types, such as integer IDs for entities, or storing timestamps as integers, simply adjust the parameters accordingly.
Before we set up the systems, let's make a helper module for storing any shared mathematical logic. In particular, we'll need a function for calculating the distance between two entities. This will come in handy for several systems in the future.
defmodule MyApp.SystemUtils do
@moduledoc """
Useful math functions used by multiple systems.
"""
alias MyApp.Components.XPosition
alias MyApp.Components.YPosition
def distance_between(entity_1, entity_2) do
x_1 = XPosition.get_one(entity_1)
x_2 = XPosition.get_one(entity_2)
y_1 = YPosition.get_one(entity_1)
y_2 = YPosition.get_one(entity_2)
x = abs(x_1 - x_2)
y = abs(y_1 - y_2)
:math.sqrt(x ** 2 + y ** 2)
end
end
Now we're onto the Targeting system, which operates only on entities with the SeekingTarget component, checking the distance to all other ships, and comparing them to the entity's attack range. When an enemy ship is found to be within range, we can remove SeekingTarget and replace it with an AttackTarget:
$ mix ecsx.gen.system Targeting
defmodule MyApp.Systems.Targeting do
...
use ECSx.System
alias MyApp.Components.AttackRange
alias MyApp.Components.AttackTarget
alias MyApp.Components.HullPoints
alias MyApp.Components.SeekingTarget
alias MyApp.SystemUtils
def run do
entities = SeekingTarget.get_all()
Enum.each(entities, &attempt_target/1)
end
defp attempt_target(self) do
case look_for_target(self) do
nil -> :noop
{target, _hp} -> add_target(self, target)
end
end
defp look_for_target(self) do
# For now, we're assuming anything which has HullPoints can be attacked
HullPoints.get_all()
# ... except your own ship!
|> Enum.reject(fn {possible_target, _hp} -> possible_target == self end)
|> Enum.find(fn {possible_target, _hp} ->
distance_between = SystemUtils.distance_between(possible_target, self)
range = AttackRange.get_one(self)
distance_between < range
end)
end
defp add_target(self, target) do
SeekingTarget.remove(self)
AttackTarget.add(self, target)
end
end
The Attacking system will also check distance, but only to the target ship, in case it has moved out-of-range. If not, we just need to check on the cooldown, and do the attack.
$ mix ecsx.gen.system Attacking
defmodule MyApp.Systems.Attacking do
...
use ECSx.System
alias MyApp.Components.ArmorRating
alias MyApp.Components.AttackCooldown
alias MyApp.Components.AttackDamage
alias MyApp.Components.AttackRange
alias MyApp.Components.AttackSpeed
alias MyApp.Components.AttackTarget
alias MyApp.Components.HullPoints
alias MyApp.Components.SeekingTarget
alias MyApp.SystemUtils
def run do
attack_targets = AttackTarget.get_all()
Enum.each(attack_targets, &attack_if_ready/1)
end
defp attack_if_ready({self, target}) do
cond do
SystemUtils.distance_between(self, target) > AttackRange.get_one(self) ->
# If the target ever leaves our attack range, we want to remove the AttackTarget
# and begin searching for a new one.
AttackTarget.remove(self)
SeekingTarget.add(self)
AttackCooldown.exists?(self) ->
# We're still within range, but waiting on the cooldown
:noop
:otherwise ->
deal_damage(self, target)
add_cooldown(self)
end
end
defp deal_damage(self, target) do
attack_damage = AttackDamage.get_one(self)
# Assuming one armor rating always equals one damage
reduction_from_armor = ArmorRating.get_one(target)
final_damage_amount = attack_damage - reduction_from_armor
target_current_hp = HullPoints.get_one(target)
target_new_hp = target_current_hp - final_damage_amount
HullPoints.add(target, target_new_hp)
end
defp add_cooldown(self) do
now = DateTime.utc_now()
ms_between_attacks = calculate_cooldown_time(self)
cooldown_until = DateTime.add(now, ms_between_attacks, :millisecond)
AttackCooldown.add(self, cooldown_until)
end
# We're going to model AttackSpeed with a float representing attacks per second.
# The goal here is to convert that into milliseconds per attack.
defp calculate_cooldown_time(self) do
attacks_per_second = AttackSpeed.get_one(self)
seconds_per_attack = 1 / attacks_per_second
ceil(seconds_per_attack * 1000)
end
end
Phew, that was a lot! But we're still using the same basic concepts: get_all/0
to fetch the list of all relevant entities, then get_one/1
and exists?/1
to check specific attributes of the entities, and finally add/2
for creating new components, or overwriting existing ones. We're also starting to see the use of remove/1
for excluding an entity from game logic which is no longer necessary.
cooldowns
Cooldowns
Our attacking system will add a cooldown with an expiration timestamp, but the next step is to ensure the cooldown component is removed from the entity once the time is reached, so it can attack again. For that, we'll create a CooldownExpiration
system:
$ mix ecsx.gen.system CooldownExpiration
Note: going forwards, aliases will be omitted from the examples to save space. Don't forget to include the required aliases for your component types!
defmodule MyApp.Systems.CooldownExpiration do
...
def run do
now = DateTime.utc_now()
cooldowns = AttackCooldown.get_all()
Enum.each(cooldowns, &remove_when_expired(&1, now))
end
defp remove_when_expired({entity, timestamp}, now) do
case DateTime.compare(now, timestamp) do
:lt -> :noop
_ -> AttackCooldown.remove(entity)
end
end
end
This system will check the cooldowns on each game tick, removing them as soon as the expiration time is reached.
death-destruction
Death & Destruction
Next let's handle what happens when a ship has its HP reduced to zero or less:
$ mix ecsx.gen.component DestroyedAt datetime
$ mix ecsx.gen.system Destruction
defmodule MyApp.Systems.Destruction do
...
def run do
ships = HullPoints.get_all()
Enum.each(ships, fn {entity, hp} ->
if hp <= 0, do: destroy(entity)
end)
end
defp destroy(entity) do
ArmorRating.remove(entity)
AttackCooldown.remove(entity)
AttackDamage.remove(entity)
AttackRange.remove(entity)
AttackSpeed.remove(entity)
AttackTarget.remove(entity)
HullPoints.remove(entity)
SeekingTarget.remove(entity)
XPosition.remove(entity)
XVelocity.remove(entity)
YPosition.remove(entity)
YVelocity.remove(entity)
# when a ship is destroyed, other ships should stop targeting it
untarget(entity)
DestroyedAt.add(entity, DateTime.utc_now())
end
defp untarget(target) do
for entity <- AttackTarget.search(target) do
AttackTarget.remove(entity)
SeekingTarget.add(entity)
end
end
end
In this example we remove all the components the entity might have, then add a new DestroyedAt component with the current timestamp. If we wanted some components to persist - such as the position and/or velocity, so the wreckage could still be visible on the player displays - we could keep them around and possibly have another system clean them up later on. Likewise if there were other components to add, such as a RespawnTimer
or FinalScore
, we could add them here as well.
initializing-components
Initializing Components
By now you might be wondering "How did those components get created in the first place?" We have code for adding AttackCooldown
and DestroyedAt
, when needed, but the basic components for the ships still need to be added before the game can even start. For that, we'll check out lib/my_app/manager.ex
:
defmodule MyApp.Manager do
...
use ECSx.Manager, tick_rate: 20
setup do
# Load your initial components
end
def components do
...
end
def systems do
...
end
end
This module holds four critical pieces of data - the server's tick rate, data initialization, a list of every valid component type, and a list of each game system in the order they are to be run. Let's initialize some ship data inside the setup
block:
setup do
for _player_count <- 1..4 do
# First generate a unique ID to represent the new entity
entity = Ecto.UUID.generate()
# Then use that ID to create the components which make up a ship
ArmorRating.add(entity, 0)
AttackDamage.add(entity, 5)
AttackRange.add(entity, 10)
AttackSpeed.add(entity, 1.05)
HullPoints.add(entity, 50)
SeekingTarget.add(entity)
XPosition.add(entity, Enum.random(1..100))
XVelocity.add(entity, 0)
YPosition.add(entity, Enum.random(1..100))
YVelocity.add(entity, 0)
end
end
Now when the server starts, there will be four ships set up and ready to go.
coming-soon
Coming Soon
User input, display