Home Posts Post Search Tag Search

LiveView 34 - Chapter 13: Your Turn
Published on: 2026-02-25 Tags: elixir, Blog, Side Project, LiveView, Keyboard, Phoenix, Graphics

Your Turn

Give It a Try

• First, refactor our Pentominoes game by removing all references to Pen-
  to.Game.Board from the Board live component. Instead, the component should
  only call on Pento.Game, which can in turn call on Pento.Game.Board. This
  allows our live view to confine all of its interactions with our gaming logic
  to the single Pento.Game API, reaching only one level deep into the game’s
  abstractions.
• Now, implement a score-keeping feature that tracks a user’s score as they
  play a single game of Pentominoes. Assign 500 points for each piece that
  is placed on the board, and subtract one point for every move. A user gets
  a higher score for solving the puzzle in fewer moves.
• Next, build a button that allows a user to “give up”. When the button is
  clicked, the game ends.
• Build a “You’ve Won” page to motivate the user. If all of the pieces are
  placed on a page, let the user know they’ve won. What can you show them
  to celebrate their success?
• Add the new route to the nav pane.
• Make sure that the control panel works when you click a button.
• Add a way to navigate back to the picker.
• Fix the description.
• Fix the flash messages.
• Add in an escape key press so that you can drop the active piece.
• Put this game in your game site!!!

• First let’s deal with the abstractions

  def pick(board, shape_name) do
    Board.pick(board, shape_name)
  end

  def new(puzzle) do
    Board.new(puzzle)
  end

  def to_shapes(board) do
    Board.to_shapes(board)
  end

  def active?(board, shape) do
    Board.active?(board, shape)
  end

Now you can remove all the calls to Board. and make them Game.


• Okay so now we want to add in a score system. We will need to add in the number of moves so far as well as the current amount of pieces placed.

#board.ex
  def drop(%{active_pento: pento} = board) do
    board
    |> Map.put(:active_pento, nil)
    |> Map.put(:completed_pentos, [pento | board.completed_pentos])
    |> Map.update(:moves, 0, &(&1 + 1))
  end

#GameLive.Board
  def update(%{puzzle: puzzle, id: id}, socket) do
    {:ok,
     socket
     |> assign_params(id, puzzle)
     |> assign_board()
     |> assign_shapes()}
  end

    def render(assigns) do
    ~H"""
    <div id={@id} phx-window-keydown="key" phx-target={@myself}>
      <.score_board score={score(@board)} moves={@board.moves} />
      <.canvas view_box="0 0 200 100">
        <%= for shape <- @shapes do %>
          <.shape
            points={shape.points}
            fill={color(shape.color, Game.active?(@board, shape.name), false)}
            name={shape.name}
          />
        <% end %>
      </.canvas>
      <hr />
      <.palette
        shape_names={@board.palette}
        completed_shape_names={Enum.map(@board.completed_pentos, & &1.name)}
      />
      <.control_panel viewBox="0 0 200 40">
        <.triangle x={20} y={0} rotate={0} fill={color(:orange, true, true)} />
        <.triangle x={30} y={10} rotate={90} fill={color(:orange, true, false)} />
        <.triangle x={20} y={20} rotate={180} fill={color(:orange, true, false)} />
        <.triangle x={10} y={10} rotate={270} fill={color(:orange, true, false)} />
      </.control_panel>
      <hr />
    </div>
    """
  end

  def score(board) do
    500 * length(board.completed_pentos) - board.moves
  end


# GameLive.Component
  attr(:score, :integer, required: true)
  attr(:moves, :integer, required: true)

  def score_board(assigns) do
    ~H"""
    <div class="flex justify-between items-center px-4 py-2
                bg-gray-100 dark:bg-gray-800
                rounded-lg shadow-sm">

      <div>
        <div class="text-xs uppercase tracking-wide
                    text-gray-500 dark:text-gray-400">
          Score
        </div>

        <div class="text-2xl font-semibold tabular-nums
                    text-gray-900 dark:text-gray-100">
          <%= @score %>
        </div>
      </div>

      <div class="text-right">
        <div class="text-xs uppercase tracking-wide
                    text-gray-500 dark:text-gray-400">
          Moves
        </div>

        <div class="text-lg tabular-nums
                    text-gray-900 dark:text-gray-100">
          <%= @moves %>
        </div>
      </div>

    </div>
    """
  end

