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. We're going to use the classic Snake as inspiration.

design-plan

Design plan

In a classic Snake game, collision is everything. We're going to need a game world where a snake can occupy many coordinates, and each one of those coordinates should be considered for collision. We also need to "order" each occupied coordinate in order to simulate movement - by adding to the head and removing from the tail.

Let's start by creating a Coordinate aspect:

  $ mix ecsx.gen.aspect Coordinate coordinate snake_id order

We are declaring three fields in the schema, coordinate, snake_id, and order. Normally, the first field of a schema is entity_id, which will give us efficient lookups on that field. However, this is a rare exception where lookup by entity_id is not very useful, and it is coordinate which will be the basis for our querying. The id of the snake is still useful metadata, so we'll include that as the second field. The third field is unique to our Snake game, where each coordinate represents just one link to a longer chain that is a snake. We need to be able to know the first (head) and last (tail) coordinates, and we accomplish this through order. A coordinate with order 1 is the head, and a coordinate where order is equal to the total length of the snake, must be the tail.

Since we know that snake length will be useful data, let's create another Aspect for it:

  $ mix ecsx.gen.aspect Length snake_id length

This is a standard Aspect where we will query its Components by snake_id, so that should be the first field.

Next we can also anticipate needing to label snakes with a Direction of movement:

  $ mix ecsx.gen.aspect Direction snake_id direction

Now that we have modeled world data and snake data, let's think about the Systems which will organize game logic. What makes a Snake game work?

  • Snakes move forwards every game tick
  • Snakes get longer over time (or based on other game conditions)
  • When there is a collision, one or both snakes are removed from the game

Let's start with the most important System - the physics. We'll call it Driver:

  $ mix ecsx.gen.system Driver

Heading over to the generated file lib/your_app/systems/driver.ex and we'll add some code:

defmodule YourApp.Systems.Driver do
  ...
  alias YourApp.Aspects.Moving
  alias YourApp.Aspects.Position
  ...
  def run do
    # First we get all the Coordinate components
    coordinates = Coordinate.query_all()

    # Each coordinate will be appropriately updated to simulate the movement of the snakes
    Enum.each(coordinates, &update_coordinate/1)
  end

  defp update_coordinate(%{coordinate: {x, y}, snake_id: id, order: 1} = component) do
    # If the order is 1, then this is a head;  we need to occupy an adjacent coordinate.
    # First let's find the direction this snake is headed
    direction = Direction.query_one(match: [snake_id: id], value: :direction)

    # From this direction we need to calculate the coordinate we're moving into
    new_coord = calculate_new_position(x, y, direction)

    # Insert new head coordinate and increment the order of this one
    Coordinate.add_component(coordinate: new_coord, snake_id: id, order: 1)
    increment_order(component)
  end

  defp update_coordinate(%{coordinate: coordinate, snake_id: id, order: order} = component) do
    # Since we know this isn't the head, we just need to check if it's the tail
    length = Length.query_one(match: [snake_id: id], value: :length)

    if length == order do
      # This is the tail, we should un-occupy the coordinate as the snake moves out
      Coordinate.remove_component(coordinate: coordinate, snake_id: id)
    else
      # All other coordinates in-between get their order incremented by one
      increment_order(component)
    end
  end

  defp calculate_new_position(x, y, :north), do: {x, y + 1}
  defp calculate_new_position(x, y, :east), do: {x + 1, y}
  defp calculate_new_position(x, y, :south), do: {x, y - 1}
  defp calculate_new_position(x, y, :west), do: {x - 1, y}

  defp increment_order(%{coordinate: coordinate, snake_id: id, order: order}) do
    Coordinate.remove_component(coordinate: coordinate, snake_id: id)
    Coordinate.add_component(coordinate: coordinate, snake_id: id, order: order + 1)
  end
end

You probably noticed that this System creates new coordinates without checking if there is any collision with existing coordinates. This is intentional; to demonstrate why, imagine an example where Snake A is exiting a coordinate, and Snake B is entering the same coordinate, on the same server tick. Now, if we check for collision in the Driver system, the result will depend on which Component gets updated first:

  • If Snake A's tail Component is updated first, then the check will show the coordinate as unoccupied, and there will be no collision.
  • If Snake B's head Component is updated first, then the check will show the coordinate as occupied by Snake A's tail, and there will be a collision.

We want to avoid this kind of inconsistency, and ensure that the result is the same, regardless of which Component is stored earlier in the table. Therefore we allow duplicate coordinates, and will have another System handle the cleanup afterwards.

You've probably guessed that we'll start by running the generator:

  $ mix ecsx.gen.system Collision

But before we start coding, let's think of a plan for how to efficiently check for collisions. One approach could be to fetch all the Coordinate Components, iterate over the list, grouping them by {x, y} pair, then iterate over the groups, checking if any have more than one member. This might be fine for some games, but if we want to optimize performance, we should only check for collision where there is actually a possibility of collision. The only possible points of collision are those coordinates where there is a snake head (order 1).

Then, in lib/your_app/systems/collision.ex:

defmodule YourApp.Systems.Collision do
  ...
  def run do
    # Fetch coordinates for all snake heads
    possible_collision_coords = Coordinate.query_all(match: [order: 1])
    # Update components for any entities which have collided
    Enum.each(possible_collision_coords, &check_for_collision/1)
  end

  defp check_for_collision(%{coordinate: coordinate, snake_id: id, order: order}) do
    case Coordinate.query_all(match: [coordinate: coordinate]) do
      [] -> :ok
      [_] -> :ok
      multiple_results -> Enum.each(multiple_results, &handle_collision/1)
    end
  end

  defp handle_collision(%{coordinate: coordinate, snake_id: id, order: 1}) do
    # When a snake head collides, it dies - we can remove its components
    Position.remove_component(id)
    Moving.remove_component(id)
    Length.remove_component(id)

    # Without any components, the entity will cease to exist!
    # Maybe we would like to keep some record of the entity instead:
    CrashRecord.add_component(
      entity_id: id,
      crash_time: DateTime.utc_now(),
      crash_location: coordinate
    )
  end

  # If the order is not 1, then the collision was on the snake's tail, and it will survive
  defp handle_collision(_), do: :ok
end

Whenever we need a new Aspect (such as CrashRecord), we can simply run the generator again:

  $ mix ecsx.gen.aspect CrashRecord entity_id crash_time crash_location