DEV Community

druchan
druchan

Posted on

Part 3 – Build a Wordle Helper Using Elm and A Little Bit of Logic

We have:

  • a list of typed letters (and we know what position each word is in, relative to a 5-letter word)
  • we know, for each letter, whether it's in the word or not and if it's in the word, whether it's in the right position or not.
  • and we have a master-list of words

What remains is a way to compare the clues with the master list to produce a shortlist of candidate words.

isWordACandidate : Array.Array Letter -> String -> Bool
isWordACandidate letters wordToCompare =
    if hasExcludedChar letters wordToCompare then
        False

    else
        let
            lettersInPosition =
                Array.filter (\l -> l.status == InPosition) letters

            lettersNotInPosition =
                Array.filter (\l -> l.status == NotInPosition) letters
        in
        hasCharNotInPositionForAllChars (Array.map convertLetterToIndexedChar lettersNotInPosition) wordToCompare && hasCharInPositionForAllChars (Array.map convertLetterToIndexedChar lettersInPosition) wordToCompare

convertLetterToIndexedChar : Letter -> { char : String, index : Int }
convertLetterToIndexedChar { char, index } =
    if modBy 5 index == 0 then
        { char = char, index = 5 }

    else
        { char = char, index = modBy 5 index }

hasCharInPositionForAllChars : Array.Array { char : String, index : Int } -> String -> Bool
hasCharInPositionForAllChars xs word =
    Array.all (\x -> hasCharInPosition x word) xs

hasCharInPosition : { char : String, index : Int } -> String -> Bool
hasCharInPosition { char, index } word =
    if String.contains char word then
        word
            |> String.split ""
            |> Array.fromList
            |> Array.indexedMap
                (\idx c -> c == char && (idx + 1) == index)
            |> Array.any ((==) True)

    else
        False

hasCharNotInPositionForAllChars : Array.Array { char : String, index : Int } -> String -> Bool
hasCharNotInPositionForAllChars xs word =
    Array.all (\x -> hasCharNotInPosition x word) xs

hasCharNotInPosition : { char : String, index : Int } -> String -> Bool
hasCharNotInPosition { char, index } word =
    if String.contains char word then
        word
            |> String.split ""
            |> Array.fromList
            |> Array.indexedMap
                (\idx c -> c == char && (idx + 1) == index)
            |> Array.any ((==) True)
            |> not

    else
        False

hasExcludedChar : Array.Array Letter -> String -> Bool
hasExcludedChar excludedChars word =
    Array.any (\c -> c.status == NotInWord && String.contains c.char word) excludedChars

view : Model -> Html Msg
view model =
    div
        [ Attr.style "padding" "2rem"
        , Attr.style "display" "grid"
        , Attr.style "grid-template-columns" "repeat(2, 500px)"
        , Attr.style "grid-gap" "2rem"
        ]
        [ div [] [ viewPlayground model ]
        , div [] [ viewResults model ]
        ]


viewResults : Model -> Html Msg
viewResults model =
    div [ Attr.style "border" "1px dotted gray", Attr.style "padding" "1rem" ]
        [ div [ Attr.style "margin-bottom" "1rem" ] [ text "Candidates:" ]
        , div
            [ Attr.style "whitespace" "wrap" ]
            [ text (String.join ", " (List.filter (isWordACandidate model.typedChars) model.words)) ]
        ]


viewPlayground : Model -> Html Msg
viewPlayground model =
    div
        [ Attr.style "display" "grid"
        , Attr.style "grid-template-columns" "repeat(5,48px)"
        , Attr.style "gap" "10px"
        ]
    <|
        List.map viewLetter (Array.toList model.typedChars)

Enter fullscreen mode Exit fullscreen mode

Let's break this code down.

The broad idea is this:

  • we have a master list of words.
  • we are going to "filter" this list by comparing each word in the list to the "clues".
  • that essentially means we apply a filter function on the list.

The largest chunk of the code is all about that filter function. It's called isWordACandidate.

isWordACandidate : Array.Array Letter -> String -> Bool
isWordACandidate letters wordToCompare =
    if hasExcludedChar letters wordToCompare then
        False

    else
        let
            lettersInPosition =
                Array.filter (\l -> l.status == InPosition) letters

            lettersNotInPosition =
                Array.filter (\l -> l.status == NotInPosition) letters
        in
        hasCharNotInPositionForAllChars (Array.map convertLetterToIndexedChar lettersNotInPosition) wordToCompare && hasCharInPositionForAllChars (Array.map convertLetterToIndexedChar lettersInPosition) wordToCompare
Enter fullscreen mode Exit fullscreen mode

