Part 2 – Build a Wordle Helper Using Elm and A Little Bit of Logic
druchan
Posted on August 7, 2023
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)
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)
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)
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
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
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.
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 )
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 isNotInPosition
in ourStatus
type). - if you click on a char that's
NotInPosition
, it should become marked asInPosition
. (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 beingNotInWord
.
-- 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
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)
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.
Posted on August 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.