Part 3 – Build a Wordle Helper Using Elm and A Little Bit of Logic
Tags: elm, wordle, game, functional
Published: 2023-08-07 14:32:24

---
title: "Part 3 – Build a Wordle Helper Using Elm and A Little Bit of Logic"
published: true
description: "An Elm program to help solve Wordle puzzles."
tags: elm, wordle, game, functional
series: "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.

```elm
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)

```


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`.

```elm
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
```

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:

```elm
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
```

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.
```

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

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


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

```elm
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
```

The core trick in this logic is that:
- we split the `typedChars` based on their `Status` 
- we use different comparing functions for different `Status`es (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.

```elm
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 ]
```

This produces:

![](https://i.imgur.com/qe2Vaaz.png)
The [full source-code can be found here](https://github.com/chandru89new/elm-wordle-helper).


---

#### 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!)