View Source Web Frontend with LiveView

Since we're using Phoenix, we can take advantage of the many features it brings for building a web interface.

player-auth

Player Auth

When it comes to player auth, there are two sides to the coin: authentication (AuthN) and authorization (AuthZ). The former refers to verifying the identity of a player (and will be our primary focus, for now), while the latter refers to checking whether a user has permission to take a restricted action.

Phoenix comes with an AuthN generator built-in, which should be more than enough for our needs:

$ mix phx.gen.auth Players Player players --binary-id
$ mix deps.get
$ mix ecto.migrate

This will expect players to register an email and password, which will be used to log in. A unique ID will also be created for each player upon registration, allowing us to begin thinking of players as entities. However, we can't just take the player input and start creating components with it - only systems can create components. Instead, we'll use a special component type provided for this purpose: ECSx.ClientEvents.

client-input-via-liveview

Client Input via LiveView

First consider the goals for our frontend:

  • Authenticate the player and hold player ID
  • Spawn the player's ship upon connection (writes components)
  • Hold the coordinates for the player's ship
  • Hold the coordinates for enemy ships
  • Validate user input to move the ship (writes components)

When we need to write components, ECSx.ClientEvents will be our line of communication from the frontend to the backend.

Let's create /lib/ship_web/live/game_live.ex and put it to use:

defmodule ShipWeb.GameLive do
  use ShipWeb, :live_view

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

  def mount(_params, %{"player_token" => token} = _session, socket) do
    # This context function was generated by phx.gen.auth
    player = Ship.Players.get_player_by_session_token(token)

    socket =
      socket
      |> assign(player_entity: player.id)
      # Keeping a set of currently held keys will allow us to prevent duplicate keydown events
      |> assign(keys: MapSet.new())
      # We don't know where the ship will spawn, yet
      |> assign(x_coord: nil, y_coord: nil, current_hp: nil)

    # We don't want these calls to be made on both the initial static page render and again after
    # the LiveView is connected, so we wrap them in `connected?/1` to prevent duplication
    if connected?(socket) do
      ECSx.ClientEvents.add(player.id, :spawn_ship)
      :timer.send_interval(50, :load_player_info)
    end

    {:ok, socket}
  end

  def handle_info(:load_player_info, socket) do
    # This will run every 50ms to keep the client assigns updated
    x = XPosition.get(socket.assigns.player_entity)
    y = YPosition.get(socket.assigns.player_entity)
    hp = HullPoints.get(socket.assigns.player_entity)

    {:noreply, assign(socket, x_coord: x, y_coord: y, current_hp: hp)}
  end

  def handle_event("keydown", %{"key" => key}, socket) do
    if MapSet.member?(socket.assigns.keys, key) do
      # Already holding this key - do nothing
      {:noreply, socket}
    else
      # We only want to add a client event if the key is defined by the `keydown/1` helper below
      maybe_add_client_event(socket.assigns.player_entity, key, &keydown/1)
      {:noreply, assign(socket, keys: MapSet.put(socket.assigns.keys, key))}
    end
  end

  def handle_event("keyup", %{"key" => key}, socket) do
    # We don't have to worry about duplicate keyup events
    # But once again, we will only add client events for keys that actually do something
    maybe_add_client_event(socket.assigns.player_entity, key, &keyup/1)
    {:noreply, assign(socket, keys: MapSet.delete(socket.assigns.keys, key))}
  end

  defp maybe_add_client_event(player_entity, key, fun) do
    case fun.(key) do
      :noop -> :ok
      event -> ECSx.ClientEvents.add(player_entity, event)
    end
  end

  defp keydown(key) when key in ~w(w W ArrowUp), do: {:move, :north}
  defp keydown(key) when key in ~w(a A ArrowLeft), do: {:move, :west}
  defp keydown(key) when key in ~w(s S ArrowDown), do: {:move, :south}
  defp keydown(key) when key in ~w(d D ArrowRight), do: {:move, :east}
  defp keydown(_key), do: :noop

  defp keyup(key) when key in ~w(w W ArrowUp), do: {:stop_move, :north}
  defp keyup(key) when key in ~w(a A ArrowLeft), do: {:stop_move, :west}
  defp keyup(key) when key in ~w(s S ArrowDown), do: {:stop_move, :south}
  defp keyup(key) when key in ~w(d D ArrowRight), do: {:stop_move, :east}
  defp keyup(_key), do: :noop

  def render(assigns) do
    ~H"""
    <div id="game" phx-window-keydown="keydown" phx-window-keyup="keyup">
      <p>Player ID: <%= @player_entity %></p>
      <p>Player Coords: <%= inspect({@x_coord, @y_coord}) %></p>
      <p>Hull Points: <%= @current_hp %></p>
    </div>
    """
  end
