Home Posts Post Search Tag Search

LiveView 33 - Chapter 13: Establish Boundaries and APIs
Published on: 2026-02-25 Tags: elixir, Blog, Side Project, LiveView, Game Site, Phoenix

Chapter 13: Establish Boundaries and APIs

It’s Alive: Plan User Interactions

Remember that the boundary for the code is the place where uncertainty lives. We need to have checks and order for things like: a player trying to place a piece out of bounds, for trying to place a piece on top of and other piece, etc.


The Basic rules work like this:

• The user can pick up pieces, manipulate them, and drop them on the
board.
• The user can place pieces until the whole puzzle board is covered.

Now for every action we need to check some things. Most basically we will test to see what the user is trying to do, if that is not a available it will “fail” if it is allowed we can move onto the next bit. Ill go over the entire logic here but we will go into each step as we go.

• When: The user clicks a point
  – If: The point is part of a shape on the palette and there is no active
  shape
  – Then: Make the clicked shape the active shape and center it on the
  board
• When: The user clicks a point
  – If: The point is part of an existing shape placed on the board and
  There is no active shape
  – Then: Make the clicked shape the active shape and center it on the
board
• When: The user types an arrow key to move or the user types the shift
  key to rotate or the user types the enter key to flip
  – If: The middle point of the pentomino is on the board
  – Then: Move the pentomino
  – Else: Don’t move the pentomino and report an error
• When: The user hits the space bar to drop a pentomino
  – If: All points cover the board and no points overlap existing 
  pentominoes
  – Then: Drop the pentomino
  – Else: Don’t drop the pentomino and report an error
• When: The user clicks anything else on the SVG
  – Then: Do nothing

Process User Interactions in the Core

We now need to work on the logic for when a user is clicking.

  def pick(board, :board), do: board

  def pick(%{active_pento: pento} = board, sname) when not is_nil(pento) do
    if pento.name == sname do
      %{board | active_pento: nil}
    else
      board
    end
  end

  def pick(board, shape_name) do
    active =
      board.completed_pentos
      |> Enum.find(&(&1.name == shape_name))
      |> Kernel.||(new_pento(board, shape_name))

    completed = Enum.filter(board.completed_pentos, &(&1.name != shape_name))
    %{board | active_pento: active, completed_pentos: completed}
  end

This is the logic for when we are clicking on a new pento from the palette. The first one is for when we are trying to click on something that is not within the board. The next one is from when we are clicking when there is an active pento. The last is for when we are trying to pickup a piece from the board while there is no active piece.


I want to go over the last one as it has the most and is not readily understandable. First we are trying to find the pentos on the board and return the first one that matches and if there is no match we will create a new pento with that shape and place it in the center of the board. Lastly we will be sure to remove the pento from the completed as well.


Okay now let’s look at the new_pento/2 so we can go over that logic.

  defp new_pento(board, shape_name) do
    Pentomino.new(name: shape_name, location: midpoints(board))
  end

  defp midpoints(board) do
    {xs, ys} = Enum.unzip(board.points)
    {midpoint(xs), midpoint(ys)}
  end

  defp midpoint(i), do: round(Enum.max(i) / 2.0)

There isn’t much here but we will need to be sure that we always put the new pento in the middle of the board of any size.


Okay so now we can build the function to deal with the drop of a pento.

  def drop(%{active_pento: nil} = board), do: board

  def drop(%{active_pento: pento} = board) do
    board
    |> Map.put(:active_pento, nil)
    |> Map.put(:completed_pentos, [pento | board.completed_pentos])
  end

Looking at this we see that there is 2 types one where we have an active pento and one where we don’t. Keep in mind that this is just the drop and there is no logic checks for validity.


Okay so now we can look into the legal_drop?/1 and the other functions that we will need for that.

  def legal_drop?(%{active_pento: pento}) when is_nil(pento), do: false

  def legal_drop?(%{active_pento: pento, points: board_points} = board) do
    points_on_board =
      Pentomino.to_shape(pento).points
      |> Enum.all?(fn point -> point in board_points end)

    no_overlapping_pentos =
      !Enum.any?(board.completed_pentos, &Pentomino.overlapping?(pento, &1))

    points_on_board and no_overlapping_pentos
  end

  def legal_move?(%{active_pento: pento, points: points} = _board) do
    pento.location in points
  end

