#184: Phoenix LiveView Tutorial Part 8

Published January 27, 2024 8m 47s

Now that we can add letters to our guesses, and remove them, let’s update our game so that players can submit their guesses. When a guess is submitted we need to do a few things. We need to check that the guess is a valid word and then if it’s valid, we’ll need to compare it against the solve for the game. Let’s get started.

We’ll open up our WordBank context module. When we generated our Solve module in Part 2 of this series, it added some functions to the file, but the alias for the Werdle.WordBank.Solve module was added to the middle of the module. Let’s go ahead and move that to the top of our module.

Now let’s create a function that checks if a word exists in our database, which we can use later to check that a guess is valid. We’ll create a new function named word_exists? that will take the name of a word. Then let’s use Ecto to check if any words with the same name exist in our “words” table.

Great, now let’s also create another function we’ll need. Whenever a new game is started, we’ll need to randomly get a “solve” from the database to use as the “solve” for the game. Let’s call our function to do this random_solve. We’ll return a random “solve” by calling order_by with an Ecto fragment to use the SQL RANDOM() function. With this, the rows in our “solves” table are shuffled into a random order. Then we’re limiting the result of the query to just one record, with limit: 1. And finally calling Repo.one to return a single “solve” from the database.

lib/werdle/word_bank.ex...

alias Werdle.WordBank.{Word, Solve}

def random_solve do
  query =
    from solve in Solve, order_by: fragment("RANDOM()"), limit: 1
  Repo.one(query)
end

def word_exists?(name) do
  query =
    from word in Word, where: word.name == ^name
  Repo.exists?(query)
end

...

With those added let’s open our WordLive.Index live view. When a new game starts we’ll assign a solve to our socket using the WordBank.random_solve function. And let’s add an alias for the WordBank module so we can call it without the prefix.

lib/werdle_web/live/word_live/index.exdefmodule WerdleWeb.WordLive.Index do
  use WerdleWeb, :live_view

  alias Werdle.{WordBank, Game}

  @impl true
  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:solve, WordBank.random_solve())
      |> assign(:cell_backgrounds, %{})
      |> assign(:keyboard_backgrounds, %{})
      |> assign(:changeset, Game.change_guesses())
      |> assign(:current_guess, 0)

    {:ok, socket}
  end

  ...
end

To submit a guess, the player will select the “enter” keycap. Let’s update that component to send an event to our LiveView when a guess is submitted. We’ll open the KeycapComponent module and just like we’ve done for our other render callbacks, we’ll add phx-click binding to the “enter” keycap. And have it send an event named “enter” to our LiveView when clicked.

Module path: lib/werdle_web/components/game_board/keycap_component.ex

...

def render(%{keycap_value: "enter"} = assigns) do
  ~H"""
  <kbd id={@id} phx-click="enter" class="...">
  ...
  </kbd>
  """
end

...

Let’s go back to our LiveView and update it to handle the event. We’ll add another handle_event callback pattern matching the “enter” event. We can ignore the params since we won’t need them here. Then let’s get the changeset and the current guess row from the socket assigns. And we’ll also need to get the “solve” so we can compare the player’s guess against it.

Now that we have these, we need to validate that the player’s guess is valid, and then if it’s valid check if it’s the correct guess. Let’s create some functions to help us do this. We’ll open our Game module and let’s create a function that will check that the guess is valid. We’ll call it validate_guess and we’ll pass it the changeset and guess_row. Then inside we’ll return the guess_field.

With that let’s get the guess field from the changeset and this will return a list of letters. Let’s pipe them into Enum.join to combine our guess into a string. Then let’s create a cond where if the guess’s length is not equal to 5 characters, we’ll return an :error tuple, with the message “Guess must be 5 letters”. Let’s add another condition for when the guess does not exist in the “words” table, using our WordBank.word_exists? function. If it doesn’t exist we’ll return another :error tuple with the message “guess is not a valid word”.

Now if our guess gets to this point it’s valid, so let’s match on true and then return an :ok tuple with the changset. Let’s create another public function to check if the guess is correct. We’ll call it check_guess_correctness and it will take a changeset the guess_row and the solve for the game. Then let’s copy the first two lines from our validate_guess function since we’ll need the guess_field and the player’s guess. Now if it’s a correct guess and our guess is equal to our solve we’ll return a :correct tuple with the changeset. Then if it’s an incorrect guess we’ll return an :incorrect tuple. Let’s also add an alias for WordBank so we can call it without the prefix.

lib/werdle/game.ex...

alias Werdle.WordBank

