View Source Backend Basics

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

We'll 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/ship/systems/driver.ex and we'll add some code:

defmodule Ship.Systems.Driver do
  ...
  @behaviour ECSx.System

  alias Ship.Components.XPosition
  alias Ship.Components.YPosition
  alias Ship.Components.XVelocity
  alias Ship.Components.YVelocity

  @impl ECSx.System
  def run do
    for {entity, x_velocity} <- XVelocity.get_all() do
      x_position = XPosition.get(entity)
      new_x_position = x_position + x_velocity
      XPosition.update(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(entity)
      new_y_position = y_position + y_velocity
      YPosition.update(entity, new_y_position)
    end
  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 config/config.ex and fetched at runtime by calling ECSx.tick_rate/0.

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 Ship.SystemUtils do
  @moduledoc """
  Useful math functions used by multiple systems.
  """

  alias Ship.Components.XPosition
  alias Ship.Components.YPosition

  def distance_between(entity_1, entity_2) do
    x_1 = XPosition.get(entity_1)
    x_2 = XPosition.get(entity_2)
    y_1 = YPosition.get(entity_1)
    y_2 = YPosition.get(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 Ship.Systems.Targeting do
  ...
  @behaviour ECSx.System

  alias Ship.Components.AttackRange
  alias Ship.Components.AttackTarget
  alias Ship.Components.HullPoints
  alias Ship.Components.SeekingTarget
  alias Ship.SystemUtils

  @impl ECSx.System
  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(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 Ship.Systems.Attacking do
  ...
  @behaviour ECSx.System

  alias Ship.Components.ArmorRating
  alias Ship.Components.AttackCooldown
  alias Ship.Components.AttackDamage
  alias Ship.Components.AttackRange
  alias Ship.Components.AttackSpeed
  alias Ship.Components.AttackTarget
  alias Ship.Components.HullPoints
  alias Ship.Components.SeekingTarget
  alias Ship.SystemUtils
  
  @impl ECSx.System
  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(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(self)
    # Assuming one armor rating always equals one damage
    reduction_from_armor = ArmorRating.get(target)
    final_damage_amount = attack_damage - reduction_from_armor

    target_current_hp = HullPoints.get(target)
    target_new_hp = target_current_hp - final_damage_amount

    HullPoints.update(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(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/1 and exists?/1 to check specific attributes of the entities, add/2 for creating new components, and update/2 for 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
defmodule Ship.Systems.CooldownExpiration do
  ...
  @behaviour ECSx.System

  alias Ship.Components.AttackCooldown

  @impl ECSx.System
  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 Ship.Systems.Destruction do
  ...
  @behaviour ECSx.System

  alias Ship.Components.ArmorRating
  alias Ship.Components.AttackCooldown
  alias Ship.Components.AttackDamage
  alias Ship.Components.AttackRange
  alias Ship.Components.AttackSpeed
  alias Ship.Components.AttackTarget
  alias Ship.Components.DestroyedAt
  alias Ship.Components.HullPoints
  alias Ship.Components.SeekingTarget
  alias Ship.Components.XPosition
  alias Ship.Components.XVelocity
  alias Ship.Components.YPosition
  alias Ship.Components.YVelocity

  @impl ECSx.System
  def run do
    ships = HullPoints.get_all()

    Enum.each(ships, fn {entity, hp} ->
      if hp <= 0, do: destroy(entity)
    end)
  end

  defp destroy(ship) do
    ArmorRating.remove(ship)
    AttackCooldown.remove(ship)
    AttackDamage.remove(ship)
    AttackRange.remove(ship)
    AttackSpeed.remove(ship)
    AttackTarget.remove(ship)
    HullPoints.remove(ship)
    SeekingTarget.remove(ship)
    XPosition.remove(ship)
    XVelocity.remove(ship)
    YPosition.remove(ship)
    YVelocity.remove(ship)

    # when a ship is destroyed, other ships should stop targeting it
    untarget(ship)

    DestroyedAt.add(ship, DateTime.utc_now())
  end

  defp untarget(target) do
    for ship <- AttackTarget.search(target) do
      AttackTarget.remove(ship)
      SeekingTarget.add(ship)
    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-component-data

Initializing Component Data

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/ship/manager.ex:

defmodule Ship.Manager do
  ...
  use ECSx.Manager

  def setup do
    ...
  end

  def startup do
    ...
  end

  def components do
    ...
  end

  def systems do
    ...
  end
end

This module holds three critical pieces of data - component setup, a list of every valid component type, and a list of each game system in the order they are to be run. Let's create some ship components inside the startup block:

def startup do
  for _ships <- 1..40 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    
    Ship.Components.ArmorRating.add(entity, 0)
    Ship.Components.AttackDamage.add(entity, 5)
    Ship.Components.AttackRange.add(entity, 10)
    Ship.Components.AttackSpeed.add(entity, 1.05)
    Ship.Components.HullPoints.add(entity, 50)
    Ship.Components.SeekingTarget.add(entity)
    Ship.Components.XPosition.add(entity, Enum.random(1..100))
    Ship.Components.YPosition.add(entity, Enum.random(1..100))
    Ship.Components.XVelocity.add(entity, 0)
    Ship.Components.YVelocity.add(entity, 0)
  end
end

Now whenever the server starts, there will be forty ships set up and ready to go.