This goes in the Pento.Game.Pentomino.ex module

  def overlapping?(pento1, pento2) do
    {p1, p2} = {to_shape(pento1).points, to_shape(pento2).points}
    Enum.count(p1 -- p2) != 5
  end

So the first function is relatively simple if we take the functions at face value. We are building the points on the Pento. Then sending the pento into a list of points on the board then see if that list is contained in the board. Then we need to check that there is no overlapping with the new pento.


I want to go over the overlapping?/2. This is responsible for taking the pento and the completed pentos and seeing if there are overlapping. The list subtraction will remove any values that are in p1 and p2. If the count after is != 5 we know there is some overlap.


Lastly we need to check if a move is legal. This will simply check to see if the pento is on the board.

Build a Game Boundary Layer

Let’s create a new file that will be the boundary for the game. pento/lib/pento/game.ex

defmodule Pento.Game do
  alias Pento.Game.{Board, Pentomino}

  @messages %{
    out_of_bounds: "Out of bounds!",
    illegal_drop: "Oops! You can't drop out of bounds or on another piece."
  }
end

Let’s now implement the maybe_move/2 so that we can start to fill out the functionality of the code. It should work like this:

• Take in a board struct and some attempted move.
• If the move is legal, apply it to the board state and return an ok-tagged
  tuple with the new board struct.
• If the move is not legal, return an error-tagged tuple with the unchanged
  board struct.

  def maybe_move(%{active_pento: p} = board, _m) when is_nil(p) do
    {:ok, board}
  end

  def maybe_move(board, move) do
    new_pento = move_fn(move).(board.active_pento)
    new_board = %{board | active_pento: new_pento}

    if Board.legal_move?(new_board),
      do: {:ok, new_board},
      else: {:error, @messages.out_of_bounds}
  end

  defp move_fn(move) do
    case move do
      :up -> &Pentomino.up/1
      :down -> &Pentomino.down/1
      :left -> &Pentomino.left/1
      :right -> &Pentomino.right/1
      :flip -> &Pentomino.flip/1
      :rotate -> &Pentomino.rotate/1
    end
  end

  def maybe_drop(board) do
    if Board.legal_drop?(board) do
      {:ok, Board.drop(board)}
    else
      {:error, @messages.illegal_drop}
    end
  end

As usual we need to have a function that will “work” when there is no active pento. Next we need to have a check for the move. We first build the move and then the board if we have that new move. We then try and either add the new board to the assigns or we show a message and do nothing with the assigns.


Let’s go over the move_fn/1 so that we can see what is happening there. It will leverage the Pentomino.{move/trans}/1 functions on the piece. The original function that is calling that maybe_move/2 will need to have access to the atom of the move/transformation.


Last we have the maybe_drop/1 This is relatively simple. Keep in mind that the board we pass will have access to the active pento (and its location), as well as all the completed pentos. So it is simply will check if it works then drop the piece if there is no issue.

Extend the Game Live View

defmodule PentoWeb.GameLive.Board do
  use PentoWeb, :live_component
  alias Pento.Game.{Board, Pentomino}
  alias Pento.Game
  import PentoWeb.GameLive.{Colors, Component}

Add this in and then we can start to redo some of the hard coded pentos and deal with all the events.

  def assign_board(%{assigns: %{puzzle: puzzle}} = socket) do
    board =
      puzzle
      |> String.to_existing_atom()
      |> Board.new()

    assign(socket, board: board)
  end

