This is part 2 of the series "Writing A Word Memory Game In Elm", find:
- Part 1: Setting Up an Elm Application With Parcel
- Part 2: Modeling And Building a Basic Word Memory Game
- Part 3: Rethinking the Model
- Part 4: Spicing Things Up With Randomness
- Part 5 - More Randomness And More Game
The game view should show the sentence with missing words, and the missing words as a list below it. The player will be able to choose a word and, if this word is correct, it will be marked in green in the list. For the "easy" level the view will show a dropdown list of available words.
Let's think about how the Model of the game should look like. For modeling a word let's declare a custom type Word with variants SentenceWord and HiddenWord, each holding a String value:
type Word
= SentenceWord String
| HiddenWord String
The sentence is a List of Word which we can alias as Words:
type alias Words =
List Word
To update the correct word in a sentence when the player makes a choice, we can use the index of the word within the sentence. This means that we should update the Word type to hold an index with a String value by using a Tuple:
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String )
The Model now looks like this:
type alias Model =
{ sentence : String
, chosenWords : Words
, chosenSentence : Words
}
For now we can hardcode the initialModel:
initialModel : Model
initialModel =
{ sentence = "The pen is mightier than the sword"
, chosenWords =
[ HiddenWord ( 1, "pen" )
, HiddenWord ( 6, "sword" )
, HiddenWord ( 3, "mightier" )
]
, chosenSentence =
[ SentenceWord ( 0, "The" )
, HiddenWord ( 1, "" )
, SentenceWord ( 2, "is" )
, HiddenWord ( 3, "" )
, SentenceWord ( 4, "than" )
, SentenceWord ( 5, "the" )
, HiddenWord ( 6, "" )
]
}
In chosenSentence, we start by setting the String value of each HiddenWord to be empty. These will then be updated with the word the player chooses, and compared with chosenWords entries by index. If this HiddenWord in chosenSentence equals to one of HiddenWord in chosenWords, the choice is correct. For example:
chosenWords =
[ HiddenWord ( 1, "pen" )
, HiddenWord ( 6, "sword" )
, HiddenWord ( 3, "mightier" )
]
chosenSentence =
[ SentenceWord ( 0, "The" )
, HiddenWord ( 1, "pen" ) -- this choice is correct
, SentenceWord ( 2, "is" )
, HiddenWord ( 3, "sword" ) -- this choice is incorrect, wrong index
, SentenceWord ( 4, "than" )
, SentenceWord ( 5, "the" )
, HiddenWord ( 6, "" )
]
Let's add a new viewSentence function to render the sentence:
viewSentence : Words -> Words -> Html msg
viewSentence sentence chosenWords =
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWord ( _, word ) ->
span [ class "sentence-word" ] [ text word ]
HiddenWord ( index, word ) ->
span [ class "hidden-word" ] [ text "-----" ]
)
sentence
)
It's pretty straightforward: we are rendering Words that can be SentenceWord for showing a regular word and a HiddenWord for showing a word that was chosen. As a first iteration we are just rendering dashes. In the next step we will render a dropdown <select>.
In order for the words to have a space between them let's add a style.css inside src folder:
.hidden-word,
.sentence-word {
display: inline-block;
margin-left: 0.5em;
margin-bottom: 1em;
line-height: 2em;
}
.hidden-word:first-child,
.sentence-word:first-child {
margin-left: 0;
}
And import it inside our index.js:
import { Elm } from './src/Main.elm';
import './src/style.css';
Elm.Main.init({
node: document.getElementById('app')
});
Update the main view function with a call to viewSentence:
view : Model -> Html msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ text model.sentence ]
, viewSentence model.chosenSentence model.chosenWords
]
]
]
Ellie: https://ellie-app.com/5CQqN2ptGsha1
Rendering The DropDown For Hidden Words
Let's add the new viewHiddenWord function that will render <select> for a HiddenWord:
viewHiddenWord : Word -> List Word -> Html msg
viewHiddenWord hiddenWord chosenWords =
case hiddenWord of
HiddenWord ( _, hiddenWordText ) ->
let
viewOption : String -> Html msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : Word -> Html msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
in
div [ class "select" ]
[ select [ class "hidden-word" ] <|
option []
[ text "" ]
:: List.map wordElement chosenWords
]
_ ->
text ""
The viewHiddenWord accepts the current hidden word and a list of all missing words and renders a drop down for a selection.
First thing we do is to pattern match the Word type to get our HiddenWord, because only for HiddenWord should we render the <select> element. The SentenceWord render is still handled in the main view function.
The viewHiddenWord function renders a <select> element and maps over the list of all missing words. For each one of them it calls wordElement function. Here we must pattern match both cases but we render the same <option> element with word's text.
Let's call it from viewSentence:
viewSentence : Words -> Words -> Html msg
viewSentence sentence chosenWords =
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWord ( _, word ) ->
span [ class "sentence-word" ] [ text word ]
HiddenWord ( index, word ) ->
viewHiddenWord sentenceWord chosenWords
)
sentence
)
We should see that we can now choose any of the missing words:
Ellie: https://ellie-app.com/5CQyR6WT3zca1
Adding Interaction
The player can chose a word, but our Model is not updated with this change. We can use the onInput event on select and update the model with the current selection. Since we have a chosenSentence as a List of Tuples that have an index - we know which Word we need to update.
Let's delete the NoOp message type and add a new one that will get an index and a new word String:
type Msg
= WordChanged Int String
Change the update function to handle the new Msg (don't forget to delete the code related to NoOp message):
update : Msg -> Model -> Model
update msg model =
case msg of
WordChanged index wordString ->
let
updateWord : Word -> Word
updateWord word =
case word of
(HiddenWord ( hiddenIndex, _ )) as hiddenWord ->
if hiddenIndex == index then
HiddenWord ( index, wordString )
else
hiddenWord
_ ->
word
newSentence : Words
newSentence =
List.map updateWord model.chosenSentence
in
{ model | chosenSentence = newSentence }
We map over all the words in the chosenSentence and if the index of the current sentence (hiddenIndex) word equals to the index of the changed word (index) - we replace the String part of the Tuple with the new wordString.
We are using as syntax to have a reference to the whole matched word, which we return if the index is not equal to hiddenIndex.
If the current word is not a HiddenWord - we just return the current word.
Let's update the viewHiddenWord function to emit the message from select element:
viewHiddenWord : Word -> List Word -> Html Msg
viewHiddenWord hiddenWord chosenWords =
case hiddenWord of
HiddenWord ( hiddenIndex, hiddenWordText ) ->
let
viewOption : String -> Html Msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : Word -> Html Msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement chosenWords
]
_ ->
text ""
The onInput expects a function that accepts a String as a parameter. The WordChanged message accepts an Int and a String. We pass to onInput (WordChanged hiddenIndex) that is exactly what it needs. In Elm every function is automatically curried and, therefore, (WordChanged hiddenIndex) returns a function that is waiting for a String.
Note that we have changed the msg (any type) to Msg (our specific message type). We also need to make the same change in ViewSentence function since it is now also propagates our new message:
viewSentence : Words -> Words -> Html Msg
-- omitted
The Model has initial missing words as an empty String:
The Model is updated when a word is chosen in the dropdown:
Ellie: https://ellie-app.com/5CQDxjPhpwpa1
Hint Correctness
We have a nice interactive game already, but the player has no indication whether the words they chose are correct.
We can render a list of missing words and hint the player if their chosen word is the correct one.
To render a list of missing words and know if any word is a correct choice, we need a list of missing words and a list of sentence words. If a missing word is a member of sentence words - has the same index and a word string - the word was chosen correctly:
viewChosenWords : Words -> Words -> Html msg
viewChosenWords chosenWords sentenceWords =
let
viewChosenWord : Word -> Html msg
viewChosenWord chosenWord =
case chosenWord of
HiddenWord ( _, wordString ) ->
let
isCorrectGuess : Bool
isCorrectGuess =
List.member chosenWord sentenceWords
className : String
className =
if isCorrectGuess then
"has-text-success"
else
"has-text-grey-light"
in
li []
[ span [ class className ]
[ text wordString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
SentenceWord _ ->
text ""
in
ul [] (List.map viewChosenWord chosenWords)
We are using Font Awesome's icon and Bulma's classes for coloring the words in light grey or green.
Add viewChosenWords to the main view function to render the list:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ text model.sentence ]
, viewSentence model.chosenSentence model.chosenWords
, viewChosenWords model.chosenWords model.chosenSentence
]
]
]
And now every time a player choses the correct word - it is colored in green in the chosen words list:
Ellie: https://ellie-app.com/5CQKJZdYGMKa1
The game works, but the Model can be improved. In the next post we will refactor it to eliminate invalid states. Stay tuned!
The current progress is saved in the repo under a Tag v1.0 https://github.com/mickeyvip/words-memory-game/tree/v1.0.





Top comments (2)
viewSentence takes one argument:
But in the view you are passing two arguments:
Hi, Anton.
You are absolutely right! I was trying to code along with the writing, but still got some things wrong.
Just updated the article and added Ellie links.
Thank you.