Okay so we added in a key-pair for moves to the board that we then leveraged to make the score along with the completed_pentos. Once that was set we added in a component that allowed us to display a nice looking Score board above the board. Lastly we added the new component with the right values.


• Okay now we need to add in a “give-up” button that will allow the user to start over or pick a new puzzle. For this I want to take the components that we have within the game_live.ex and add in that button. I want it to look nice and then take them back to the puzzle selection screen.

# game_live.ex
  def give_up(assigns) do
    ~H"""
    <.button navigate={~p"/play"} data-confirm="Are you sure you want to give up?">
       Give Up?
    </.button>
    """
  end

  def render(assigns) do
    ...
    <div class="flex justify-between items-center">
        <.help /> <.give_up />
    </div>
    ...
  end

As you can see this wasn’t too bad as I was able to just add in a button that has a click and then a check. I also made sure that the button was opposite the help button with a flex.


• Okay so now we want to add in a check for all the game pieces being placed. We want to show some sort of fireworks or something and then show the score. I’m not sure how to do this but we can learn together.


So for this we can use the send(self(), :message) to the parent liveview so it knows that the game is complete. I wanted it to have the game_live execute the fireworks and the score.

# GameLive.Board.ex
  defp drop(socket) do
    case Game.maybe_drop(socket.assigns.board) do
      {:error, message} ->
        put_flash(socket, :info, message)

      {:ok, board} ->
        if all_pieces_placed?(board),
          do: send(self(), :board_complete)

        socket |> assign(board: board) |> assign_shapes
    end
  end

  def all_pieces_placed?(board) do
    length(board.completed_pentos) == length(board.palette)
  end

# GameLive
  def handle_info(:board_complete, socket) do
    {:noreply,
     socket
     |> push_event("fireworks", %{}) 
     |> put_flash(:info, "Congratulations! You've completed the board!")}
  end

# root.html.heex
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>

# app.js
// Fireworks effect when the board is complete.
// push_event on the server dispatches as "phx:<event_name>" on the window.
window.addEventListener("phx:fireworks", () => {
  console.log("🎆 Fireworks! 🎆")
  confetti({
    particleCount: 150,
    spread: 80,
    origin: { y: 0.6 },
  })
  // Fire a second burst for extra flair
  setTimeout(() => {
    confetti({
      particleCount: 100,
      angle: 60,
      spread: 55,
      origin: { x: 0 },
    })
    confetti({
      particleCount: 100,
      angle: 120,
      spread: 55,
      origin: { x: 1 },
    })
  }, 300)
})

Now we have a check for when the board is complete and then we send a message to the parent liveview. The parent liveview then pushes an event to the client that triggers the fireworks. We also add in a flash message to congratulate the user.


• This one is easy.

<div>
  <a href="/guess" class="btn btn-ghost"> Guessing Game </a>
  <a href="/survey" class="btn btn-ghost"> Survey </a>
  <a href="/products" class="btn btn-ghost"> Products </a>
  <a href="/questions" class="btn btn-ghost"> FAQ </a>
  <a href="/promo" class="btn btn-ghost"> Promo </a>
  <a href="/search" class="btn btn-ghost"> Search </a>
  <a href="/admin/dashboard" class="btn btn-ghost"> Dashboard </a>
  <a href="/play" class="btn btn-ghost"> Pentos </a>
</div>

Just added in a link to the nav pane that takes us to the puzzle picker.


• Okay so now we want to add in the ability to use the buttons on the control panel. We need to add in the rotate and the flip buttons and then make sure that they are able to be used.