end

handling-client-events

Handling Client Events

Finally, spin up a new system for handling the events:

$ mix ecsx.gen.system ClientEventHandler
defmodule Ship.Systems.ClientEventHandler do
  ...
  @behaviour ECSx.System

  alias Ship.Components.ArmorRating
  alias Ship.Components.AttackDamage
  alias Ship.Components.AttackRange
  alias Ship.Components.AttackSpeed
  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
    client_events = ECSx.ClientEvents.get_and_clear()

    Enum.each(client_events, &process_one/1)
  end

  defp process_one({player, :spawn_ship}) do
    # We'll give player ships better stats than the enemy ships
    # (otherwise the game would be very short!)
    ArmorRating.add(player, 2)
    AttackDamage.add(player, 6)
    AttackRange.add(player, 15)
    AttackSpeed.add(player, 1.2)
    HullPoints.add(player, 75)
    SeekingTarget.add(player)
    XPosition.add(player, Enum.random(1..100))
    YPosition.add(player, Enum.random(1..100))
    XVelocity.add(player, 0)
    YVelocity.add(player, 0)
  end

  # Note Y movement will use screen position (increasing Y goes south)
  defp process_one({player, {:move, :north}}), do: YVelocity.update(player, -1)
  defp process_one({player, {:move, :south}}), do: YVelocity.update(player, 1)
  defp process_one({player, {:move, :east}}), do: XVelocity.update(player, 1)
  defp process_one({player, {:move, :west}}), do: XVelocity.update(player, -1)

  defp process_one({player, {:stop_move, :north}}), do: YVelocity.update(player, 0)
  defp process_one({player, {:stop_move, :south}}), do: YVelocity.update(player, 0)
  defp process_one({player, {:stop_move, :east}}), do: XVelocity.update(player, 0)
  defp process_one({player, {:stop_move, :west}}), do: XVelocity.update(player, 0)
end

Notice how the LiveView client can write to ECSx.ClientEvents, while the system handles and also clears the events. This ensures that we don't process the same event twice, nor will any events get "lost" and not processed.

creating-a-phoenix-route

Creating a Phoenix Route

Head into router.ex and look for the new scope which uses :require_authenticated_player. We're going to add a new route for our game interface:

scope "/", ShipWeb do
  pipe_through [:browser, :require_authenticated_player]

  live_session :require_authenticated_player,
    on_mount: [{ShipWeb.PlayerAuth, :ensure_authenticated}] do
    live "/game", GameLive
    ...
  end
end

Now we can run

$ iex -S mix phx.server

and go to localhost:4000/game to test the input. Once you are logged in, wait for the player coords to display (this will be the indicator that your ship has spawned), and try moving around with WASD or arrow keys!

loading-screen

Loading Screen

You might notice that while the ship is spawning, the Player Coords and Hull Points don't display properly - this isn't a major issue now, but once our coordinates are being used by a more sophisticated display, this will not be acceptable. What we need is a loading screen to show the user until the necessary data is properly loaded.

First, let's create a new ECSx.Tag to mark when a player's ship has finished spawning:

$ mix ecsx.gen.tag PlayerSpawned

Then we'll add this tag at the end of the :spawn_ship client event

