Powered by AppSignal & Oban Pro

Monster Spawner

exercises/monster_spawner.livemd

Monster Spawner

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.8.0", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"},
])

Navigation

Return Home Report An Issue

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively you can evaluate the Elixir cells as you read.

Overview

Many video games contain Spawners which spawn enemy monsters.

Typically, the spawner has a limit for how many enemy creatures it spawns. If a monster dies, the spawner then re-spawns that monster.

Sound familiar? We can leverage supervisors to create this effect.

flowchart TD 
    Supervisor
    Supervisor --> Monster1
    Supervisor --> Monster2
    Supervisor --> Monster3

    classDef crashed fill:#fe8888;
    classDef terminated fill:#fbab04;
    classDef restarted stroke:#0cac08,stroke-width:4px

    class Monster1 crashed
    class Monster1 restarted

Monster

First, you’re going to create a Monster GenServer process that represents a Monster you would fight in a video game.

A monster should start with 100 health. Store health in a map with the :health key.

You should be able to send the Monster process a message to apply damage to the monster. The Monster process should crash and raise "dying!" when its health is 0 or lower.

You should also be able to start the Monster process as a named process by passing a :name option.

Example Solution

defmodule Monster do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, [], name: opts[:name])
  end

  def attack(monster_pid, amount) do
    GenServer.cast(monster_pid, {:attack, amount})
  end

  def health(monster_pid) do
    GenServer.call(monster_pid, :health)
  end

  @impl true
  def init(_opts) do
    {:ok, %{health: 100}}
  end

  @impl true
  def handle_cast({:attack, damage}, state) do
    new_health = state.health - damage

    if new_health <= 0 do
      raise "dying!"
    else
      {:noreply, %{state | health: new_health}}
    end
  end

  @impl true
  def handle_call(:health, _from, state) do
    {:reply, state.health, state}
  end
end

Implement the Monster process as documented below.

defmodule Monster do
  use GenServer

  @doc """
  Start the `Monster` process.

  ## Examples

      iex> {:ok, pid} = Monster.start_link([])
  """
  def start_link(opts) do
    # IO.inspect/2 to observe when a `Monster` process starts.
    IO.inspect(opts, label: "Monster Started")
  end

  @doc """
  Attack a `Monster` process and apply damage.

  ## Examples

      iex> {:ok, pid} = Monster.start_link([])
      iex> :sys.get_state(pid)
      %{health: 100}
      iex> Monster.attack(pid, 30)
      iex> :sys.get_state(pid)
      %{health: 70}
  """
  def attack(monster_pid, amount) do
  end

  @doc """
  Retrieve the `Monster` health.

  ## Examples

      iex> {:ok, pid} = Monster.start_link([])
      iex> Monster.health(pid)
      100
  """
  def health(monster_pid) do
  end

  @doc """
  Callback function to start the `Monster` process. 
  Monsters should start with a `:health` value in a map.

  ## Examples

      iex> {:ok, pid} = GenServer.start_link(Monster, [])
      iex> :sys.get_state(pid)
      %{health: 100}
  """
  @impl true
  def init(_opts) do
  end

  @doc """
  Callback function to damage the `Monster`.

  When a `Monster`'s health reaches zero it should crash and raise "dying!".

  ## Examples

      iex> {:ok, pid} = GenServer.start_link(Monster, [])
      iex> :sys.get_state(pid)
      %{health: 100}
      iex> GenServer.cast(pid, {:attack, 20})
      iex> :sys.get_state(pid)
      %{health: 80}
      iex> GenServer.cast(pid, {:attack, 80})
      ** (RuntimeError) dying!
  """
  @impl true
  def handle_cast({:attack, damage}, state) do
  end

  @doc """
  Callback function to retrieve the current health of a monster.

  ## Examples

      iex> {:ok, pid} = GenServer.start_link(Monster, [])
      iex> GenServer.call(pid, :health)
      100
  """
  @impl true
  def handle_call(:health, _from, state) do
  end