# GameLive.Component
  def control_panel(assigns) do
    ~H"""
    <svg viewBox={@viewBox}>
      <defs>
        <polygon id="triangle" points="6.25 1.875, 12.5 12.5, 0 12.5" />

        <path
    id="rotate_symbol"
    d="M20 8 A10 10 0 1 0 20 16"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    />
    <polygon id="rotate_arrow" points="22,18 22,11 17,15" fill="currentColor" />

        <polygon id="flip_left" points="6,12 10,8 10,16" fill="currentColor" />
        <polygon id="flip_right" points="18,12 14,8 14,16" fill="currentColor" />
        <line id="flip_axis" x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="2" />
      </defs>
      {render_slot(@inner_block)}
    </svg>
    """
  end

  attr(:x, :integer, required: true)
  attr(:y, :integer, required: true)
  attr(:rotate, :integer, required: true)
  attr(:fill, :string, required: true)
  attr(:on_click, :string, required: false)

  def triangle(assigns) do
    local_cx = 6.25
    local_cy = 8.958

    ~H"""
    <use
      x={@x}
      y={@y}
      transform={"rotate(#{@rotate} #{(@x + local_cx)} #{(@y + local_cy)})"}
      href="#triangle"
      fill={@fill}
      phx-click={@on_click}
      phx-target="#board-component"
      style="cursor: pointer"
    />
    """
  end

  attr(:x, :integer, required: true)
  attr(:y, :integer, required: true)
  attr(:size, :integer, default: 24)
  attr(:fill, :string, required: true)
  attr(:on_click, :string, required: false)

  def rotate_symbol(assigns) do
    ~H"""
    <g
      transform={"translate(#{@x}, #{@y}) scale(#{@size / 24})"}
      style={"color: #{@fill}"}
      phx-click={@on_click}
      phx-target="#board-component"
    >
      <use href="#rotate_symbol" style="cursor: pointer" />
      <use href="#rotate_arrow" style="cursor: pointer" />
    </g>
    """
  end

  attr(:x, :integer, required: true)
  attr(:y, :integer, required: true)
  attr(:size, :integer, default: 24)
  attr(:fill, :string, required: true)
  attr(:on_click, :string, required: false)

  def flip_symbol(assigns) do
    ~H"""
    <g
      transform={"translate(#{@x}, #{@y}) scale(#{@size / 24})"}
      style={"color: #{@fill}"}
      phx-click={@on_click}
      style="cursor: pointer"
      phx-target="#board-component"
    >
      <use href="#flip_left" style="cursor: pointer" />
      <use href="#flip_right" style="cursor: pointer" />
      <use href="#flip_axis" style="cursor: pointer" />
    </g>
    """
  end

This added in the new images now I need to implement the actions for them.


# GameLive.Board
  def render(assigns) do
    ...

      <.control_panel viewBox="0 0 200 40">
        <.triangle x={20} y={0} rotate={0} fill={color(:orange, true, true)} on_click="up" />
        <.triangle x={30} y={10} rotate={90} fill={color(:orange, true, false)} on_click="right" />
        <.triangle x={20} y={20} rotate={180} fill={color(:orange, true, false)} on_click="down" />
        <.triangle x={10} y={10} rotate={270} fill={color(:orange, true, false)} on_click="left" />

        <.rotate_symbol x={50} y={5} size={30} fill={color(:orange, true, false)} on_click="rotate" />
        <.flip_symbol x={80} y={5} size={30} fill={color(:orange, true, false)} on_click="flip" />
      </.control_panel>

    ...
  end

  @impl true
  def handle_event("up", _, socket), do: handle_event("key", %{"key" => "ArrowUp"}, socket)
  @impl true
  def handle_event("down", _, socket), do: handle_event("key", %{"key" => "ArrowDown"}, socket)
  @impl true
  def handle_event("left", _, socket), do: handle_event("key", %{"key" => "ArrowLeft"}, socket)
  @impl true
  def handle_event("right", _, socket), do: handle_event("key", %{"key" => "ArrowRight"}, socket)
  @impl true
  def handle_event("rotate", _, socket), do: handle_event("key", %{"key" => "Shift"}, socket)
  @impl true
  def handle_event("flip", _, socket), do: handle_event("key", %{"key" => "Enter"}, socket)

