elm

Writing A Word Memory Game In Elm - Part 2: Modeling And Building a Basic Word Memory Game

mickeyvip

Mickey

Posted on March 22, 2019

Writing A Word Memory Game In Elm - Part 2: Modeling And Building a Basic Word Memory Game

This is part 2 of the series "Writing A Word Memory Game In Elm", find:


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.

What we want the game to look like

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
Enter fullscreen mode Exit fullscreen mode

The sentence is a List of Word which we can alias as Words:

type alias Words =
    List Word
Enter fullscreen mode Exit fullscreen mode

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 )
Enter fullscreen mode Exit fullscreen mode

The Model now looks like this:

type alias Model =
    { sentence : String
    , chosenWords : Words
    , chosenSentence : Words
    }
Enter fullscreen mode Exit fullscreen mode

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, "" )
        ]
    }
Enter fullscreen mode Exit fullscreen mode

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, "" )
    ]
Enter fullscreen mode Exit fullscreen mode

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
        )
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

And import it inside our index.js:

import { Elm } from './src/Main.elm';
import './src/style.css';

Elm.Main.init({
    node: document.getElementById('app')
});
Enter fullscreen mode Exit fullscreen mode

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
                ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

Rendering a sentence with hidden words

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 ""
Enter fullscreen mode Exit fullscreen mode

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
        )
Enter fullscreen mode Exit fullscreen mode

We should see that we can now choose any of the missing words:

Player can choose a word

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 ""

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Model has initial missing words as an empty String:

The  raw `Model` endraw  has initial hidden words as an empty  raw `String` endraw

The Model is updated when a word is chosen in the dropdown:

The  raw `Model` endraw  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)

Enter fullscreen mode Exit fullscreen mode

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
                ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

And now every time a player choses the correct word - it is colored in green in the chosen words list:

Hint for a correct choice

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.

💖 💪 🙅 🚩
mickeyvip
Mickey

Posted on March 22, 2019

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

Sign up to receive the latest update from our blog.

Related