#187: Phoenix LiveView Tutorial Part 11
When we first started building components for our game we added the cell_backgrounds assign in our mount callback here in case we needed to pass the backgrounds for our cells down to the CellComponent. But instead of doing that, there’s another way.
In part 10 we pushed events from our LiveView to the client using JavaScript Hooks. Now let’s use a Hook to push events back to the LiveView. Specifically, when a player submits a guess, we want to flip the cells in our grid to reveal whether each letter in the guess is in the “solve” and if it is, if it’s in the correct position. To indicate the status of the letter, we’ll update the background color of the cell.
Then once the cell has flipped let’s send an event to the CellComponent with the updated background color. To do that we’ll use the pushEventTo method to push events to specific components. But before we get to that, we won’t need the cell_component assign so let’s remove it from our mount callback here. Then we’ll remove it from our GridComponent, the RowComponent, and finally the CellComponent.
Now before we add our Hook and send events to it, we’ll need a way to compare a guess against the solve and return the letters in the guess and whether that letter was a match, partial match, or no match. To do that let’s open our Game module and create a new function called compare_guess that will take a changeset, the guess_row, and the solve. Then let’s take our solve and transform it from a string to a list with String.codepoints. Then we’ll get the guess_field and use that to get the current guess list.
Now we need to compare each letter in our guess_list with the letter at the corresponding index in the solve to get the status of the guess match. Let’s take the guess_list and pipe that into Enum.with_index to get the index of each letter then we’ll pipe that into Enum.map and we’ll pattern match on the character of the guess and then the index. Inside the function we’ll check if the guess_char is equal to the character in the solve_list that’s at the same index. If it is we’ll return a :correct atom, because this letter in the guess is in the same place as it is in the solve. And if it’s not we’ll need to check if it’s a partial match, let’s create a new function to do that. It will take the guess_char and the solve_list.
We’ll create our new function check_partial_match and inside the function, we’ll use Enum.any? to see if a letter from our guess exists in the solve. If it does we’ll return the atom :partial from the function and if it doesn’t we’ll return :incorrect. This will return a list of the guess statuses. Let’s assign those to the variable guess_status_list and then let’s use Enum.zip to combine our original guess_list with the guess statuses. This will return a list of two-element tuples that have the letters of the guess and then the status.
lib/werdle/game.exdefmodule Werdle.Game do
...
def compare_guess(changeset, guess_row, solve) do
solve_list = String.codepoints(solve)
guess_field = guess_field(guess_row)
guess_list = Changeset.get_field(changeset, guess_field)
guess_status_list =
guess_list
|> Enum.with_index()
|> Enum.map(fn {guess_char, index} ->
if guess_char == Enum.at(solve_list, index) do
:correct
else
check_partial_match(guess_char, solve_list)
end
end)
Enum.zip(guess_list, guess_status_list)
end
def check_partial_match(guess_char, solve_list) do
if Enum.any?(solve_list, fn char -> char == guess_char end) do
:partial
else
:incorrect
end
end
...
end
To get a better idea of how it works, let’s go to the command line and start our application inside an IEx session. Then we’ll alias our Game module, create an empty changeset, and update it with a 5-letter guess - “apple”. We’ll call Game.compare_guess with our changeset, the guess row, and we’ll say the solution for this is “adapt”. And great, we can see the structure of our list, which has the letter of our guess and the guess status.
$ iex -S mix
> alias Werdle.Game
> changeset = Game.change_guesses()
> changeset = Game.update_guesses(changeset, "a", 0)
...
> changeset = Game.update_guesses(changeset, "e", 0)
#Ecto.Changeset<
action: nil,
changes: %{guess_0: ["a", "p", "p", "l", "e"]},
errors: [],
data: #Werdle.Game.Guesses<>,
valid?: true
> Game.compare_guess(changeset, 0, "adapt")
[
{"a", :correct},
{"p", :partial},
{"p", :partial},
{"l", :incorrect},
{"e", :incorrect}
]
Now we can take this data and use it with a LiveView hook to determine the background of each letter when a player submits their guess. We’ll go back to the Index live view and let’s consolidate how we’re pushing our events to the server. Let’s create a new private function called create_guess_response that will accept the socket and a message.
Inside it, we’ll get the current guess row from the assigns. Then we’ll get the guess results by calling Game.compare_guess and assigning it to a variable, comparison_results. Once we have that, let’s get a list of only the statuses of each letter from the results.
The reason I’m not converting these to a map to use instead is because in Elixir the order of elements in a map is not preserved and we need the correct order of each status. So it will be easier to use a list of letter statuses and then use the index of a letter in the list and the index of the cell in the grid row to decide what background to update.
We’ll call Enum.map passing in comparison_results, and then pattern matching on the letter and letter_status. We can ignore the letter since we won’t need it and then let’s return the letter_status from the function. Let’s assign these to the variable letter_statuses - we’ll include these in the payload for our hook to use. Now let’s take the socket and pipe it into a function we’ll create called maybe_push_event with the name of our event "guess-validation-text" and the message. Then we’ll pipe that into push_event with the name of "guess-reveal-animation" - this will be for the new Hook we’ll create that will reveal the status of each letter in the guess. We’ll need to include in the payload the guess_row so we know what row of letters to flip. And then the letter_statuses.
Now let’s implement this maybe_push_event function. We only want this to push an event if we include one, so let’s first pattern match on nil for cases where no event is included. And we can ignore the message in this function since we won’t need it. And in those cases, we’ll return the socket. Then if we have an event we’ll call push_event passing in the socket, event, and the message. Oh, and let’s fix this typo in the message. It should be a variable and not a string.
Alright, with that let’s go ahead and update our game scenario functions to use this new create_guess_response function. We’ll replace the push_event in handle_correct_guess with create_guess_response. Then we’ll do the same for handle_game_over.
And then in handle_incorrect_guess we’ve been displaying a message to the player. However, with our new hook that will update the background of each letter in the guess we won’t need this message going forward so let’s include nil here.
With that let’s also update the function parameters. We can get the guess_row from the socket so let’s remove that and then we’ll update the function to get the current_guess from the assigns. Let’s remove solve in handle_game_over and then return it from the assigns in the function. We can’t get the error_message from the socket, so we’ll keep that. But we’ll do the same thing in handle_correct_guess and remove the solve parameter and get it from the assigns. We’ll also need to update where we call these functions in the handle_event callback above.
lib/werdle_web/live/word_live/index.ex...
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, _changeset} <- Game.check_guess_correctness(changeset, guess_row, solve.name) do
{:noreply, handle_correct_guess(socket)}
else
{:error, error_message} ->
{:noreply, handle_invalid_guess(socket, error_message)}
{:incorrect, _changeset} when guess_row == 5 ->
{:noreply, handle_game_over(socket)}
{:incorrect, _changeset} ->
{:noreply, handle_incorrect_guess(socket)}
end
end
defp handle_correct_guess(socket) do
message = "You won! #{String.upcase(socket.assigns.solve.name)} was the correct word"
create_guess_response(socket, message)
end
defp handle_invalid_guess(socket, error_message) do
socket
|> push_event("guess-validation-text", %{message: error_message})
|> push_event("shake-row", %{row: socket.assigns.current_guess})
end
defp handle_game_over(socket) do
message = "The solve was #{String.upcase(socket.assigns.solve.name)}"
socket
|> create_guess_response(message)
|> push_event("shake-row", %{row: socket.assigns.current_guess})
end
defp handle_incorrect_guess(socket) do
socket
|> create_guess_response(nil)
|> push_event("shake-row", %{row: socket.assigns.current_guess})
|> assign(:current_guess, socket.assigns.current_guess + 1)
end
defp create_guess_response(socket, message) do
guess_row = socket.assigns.current_guess
comparison_results = Game.compare_guess(socket.assigns.changeset, guess_row, socket.assigns.solve.name)
letter_statuses = Enum.map(comparison_results, fn {_letter, letter_status} -> letter_status end)
socket
|> maybe_push_event("guess-validation-text", message)
|> push_event("guess-reveal-animation", %{
guess_row: guess_row,
letter_statuses: letter_statuses
})
end
defp maybe_push_event(socket, nil, _), do: socket
defp maybe_push_event(socket, event, message) do
push_event(socket, event, %{message: message})
end
...
Now below in create_guess_response we’re pushing the "guess-reveal-animation" event to the client with the payload of the guess_row and the letter_statuses list. Let’s add the JavaScript Hook to handle this. We’ll create a new file for our hook called guess_reveal_animation.js. And just like in our previous two hooks, we’ll export our JavaScript object and add a mounted callback. I’ll paste in the rest of the code for our hook, but let’s walk through what it’s doing.
We’ve got a classMappings variable that has our guess status values as keys that correspond to a Tailwind CSS background class that we’ll use for each guess. Then we’re using the handleEvent method to handle the 'guess-reveal-animation' that we’re sending from the LiveView and then we’ve got the payload data. Inside we’re using the guess_row from the data to get the current row that we want to flip and then we’re getting the child input cells from the row. Once we have the child cells we’re looping over each cell with its index. Then we use setTimeout to delay the execution of the code. This will stagger each letter guess revealed in the UI.
First, we get the letterGuessResult by getting the letter status with the corresponding index. Then we’re getting the appropriate background color from the classMappings. Now to update the component’s background color we need to get that component’s ID. So we’ll build a string that targets the appropriate cell component’s ID. With that we can use the pushEventTo method with the ID to push the “cell_background_update” event to the component. And then we’ll want to send the background over in the params. The delay for setTimeout is being set here with index * 500 - meaning the delay increases with each cell, which creates the staggering effect.
assets/js/hooks/guess_reveal_animation.jsexport default {
mounted() {
const classMappings = {
'correct': 'bg-green-600',
'incorrect': 'bg-red-600',
'partial': 'bg-yellow-600'
};
this.handleEvent('guess-reveal-animation', data => {
const currentRow = document.getElementById(`input-row-${data.guess_row}`);
const inputCells = Array.from(currentRow.children);
inputCells.forEach((inputCell, index) => {
setTimeout(() => {
let letterGuessResult = data.letter_statuses[index];
let backgroundColor = classMappings[letterGuessResult];
let componentID = `#input-cell-${data.guess_row}-${index}`;
this.pushEventTo(componentID, "cell_background_update", { background: backgroundColor });
}, index * 500);
});
});
}
};
Now we need to update our app.js to include import GuessRevealAnimation from './hooks/guess_reveal_animation'.
assets/js/app.js...
import GuessRevealAnimation from './hooks/guess_reveal_animation'
...
const Hooks = {
GuessRevealAnimation: GuessRevealAnimation,
ValidationMessageHandler: ValidationMessageHandler,
ShakeRowAnimation: ShakeRowAnimation
}
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks
})
...
We’ll need some CSS to handle the cell flip animation. Let’s open the app.css and I’ll add in the .flip-cell class. Inside it, we’re applying a shrink-vertical keyframes animation. The animation will complete in half a second and run once.
Then we have our @keyframes rule that defines the steps in our animation. This animation will shrink the cell vertically and then expand it to its original height, creating a flip-like visual effect.
assets/css/app.css...
.flip-cell {
animation: shrink-vertical 0.5s 1;
}
@keyframes shrink-vertical {
0% { transform: scaleY(1); }
50% { transform: scaleY(0); }
100% { transform: scaleY(1); }
}
Now let’s open our CellComponent module and handle the “cell_background_update” event. Pattern matching on the background. And we’ll need to accept the socket too.
Inside our callback we’ll need to update the background assign with the new background class. Let’s also include flip-cell to apply our animation.
Then we’ll return a :noreply tuple with the updated socket. We’ll also want to update the mount callback to use the background color from the assigns if it exists instead of “bg-transparent”.
lib/werdle_web/components/game_board/cell_component.ex...
def update(...) do
...
|> assign(:background, socket.assigns[:background] || "bg-transparent")
...
end
...
@impl true
def handle_event("cell_background_update", %{"background" => background}, socket) do
socket = assign(socket, :background, "#{background} flip-cell")
{:noreply, socket}
end
...
With our new hook, we need to add it to our DOM. Let’s open the index.html.heex template. Let’s wrap our GridComponent in a <div> with a unique id and then the phx-hook.
Template path: lib/werdle_web/live/word_live/index.html.heex
...
<div id="guess-reveal" phx-hook="GuessRevealAnimation">
<.live_component
module={WerdleWeb.GameBoard.GridComponent}
id="input-grid"
changeset={@changeset} />
</div>
...
With that let’s go to the command line and start our server.
$ mix phx.server
...
Now when we try to submit a guess it looks like we have a bug. In the logs, we see that we’re trying to call String.codepoints with an Ecto.Changeset in our Game.compare_guess function.
If we open our WordLive.Index LiveView we’re calling Game.compare_guess here with the changeset. Looking at the function we need to use the name of the solve. So let’s fix that and change it from using changeset to the solve.name. And it looks like I have a typo below with our maybe_push_event function. I’ll fix that too.
Let’s go back to the browser. And if we try to submit a guess, each cell is flipped and the updated background is applied. If we try to submit an invalid word, great we still get our message. Submitting another guess and we see we have a few letters in the solve word, but not in the correct place. Another guess with no matches. We got a few matches on this guess. And great we guessed the correct word!
Now that we have the letter backgrounds updated we need to update the keyboard backgrounds to update, which we’ll do in the next episode.