...

def check_guess_correctness(changeset, guess_row, solve) do
  guess_field = guess_field(guess_row)
  guess = Changeset.get_field(changeset, guess_field) |> Enum.join()
  if guess == solve, do: {:correct, changeset}, else: {:incorrect, changeset}
end

def validate_guess(changeset, guess_row) do
  guess_field = guess_field(guess_row)
  guess = Changeset.get_field(changeset, guess_field) |> Enum.join()
  cond do
    String.length(guess) != 5 ->
      {:error, "Guess must be 5 letters"}
    not WordBank.word_exists?(guess) ->
      {:error, "Guess is not a valid word"}
    true ->
      {:ok, changeset}
  end
end

...

Great, with our two functions done, let’s go back to our LiveView and go back in the handle_event for our “enter” event. Because we’re going to be working with multiple functions that may return error tuples, let’s use a with statement to help reduce the complexity and make it more readable.

Let’s first call our Game.validate_guess function and we’ll pattern match on the :ok tuple that’s returned if it’s a valid guess. We can ignore the changeset here since we won’t need it. If it’s a valid guess, let’s call Game.check_guess_correctness and we’ll pattern match on the :correct tuple that’s returned if it’s the correct guess. Again ignoring the changeset since we won’t need it. Then if it is the correct guess, we’ll return a :noreply tuple with our socket.

Now let’s pattern match on the error tuples that can be returned. Let’s first pattern match on the :error tuple that’s returned if the guess isn’t valid. We’ll pattern match on the error_message so we can use that. And we’ll return {:noreply, socket}. Then let’s pattern match on the :incorrect tuple that’s returned when we have the incorrect guess, ignoring the changeset. And let’s also check if this is the last guess in a game by adding when guess_row == 5. Then let’s pattern match on the :incorrect tuple for guesses that aren’t the last in the game.

Now here where it’s not the last guess in the game and it’s incorrect, we need to increment the guess_row and update the socket with the new current_guess so the player’s next guess will appear on the next row when they play the game.

lib/werdle_web/live/word_live/index....

def handle_event("enter", _params, socket) do
  changeset = socket.assigns.changeset
  guess_row = socket.assigns.current_guess
  solve = socket.assigns.solve

  with {:ok, _changeset} <- Game.validate_guess(changeset, guess_row),
       {:correct, _changset} <- Game.check_guess_correctness(changeset, guess_row, solve.name) do
    IO.puts("Result: You won")
    {:noreply, socket}

  else
    {:error, error_message} ->
      IO.puts("Result: #{error_message}")
      {:noreply, socket}

    {:incorrect, _changeset} when guess_row == 5 ->
      IO.puts("Result: You lost")
      {:noreply, socket}

    {:incorrect, _changeset} ->
      IO.puts("Result: Try again")
      socket = assign(socket, :current_guess, guess_row + 1)
      {:noreply, socket}

  end

end
...

With these changes let’s go back to our game in the browser and when we have a guess and select “enter” to submit it. Our current guess row should be incremented and the letters should appear on the next row. Great, they do, but our previous guess disappears. We have a bug in our function that updates our guess.

Let’s open our Game module and go to the update_guesses function. If there’s a current guess for the guess field, we update the guess below.

However, if there’s no guess for the guess field, we return a new changeset for only that row, which works for the first row, but when we move on to the next guess, our previous state is lost. Let’s remove the if/else conditional because we actually don’t really need it. We can simplify this by updating Changeset.get_change to return an empty list for the default when no guesses exist for a guess field. With that, we should be able to keep the rest of our update logic here the same.

lib/werdle/game.ex...

def update_guesses(changeset, new_character, guess_row) do
  guess_field = guess_field(guess_row)
  current_guess = Changeset.get_change(changeset, guess_field, [])
  updated_guess = current_guess ++ [new_character]
  changeset.changes
  |> Map.merge(%{guess_field => updated_guess})
  |> change_guesses()
end

...

Now let’s go test out our game again. If we guess a word and then select “enter” - perfect - our next guess appears on the row below and our previous guess doesn’t disappear.

Let’s go back to our LiveView and our game will eventually have some nice feedback in the UI, but for now, let’s quickly add some IO.puts statements so we can get some game results in our logs. And then if we go back to our game and submit a word we see it was incorrect and to try again. Then if we try a word that’s not 5 letters. We get that feedback in the logs as well as a 5 letter guess that isn’t a valid word.

Ready to Learn More?

Subscribe to get access to all episodes and exclusive content.

Subscribe Now