defmodule Ship.Systems.ClientEventHandler do
  ...
  alias Ship.Components.PlayerSpawned
  ...
  defp process_one({player, :spawn_ship}) do
    ...
    PlayerSpawned.add(player)
  end
  ...
end

Now we'll update our LiveView to use a new @loading assign which is initially set to true, then set to false after the ship is spawned and the data is loaded for the first time.

Replace both the current mount and handle_info functions with the below functions, and replace the existing render function with the new render function below

defmodule ShipWeb.GameLive do
  ...
  alias Ship.Components.PlayerSpawned
  ...
  def mount(_params, %{"player_token" => token} = _session, socket) do
    player = Ship.Players.get_player_by_session_token(token)

    socket =
      socket
      |> assign(player_entity: player.id)
      |> assign(keys: MapSet.new())
      # This gets its own helper in case we need to return to this state again later
      |> assign_loading_state()

    if connected?(socket) do
      ECSx.ClientEvents.add(player.id, :spawn_ship)
      # The first load will now have additional responsibilities
      send(self(), :first_load)
    end
    

    {:ok, socket}
  end

  defp assign_loading_state(socket) do
    assign(socket,
      x_coord: nil,
      y_coord: nil,
      current_hp: nil,
      # This new assign will control whether the loading screen is shown
      loading: true
    )
  end

  def handle_info(:first_load, socket) do
    # Don't start fetching components until after spawn is complete!
    :ok = wait_for_spawn(socket.assigns.player_entity)

    socket =
      socket
      |> assign_player_ship()
      |> assign(loading: false)

    # We want to keep up-to-date on this info
    :timer.send_interval(50, :refresh)

    {:noreply, socket}
  end

  def handle_info(:refresh, socket) do
    {:noreply, assign_player_ship(socket)}
  end

  defp wait_for_spawn(player_entity) do
    if PlayerSpawned.exists?(player_entity) do
      :ok
    else
      Process.sleep(10)
      wait_for_spawn(player_entity)
    end
  end

  # Our previous :load_player_info handler becomes a shared helper for the new handlers
  defp assign_player_ship(socket) do
    x = XPosition.get(socket.assigns.player_entity)
    y = YPosition.get(socket.assigns.player_entity)
    hp = HullPoints.get(socket.assigns.player_entity)

    assign(socket, x_coord: x, y_coord: y, current_hp: hp)
  end

  def handle_event("keydown", %{"key" => key}, socket) do
  ...

  def render(assigns) do
    ~H"""
    <div id="game" phx-window-keydown="keydown" phx-window-keyup="keyup">
      <%= if @loading do %>
        <p>Loading...</p>
      <% else %>
        <p>Player ID: <%= @player_entity %></p>
        <p>Player Coords: <%= inspect({@x_coord, @y_coord}) %></p>
        <p>Hull Points: <%= @current_hp %></p>
      <% end %>
    </div>
    """
  end
end

player-gui-using-svg

Player GUI using SVG

One of the simplest ways to build a display for web is with SVG. Each entity can be represented by a single SVG element, which only requires its coordinates. Then a viewBox can zoom the player's display in to show just the local area around their ship.

defmodule ShipWeb.GameLive do
  ...
  def render(assigns) do
    ~H"""
    <div id="game" phx-window-keydown="keydown" phx-window-keyup="keyup">
      <svg
        viewBox={"#{@x_offset} #{@y_offset} #{@screen_width} #{@screen_height}"}
        preserveAspectRatio="xMinYMin slice"
      >
        <rect width={@game_world_size} height={@game_world_size} fill="#72eff8" />

        <%= if @loading do %>
          <text x={div(@screen_width, 2)} y={div(@screen_height, 2)} style="font: 1px serif">
            Loading...
          </text>
        <% else %>
          <image
            x={@x_coord}
            y={@y_coord}
            width="1"
            height="1"
            href={~p"/images/#{@player_ship_image_file}"}
          />
          <%= for {_entity, x, y, image_file} <- @other_ships do %>
            <image
              x={x}
              y={y}
              width="1"
              height="1"
              href={~p"/images/#{image_file}"}
            />
          <% end %>
          <text x={@x_offset} y={@y_offset + 1} style="font: 1px serif">
            Hull Points: <%= @current_hp %>
          </text>
        <% end %>
      </svg>
    </div>
    """
  end