That is it we now have the ability to use the control panel.


Looks like I forgot the place button.

# GameLive.Board
  @impl true
  def handle_event("drop", _, socket), do: handle_event("key", %{"key" => " "}, socket)

  def render(assigns) do
    ...
    <.control_panel viewBox="0 0 200 40">
        <.triangle x={20} y={0} rotate={0} fill={color(:orange, true, true)} on_click="up" />
        <.triangle x={30} y={10} rotate={90} fill={color(:orange, true, false)} on_click="right" />
        <.triangle x={20} y={20} rotate={180} fill={color(:orange, true, false)} on_click="down" />
        <.triangle x={10} y={10} rotate={270} fill={color(:orange, true, false)} on_click="left" />

        <.rotate_symbol x={50} y={5} size={30} fill={color(:orange, true, false)} on_click="rotate" />
        <.flip_symbol x={80} y={5} size={30} fill={color(:orange, true, false)} on_click="flip" />
        <.drop_symbol x={105} y={5} size={30} fill={color(:orange, true, false)} on_click="drop" />
    </.control_panel>

    ...

  end

# GameLive.Component
attr(:viewBox, :string)
  slot(:inner_block, required: true)

  def control_panel(assigns) do
    ~H"""
    <svg viewBox={@viewBox}>
      <defs>
        <polygon id="triangle" points="6.25 1.875, 12.5 12.5, 0 12.5" />

        <path
          id="rotate_symbol"
          d="M20 8 A10 10 0 1 0 20 16"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
        />
        <polygon id="rotate_arrow" points="22,18 22,11 17,15" fill="currentColor" />

        <polygon id="flip_left" points="6,12 10,8 10,16" fill="currentColor" />
        <polygon id="flip_right" points="18,12 14,8 14,16" fill="currentColor" />
        <line id="flip_axis" x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="2" />

        <line id="drop_axis" x1="22" y1="8" x2="6" y2="8" stroke="currentColor" stroke-width="2" />
        <polygon id="drop_arrow" points="20,12 8,12 14,18" fill="currentColor" />

      </defs>
      {render_slot(@inner_block)}
    </svg>
    """
  end

  ...

  attr(:x, :integer, required: true)
  attr(:y, :integer, required: true)
  attr(:size, :integer, default: 24)
  attr(:fill, :string, required: true)
  attr(:on_click, :string, required: false)

  def drop_symbol(assigns) do
    ~H"""
    <g
      transform={"translate(#{@x}, #{@y}) scale(#{@size / 24})"}
      style={"color: #{@fill}"}
      phx-click={@on_click}
      phx-target="#board-component"
    >
      <use href="#drop_axis" style="cursor: pointer" />
      <use href="#drop_arrow" style="cursor: pointer" />
    </g>
    """
  end

• For the way to navigate back to the picker I have: the nav panel, the give_up button, and Ill add something for when the user completes the puzzle.