Add Help with JavaScript

  def render(assigns) do
    ~H"""
    <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 />
      <.help />
      <.live_component module={Board} puzzle={@puzzle} id="board-component" />
    </section>
    """
  end

  def help(assigns) do
    ~H"""
    <div class="relative">
      <.help_button />
      <.help_page />
    </div>
    """
  end

  attr(:class, :string, default: "h-8 w-8 text-slate hover:text-slate-400")

  def help_button(assigns) do
    ~H"""
    <button
      phx-click={JS.toggle(to: "#info", in: "fade-in", out: "fade-out")}
      class="text-slate hover:text-slate-400"
    >
      <.icon name="hero-question-mark-circle-solid" class="h-8 w-8" />
    </button>
    """
  end

  def help_page(assigns) do
    ~H"""
    <div
      id="info"
      class="absolute right-0 top-10 bg-base-100 border-2 border-base-300 text-base-content
    p-4 z-10 w-80 shadow-lg rounded hidden"
    >
      <ul class="list-disc list-inside">
        <li>Click on a pento to pick it up</li>
        <li>Drop a pento with a space</li>
        <li>Pentos can't overlap</li>
        <li>Pentos must be fully on the board</li>
        <li>Rotate a pento with shift</li>
        <li>Flip a pento with enter</li>
        <li>Place all the pentos to win</li>
      </ul>
    </div>
    """
  end

Build a Picker Control Navigation

Okay let’s start by adding in a new file pento/lib/pento_web/live/game_live/picker.ex This will help a use pick the game that they want.

defmodule PentoWeb.GameLive.Picker do
  use PentoWeb, :live_view
  alias Pento.Game.Board
  import PentoWeb.GameLive.{Colors, Component}

  def mount(_params, _session, socket) do
    {:ok, assign_boards(socket)}
  end

  def assign_boards(socket) do
    assign(
      socket,
      :boards,
      Board.puzzles()
      |> Enum.map(&{&1, Board.new(&1)})
    )
  end

  def render(assigns) do
    ~H"""
    <h1 class="font-heavy text-4xl text-center mb-6">
      Choose a Puzzle
    </h1>
    <%= for {puzzle, board} <- @boards do %>
      <.row board={board} puzzle={puzzle} />
    <% end %>
    """
  end

  attr(:board, :any, required: true)
  attr(:puzzle, :atom, required: true)

  def row(assigns) do
    ~H"""
    <.link navigate={~p"/game/#{@puzzle}"}>
      <div class="grid grid-cols-2 hover:bg-slate-200">
        <div>
          <h3 class="text-2xl">Pieces</h3>
        </div>
        <div>
          <h3 class="text-2xl">
            {@puzzle |> to_string |> String.capitalize()} Puzzle
          </h3>
        </div>
        <.palette shape_names={@board.palette} />
        <.board board={@board} />
      </div>
    </.link>
    """
  end

  attr(:board, :any, required: true)

  def board(assigns) do
    ~H"""
    <div>
      <.canvas view_box={"0 0 400 #{height(@board) * 10 + 25}"}>
        <.shape
          points={Board.to_shape(@board).points}
          fill={color(:purple)}
          name="board"
        />
      </.canvas>
    </div>
    """
  end

  defp height(board) do
    board.points
    |> Enum.map(fn {_, y} -> y end)
    |> Enum.max()
  end
end

Ill go over this


Now you need to add in the route to the router.ex

      live("/game/:puzzle", GameLive)
      live("/play", GameLive.Picker)

Add Some Puzzles to the board

Let’s add in a few more puzzles

  def puzzles(), do: ~w[tiny small ball donut default wide widest medium skew]a

  def new(palette, points, hole \\ []) do
    %__MODULE__{palette: palette(palette), points: points -- hole}
  end

  def new(:tiny), do: new(:small, rect(5, 3))
  def new(:small), do: new(:medium, rect(7, 5))
  def new(:widest), do: new(:all, rect(20, 3))
  def new(:wide), do: new(:all, rect(15, 4))
  def new(:medium), do: new(:medium, rect(12, 5))
  def new(:default), do: new(:all, rect(10, 6))
  def new(:skew), do: new(:small, skewed_rect())

  def new(:donut) do
    new(:all, rect(8, 8), for(x <- 4..5, y <- 4..5, do: {x, y}))
  end

  def new(:ball) do
    new(:all, rect(8, 8), for(x <- [1, 8], y <- [1, 8], do: {x, y}))
  end

  def new(_), do: new(:default)

  ...

  defp palette(:medium), do: [:t, :y, :l, :p, :n, :v, :u]