View Source Tutorial
The objective of this tutorial is to develop a basic game utilizing the Ecspanse framework. The game is the initial stage of an RPG game with minimal features. The implementation focuses on the on the game logic and it does not have a UI. All the user interactions and minimal display will be done via Livebook integration.
Story
The story of the game is simple:
- the game features a single character (Hero).
- the hero has energy. It starts with 50 energy points and it can have a maximum of 100 energy points. The energy points are used to perform actions. Every 3 seconds the hero restores 1 energy point.
- the hero can move in four directions in a tiles-like manner, without actually implementing a tile system. For example, if the hero moves right it will transition from (0,0) to (1,0) and so on. Each move costs 1 energy point.
- on each move the hero has a chance to find resources: gold or gems. Resources are not inventory items, but tradeable items. The hero can trade resources for inventory items.
- the hero starts with some items in their inventory: 2 potions and one pair of boots.
- the hero can purchase a map with 2 gold and a compass with 3 gold and 2 gems.
This setup enables us to delve into fundamental concepts of ECS in general and Ecspanse in particular:
- creating new entities from components
- querying for components
- interacting with the system via events
- scheduling systems to perform actions
- managing entities relationships
- different ways to approach collections of entities or components
- using time-based systems and events
Spawning the Hero
The goal of this chapter is to spawn the hero entity with its components on game startup.
Ecspanse Concepts 1
- creating components
- using commands to spawn entities
- creating and scheduling systems
- querying components
Adding the Components
The hero entity will have for now the following components:
defmodule Demo.Components.Hero do
use Ecspanse.Component, state: [name: "Hero"]
end
defmodule Demo.Components.Energy do
use Ecspanse.Component, state: [current: 50, max: 100]
end
defmodule Demo.Components.Position do
use Ecspanse.Component, state: [x: 0, y: 0]
end
The Hero
component holds generic information about the hero. The Energy
holds the current and maximum energy points. The Position
component holds the current position of the hero as horizontal and vertical coordinates.
Under the hood, the components are structs, with some metadata added by the library.
The following options are available when defining a component:
:state
- the fields and the initial state of the component. It should be a list or a keyword list.:tags
- a list of atoms that can be used to tag the component. Tags are an alternate way of querying components.
The Hero Entity Spec
While this is not mandatory, we extracted the hero entity spec composition in the Demo.Entities.Hero
module.
defmodule Demo.Entities.Hero do
alias Demo.Components
@spec new() :: Ecspanse.Entity.entity_spec()
def new do
{Ecspanse.Entity,
components: [
Components.Hero,
Components.Energy,
Components.Position
]}
end
end
The new/0
function does not have any effect. It just prepares the entity spec (of type Ecspanse.Entity.entity_spec/0
) to be spawned.
The Spawn Hero System
The system that spawns the hero is a simple one. It is scheduled to run once, when the game starts.
defmodule Demo.Systems.SpawnHero do
use Ecspanse.System
@impl true
def run(_frame) do
%Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Hero.new())
end
end
The system must implement the Ecspanse.System.WithoutEventSubscriptions.run/1
or Ecspanse.System.WithEventSubscriptions.run/2
callback. In this case, it is a generic system, not subscribing to any events, so we will use run/1
. The callback receives the Ecspanse.Frame.t/0
as argument.
Operations that involve the creation of entities or components are done via the functions in the Ecspanse.Command
module. The commands cannot be executed outside of a system.
Scheduling the Spawn Hero System
It is now time to schedule the newly created system as a startup system. This is done by updating the Ecspanse.setup/1
function in the Demo
module. We already created this function in the Getting Started guide.
defmodule Demo do
use Ecspanse
alias Demo.Systems
@impl Ecspanse
def setup(data) do
data
|> Ecspanse.add_startup_system(Systems.SpawnHero)
end
end
If we start the application now, the hero entity will be spawned.
Querying for the Hero
Next, we will incorporate some helper functions that will prove useful in the next chapters.
defmodule Demo.Entities.Hero do
alias Demo.Components
#...
def fetch do
Ecspanse.Query.select({Ecspanse.Entity}, with: [Components.Hero])
|> Ecspanse.Query.one()
|> case do
{%Ecspanse.Entity{} = entity} -> {:ok, entity}
_ -> {:error, :not_found}
end
end
end
In the Demo.Entities.Hero
module we added the fetch/0
function. This function uses the Ecspanse.Query
module to select the hero entity. The Ecspanse.Query.select/2
function is the most flexible way to query for entities and components and it will be used many times in this tutorial. In the current context, the query can be interpreted as:
- select tuples with a single element, Entity -> this queries the
%Ecspanse.Entity{}
struct itself. When we want to return the entity as part of a more complex select query, it needs to be in the first position of the tuple. - that has the
Demo.Components.Hero
component attached to it. - return just one record -> this would return the selected entity tuple if found, or otherwise nil. It is important to note that, if many records match the query, it will raise an error. For such cases, the
Ecspanse.Query.stream/1
should be used and it will return a stream of results.
We can actually test the function in the iex
console after starting the server:
iex(1)> Demo.Entities.Hero.fetch()
{:ok, %Ecspanse.Entity{id: "e950bf44-16d5-46b5-bd21-85aabae50ce8"}}
For the other helper function, we will create an API module. Again, this is not part of the library, but it provides an easy way to interact with the game, no matter what front end we will use.
defmodule Demo.API do
@spec fetch_hero_details() :: {:ok, map()} | {:error, :not_found}
def fetch_hero_details do
Ecspanse.Query.select(
{Ecspanse.Entity, Demo.Components.Hero, Demo.Components.Energy, Demo.Components.Position}
)
|> Ecspanse.Query.one()
|> case do
{hero_entity, hero, energy, position} ->
{:ok, %{name: hero.name, energy: energy.current, max_energy: energy.max, pos_x: position.x, pos_y: position.y}}
_ ->
{:error, :not_found}
end
end
end
This function, returns a map with the hero details we have implemented so far. This time, the select/2
function is used to select multiple components. The query can be interpreted as:
- select tuples with three elements,
Demo.Components.Hero
,Demo.Components.Energy
,Demo.Components.Position
only from entities that have all three components attached to them. - return just one record -> this will return a tuple with the three components structs if found, or nil otherwise.
We can test the function in the iex
console after starting the server:
iex(2)> Demo.API.fetch_hero_details()
%{name: "Hero", energy: 50, max_energy: 100, pos_x: 0, pos_y: 0}
Hero Movement
The goal of this chapter is to implement the hero movement. The hero will be able to move in the four directions: up, down, left and right.
Ecspanse Concepts 2
- receiving external input through events
- implementing and scheduling async systems
- locking components for parallel operations
- implementing systems that subscribe to events
- updating components with commands
The Move Event
Ecspanse receives external input through events. Let's implement the move event.
defmodule Demo.Events.MoveHero do
use Ecspanse.Event, fields: [:direction]
end
Similar to the components, the events are structs under the hood. The fields and their default values are defined with the :fields
option.
We can also expose the event in the API module:
defmodule Demo.API do
#...
@spec move_hero(direction :: :up | :down | :left | :right) :: :ok
def move_hero(direction) do
Ecspanse.event({Demo.Events.MoveHero, direction: direction})
end
end
We create another event that will be emitted when the hero actually moved to handle various side effects.
defmodule Demo.Events.HeroMoved do
use Ecspanse.Event
end
The Move System
The role of the move system is to listen to move events, then check if the hero has enough energy to move and if so, update the hero position and adjust the energy. We want this system to run asynchronously.
defmodule Demo.Systems.MoveHero do
use Ecspanse.System,
lock_components: [Demo.Components.Position, Demo.Components.Energy],
event_subscriptions: [Demo.Events.MoveHero]
alias Demo.Components
@impl true
def run(%Demo.Events.MoveHero{direction: direction}, _frame) do
components =
Ecspanse.Query.select({Components.Position, Components.Energy}, with: [Components.Hero])
|> Ecspanse.Query.one()
with {position, energy} <- components,
:ok <- validate_enough_energy_to_move(energy) do
Ecspanse.Command.update_components!([
{energy, current: energy.current - 1},
{position, update_coordinates(position, direction)}
])
Ecspanse.event(Demo.Events.HeroMoved)
end
end
defp validate_enough_energy_to_move(%Components.Energy{current: current_energy}) do
if current_energy >= 1 do
:ok
else
{:error, :not_enough_energy}
end
end
defp update_coordinates(%Components.Position{x: x, y: y}, direction) do
case direction do
:up -> [x: x, y: y + 1]
:down -> [x: x, y: y - 1]
:left -> [x: x - 1, y: y]
:right -> [x: x + 1, y: y]
_ -> [x: x, y: y]
end
end
end
Component Locking
We said that the move system will run asynchronously. This means that it will run in parallel with the other systems. The lock_components
option is used to specify the components that will be locked by the system. That means that no other systems that lock at least one of the locked components will run in the same parallel batch as the MoveHero
system. In our case, we want to lock the Demo.Components.Position
and Demo.Components.Energy
components. This is because we want to update the hero position and energy, and we need to avoid race conditions.
The commands will check if the system is async and will raise an error if we try to update, insert or delete components that are not locked. For extra safety we can also lock components for which we don't update the state, but we read and depend on it.
Event Subscriptions
Not all systems are required to run every single frame. The MoveHero
system is useful only when a MoveHero
event is received. The event_subscriptions
option is used to specify the events that the system is interested in. The system will run only when at least one of the subscribed events is received.
The systems that have events subscriptions need to implement the run/2
callback. The first argument is the event that triggered the system. The second argument is the current frame.
Updating the Components
It is a good practice to make sure that actions can be performed before committing any updates. Reverting the changes would be more difficult and inefficient.
In our case, we want to make sure that the hero has enough energy before updating the Energy
and Position
components state.
Scheduling the Move System
We use add_system/2
to schedule the MoveHero
system to run asynchronously.
defmodule Demo do
#...
def setup(data) do
data
#...
|> Ecspanse.add_system(Systems.MoveHero)
end
end
We can try the hero movement in the iex
console:
iex(1)> Demo.API.fetch_hero_details()
%{name: "Hero", energy: 50, max_energy: 100, pos_x: 0, pos_y: 0}
iex(2)> Demo.API.move_hero(:up)
:ok
iex(3)> Demo.API.move_hero(:right)
:ok
iex(4)> Demo.API.fetch_hero_details()
%{name: "Hero", energy: 48, max_energy: 100, pos_x: 1, pos_y: 1}
Energy Regeneration
The goal of this chapter is to implement the energy regeneration. The hero will restore 1 point of energy every 3 seconds.
Ecspanse Concepts 3
- using the timer to schedule events at precise intervals
- using built-in component and event templates
- ordering async systems
- conditionally running systems
- new ways of querying entities and components
The Energy Timer Component
Timer-based components use the provided Ecspanse.Template.Component.Timer
component template. The timer component is a special component that is used to schedule events at precise intervals. The timer component template exposes the following fields:
:duration
- the countdown duration in milliseconds:time
- the current countdown time in milliseconds:event
- the event that will be triggered when the countdown reaches 0:mode
-:repeat | :once | :temporary
- decides the timer behavior after the countdown reaches 0.:paused
-boolean
- can be used to pause the timer
We will discuss more about template components in the next chapter.
defmodule Demo.Components.EnergyTimer do
use Ecspanse.Template.Component.Timer,
state: [duration: 3000, time: 3000, event: Demo.Events.EnergyTimerFinished, mode: :repeat]
end
We add the new component to the Hero entity:
defmodule Demo.Entities.Hero do
#...
@spec new() :: Ecspanse.Entity.entity_spec()
def new do
{Ecspanse.Entity,
components: [
Components.Hero,
Components.Energy,
Components.Position,
Components.EnergyTimer
]}
end
#...
end
The Energy Timer Finished Event
Timer-based events use the provided Ecspanse.Template.Event.Timer
event template. The timer event template exposes the following fields:
entity_id
- the id of the entity that owns the timer component
defmodule Demo.Events.EnergyTimerFinished do
use Ecspanse.Template.Event.Timer
end
This event will be automatically triggered when the EnergyTimer component duration reaches 0.
The Energy Restore System
defmodule Demo.Systems.RestoreEnergy do
use Ecspanse.System,
lock_components: [Demo.Components.Energy],
event_subscriptions: [Demo.Events.EnergyTimerFinished]
@impl true
def run(%Demo.Events.EnergyTimerFinished{entity_id: entity_id}, _frame) do
with {:ok, entity} <- Ecspanse.Query.fetch_entity(entity_id),
{:ok, energy} <- Ecspanse.Query.fetch_component(entity, Demo.Components.Energy) do
Ecspanse.Command.update_component!(energy, current: energy.current + 1)
end
end
end
The system locks the Energy
component to update its state. And subscribes to the EnergyTimerFinished
event because it is interested only in that timer event.
The system adds 1 point to the current energy. You are right to wonder why don't we first check the max energy cap. We will clarify this in the next section.
This system also introduces new ways of querying entities and components: Ecspanse.Query.fetch_entity/1
and Ecspanse.Query.fetch_component/2
.
TIP The following functions would produce the same results:
Ecspanse.Query.fetch_component(entity, Demo.Components.Energy)
#and
Demo.Components.Energy.fetch(entity)
Rescheduling the Systems Execution
It is time to re-write the Demo module:
defmodule Demo do
use Ecspanse
alias Demo.Systems
@impl Ecspanse
def setup(data) do
data
|> Ecspanse.add_startup_system(Systems.SpawnHero)
|> Ecspanse.add_system(Systems.RestoreEnergy, run_if: [{__MODULE__, :energy_not_max?}])
|> Ecspanse.add_system(Systems.MoveHero, run_after: [Systems.RestoreEnergy])
|> Ecspanse.add_frame_end_system(Ecspanse.System.Timer)
end
def energy_not_max? do
Ecspanse.Query.select({Demo.Components.Energy}, with: [Demo.Components.Hero])
|> Ecspanse.Query.one()
|> case do
{%Demo.Components.Energy{current: current, max: max}} ->
current < max
_ ->
false
end
end
end
The Conditional System Execution
By using the :run_if
option, the RestoreEnergy
system will run only if the current energy is below the max energy. The energy_not_max?/0
function must always return a boolean value. Please note, this is not an efficient implementation. The energy_not_max?/0
function will be called every frame. If the check would happen in the RestoreEnergy
system, it would run only once every 3 seconds. But we took the opportunity to exemplify conditionally running systems.
The System Execution Order
By using the :run_after
option, the MoveHero
system will run after the RestoreEnergy
system. Both are async systems, but even the async systems run in batches, not all at once. The batches are scheduled depending on the locked components and the specified order of execution of the systems.
Note
It does not matter if the
RestoreEnergy
system actually runs this turn. TheMoveHero
will still run if receiving theMoveHero
event. The:run_after
option just guarantees that if both systems are running, theMoveHero
will run after theRestoreEnergy
.
Scheduling the Built-in Timer System
Once we start using timer-based components, the built-in Ecspanse.System.Timer
system must be scheduled to run synchronously at the beginning or at the end of every frame. It will update all the timer-based components and trigger the timer events.
Finding Resources
The goal of this chapter is to implement the resource gathering. With each move, the hero has a chance to find gold or gems.
Ecspanse Concepts 4
- using tags to manage collections of components
- using advanced component specs
- using component templates
- using the auto-emitted component updated events
Creating the Resource Template Component
Template components are used to define the structure for related components. It is a guarantee that certain components will have certain fields in their state.
Together with tags, this is a powerful way to achieve polymorphism in components.
defmodule Demo.Components.Resource do
use Ecspanse.Template.Component, state: [:id, :name, amount: 0], tags: [:resource]
@impl true
def validate(state) do
with :ok <- validate_integer_amount(state[:amount]),
:ok <- validate_positive_amount(state[:amount]) do
:ok
end
end
defp validate_integer_amount(amount) do
if is_integer(amount) do
:ok
else
{:error, "#{inspect(amount)} must be an integer"}
end
end
defp validate_positive_amount(amount) do
if amount >= 0 do
:ok
else
{:error, "#{inspect(amount)} must be positive"}
end
end
end
Please note that the template Ecspanse.Template.Component.validate/1
callback is optional. It runs only at compile time and it takes the list of state fields as argument.
Creating the Resource Components
defmodule Demo.Components.Gems do
use Demo.Components.Resource,
state: [id: :gems, name: "Gems", amount: 0], tags: [:resource]
end
defmodule Demo.Components.Gold do
use Demo.Components.Resource,
state: [id: :gold, name: "Gold", amount: 0], tags: [:resource]
end
As you can observe, the two components are invoking the newly defined template with use Demo.Components.Resource
instead of use Ecspanse.Component
.
Another new concept introduced both here and in the template definition is the :tags
option. It is a list of atoms that can be used to group and query components. The resource components can now be used as a resource store for the user, but they can also be used to represent the cost of various items. We will handle the second use case in the next chapters.
For such cases, it is important to use a standardized approach, a perfect use-case for templates. E.g., all the resource components should have the same state fields.
Adding the Resources Components to the Hero Entity
defmodule Demo.Entities.Hero do
#...
def new do
{Ecspanse.Entity,
components: [
#...
{Components.Gold, [], [:available]},
{Components.Gems, [], [:available]}
]}
end
#...
end
We use the Ecspanse.Component.component_spec/0
type to specify the component spec. The first element of the tuple is the component module, the second element is the initial state of the component, and the third element is a list of tags.
The initial state of the component can be changed at runtime like {Components.Gold, [amount: 5], [:available]}
. Also, new tags can be added at the time of the component creation. They will be appended to the list defined in the component module.
Runtime tag setting enables the reusability of components in various scenarios. It's important to note that changing a component's tags after it has been created is not supported.
Storing Found Resources
Our new MaybeFindResources
system subscribes to Demo.Events.HeroMoved
emitted by the MoveHero
system. Then it randomly decides if the current position contains resources, and the type of resource.
defmodule Demo.Systems.MaybeFindResources do
use Ecspanse.System,
lock_components: [Demo.Components.Gems, Demo.Components.Gold],
event_subscriptions: [Demo.Events.HeroMoved]
alias Demo.Components
@impl true
def run(%Demo.Events.HeroMoved{}, _frame) do
with true <- found_resource?(),
resource_module <- pick_resource(),
{:ok, hero_entity} <- Demo.Entities.Hero.fetch(),
{:ok, resource} <- Ecspanse.Query.fetch_component(hero_entity, resource_module) do
Ecspanse.Command.update_component!(resource, amount: resource.amount + 1)
end
end
def run(_event, _frame), do: :ok
defp found_resource?, do: Enum.random([true, false])
defp pick_resource, do: Enum.random([Components.Gems, Components.Gold])
end
Here we take advantage of the standardized resource approach, so the system would update the resource amount without caring about the actual resource type.
Then we add the new system to the setup
:
defmodule Demo do
use Ecspanse
# ...
def setup(data) do
data
# ...
|> Ecspanse.add_system(Systems.MoveHero, run_after: [Systems.RestoreEnergy])
|> Ecspanse.add_system(Systems.MaybeFindResources)
|> Ecspanse.add_frame_end_system(Ecspanse.System.Timer)
end
end
The last step of the current section is to expose the resources in the fetch_hero_details/0
function in the Demo.API
module.
defp list_hero_resources(hero_entity) do
hero_entity
|> Ecspanse.Query.list_tagged_components_for_entity([:resource, :available])
|> Enum.map(&%{name: &1.name, amount: &1.amount})
end
Here we use the Ecspanse.Query.list_tagged_components_for_entity/2
function to get all the components tagged with :resource
and :available
for the hero entity.
The map returned by fetch_hero_details/0
function should be updated with the new resources field:
%{
# ...
pos_y: position.y,
resources: list_hero_resources(hero_entity),
}
Starting the application and moving the hero around will now start to accumulate resources:
iex(14)> Demo.API.fetch_hero_details
%{
name: "Hero",
resources: [%{name: "Gems", amount: 2}, %{name: "Gold", amount: 5}],
energy: 56,
max_energy: 100,
pos_x: -3,
pos_y: -5
}
--
Market and Inventory Items
The goal of this chapter is to implement inventory items and a market. The hero can buy items from the market with resources and store them in the inventory.
Ecspanse Concepts 5
- using relationships to manage collections of entities
- querying components within entities relationships
Inventory Items Components and Entities Specs
We start by defining the inventory items and the market components:
defmodule Demo.Components.Market do
use Ecspanse.Component
end
defmodule Demo.Components.Boots do
use Ecspanse.Component, state: [name: "Boots"], tags: [:inventory]
end
defmodule Demo.Components.Compass do
use Ecspanse.Component, state: [name: "Compass"], tags: [:inventory]
end
defmodule Demo.Components.Map do
use Ecspanse.Component, state: [name: "Map"], tags: [:inventory]
end
defmodule Demo.Components.Potion do
use Ecspanse.Component, state: [name: "Potion"], tags: [:inventory]
end
The inventory items, however are more complex than this. They cost resources, and in the future they may have various attributes impacting the hero's abilities. So the items will be entities of their own. We will create a new Entities.Inventory
module to manage the inventory items specs.
defmodule Demo.Entities.Inventory do
alias Demo.Components
@spec new_boots() :: Ecspanse.Entity.entity_spec()
def new_boots do
{Ecspanse.Entity, components: [Components.Boots, {Components.Gold, [amount: 3], [:cost]}]}
end
@spec new_compass() :: Ecspanse.Entity.entity_spec()
def new_compass do
{Ecspanse.Entity,
components: [
Components.Compass,
{Components.Gold, [amount: 3], [:cost]},
{Components.Gems, [amount: 2], [:cost]}
]}
end
@spec new_map() :: Ecspanse.Entity.entity_spec()
def new_map do
{Ecspanse.Entity, components: [Components.Map, {Components.Gold, [amount: 2], [:cost]}]}
end
@spec new_potion() :: Ecspanse.Entity.entity_spec()
def new_potion do
{Ecspanse.Entity, components: [Components.Potion, {Components.Gold, [amount: 1], [:cost]}]}
end
@spec list_inventory_components(Ecspanse.Entity.t()) :: [component :: struct()]
def list_inventory_components(parent) do
Ecspanse.Query.list_tagged_components_for_children(parent, [:inventory])
end
end
Each item is defined together with their cost in resources. Please note that now we are using the :cost
tag to mark the cost resource components.
The list_inventory_components/1
function is used to list all the inventory items for a given parent entity. We will see what this means in the next section.
Inventory Items as Children Entities
Let's start by updating the existing SpawnHero
system.
defmodule Demo.Systems.SpawnHero do
use Ecspanse.System
@impl true
def run(_frame) do
hero_entity = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Hero.new())
potion_entity_1 = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Inventory.new_potion())
potion_entity_2 = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Inventory.new_potion())
boots_entity = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Inventory.new_boots())
Ecspanse.Command.add_children!([ {hero_entity, [potion_entity_1, potion_entity_2, boots_entity]} ])
end
end
The hero starts the journey with two potions and a pair of boots. We use the Ecspanse.Command.add_children!/1
function to add the inventory items as children of the hero entity. This way we can build complex entities by composing smaller entities.
The Ecspanse library provides many helper functions to query and change entities relations.
The next step is to create a new system that spawns a market entity that holds more items.
defmodule Demo.Systems.SpawnMarket do
use Ecspanse.System
@impl true
def run(_frame) do
compass_entity = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Inventory.new_compass())
map_entity = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Inventory.new_map())
Ecspanse.Command.spawn_entity!({ Ecspanse.Entity,
components: [Demo.Components.Market], children: [compass_entity, map_entity]
})
end
end
This shows another way to spawn an entity with children already attached. The new system needs to be added to the setup/1
as startup system:
#...
|> Ecspanse.add_startup_system(Systems.SpawnMarket)
#...
One last thing we can do in this chapter is to add new functions to our API:
defmodule Demo.API do
#...
defp list_hero_inventory(hero_entity) do
hero_entity
|> Demo.Entities.Inventory.list_inventory_components()
|> Enum.map(&%{name: &1.name})
end
@spec fetch_market_items() :: {:ok, list(map())} | {:error, :not_found}
def fetch_market_items do
Ecspanse.Query.select({Ecspanse.Entity}, with: [Demo.Components.Market])
|> Ecspanse.Query.one()
|> case do
{market_entity} -> list_market_items(market_entity)
_ -> {:error, :not_found}
end
end
defp list_market_items(market_entity) do
market_entity
|> Demo.Entities.Inventory.list_inventory_components()
|> Enum.map(fn item_component ->
item_entity = Ecspanse.Query.get_component_entity(item_component)
%{entity_id: item_entity.id, name: item_component.name, cost: item_cost(item_entity)}
end)
end
defp item_cost(item_entity) do
item_entity
|> Ecspanse.Query.list_tagged_components_for_entity([:resource, :cost])
|> Enum.map(&%{name: &1.name, amount: &1.amount})
end
end
The map returned by fetch_hero_details/0
function should be updated with the new inventory field:
%{
# ...
pos_y: position.y,
resources: list_hero_resources(hero_entity),
inventory: list_hero_inventory(hero_entity)
}
We will now display the hero's inventory and the market items with their respective prices.
We can test the new functions in the iex
console:
iex(1)> Demo.API.fetch_hero_details()
%{
name: "Hero",
resources: [%{name: "Gems", amount: 0}, %{name: "Gold", amount: 0}],
inventory: [%{name: "Boots"}, %{name: "Potion"}, %{name: "Potion"}],
energy: 60,
max_energy: 100,
pos_x: 0,
pos_y: 0
}
iex(1)> Demo.API.fetch_market_items()
[
%{
name: "Map",
entity_id: "361c00ba-4dd3-4be8-b171-00e99c0b8ef7",
cost: [%{name: "Gold", amount: 2}]
},
%{
name: "Compass",
entity_id: "b027fd01-d4fe-4ac1-9736-6b6f8c58fbd1",
cost: [%{name: "Gold", amount: 3}, %{name: "Gems", amount: 2}]
}
]
Purchasing Items from the Market
The goal of this chapter is to allow the hero to purchase items from the market using resources.
Ecspanse Concepts 6
- in-depth entity relationships
Purchasing Items Event
The event that triggers an item purchase is very simple:
defmodule Demo.Events.PurchaseMarketItem do
use Ecspanse.Event, fields: [:item_entity_id]
end
It stores only the ID of the entity of the item being purchased. On the other hand, the system is a bit more complex.
Purchasing Items System
Let's start with the code:
defmodule Demo.Systems.PurchaseMarketItem do
use Ecspanse.System, event_subscriptions: [Demo.Events.PurchaseMarketItem]
@impl true
def run(%Demo.Events.PurchaseMarketItem{item_entity_id: item_entity_id}, _frame) do
with {:ok, item_entity} <- Ecspanse.Query.fetch_entity(item_entity_id),
{:ok, market_entity} <- fetch_market_entity(),
{:ok, hero_entity} <- Demo.Entities.Hero.fetch(),
true <- Ecspanse.Query.is_child_of?(parent: market_entity, child: item_entity),
hero_available_resources_components =
Ecspanse.Query.list_tagged_components_for_entity(hero_entity, [:resource, :available]),
item_cost_components =
Ecspanse.Query.list_tagged_components_for_entity(item_entity, [:resource, :cost]),
true <- has_enough_resources?(hero_available_resources_components, item_cost_components) do
spend_resources(hero_available_resources_components, item_cost_components)
Ecspanse.Command.remove_child!(market_entity, item_entity)
Ecspanse.Command.add_child!(hero_entity, item_entity)
end
end
defp fetch_market_entity do
Ecspanse.Query.select({Ecspanse.Entity}, with: [Demo.Components.Market])
|> Ecspanse.Query.one()
|> case do
{market_entity} -> {:ok, market_entity}
_ -> {:error, :not_found}
end
end
defp has_enough_resources?(available_resources, cost_resources) do
Enum.all?(cost_resources, fn cost_resource ->
Enum.any?(available_resources, fn available_resource ->
available_resource.id == cost_resource.id &&
available_resource.amount >= cost_resource.amount
end)
end)
end
defp spend_resources(available_resources, cost_resources) do
Enum.each(cost_resources, fn cost_resource ->
available_resource =
Enum.find(available_resources, fn available_resource ->
available_resource.id == cost_resource.id
end)
Ecspanse.Command.update_component!(available_resource,
amount: available_resource.amount - cost_resource.amount
)
end)
end
end
This system modifies many components, so one option is to make it synchronous. This way we don't have to individually lock each modified component. Later on it can be refactored into async if needed.
Before committing any component state changes it is important to perform all the required validations.
Validate Entities Exist
First of all, we want to make sure that the affected entities still exist. We use the Ecspanse.Query.fetch_entity/1
function to validate that the item entity exists. For the market entity we implement a custom query, while for the hero, we use the helper function we created earlier.
Validate Relationships
We need to make sure that the item we want to purchase is still available in the market. In a multiplayer game scenario this would avoid race conditions where two players purchase the same item in the same time. We use the Ecspanse.Query.is_child_of?/1
function to validate that the item is still a child of the market.
Validate Resources
Before the purchase is made, we need to make sure that the hero has enough resources to buy the item. Again, the tags prove useful. They allow us to query the same components from different entities and compare them.
Spending the Resources
Once all the validations are done, the resources can be spent. We iterate through the costs, then reduce the amount of the corresponding available resource.
Changing the Item Entity Parent
Finally, we remove the item from the market and add it to the hero's inventory. For this, we use the Ecspanse.Command.remove_child!/2
and Ecspanse.Command.add_child!/2
functions.
The Purchase API
We first need to add the PurchaseMarketItem
event to setup as sync system:
#...
|> Ecspanse.add_frame_end_system(Systems.PurchaseMarketItem)
#...
Then expose the event in the API:
@spec purchase_market_item(item_entity_id :: Ecspanse.Entity.id()) :: :ok
def purchase_market_item(item_entity_id) do
Ecspanse.event({Demo.Events.PurchaseMarketItem, item_entity_id: item_entity_id})
end
Now we can test it in the console. First make sure that the hero has enough resources by walking around and using the exposed fetch_hero_details/0
function. Then check the market items with fetch_market_items/0
and note down the desired item entity ID. Purchase the item with purchase_market_item/1
. Finally, check the hero details again to see the item in the inventory.
Testing the Systems
The goal of this chapter is to learn how to test the systems. We will use the MoveHero
system as an example.
Ecspanse Concepts 7
- testing systems in isolation
- using a custom Ecspanse setup
- using the system debugger to run systems manually
The game story is now ready. Time to see how we can test it.
Before we start, it is important to note that in test mode, the Ecspanse.Server
is not automatically started. This allows us to decide the moment when the server should start, and what systems to run.
There are many ways to test systems. We will choose the most straightforward for this tutorial: testing the systems in isolation. That means that the systems scheduled under Demo.setup/1
are not running. We will create a new setup function for testing, and call the systems manually.
defmodule Demo.Systems.MoveHeroTest do
use ExUnit.Case, async: false
defmodule DemoTest do
use Ecspanse
@impl true
def setup(data) do
data
end
end
setup do
{:ok, _pid} = start_supervised({DemoTest, :test})
Ecspanse.System.debug()
hero_entity = %Ecspanse.Entity{} = Ecspanse.Command.spawn_entity!(Demo.Entities.Hero.new())
{:ok, position_component} = Demo.Components.Position.fetch(hero_entity)
assert position_component.x == 0
assert position_component.y == 0
{:ok, energy_component} = Demo.Components.Energy.fetch(hero_entity)
assert energy_component.current == 50
{:ok, hero_entity: hero_entity, energy_component: energy_component}
end
test "hero moves if enough energy", %{hero_entity: hero_entity} do
event = move(:up)
frame = frame(event)
Demo.Systems.MoveHero.run(event, frame)
{:ok, position_component} = Demo.Components.Position.fetch(hero_entity)
assert position_component.x == 0
assert position_component.y == 1
{:ok, energy_component} = Demo.Components.Energy.fetch(hero_entity)
assert energy_component.current == 49
#...
end
test "hero doesn not move if not enough energy", %{
hero_entity: hero_entity,
energy_component: energy_component
} do
Ecspanse.Command.update_component!(energy_component, current: 0)
event = move(:up)
frame = frame(event)
Demo.Systems.MoveHero.run(event, frame)
{:ok, position_component} = Demo.Components.Position.fetch(hero_entity)
assert position_component.x == 0
assert position_component.y == 0
{:ok, energy_component} = Demo.Components.Energy.fetch(hero_entity)
assert energy_component.current == 0
end
defp move(direction) do
%Demo.Events.MoveHero{direction: direction, inserted_at: System.os_time()}
end
defp frame(event) do
%Ecspanse.Frame{event_batches: [[event]], delta: 1}
end
end
Test Dependencies
We start by creating a DemoTest
module and implement a setup/1
function that does not schedule any systems.
Test Setup
There are 2 things happening at the top of the setup
block:
- we manually start the server by passing the
{DemoTest, :test}
tuple to thestart_supervised/1
function. Compared to the normal setup where we addDemo
to the supervision tree, the{MODULE, :test}
tuple allows us to start the server in test mode. - we run the
Ecspanse.System.debug/0
function. This function "upgrades" the current test PID to a system, which allows us to run systems manually. As mentioned previously, commands can be run only from inside a system. Making the test PID a system allows us to run commands directly in our tests.
For the rest of the setup block we setup the test data. We spawn a hero entity and fetch its position and energy components. We assert that the hero is at the starting position and has the starting energy.
Test the Hero Can Move
We create two helper functions that would create a MoveHero
event and a Ecspanse.Frame.t/0
with the event in it.
Then we can run the MoveHero
system manually by simply calling Demo.Systems.MoveHero.run(event, frame)
.
From there on, we can query any component and do any relevant assertion. In this case, we assert that the hero has moved and that the energy has been reduced.
Running the Demo
The code for this tutorial, together with instructions on how to run it in Livebook is available on GitHub.
Also, you can find a more complex example of a multiplayer game built with Ecspanse on GitHub.