end

We've added a lot here, so let's go line-by-line:

We're filling the screen with an svg viewBox, which takes four arguments. The first two - x and y offsets - tell the viewBox what area of the map to focus on, while the latter two - screen width and height - tell it how much to zoom in. To get the offsets, we'll need to calculate the coordinate pair which should be at the very top-left of the player's view. This will need to be updated every time the player moves. The screen width and height (measured by game coordinates) will be assigned on mount and won't change. The preserveAspectRatio has two parts: xMinYMin means we define the offset by the top-left coordinate, and slice means we're only expecting to show a "slice" of the map in our viewBox, not the whole thing.

Our first element will be a simple rect (rectangle) with a light-blue fill to cover the entire world map. This will be the "background" - representing the ocean. The game world will consistently be 100x100, so we can assign a game_world_size of 100 for this purpose.

The loading screen will still use the same viewBox and background, but with only one other element - text to display a loading message in the center of the screen. One curiosity regarding the font size: we use 1px, but you'll see later that the text is actually quite large. This is because the viewBox considers each game "tile" to be 1 pixel, and automatically scales these pixels up to a larger size based on the screen width and height compared to the size of the browser window. So when we say font: 1px it means the text will be as tall as one game tile.

Once the game is finished loading, we'll display three things: the player's ship, other ships, and the player's current HP.

For the player's ship, we'll make an image element, using the existing x and y coordinates, defining the size as one game tile, and pointing to the player's ship image file.

For other ships, we'll need a new assign to hold that data - ID and coordinates, at minimum. Then each one will get an image just like the player's ship.

Lastly, we'll put an HP display near the top-left corner.

Lets first create an ImageFile component:

$ mix ecsx.gen.component ImageFile binary

Next step is to update our LiveView with these new assigns:

defmodule ShipWeb.GameLive do
  ...
  alias Ship.Components.ImageFile
  ...
  def mount(_params, %{"player_token" => token} = _session, socket) do
    player = Ship.Players.get_player_by_session_token(token)

    socket =
      socket
      |> assign(player_entity: player.id)
      |> assign(keys: MapSet.new())
      # These will configure the scale of our display compared to the game world
      |> assign(game_world_size: 100, screen_height: 30, screen_width: 50)
      |> assign_loading_state()
    
    if connected?(socket) do
      ECSx.ClientEvents.add(player.id, :spawn_ship)
      send(self(), :first_load)
    end

    {:ok, socket}
  end

  defp assign_loading_state(socket) do
    assign(socket,
      x_coord: nil,
      y_coord: nil,
      current_hp: nil,
      player_ship_image_file: nil,
      other_ships: [],
      x_offset: 0,
      y_offset: 0,
      loading: true
    )
  end

  def handle_info(:first_load, socket) do
    :ok = wait_for_spawn(socket.assigns.player_entity)

    socket =
      socket
      |> assign_player_ship()
      |> assign_other_ships()
      |> assign_offsets()
      |> assign(loading: false)

    :timer.send_interval(50, :refresh)

    {:noreply, socket}
  end

  def handle_info(:refresh, socket) do
    socket =
      socket
      |> assign_player_ship()
      |> assign_other_ships()
      |> assign_offsets()

    {:noreply, socket}
  end

  defp wait_for_spawn(player_entity) do
    if PlayerSpawned.exists?(player_entity) do
      :ok
    else
      Process.sleep(10)
      wait_for_spawn(player_entity)
    end
  end

  defp assign_player_ship(socket) do
    x = XPosition.get(socket.assigns.player_entity)
    y = YPosition.get(socket.assigns.player_entity)
    hp = HullPoints.get(socket.assigns.player_entity)
    image = ImageFile.get(socket.assigns.player_entity)

    assign(socket, x_coord: x, y_coord: y, current_hp: hp, player_ship_image_file: image)
  end

  defp assign_other_ships(socket) do
    other_ships =
      Enum.reject(all_ships(), fn {entity, _, _, _} -> entity == socket.assigns.player_entity end)

    assign(socket, other_ships: other_ships)
  end

  defp all_ships do
    for {ship, _hp} <- HullPoints.get_all() do
      x = XPosition.get(ship)
      y = YPosition.get(ship)
      image = ImageFile.get(ship)
      {ship, x, y, image}
    end
  end

  defp assign_offsets(socket) do
    # Note: the socket must already have updated player coordinates before assigning offsets!
    %{screen_width: screen_width, screen_height: screen_height} = socket.assigns
    %{x_coord: x, y_coord: y, game_world_size: game_world_size} = socket.assigns

    x_offset = calculate_offset(x, screen_width, game_world_size)
    y_offset = calculate_offset(y, screen_height, game_world_size)

    assign(socket, x_offset: x_offset, y_offset: y_offset)
  end

  defp calculate_offset(coord, screen_size, game_world_size) do
    case coord - div(screen_size, 2) do
      offset when offset < 0 -> 0
      offset when offset > game_world_size - screen_size -> game_world_size - screen_size
      offset -> offset
    end
  end

  def handle_event("keydown", %{"key" => key}, socket) do 
  ...