What's happening is:

  • we first check if the word (from the master list) has any character that has been marked as NotInWord. If yes, we immediately filter that word out. (This is a bug as you'll see later, but OK for now/most use-cases).
  • then, we check if the word (from the master list) has letters that have been marked as "NotInPosition" through the hasCharNotInPositionForAllChars function (which in itself is a composition of another smaller helper function). This function ensures that the words marked as "NotInPosition" do appear in the word (from the master list) but at the same time, they do not appear in the same position as that in the guessed word. (That is, if 'S' is marked as position 1, all words starting with 'S' are weeded out but words that have 'S' elsewhere in the word will be accepted. And this is repeated for all the other letters marked so.)
  • then we check if letters marked as InPosition do appear in the word (from the master list) and in the same position as in the guessed word.

To construct these functions, we go from the atomic level.

For example, to check and compare a word with a guess for all letters marked as InPosition, we first have a function that checks it for just one letter:

hasCharInPosition : { char : String, index : Int } -> String -> Bool
hasCharInPosition { char, index } word =
    if String.contains char word then
        word
            |> String.split "" -- first split the word from the master list
            -- this produces a (List String)
            |> Array.fromList -- convert it into (Array String)
            -- because we need to do an `indexedMap`
            |> Array.indexedMap -- then map over it with the index
                (\idx c -> c == char && (idx + 1) == index) -- and check 
                -- if the indices are equal and the character is the same
                -- as in the guess.
                -- this step produces (Array Bool)
            |> Array.any ((==) True) -- check if the condition is true for
            -- any one letter in the word

    else
        False
Enter fullscreen mode Exit fullscreen mode

As an example:

let word from master list = PLAYS
let guessed word = PINES

And let's say you marked 'P' as `InPosition` (so, green)

hasCharInPosition ({ char = "P", index = 1 }) "PLAYS" =
0. does P exist in PLAYS? Yes...
1. split word = [ P, L, A, Y, S ]
2. index map and compare => 
    1. P -> char == c? (P == P) => true
    2. idx + 1 (0 + 1) = index (1) => true
    3. so, return True
    4. L -> char == c? (L == P) => false
    5. so, return False
    6. ... and so on for other letters in the word.
3. Array.any ((==) True) [ True, False, False, False, False ] => True! 

So for char=P, index=1, "PLAYS" will return True.

If you do the same for char=T, index=1, this whole process will return False.
Enter fullscreen mode Exit fullscreen mode

Then, we use this atomic function to create a larger function that compares the whole list of typedChars.

hasCharInPositionForAllChars : Array.Array { char : String, index : Int } -> String -> Bool
hasCharInPositionForAllChars xs word =
    Array.all (\x -> hasCharInPosition x word) xs
Enter fullscreen mode Exit fullscreen mode

We do the same thing for the letters that are in the word but not in position:

hasCharNotInPosition : { char : String, index : Int } -> String -> Bool
hasCharNotInPosition { char, index } word =
    if String.contains char word then
        word
            |> String.split ""
            |> Array.fromList
            |> Array.indexedMap
                (\idx c -> c == char && (idx + 1) == index)
            |> Array.any ((==) True)
            |> not

    else
        False


hasCharNotInPositionForAllChars : Array.Array { char : String, index : Int } -> String -> Bool
hasCharNotInPositionForAllChars xs word =
    Array.all (\x -> hasCharNotInPosition x word) xs
Enter fullscreen mode Exit fullscreen mode

The core trick in this logic is that:

  • we split the typedChars based on their Status
  • we use different comparing functions for different Statuses (like hasExcludedChar for NotInWord, hasCharNotInPositionForAllChars for NotInPosition etc.)
  • and we combine these comparing functions to create the isWordACandidate filter function.

Finally, we modify the view function so that the left-side is the playground (where the input letters show up and we can toggle their status) and the right-side is where the program shows the shortlist of words.

view : Model -> Html Msg
view model =
    div
        [ Attr.style "padding" "2rem"
        , Attr.style "display" "grid"
        , Attr.style "grid-template-columns" "repeat(2, 500px)"
        , Attr.style "grid-gap" "2rem"
        ]
        [ div [] [ viewPlayground model ]
        , div [] [ viewResults model ]
        ]


viewResults : Model -> Html Msg
viewResults model =
    div [ Attr.style "border" "1px dotted gray", Attr.style "padding" "1rem" ]
        [ div [ Attr.style "margin-bottom" "1rem" ] [ text "Candidates:" ]
        , div
            [ Attr.style "whitespace" "wrap" ]
            [ text (String.join ", " (List.filter (isWordACandidate model.typedChars) model.words)) ]
        ]


viewPlayground : Model -> Html Msg
viewPlayground model =
    div
        [ Attr.style "display" "grid"
        , Attr.style "grid-template-columns" "repeat(5,48px)"
        , Attr.style "gap" "10px"
        ]
    <|
        List.map viewLetter (Array.toList model.typedChars)

iewLetter : Letter -> Html Msg
viewLetter word =
    let
        bgColor =
            case word.status of
                NotInWord ->
                    "gainsboro"

                NotInPosition ->
                    "moccasin"

                InPosition ->
                    "yellowgreen"
    in
    div
        [ Attr.style "display" "flex"
        , Attr.style "justify-content" "center"
        , Attr.style "align-items" "center"
        , Attr.style "width" "44px"
        , Attr.style "height" "44px"
        , Attr.style "border" ("1px solid " ++ bgColor)
        , Attr.style "background" bgColor
        , Attr.style "text-transform" "uppercase"
        , Attr.style "cursor" "default"
        , Events.onClick (Toggle word.index)
        ]
        [ text word.char ]
Enter fullscreen mode Exit fullscreen mode

This produces:


The full source-code can be found here.


Some closing thoughts, bugs to fix, ideas to explore further etc.

  • I do a mapError in the getWords API call. This effectively throws away the error and returns just a string called "Error" if there was an error in fetching. This could be improved.
  • Also notice how pointless it is to do a Debug.log in the error branch of GotWords. (Because it's always going to print "Error" because of the mapError).
  • On the UI, we don't show anything if we do hit a snag fetching the master words list. Nor do we show a loading state while the fetch happens.
  • More critical: you can actually type numbers! You shouldn't be able to. Explore how to resolve this bug by making use of charCode.
  • Even more critical: there is a bug in the logic. If you typed a word with repeating letters (like GUESS), and Wordle says one S is yellow (in word, wrong position) and other S is grey (not in word), our logic will fail and show no results.
  • The word list we use often falls short. (eg "Polyp" does not figure on the list!)

Top comments (0)