Designing the Puzzle Component

Some toil, some trouble, and we get a puzzle

Puzzling

Designing the Puzzle Component

Some toil, some trouble, and we get a puzzle

After deciding to do a clone of what is perhaps the most popular word game, I realized that never having played it could be a liability. I’d heard descriptions, seen everyone post their score pictures to Twitter, and read up on strategy. I’ve yet to even open the website. Let’s get started!

The Basics

The basics of the game involve guessing a five letter word – five letters, six tries to guess it, and some help along the way. There is a grid where the columns contain the letters and the rows are each guess. After each guess the letters are colored to indicate if the letter is in the correct position, somewhere else or not in the answer.

To get started we need a struct to hold the current state of the game, the answer, and the guesses. The list of guesses also contains the results of those guesses.

Let’s create a sample puzzle with an answer “paper”.

iex> puzzle = Puzzle.new(answer: "paper")
%Subtle.Puzzle{
  state: :playing,
  word_length: 5,
  max_guesses: 6,
  answer: "paper",
  guesses: []
}

Now let’s guess the word “apple”.

iex> Puzzle.make_guess(puzzle, "apple")
{:ok,
 %Subtle.Puzzle{
   state: :playing,
   word_length: 5,
   max_guesses: 6,
   answer: "paper",
   guesses: [
     %Subtle.Guess{
       guess: "apple",
       results: [
         {"a", :wrong_position},
         {"p", :wrong_position},
         {"p", :correct},
         {"l", :wrong_letter},
         {"e", :wrong_position}
       ]
     }
   ]
 }}

Here you can see a new structure, Subtle.Guess, added to our list of guesses. It contains the word guessed and the results for each letter. In this case:

  • “a”, in the answer but not here
  • “p”, in the answer but not here
  • “p”, correct letter for this location
  • “l”, there is no “l” in the answer
  • “e”, another correct letter but not here

By building up the list after each guess we can beautify and clarify this information on the LiveView.

Letter Comparison

The heart of Guess is compare_letters. Its job is to compare the guess with the answer and return a list with the results for each letter pair. As an example with the answer “paper” and a guess “apple” here is what happens:

  • Convert the words into grapheme lists:

[“a”, “p”, “p”, “l”, “e”], [“p”, “a”, “p”, “e”, “r”]

  • Then zip these together so we can process them as pairs:

[{“a”, “p”}, {“p”, “a”}, {“p”, “p”}, {“l”, “e”}, {“e”, “r”}]

  • Now map_reduce this list, starting with a frequency count of the letters (accumulator) and then comparing each pair while reducing the letter count in the accumulator. Notice the accumulator remove letters at each step.

{“a”, “p”}, %{“a” => 1, “e” => 1, “p” => 2, “r” => 1} -> {“a”, :wrong_position}
{“p”, “a”}, %{“a” => 0, “e” => 1, “p” => 2, “r” => 1} -> {“p”, :wrong_position}
{“p”, “p”}, %{“a” => 0, “e” => 1, “p” => 1, “r” => 1} -> {“p”, :correct}
{“l”, “e”}, %{“a” => 0, “e” => 1, “p” => 0, “r” => 1} -> {“l”, :wrong_letter}
{“e”, “r”}, %{“a” => 0, “e” => 1, “p” => 0, “r” => 1} -> {“e”, :wrong_position}

This works remarkably well most of the time. I did start to notice some bugs and had to find a specific case so I could analyze and make changes. Given a guess “rigid” for the answer “strip” the first ‘i’ gets marked as :wrong_position when it should be :wrong_letter instead as there is only one ‘i’ and it is correct in the second occurance. To fix this we first allow underflows in the letter frequency accumulator and then go back and correct for those underflows. In this case the first ‘i’ would initially be marked :wrong_position, the second ‘i’ would get marked :wrong_letter, and then after we adjust the underflows we will get :wrong_letter followed by :correct.

  @spec compare_letters(String.t, String.t) :: list
  def compare_letters(guess, answer) do
    answer_letters = String.graphemes(answer)
    guess_letters = String.graphemes(guess)

    {answers, counts} =
      Enum.zip(guess_letters, answer_letters)
      |> Enum.map_reduce(letter_counts(answer),
          fn pair, counts -> process_letter_pair(pair, counts) end)

    # If we see a correct letter in the wrong position before the
    # correct postion we might incorrectly mark it as :wrong_position.
    # In the case of "rigid" for "strip" the first 'i' gets marked as
    # :wrong_position when it should be :wrong_letter.
    #
    # To fix this go through the answers, removing underflows
    # "rigid" for "strip", first 'i' should be :wrong_letter
    {adjusted, _adjusted_counts} =
      Enum.map_reduce(answers, counts,
        fn letter_result, counts ->
          remove_underflows(letter_result, counts) end)

    # return only the answers, not the counts
    adjusted
  end

  def compare_letters(answer) do
    compare_letters(answer, answer)
  end

Sidenote

Initially I wrote the following code to find each letter and create of map of how many were in the word:

def letter_counts(word) do
  word
  |> String.graphemes()
  |> Enum.reduce(%{}, fn char, acc ->
       Map.put(acc, char, (acc[char] || 0) + 1)
     end)
end

Naively proud, I was satisfied with the use of Enum.reduce/3 and Map.put/3 to process the word – this is functional huzzah! The experienced among you will realize (as I eventually did) that I had simply reinvented Enum.frequencies/1! Well, I yanked that and happily replaced it, which is almost always the better choice.

def letter_counts(word) do
  word
  |> String.graphemes()
  |> Enum.frequencies()
end

Making a Guess

When making a guess we need to account for success and potential errors.

  • Correct answer – Returns {:ok, puzzle} tuple which contains the final state of the puzzle, including state: :game_won.
  • Incorrect guess – Returns {:ok, puzzle} tuple with the puzzle containing state: :playing if more guesses are available or state: :game_over if all the guesses are exhausted.
  • Bad input – Returns {:error, :baby_dont_hurt_me} when the input has the wrong length, isn’t a string, is nil, etc.
  @doc """
  Make a guess with the given word
  Returns a tuple
    {:ok, puzzle} with guess and results appended to puzzle
    {:error, reason}
  """
  def make_guess(%Puzzle{state: :playing} = puzzle, guess) do
    case Guess.guess_word(guess, puzzle.answer) do
      # Correct guess will transition the puzzle to :game_won
      {:ok, :correct, guess_result} ->
        {:ok, puzzle
              |> change_state(:game_won)
              |> add_result(guess_result)}
      # Incorrect guess will update the puzzle and check for :game_over
      {:ok, :incorrect, guess_result} ->
        case last_guess?(puzzle) do
          true ->
            {:ok, puzzle
                  |> change_state(:game_over)
                  |> add_result(guess_result)}
          false ->
            {:ok, add_result(puzzle, guess_result)}
        end
      # Don't pass in garbage!
      {:error, :invalid_length} -> {:error, :invalid_length}
      {:error, _} -> {:error, :baby_dont_hurt_me}
    end
  end

  def make_guess(_puzzle, _guess) do
    {:error, :game_over}
  end

Verifying a Guess

The error code is very simple and seemingly non-descriptive, but make_guess/2 is meant to be paired with verify_guess/2. Calling this will either return {:ok, puzzle} to indicate it’s ok to proceed, or it will return {:error, reason} to give feedback if the guess isn’t a string, is the wrong length, isn’t in our answer dictionary, or some other nefarious problem.

  def verify_guess(%Puzzle{state: :playing} = puzzle, guess) do
    with  true <- is_binary(guess),
          {:ok, @word_length} <- verify_guess_length(guess),
          {:dict, true} <- {:dict, PuzzleDictionary.verify_word(guess)}
    do
      {:ok, puzzle}
    else
      false ->                        # didn't pass in a string
        {:error, :invalid_arguments}
      {:error, :invalid_length} ->    # wrong length
        {:error, :invalid_length}
      {:dict, false} ->               # guess not in the dictionary
        {:error, :invalid_word}
      {:error, _} ->                  # we should never, ever get here
        {:error, :baby_dont_hurt_me}
    end
  end
  
  def verify_guess(_puzzle, _guess) do
    {:error, :game_over}
  end
  
  defp verify_guess_length(guess) do
    if (Enum.count(String.graphemes(guess)) == @word_length),
      do:   {:ok, @word_length},
      else: {:error, :invalid_length}
  end

Wrapping Up

This was a fun problem to solve, not least because it stretched me differently than following a tutorial. It’s also tempting to revert to ingrained habits of declarative and OOP thinking. Things that made this enjoyable (and easier):

  • Creating the data structures.
  • Pattern matching for control flow.
  • Piping results to process data.
  • Using IEx to prototype function calls.

I like the richness and easiness of combining maps, lists, tuples, and atoms to represent the problem and solution. When first looking at Elixir, atoms were alien; however one day it clicked that they were an idea, or name, or label, or state. I’d never seen or dealt with tuples while coding so they felt off initially, but then I realized how they allow richer return values and data. The rich standard library functions to manipulate enums, maps, lists, strings, etc made using the data structures easy.

Pattern matching is next level stuff. It simplifies so much control flow, error handling, data fetching, etc. This is another thing that baffled me when initially learning Elixir and trying to process the symbols I saw in Elixir code since they don’t mean the same thing as in C. When I finally understood it, I wondered why I’d never seen it before.

Function pipelines take complex data flow and make it more readable. It sure beats nested function calls or endless temporary variables passed along to the next function.

Coming to understand how to use IEx has made learning Elixir much easier. LiveBook also helps a lot with working through an idea. Again, when first learning Elixir I was lost: “how can I learn when all I have is a text editor and a command line and print statements?” Somewhere I watched José working with IEx and LiveBook and had my ahah moment.

What’s Next?

Soon I will delve into the word dictionary, the Possibles module that helps the player find answers when stuck, and the Game that ties all the parts together.


See also