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

druchan

druchan

Posted on August 7, 2023

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

On Wordle, you can enter 5-letter words. After you enter each word, the app gives you clues by way of color-codes:

  • a grey boxed letter means that letter does not exist in the answer word.
  • an amber/yellow boxed letter means that letter exists in the word but not in that position.
  • a green boxed letter means that letter exists in the word and in the same position as you typed.

To help our program shortlist the potential answers from a giant master list, we need to be able to tell these clues to it.

We'll keep it simple:

  • every keystroke of a valid letter will type a letter on the screen
  • we can "click" on the letter-tile / box to mark the letter as
    • not being in the word (grey)
    • being in the word but in a different position (yellow)
    • in the word and in the same position (green)
  • backspace will clear each letter (in reverse)

In code:

-- update imports
-- the previous imports as they are, plus these new ones:
import Array
import Browser.Events as BEvents
import Html exposing (Html, button, div, text)
import Html.Attributes as Attr
import Html.Events as Events
import Json.Decode as JD

-- update the Model to include `typedChars`, a key that contains all typed letters
type alias Model =
    { words : List String, typedChars : Array.Array Letter }

type alias Letter =
    { index : Int, char : String, status : Status }

type Status
    = NotInWord
    | NotInPosition
    | InPosition

-- update Msg
type Msg
    = GotWords (Result String String)
    | KeyPress Key
    | Toggle Int


type Key
    = Valid Char
    | Backspace
    | IgnoreKey

-- update init coz model has changed a bit:
init : () -> ( Model, Cmd Msg )
init _ =
    ( { words = [], typedChars = Array.empty }, getWords )

-- update function now has to handle two new `Msg` types:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Toggle index ->
            ( { model
                | typedChars =
                    Array.indexedMap
                        (\idx ltr ->
                            if idx == index - 1 then
                                { ltr | status = toggleStatus ltr.status }

                            else
                                ltr
                        )
                        model.typedChars
              }
            , Cmd.none
            )

        KeyPress key ->
            case key of
                Valid char ->
                    ( { model | typedChars = Array.push (Letter (Array.length model.typedChars + 1) (String.toLower <| String.fromChar char) NotInWord) model.typedChars }, Cmd.none )

                Backspace ->
                    ( { model
                        | typedChars = Array.slice 0 -1 model.typedChars
                      }
                    , Cmd.none
                    )

                _ ->
                    ( model, Cmd.none )

        GotWords res ->
            case res of
                Err e ->
                    let
                        _ =
                            Debug.log "error" e
                    in
                    ( model, Cmd.none )

                Ok wrds ->
                    ( { model
                        | words = String.lines wrds
                      }
                    , Cmd.none
                    )

toggleStatus : Status -> Status
toggleStatus status =
    case status of
        NotInWord ->
            NotInPosition

        NotInPosition ->
            InPosition

        InPosition ->
            NotInWord

-- updating the view function to render the typed letters (along with colored boxes)
view : Model -> Html Msg
view 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)

viewLetter : Letter -> Html Msg
viewLetter letter =
    let
        bgColor =
            case letter.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 letter.index)
        ]
        [ text letter.char ]

-- update the subscriptions so that the program "listens" to all keypress events and responds appropriately:
subscriptions : Model -> Sub Msg
subscriptions _ =
    BEvents.onKeyUp (JD.map KeyPress keyDecoder)

keyDecoder : JD.Decoder Key
keyDecoder =
    let
        toKey val =
            if val == "Backspace" then
                Backspace

            else if val == " " then
                IgnoreKey

            else
                case String.uncons val of
                    Just ( char, "" ) ->
                        Valid char

                    _ ->
                        IgnoreKey
    in
    JD.map toKey (JD.field "key" JD.string)
Enter fullscreen mode Exit fullscreen mode

That's some humongous piece of code so let's break it down by the logic.

First off, we need to "listen" to keypress events so that if we type "S", we show that on screen and also be able to mark/toggle the status of that letter.

To do this, we can use Browser.Events's onKeyUp function.

We need to be able to "process" the event, for which we need to use a Decoder.

import Json.Decode as JD

type Msg
    = GotWords (Result String String)
    | KeyPress Key --- this is the thing we add

-- We only need to "respond" to two kinds of keystrokes:
-- alphabets AND backspace. Everything else, we ignore.
type Key
    = Valid Char
    | Backspace
    | IgnoreKey

subscriptions : Model -> Sub Msg
subscriptions _ =
    BEvents.onKeyUp (JD.map KeyPress keyDecoder)
Enter fullscreen mode Exit fullscreen mode

The keyDecoder should help us "decode" the keyup event.

We only need to "respond" to two kinds of keystrokes: alphabets AND backspace. Everything else, we ignore.

type Key
    = Valid Char
    | Backspace
    | IgnoreKey

keyDecoder : JD.Decoder Key
keyDecoder =
    let
        toKey val =
            if val == "Backspace" then
                Backspace

            else if val == " " then
                IgnoreKey

            else
                case String.uncons val of
                    Just ( char, "" ) ->
                        Valid char

                    _ ->
                        IgnoreKey
    in
    JD.map toKey (JD.field "key" JD.string)