end

Next let's create the ImageFile components when a ship is spawned:

defmodule Ship.Manager do
  ...
  def startup do
    for _ships <- 1..40 do
      ...
      Ship.Components.ImageFile.add(entity, "npc_ship.svg")
    end
  end
  ...
end
defmodule Ship.Systems.ClientEventHandler do
  ...
  alias Ship.Components.ImageFile
  ...
  defp process_one({player, :spawn_ship}) do
    ...
    ImageFile.add(player, "player_ship.svg")
    PlayerSpawned.add(player)
  end
  ...
end

Lastly, we'll need the player_ship.svg and npc_ship.svg files. Right-click on the links and save them to priv/static/images/, where they will be found by our ~p"/images/..." calls in the LiveView template.

Now running

$ iex -S mix phx.server

and heading to localhost:4000/game should provide a usable game interface to move your ship around, ideally keeping it out of attack range of enemy ships, while remaining close enough for your own ship to attack (remember that we gave the player ship a longer attack range than the enemy ships).

projectile-animations

Projectile Animations

Currently the most challenging part of the game is knowing when your ship is attacking, and when it is being attacked. Let's implement a new feature to make attacks visible to the player(s). There are several ways to go about this; we're going to take an approach that showcases ECS design:

  • Instead of an attack immediately dealing damage, it will spawn a cannonball entity
  • The cannonball entity will have position and velocity components, like ships do
  • It will also have new components such as ProjectileTarget and ProjectileDamage
  • A Projectile system will guide it to its target, then destroy the cannonball and deal damage
  • In our LiveView, we'll create a new assign to hold the locations of projectiles
  • The new assign will be used to create SVG elements
  • To help fetch locations for projectiles only, we'll add an IsProjectile tag

Start by running the generator commands for our new components, systems, and tag:

$ mix ecsx.gen.component ProjectileTarget binary
$ mix ecsx.gen.component ProjectileDamage integer
$ mix ecsx.gen.system Projectile
$ mix ecsx.gen.tag IsProjectile