#GameLive
  @impl true
  def mount(%{"puzzle" => puzzle}, _session, socket) do
    {:ok, assign(socket, puzzle: puzzle, complete: false)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <section class="mx-auto max-w-4xl px-4 py-8">
        <h1 class="font-heavy text-3xl mb-6">Welcome to Pento!</h1>
        <GameInstructions.show />
        <div class="flex justify-between items-center">
          <.help /> <.give_up />
        </div>
        <%= if @complete do %>
        <.complete_modal puzzle={@puzzle} />
      <% end %>
        <div id="game-container" phx-hook="Fireworks" />
        <.live_component module={Board} puzzle={@puzzle} id="board-component" key={@complete} />
      </section>
    </Layouts.app>
    """
  end

  @impl true
  def handle_info(:board_complete, socket) do
    {:noreply,
     socket
     |> assign(complete: true)
     |> push_event("fireworks", %{})
     |> put_flash(:info, "Congratulations! You've completed the board!")}
  end

  def complete_modal(assigns) do
    ~H"""
    <div class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50">
      <div class="bg-white rounded-2xl shadow-2xl p-8 flex flex-col items-center gap-6 max-w-sm w-full">
        <h2 class="text-2xl font-bold text-gray-800">🎉 Puzzle Complete!</h2>
        <p class="text-gray-500 text-center">Amazing work! What would you like to do next?</p>
        <div class="flex gap-4 w-full">
          <button
            phx-click="try_again"
            class="flex-1 py-3 rounded-xl bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition"
          >
            Try Again
          </button>
          <.link
            navigate={~p"/play"}
            class="flex-1 py-3 rounded-xl bg-gray-200 text-gray-800 font-semibold hover:bg-gray-300 transition text-center"
          >
            Pick a Puzzle
          </.link>
        </div>
      </div>
    </div>
    """
  end

Just to go over what I did I added in a completed to the main live view that is being set when the :board_complete event is triggered. It will then put a module over the entire page that will ask the user if they want to play again. I added in the key={} so that the module will be rerendered if the user wants to play the same game again.

• I just removed the reference to the drag and drop on the description.


• This is how I took care of the Escape key issue.

  def do_key(socket, key) do
    case key do
      ...

      "Escape" ->
        pick(socket, :clear)

      _ ->
        socket
    end
  end

  ...

  defp pick(socket, :clear) do
    %{socket | assigns: %{socket.assigns | board: Board.pick(socket.assigns.board, :clear)}}
  end

  # Pento.Game.Board
  ...

  def pick(board, :clear) do
    %{board | active_pento: nil}
  end

• This is how I fixed the flash messages.

#GameLive
  @impl true
  def handle_info({:flash, message}, socket) do
    {:noreply, put_flash(socket, :info, message)}
  end

#GameLive
  def move(socket, move) do
    case Game.maybe_move(socket.assigns.board, move) do
      {:error, message} ->
        send(self(), {:flash, message})
        socket

      {:ok, board} ->
        socket |> assign(board: board) |> assign_shapes
    end
  end

  defp drop(socket) do
    case Game.maybe_drop(socket.assigns.board) do
      {:error, message} ->
        send(self(), {:flash, message})
        socket

      {:ok, board} ->
        if all_pieces_placed?(board),
          do: send(self(), :board_complete)

        socket |> assign(board: board) |> assign_shapes
    end
  end

The imbedded live view have there own socket so you will need to send the info to the parent in order to be able to display the errors.


• I’ll be sure to add this to the game site that I made. Ill organize all the files that need to be transferred.

# pento/lib/pento/game/
    board.ex
    pentomino.ex
    point.ex
    shape.ex

# pento/lib/pento
    game.ex

#pento/lib/pento_web/components
    game_instructions.ex

#pento/lib/pento_web/live/game_live
    board.ex
    colors.ex
    component.ex
    picker.ex

#pento/lib/pento_web/live
    game_live.ex

#router.ex
Add in the new route to the site

#change all the Pento -> GameSite

# root.html.heex
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>

# app.js
// Fireworks effect when the board is complete.
// push_event on the server dispatches as "phx:<event_name>" on the window.
window.addEventListener("phx:fireworks", () => {
  console.log("🎆 Fireworks! 🎆")
  confetti({
    particleCount: 150,
    spread: 80,
    origin: { y: 0.6 },
  })
  // Fire a second burst for extra flair
  setTimeout(() => {
    confetti({
      particleCount: 100,
      angle: 60,
      spread: 55,
      origin: { x: 0 },
    })
    confetti({
      particleCount: 100,
      angle: 120,
      spread: 55,
      origin: { x: 1 },
    })
  }, 300)
})