end

Supervisor

Create three named Monster processes under a single supervisor. When one Monster process dies after its health reaches zero, another should be restarted in it’s place.

flowchart
  S[Supervisor]
  M1[Monster 1]
  M2[Monster 2]
  M3[Monster 3]

  S --> M1
  S --> M2
  S --> M3

  classDef crashed fill:#fe8888;
  classDef restarted stroke:#0cac08,stroke-width:4px

  class M1 crashed
  class M1 restarted

Example Solution

children = [
  %{
    id: :monster1,
    start: {Monster, :start_link, [[name: :monster1]]}
  },
  %{
    id: :monster2,
    start: {Monster, :start_link, [[name: :monster2]]}
  },
  %{
    id: :monster3,
    start: {Monster, :start_link, [[name: :monster3]]}
  }
]

Supervisor.start_link(children, strategy: :one_for_one)

Enter your solution below.

Call Monster.attack/1 on your monster processes to ensure they are restarted.

Example Solution

Monster.attack(:monster1, 110)
# logs: "Monster Started: [name: :monster1]"

Bonus: Hero

Create a named Hero process. The Hero process will automatically apply a random amount of damage between 1 and 10 to each monster in the named supervisor every second.

Example Solution

defmodule Hero do
  def start_link(opts) do
    GenServer.start_link(__MODULE__, [], opts)
  end

  def init(_opts) do
    schedule_attack()
    {:ok, []}
  end

  defp schedule_attack() do
    Process.send_after(self(), :attack_monsters, 1000)
  end

  def handle_info(:attack_monsters, state) do
    Supervisor.which_children(:rpg_supervisor)
    |> Enum.each(fn
      {_name, pid, :worker, [Monster]} -> Monster.attack(pid, Enum.random(1..10))
      _ -> :ok
    end)

    schedule_attack()
    {:noreply, state}
  end
end

Start the Hero process with three other Monster processes in the same supervisor. Make the supervisor a named supervisor so you can find all of its child processes with Supervisor.which_children/1.

Example Solution

children = [
  %{
    id: :monster1,
    start: {Monster, :start_link, [[]]}
  },
  %{
    id: :monster2,
    start: {Monster, :start_link, [[]]}
  },
  %{
    id: :monster3,
    start: {Monster, :start_link, [[]]}
  },
  %{
    id: :hero1,
    start: {Hero, :start_link, [[name: :arthur]]}
  }
]

{:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one, name: :spawner)

Use Kino.Process.sup_tree/2 to visualize the processes under your named supervisor.

Example Solution

Kino.Process.sup_tree(supervisor)

For improved observability, create a function that will print the health value for each Monster process under the named supervisor.

Example Solution

Supervisor.which_children(supervisor)
|> Enum.each(fn
  {_name, pid, :worker, [Monster]} -> IO.puts(Monster.health(pid))
  _ -> :ok
end)

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

save_name =
  case Path.basename(__DIR__) do
    "reading" -> "monster_spawner_reading"
    "exercises" -> "monster_spawner_exercise"
  end

progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()

default = Map.get(existing_progress, save_name, false)

form =
  Kino.Control.form(
    [
      completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
    ],
    report_changes: true
  )

Task.async(fn ->
  for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
    File.write!(
      progress_path,
      Jason.encode!(Map.put(existing_progress, save_name, completed), pretty: true)
    )
  end
end)

form

Commit Your Progress

Run the following in your command line from the curriculum folder to track and save your progress in a Git commit. Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.

$ git checkout -b monster-spawner-exercise
$ git add .
$ git commit -m "finish monster spawner exercise"
$ git push origin monster-spawner-exercise

Create a pull request from your monster-spawner-exercise branch to your solutions branch. Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.

DockYard Academy Students Only:

Notify your teacher by including @BrooklinJazz in your PR description to get feedback. You (or your teacher) may merge your PR into your solutions branch after review.

If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.

Up Next

Previous Next
Dominoes Testing GenServers