Then we need to update the Attacking system to spawn projectiles instead of immediately dealing damage. We'll replace the existing deal_damage/2 with a spawn_projectile/2:

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

  alias Ship.Components.AttackCooldown
  alias Ship.Components.AttackDamage
  alias Ship.Components.AttackRange
  alias Ship.Components.AttackSpeed
  alias Ship.Components.AttackTarget
  alias Ship.Components.ImageFile
  alias Ship.Components.IsProjectile
  alias Ship.Components.ProjectileDamage
  alias Ship.Components.ProjectileTarget
  alias Ship.Components.SeekingTarget
  alias Ship.Components.XPosition
  alias Ship.Components.XVelocity
  alias Ship.Components.YPosition
  alias Ship.Components.YVelocity
  alias Ship.SystemUtils
  ...
  defp attack_if_ready({self, target}) do
    cond do
      ...
      :otherwise ->
        spawn_projectile(self, target)
        add_cooldown(self)
    end
  end

  defp spawn_projectile(self, target) do
    attack_damage = AttackDamage.get(self)
    x = XPosition.get(self)
    y = YPosition.get(self)
    # Armor reduction should wait until impact to be calculated
    cannonball_entity = Ecto.UUID.generate()

    IsProjectile.add(cannonball_entity)
    XPosition.add(cannonball_entity, x)
    YPosition.add(cannonball_entity, y)
    XVelocity.add(cannonball_entity, 0)
    YVelocity.add(cannonball_entity, 0)
    ImageFile.add(cannonball_entity, "cannonball.svg")
    ProjectileTarget.add(cannonball_entity, target)
    ProjectileDamage.add(cannonball_entity, attack_damage)
  end
  ...
end

Notice we start the velocity at zero, because the movement will be entirely handled by the Projectile system:

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

  alias Ship.Components.ArmorRating
  alias Ship.Components.HullPoints
  alias Ship.Components.ImageFile
  alias Ship.Components.IsProjectile
  alias Ship.Components.ProjectileDamage
  alias Ship.Components.ProjectileTarget
  alias Ship.Components.XPosition
  alias Ship.Components.XVelocity
  alias Ship.Components.YPosition
  alias Ship.Components.YVelocity

  @cannonball_speed 3

  @impl ECSx.System
  def run do
    projectiles = IsProjectile.get_all()
      
    Enum.each(projectiles, fn projectile ->
      case ProjectileTarget.get(projectile, nil) do
        nil ->
          # The target has already been destroyed
          destroy_projectile(projectile)

        target ->
          continue_seeking_target(projectile, target)
      end
    end)
  end

  defp continue_seeking_target(projectile, target) do
    {dx, dy, distance} = get_distance_to_target(projectile, target)

    case distance do
      0 ->
        collision(projectile, target)

      distance when distance / @cannonball_speed <= 1 ->
        move_directly_to_target(projectile, {dx, dy})

      distance ->
        adjust_velocity_towards_target(projectile, {distance, dx, dy})
    end
  end

  defp get_distance_to_target(projectile, target) do
    target_x = XPosition.get(target)
    target_y = YPosition.get(target)
    target_dx = XVelocity.get(target)
    target_dy = YVelocity.get(target)
    target_next_x = target_x + target_dx
    target_next_y = target_y + target_dy

    x = XPosition.get(projectile)
    y = YPosition.get(projectile)

    dx = target_next_x - x
    dy = target_next_y - y

    {dx, dy, ceil(:math.sqrt(dx ** 2 + dy ** 2))}
  end

  defp collision(projectile, target) do
    damage_target(projectile, target)
    destroy_projectile(projectile)
  end

  defp damage_target(projectile, target) do
    damage = ProjectileDamage.get(projectile)
    reduction_from_armor = ArmorRating.get(target)
    final_damage_amount = 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 destroy_projectile(projectile) do
    IsProjectile.remove(projectile)
    XPosition.remove(projectile)
    YPosition.remove(projectile)
    XVelocity.remove(projectile)
    YVelocity.remove(projectile)
    ImageFile.remove(projectile)
    ProjectileTarget.remove(projectile)
    ProjectileDamage.remove(projectile)
  end

  defp move_directly_to_target(projectile, {dx, dy}) do
    XVelocity.update(projectile, dx)
    YVelocity.update(projectile, dy)
  end

  defp adjust_velocity_towards_target(projectile, {distance, dx, dy}) do
    # We know what is needed, but we need to slow it down, so its travel
    # will take more than one tick.  Otherwise the player will not see it!
    ticks_away = ceil(distance / @cannonball_speed)
    adjusted_dx = div(dx, ticks_away)
    adjusted_dy = div(dy, ticks_away)

    XVelocity.update(projectile, adjusted_dx)
    YVelocity.update(projectile, adjusted_dy)
  end