Enter fullscreen mode Exit fullscreen mode

In JD.field "key" JD.string, we are extracting the "key" field from the keyup object that the browser throws at us.

The "key" field's value is either the typed letter (like "a", "B", "z", " " [space] etc.) or the name of the control key that was pressed like "Backspace", "Control", "Meta" etc.

We "map" that. That is, we pass it through a transformation function so that the key is transformed into a Key, a type which lets us decide what kind of a key was pressed.

toKey val =
        if val == "Backspace" then
                Backspace

        else if val == " " then
                IgnoreKey

        else
                case String.uncons val of
                        Just ( char, "" ) ->
                                Valid char

                        _ ->
                                IgnoreKey
Enter fullscreen mode Exit fullscreen mode

In this toKey function, we read the value of the key and then change it to Valid x or Backspace or IgnoreKey (that represents a keypress of any key that we want to just ignore).

Let's talk a bit about our model for storing typed characters.

type alias Model =
    { words : List String, typedChars : Array.Array Letter }

type alias Letter =
    { index : Int, char : String, status : Status }

type Status
    = NotInWord
    | NotInPosition
    | InPosition
Enter fullscreen mode Exit fullscreen mode

We just store all typed keys (ie, Valid ones) in a simple, one-dimensional array.

You'd think, how can we then decide which char belongs to which word guess?

Our Letter type also includes an index which will start with 1 and increment up.

Since all words are 5-letters long, we can use simple modBy math to figure out the position of each letter in a 5-letter word/grid.

Take an array of characters S H I R T G O A T S for example. Let it be something that you typed into the program:

S H I R T G O A T S

by `index`, this is:
index    1  2  3  4  5  6  7  8  9  10
letter   S  H  I  R  T  G  O  A  T  S

we can use `modBy 5` on index to figure out the relative position 
of each letter in a 5-letter grid:
index    1  2  3  4  5  6  7  8  9  10
letter   S  H  I  R  T  G  O  A  T  S
modBy5   1  2  3  4  0  1  2  3  4  0

this way, we know that `G` is the first position letter
in a 5-letter grid.

anything that's 0 is considered as the 5th letter.

Enter fullscreen mode Exit fullscreen mode

The Status lets us define, for each letter, whether it's in the word, not in the word or in the word but not in the right position.

Sidenote: We could implement this typedChar as a list of words instead. Like [SHIRT, GOATS,...] etc. But eventually, we'll have to split each word apart into letters to "test" against our master list. Hence, short-circuiting the whole thing to be just a list of characters.

And then, we handle the KeyPress event like so:

KeyPress key ->
        case key of
                Valid char ->
                        ( { model | typedChars = Array.push (Letter (Array.length model.typedChars + 1) (String.toLower <| String.fromChar char) NotInWord) model.typedChars }, Cmd.none )

                Backspace ->
                        ( { model
                                | typedChars = Array.slice 0 -1 model.typedChars
                            }
                        , Cmd.none
                        )

                _ ->
                        ( model, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

That is, if it's a Valid character, we just "push" that into the typedChars list. While doing so, we make its Status a NotInWord by default and add an index that we can use to identify its relative position. Also worth noting: we "normalize" the typed letter by lower-casing it and also converting it into a string. Makes things easier to handle.

And if it's Backspace, we just remove the last item in typedChar.

One last thing to add is a way to "toggle" the status of a typedChar.

The idea is this:

  • if you click on a char that's NotInWord, it should be switched to "in word, but not in the right position" (which is NotInPosition in our Status type).
  • if you click on a char that's NotInPosition, it should become marked as InPosition. (meaning, it's a letter that's in the right position in the actual word).
  • and if you click on an InPosition word, it should go back to being NotInWord.
-- this gets added in the `update` function
Toggle index ->
        ( { model
                | typedChars =
                        Array.indexedMap
                                (\idx ltr ->
                                        if idx == index - 1 then
                                                { ltr | status = toggleStatus ltr.status }

                                        else
                                                ltr
                                )
                                model.typedChars
            }
        , Cmd.none
        )

toggleStatus : Status -> Status
toggleStatus status =
    case status of
        NotInWord ->
            NotInPosition

        NotInPosition ->
            InPosition

        InPosition ->
            NotInWord
Enter fullscreen mode Exit fullscreen mode

The view function is simple.

We simply render the typed characters in a CSS grid of 5-columns each.

-- helper function to view a character (the badly-named `Word` in our type system)
viewLetter : Letter -> Html Msg
viewLetter ltr =
    let
        bgColor =
            case ltr.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 ltr.index)
        ]
        [ text ltr.char ]

view : Model -> Html Msg
view 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

In the viewLetter function, we're using three colors to show the status of the letter like how Wordle does.

If you run this through elm reactor, you will see an empty screen and once you start typing letters, they'll start showing up on the screen. And click to toggle their status.

💖 💪 🙅 🚩
druchan
druchan

Posted on August 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related