end

Note that we rely on the absence of a ProjectileTarget to know that the target is already destroyed. Currently our Destruction system does have an untarget feature for removing target components upon destruction, but this only applies to AttackTargets. We'll want to expand this feature to also cover ProjectileTargets:

defmodule Ship.Systems.Destruction do
  ...
  alias Ship.Components.ProjectileTarget
  ...
  defp untarget(target) do
    for ship <- AttackTarget.search(target) do
      AttackTarget.remove(ship)
      SeekingTarget.add(ship)
    end

    for projectile <- ProjectileTarget.search(target) do
      ProjectileTarget.remove(projectile)
    end
  end
end

Our final task is to render these projectiles in the LiveView. Let's start by adding a new assign:

defmodule Ship.GameLive do
  ...
  alias Ship.Components.IsProjectile
  ...
  defp assign_loading_state(socket) do
    assign(socket,
      ...
      projectiles: []
    )
  end

  def handle_info(:first_load, socket) do
    ...
    socket =
      socket
      |> assign_player_ship()
      |> assign_other_ships()
      |> assign_projectiles()
      |> assign_offsets()
      |> assign(loading: false)
    ...
  end

  def handle_info(:refresh, socket) do
    socket =
      socket
      |> assign_player_ship()
      |> assign_other_ships()
      |> assign_projectiles()
      |> assign_offsets()
    ...
  end
  ...
  defp assign_projectiles(socket) do
    projectiles =
      for projectile <- IsProjectile.get_all() do
        x = XPosition.get(projectile)
        y = YPosition.get(projectile)
        image = ImageFile.get(projectile)
        {projectile, x, y, image}
      end

    assign(socket, projectiles: projectiles)
  end
  ...
end

Then we'll update the render to include the projectiles:

defmodule Ship.GameLive do
  def render(assigns) do
    ~H"""
    ...
    <%= for {_entity, x, y, image_file} <- @projectiles do %>
      <image
        x={x}
        y={y}
        width="1"
        height="1"
        href={~p"/images/#{image_file}"}
      />
    <% end %>
    <%= for {_entity, x, y, image_file} <- @other_ships do %>
    ...
    """
  end
end

Lastly - cannonball.svg - we will make this file from scratch!

$ touch priv/static/images/cannonball.svg
<svg width="100" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
  <circle cx="50" cy="50" r="25" stroke="black" fill="gray" stroke-width="5" />
</svg>

The width and height values will be overriden by our LiveView render's width="1" height="1", but they still play an important role - because the circle's parameters will be measured relative to these - so we'll set them to 100 for simplicity. cx and cy represent the coordinates for the center of the circle, which should be one-half the width and height. The size of the circle will be set with r (radius) and stroke-width (the border around the circle) - we can calculate diameter = 2 * (r + stroke_width) = 60. This diameter is also relative, so when our cannonball.svg is scaled down to 1 x 1, the visible circle will be 0.6 x 0.6

limiting-ship-movement

Limiting Ship Movement

You might notice that that once the ship hits the edge of the world map it keeps moving and disappears from sight. This happens because we keep updating the ship's position regardless of it being within the limits of the map.

To solve this, let's go to the Driver system and limit the position range for the player ship:

defmodule Ship.Systems.Driver do
  ...
  def run do
    for {entity, x_velocity} <- XVelocity.get_all() do
      ...
      new_x_position = calculate_new_position(x_position, x_velocity)
      ...
    end

    for {entity, y_velocity} <- YVelocity.get_all() do
      ...
      new_y_position = calculate_new_position(y_position, y_velocity)
      ...
    end
    ...
  end

  # Do not let player ship move past the map limit
  defp calculate_new_position(current_position, velocity) do
    new_position = current_position + velocity
    new_position = Enum.min([new_position, 99])

    Enum.max([new_position, 0])
  end
end

This will limit the position for the ship on both X and Y axis to be between 0 and 99 (the size of the 100x100 world map).