DEV Community

Cover image for Writing A Word Memory Game In Elm - Part 3: Rethinking the Model
Mickey
Mickey

Posted on • Edited on

Writing A Word Memory Game In Elm - Part 3: Rethinking the Model

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


The game works now. Cool. But looking at the current Model

type Word
    = SentenceWord ( Int, String )
    | HiddenWord ( Int, String )


type alias Words =
    List Word


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

we can spot several problems:

  • The chosenSentence and chosenWords are 2 separate parts of the Model and we can accidentally make them out of sync
  • Since chosenWords is of type Words, it may contain also SentenceWord
  • The sentence may also become out of sync with the rest of the Model
  • We also have some code smell in viewHiddenWord:
viewHiddenWord : Word -> List Word -> Html msg
viewHiddenWord hiddenWord chosenWords =

            -- omitted code

            wordElement : Word -> Html Msg                
                wordElement word =
                    case word of
                        HiddenWord ( _, wordString ) ->
                            viewOption wordString

                        SentenceWord ( _, wordString ) ->
                            viewOption wordString

            -- omitted code
Enter fullscreen mode Exit fullscreen mode

And probably more.

In Elm we want to invest additional time into a good planning of our model and in getting it as close as possible to a form that will eliminate the invalid state of our application.

This idea, not new I suppose, was introduced by Richard Feldman at Elm-Conf 2016 in his amazing (as always) talk:

Since then we often hear "make impossible states impossible" in different communities, not only Elm.

How can we improve our Model and eliminate possibilities for the invalid state and code smell?

Instead of having chosenWords and sentenceWords - we can just have sentence property and rename the sentence to sentenceOriginal:

type Word
    = SentenceWord ( Int, String )
    | HiddenWord ( Int, String )


type alias Words =
    List Word


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

Looks better, but now we lost the original sentence's word or player's input, since there is only 1 String placeholder. Well... we can have the player's input and the original word in the same type constructor:

type Word
    = SentenceWord ( Int, String )
    | HiddenWord ( Int, String, String ) -- index, answer, player's choice
Enter fullscreen mode Exit fullscreen mode

Also, maybe instead of having an index that can also get out of sync, we can use List.indexedMap when rendering the sentence which will give us the index:

type Word
    = SentenceWord String
    | HiddenWord String String  -- answer, player's choice


type alias Words =
    List Word


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

We can go even further into modeling and replace String with a concrete types:

type PlayerChoice =
    PlayerChoice String    


type Answer =
    Answer String    


type Word
    = SentenceWord String
    | HiddenWord Answer PlayerChoice


type alias Words =
    List Word


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

This will prevent us from passing the answer (which is String) and the player's input (also String) to HiddenWord, in the wrong order. The code now is also more readable.

We have almost all we need now. The ability to have a random order of chosen words, however, is still missing. To solve this, we can add a SortKey to the HiddenWord:

type PlayerChoice =
    PlayerChoice String    


type Answer =
    Answer String    


type SortKey =
    SortKey Int


type Word
    = SentenceWord String
    | HiddenWord SortKey Answer PlayerChoice


type alias Words =
    List Word


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

When the type constructor grows, it may be better to turn it to a record. Let's convert HiddenWord into a record:

type alias HiddenWord =
    { sortKey : Int
    , playerChoice : PlayerChoice
    , answer : Answer
    }
Enter fullscreen mode Exit fullscreen mode

We don't need the SortKey, just Int is sufficient:

type PlayerChoice =
    PlayerChoice String    


type Answer =
    Answer String    


type alias HiddenWord =
    { sortKey : Int
    , playerChoice : PlayerChoice
    , answer : Answer
    }


type Word
    = SentenceWord String
    | HiddenWord HiddenWord


type alias Words =
    List Word


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

At this point Elm will complain about 2 HiddenWord type definitions.

Compiler complains

We need to have different names. I renamed SentenceWord and HiddenWord to SentenceWrd and HiddenWrd:

type Word
    = SentenceWrd String
    | HiddenWrd HiddenWord
Enter fullscreen mode Exit fullscreen mode

After this change we have a lot to refactor. Luckily we have our back covered with Elm compiler. Let's update the current initialModel:

type alias Model =
    { sentence : String
    , chosenWords : Words
    , chosenSentence : Words
    }


initialModel : Model
initialModel =
    { originalSentence = "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

to this:

type alias Model =
    { sentence : Words
    }


initialModel : Model
initialModel =
    { sentence =
        [ SentenceWrd "The"
        , HiddenWrd
            { sortKey = 1
            , answer = Answer "pen"
            , playerChoice = PlayerChoice ""
            }
        , SentenceWrd "is"
        , HiddenWrd
            { sortKey = 3
            , answer = Answer "mightier"
            , playerChoice = PlayerChoice ""
            }
        , SentenceWrd "than"
        , SentenceWrd "the"
        , HiddenWrd
            { sortKey = 2
            , answer = Answer "sword"
            , playerChoice = PlayerChoice ""
            }
        ]
    }
Enter fullscreen mode Exit fullscreen mode

We hardcoded the "random" order of the chosen words by setting the sortKey, also removed the originalSentence from the model (for now) and renamed chosenSentence to sentence.

The update function is the first we can refactor easily. Let's change:

  • newSentence to use List.indexedMap
  • updateWord to take an index as the first argument
update : Msg -> Model -> Model
update msg model =
    case msg of
        WordChanged index wordString ->
            let
                updateWord : Int -> Word -> Word
                updateWord wordIndex word =
                    case word of
                        HiddenWrd hiddenWord ->
                            if wordIndex == index then
                                HiddenWrd
                                    { hiddenWord
                                        | playerChoice = PlayerChoice wordString
                                    }

                            else
                                word

                        _ ->
                            word

                newSentence : Words
                newSentence =
                    List.indexedMap updateWord model.sentence
            in
            { model | sentence = newSentence }
Enter fullscreen mode Exit fullscreen mode

We now have an index of the word to update with the player's input, and wordIndex of the current word we are running List.indexedMap on. If both indexes are equal, we need to update the playerChoice field of the HiddenWrd.

Now there are a lot of compiler errors. Let's go step by step and comment out all the functions with errors, that is viewSentence, viewHiddenWord, viewChosenWords and the invocation of them in the view:

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

The compilers complains about text model.sentence because text expects a String as input, but after the refactoring model.sentence is not a simple String but Words (which is itself a type alias for List Word).

First we need something that can convert a Word into a String:

wordToString : Word -> String
wordToString word =
    case word of
        SentenceWrd wordString ->
            wordString

        HiddenWrd hiddenWord ->
            case hiddenWord.answer of
                Answer answerString ->
                    answerString
Enter fullscreen mode Exit fullscreen mode

If word is SenteceWrd, we'll take the String part of it. If it's the HiddenWrd, we'll take the Answer from it and then pattern match to get its String part.

Let's add a viewOriginalSentence function to render the sentence:

viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
    p
        [ class "has-text-centered" ]
        (List.map
            (\word ->
                wordToString word |> text
            )
            words
        )
Enter fullscreen mode Exit fullscreen mode

We are mapping each Word to a String and passing the String to Html.text.

Let's update the view function to use viewOriginalSentence:

view : Model -> Html Msg
view model =
    main_ [ class "section" ]
        [ div [ class "container" ]
            [ viewTitle
            , div [ class "box" ]
                [ p
                    [ class "has-text-centered" ]
                    [ viewOriginalSentence model.sentence ]

                --                , viewSentence model.chosenSentence model.chosenWords
                ]
                --                , viewChosenWords model.chosenWords model.chosenSentence
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

The application now compiles! Lets see how it looks:

Original sentence created from our model

Well... all the words are written without any spaces between them. We need to add some space, one way we can do it is by adding a space after each word:

viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
    p
        [ class "has-text-centered" ]
        (List.map
            (\word ->
                wordToString word ++ " " |> text
            )
            words
        )
Enter fullscreen mode Exit fullscreen mode

Or we can use List.intersperse that "Places the given value between all members of the given list.":

viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
    p
        [ class "has-text-centered" ]
        (words
            |> List.map wordToString
            |> List.intersperse " "
            |> List.map text
        )
Enter fullscreen mode Exit fullscreen mode

or event simpler with String.join:

viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
    p
        [ class "has-text-centered" ]
        [ words
            |> List.map wordToString
            |> String.join " "
            |> text
        ]
Enter fullscreen mode Exit fullscreen mode

And now the game looks better:

Original sentence created from our model with spaces between words

Let's do a small refactor before moving on. Let's add answerToString helper function to convert Answer into a String. We can use it from wordToString and it may come handy later when comparing the player's choice with the answer:

answerToString : Answer -> String
answerToString (Answer wordString) =
    wordString


wordToString : Word -> String
wordToString word =
    case word of
        HiddenWrd hiddenWord ->
            answerToString hiddenWord.answer

        SentenceWrd wordString ->
            wordString
Enter fullscreen mode Exit fullscreen mode

Great. The next in line is the view for chosen words. Let's uncomment and examine it:

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

The firs thing that we can improve is the signature. Now it takes chosenWords as Words, but it should only handle List HiddenWord.

Previously, we needed the sentenceWords to hint if the player's choice was correct. Now we have all the information in HiddenWord, both playerChoice and answer!

This means that the signature can be simplified and made to express the input better - we can only pass List HiddenWord here:

viewChosenWords : List HiddenWord -> Html msg
viewChosenWords chosenWords =
Enter fullscreen mode Exit fullscreen mode

Let's also rename viewChosenWords to viewHiddenWords because hidden and chosen are used and it's confusing:

viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
Enter fullscreen mode Exit fullscreen mode

We also need a way to get the List HiddenWord from our model. Let's add this ability. We need to filter out the HiddenWrd and take HiddenWord from it (note "Word" vs. "Wrd"). And Elm has us covered with List.filterMap:

hiddenWords : Words -> List HiddenWord
hiddenWords sentence =
    List.filterMap
        (\word ->
            case word of
                HiddenWrd hiddenWord ->
                    Just hiddenWord

                _ ->
                    Nothing
        )
        sentence
Enter fullscreen mode Exit fullscreen mode

Now we can update view to call it:

view : Model -> Html Msg
view model =
    main_ [ class "section" ]
        [ div [ class "container" ]
            [ viewTitle
            , div [ class "box" ]
                [ p
                    [ class "has-text-centered" ]
                    [ viewOriginalSentence model.sentence

                    --                , viewSentence model.chosenSentence model.chosenWords
                    ]
                , viewHiddenWords (hiddenWords model.sentence)
                ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

Back to viewHiddenWords. It has an inner viewChosenWord function. Let's rename it to viewHiddenWord and change the signature, since we now have only HiddenWord:

        viewHiddenWord : HiddenWord -> Html msg
        viewHiddenWord hiddenWord =
Enter fullscreen mode Exit fullscreen mode

We don't need the case anymore for the same reason - we only have HiddenWord now, so we can delete it:

viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
    let
        viewHiddenWord : HiddenWord -> Html msg
        viewHiddenWord hiddenWord =
            let
                isCorrectGuess : Bool
                isCorrectGuess =
                    List.member hiddenWord 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" ] [] ]
                    ]
                ]
    in
    ul [] (List.map viewHiddenWord hiddenWordList)
Enter fullscreen mode Exit fullscreen mode

How do we know that the player's choice is the correct choice? As I previously mentioned, we have all we need in the HiddenWord type:

type alias HiddenWord =
    { sortKey : Int
    , playerChoice : PlayerChoice
    , answer : Answer
    }
Enter fullscreen mode Exit fullscreen mode

All we need is to compare the payerChoice and answer. They both are container types:

type PlayerChoice
    = PlayerChoice String


type Answer
    = Answer String

Enter fullscreen mode Exit fullscreen mode

So we need to extract the String value from them. Let's add playerChoiceToString in addition to a previously declared answerToString:

playerChoiceToString : PlayerChoice -> String
playerChoiceToString (PlayerChoice stringValue) =
    stringValue


answerToString : Answer -> String
answerToString (Answer stringValue) =
    stringValue
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward using the destructuring.

Now we can use them in isCorrectGuess:

viewHiddenWord : HiddenWord -> Html msg
        viewHiddenWord hiddenWord =
            let
                isCorrectGuess : Bool
                isCorrectGuess =
                    answerToString hiddenWord.answer == playerChoiceToString hiddenWord.playerChoice
Enter fullscreen mode Exit fullscreen mode

The className needs no change. But the li declaration again needs a String where wordString was:

            li []
                [ span [ class className ]
                    [ text wordString
                    , text " "
                    , span [ class "icon is-small" ]
                        [ i [ class "far fa-check-circle" ] [] ]
                    ]
                ]
Enter fullscreen mode Exit fullscreen mode

What should go there? The String from the hiddenWord.answer of course!

            li []
                [ span [ class className ]
                    [ text <| answerToString hiddenWord.answer
                    , text " "
                    , span [ class "icon is-small" ]
                        [ i [ class "far fa-check-circle" ] [] ]
                    ]
                ]
Enter fullscreen mode Exit fullscreen mode

We can refactor it a little to reuse the value of answerToString hiddenWord.answer by adding 2 declarations:

viewHiddenWord : HiddenWord -> Html msg
        viewHiddenWord hiddenWord =
            let
                answerString : String
                answerString =
                    answerToString hiddenWord.answer

                playerChoiceString : String
                playerChoiceString =
                    playerChoiceToString hiddenWord.playerChoice
Enter fullscreen mode Exit fullscreen mode

and:

                isCorrectGuess : Bool
                isCorrectGuess =
                    answerString == playerChoiceString


-- omitted code

            li []
                [ span [ class className ]
                    [ text answerString
                    , text " "
                    , span [ class "icon is-small" ]
                        [ i [ class "far fa-check-circle" ] [] ]
                    ]
                ]
Enter fullscreen mode Exit fullscreen mode

And now our viewHiddenWords looks like this:

viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
    let
        viewHiddenWord : HiddenWord -> Html msg
        viewHiddenWord hiddenWord =
            let
                answerString : String
                answerString =
                    answerToString hiddenWord.answer

                playerChoiceString : String
                playerChoiceString =
                    playerChoiceToString hiddenWord.playerChoice

                isCorrectGuess : Bool
                isCorrectGuess =
                    answerString == playerChoiceString

                className : String
                className =
                    if isCorrectGuess then
                        "has-text-success"

                    else
                        "has-text-grey-light"
            in
            li []
                [ span [ class className ]
                    [ text answerString
                    , text " "
                    , span [ class "icon is-small" ]
                        [ i [ class "far fa-check-circle" ] [] ]
                    ]
                ]
    in
    ul [] (List.map viewHiddenWord hiddenWordList)
Enter fullscreen mode Exit fullscreen mode

Lets look at our game:

Game with hidden words

Looks good, but we are missing one more detail - the hidden words are presented in the same order as they appear in the sentence... If you remember we added sortKey to our HiddenWord to be able to show them in random order:

type alias HiddenWord =
    { sortKey : Int
    , playerChoice : PlayerChoice
    , answer : Answer
    }
Enter fullscreen mode Exit fullscreen mode

Currently, we have the random order hardcoded in the initialModel:

initialModel : Model
initialModel =
    { sentence =
        [ SentenceWrd "The"
        , HiddenWrd
            { sortKey = 3
            , answer = Answer "pen"
            , playerChoice = PlayerChoice ""
            }
        , SentenceWrd "is"
        , HiddenWrd
            { sortKey = 1
            , answer = Answer "mightier"
            , playerChoice = PlayerChoice ""
            }
        , SentenceWrd "than"
        , SentenceWrd "the"
        , HiddenWrd
            { sortKey = 2
            , answer = Answer "sword"
            , playerChoice = PlayerChoice ""
            }
        ]
    }
Enter fullscreen mode Exit fullscreen mode

So what is left to do is to sort the hidden words by the sortKey before showing them. We'll add hiddenWordsSorted and use it :

viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
    let
        hiddenWordsSorted : List HiddenWord
        hiddenWordsSorted =
            List.sortBy .sortKey hiddenWordList

        viewHiddenWord : HiddenWord -> Html msg
        viewHiddenWord hiddenWord =

-- omitted

    in
    ul [] <| List.map viewHiddenWord hiddenWordsSorted
Enter fullscreen mode Exit fullscreen mode

Randomly sorted hidden words

One last small refactor I would like to do is to wordToString:

wordToString : Word -> String
wordToString word =
    case word of
        SentenceWrd wordString ->
            wordString

        HiddenWrd hiddenWord ->
            case hiddenWord.answer of
                Answer answerString ->
                    answerString
Enter fullscreen mode Exit fullscreen mode

We have answerToString so we can use it in the second branch of the case:

wordToString : Word -> String
wordToString word =
    case word of
        SentenceWrd wordString ->
            wordString

        HiddenWrd hiddenWord ->
            answerToString hiddenWord.answer
Enter fullscreen mode Exit fullscreen mode

or even shorter:

wordToString : Word -> String
wordToString word =
    case word of
        SentenceWrd wordString ->
            wordString

        HiddenWrd { answer } ->
            answerToString answer
Enter fullscreen mode Exit fullscreen mode

The final part - viewSentence. Let's uncomment it in the view function.

            viewSentence model.chosenSentence model.chosenWords
Enter fullscreen mode Exit fullscreen mode

We don't have chosenSentence anymore since we replaced sentence, and we also got rid of the additional chosenWords. We do need to send the List HiddenWord to the viewSentence so it knows what to render as a <select> element. We already have a helper function for it - hiddenWords - that we use when calling viewHiddenWords. So the viewSentence may look like this:

view : Model -> Html Msg
view model =

-- omitted
                    [ viewOriginalSentence model.sentence
                    , viewSentence model.sentence (hiddenWords model.sentence)
                    ]
Enter fullscreen mode Exit fullscreen mode

Looks like we are sending model.sentence to viewSentence and using it to get hidden words... We don't need to send (hiddenWords model.sentence) since viewSentence can calculate them by itself!

view : Model -> Html Msg
view model =
    main_ [ class "section" ]
        [ div [ class "container" ]
            [ viewTitle
            , div [ class "box" ]
                [ p
                    [ class "has-text-centered" ]
                    [ viewOriginalSentence model.sentence
                    , viewSentence model.sentence
                    ]
                , viewHiddenWords (hiddenWords model.sentence)
                ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

Let's uncomment viewSentence and change its signature from:

viewSentence : Words -> Words -> Html Msg
viewSentence sentence chosenWords =
Enter fullscreen mode Exit fullscreen mode

to

viewSentence : Words -> Html Msg
viewSentence sentence =
Enter fullscreen mode Exit fullscreen mode

Now we'll obtain hiddenWords:

viewSentence : Words -> Html Msg
viewSentence sentence =
    let
        hiddenWords_ =
            hiddenWords sentence
    in
Enter fullscreen mode Exit fullscreen mode

And update the rest of viewSentence to:

viewSentence : Words -> Html Msg
viewSentence sentence =
    let
        hiddenWords_ : List HiddenWord
        hiddenWords_ =
            hiddenWords sentence
    in
    div [ class "has-text-centered" ]
        (List.map
            (\sentenceWord ->
                case sentenceWord of
                    SentenceWrd word ->
                        span [ class "sentence-word" ] [ text word ]

                    HiddenWrd hiddenWord ->
                        span [ class "sentence-word" ] [ text (answerToString hiddenWord.answer) ]
             --                        viewHiddenWord sentenceWord hiddenWords_
            )
            sentence
        )
Enter fullscreen mode Exit fullscreen mode

I have commented out the call to viewHiddenWord so we can see the intermediate result:

Intermediate sentence render

Now let's uncomment viewHiddenWord and return the line inside viewSentence:

viewSentence : Words -> Html Msg
viewSentence sentence =
    let
        hiddenWords_ : List HiddenWord
        hiddenWords_ =
            hiddenWords sentence
    in
    div [ class "has-text-centered" ]
        (List.map
            (\sentenceWord ->
                case sentenceWord of
                    SentenceWrd word ->
                        span [ class "sentence-word" ] [ text word ]

                    HiddenWrd hiddenWord ->
                         viewHiddenWord sentenceWord hiddenWords_
            )
            sentence
        )
Enter fullscreen mode Exit fullscreen mode

We need to change sentenceWord to hiddenWord:

viewSentence : Words -> Html Msg
viewSentence sentence =
    let
        hiddenWords_ : List HiddenWord
        hiddenWords_ =
            hiddenWords sentence
    in
    div [ class "has-text-centered" ]
        (List.map
            (\sentenceWord ->
                case sentenceWord of
                    SentenceWrd word ->
                        span [ class "sentence-word" ] [ text word ]

                    HiddenWrd hiddenWord ->
                         viewHiddenWord hiddenWord hiddenWords_
            )
            sentence
        )
Enter fullscreen mode Exit fullscreen mode

Good. Now to viewHiddenWord. Let's update its type signature from:

viewHiddenWord : Word -> List Word -> Html Msg
viewHiddenWord hiddenWord chosenWords =
Enter fullscreen mode Exit fullscreen mode

to

viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords =
Enter fullscreen mode Exit fullscreen mode

The only type that viewHiddenWord can accept now is HiddenWord. We also renamed all the chosenXYZ to hiddenXYZ.

Again, we don't need the case expression anymore!

viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords =
    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 hiddenWords
        ]
Enter fullscreen mode Exit fullscreen mode

Let's also rename the 2 instances of hiddenWords to hiddenWords_, since the compiler complains about name collisions (2nd line and the line before the last in the code snippet).

viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
    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 hiddenWords_
        ]
Enter fullscreen mode Exit fullscreen mode

The wordElement also gets simpler since it should only get HiddenWord, so the strange-looking case is gone:

viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
    let
        viewOption : String -> Html Msg
        viewOption wordString =
            option
                [ value wordString, selected (wordString == hiddenWordText) ]
                [ text <| String.toLower wordString ]

        wordElement : HiddenWord -> Html Msg
        wordElement hiddenWord_ =
            viewOption (answerToString hiddenWord_.answer)
    in
    div [ class "select" ]
        [ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
            option []
                [ text "" ]
                :: List.map wordElement hiddenWords_
        ]
Enter fullscreen mode Exit fullscreen mode

The viewOption change is a pretty simple one. We need to select the <option> where the answer from the current HiddenWord equals playerChoice from the HiddenWord that was passed in:

viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
    let
        playerChoiceString : String
        playerChoiceString =
            playerChoiceToString hiddenWord.playerChoice

        viewOption : String -> Html Msg
        viewOption answerString =
            option
                [ value answerString, selected (answerString == playerChoiceString) ]
                [ text <| String.toLower answerString ]

        wordElement : HiddenWord -> Html Msg
        wordElement hiddenWord_ =
            viewOption
                (answerToString hiddenWord_.answer)
    in
    div [ class "select" ]
        [ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
            option []
                [ text "" ]
                :: List.map wordElement hiddenWords_
        ]
Enter fullscreen mode Exit fullscreen mode

The compiler now complains about name collision between this viewHiddenWord and inner viewHiddenWord inside viewHiddenWords... Let's add _ to the inner viewHiddenWord:

viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
    let
        hiddenWordsSorted : List HiddenWord
        hiddenWordsSorted =
            List.sortBy .sortKey hiddenWordList

        viewHiddenWord_ : HiddenWord -> Html msg
        viewHiddenWord_ hiddenWord =

-- omitted

    in
    ul [] <| List.map viewHiddenWord_ hiddenWordsSorted

Enter fullscreen mode Exit fullscreen mode

Looking at the code we can see that wordElement does almost nothing and just calls viewOption, so we can just remove it and call viewOption directly:

viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
    let
        playerChoiceString : String
        playerChoiceString =
            playerChoiceToString hiddenWord.playerChoice

        viewOption : HiddenWord -> Html Msg
        viewOption hiddenWord_ =
            let
                answerString =
                    answerToString hiddenWord_.answer
            in
            option
                [ value answerString, selected (answerString == playerChoiceString) ]
                [ text <| String.toLower answerString ]
    in
    div [ class "select" ]
        [ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
            option []
                [ text "" ]
                :: List.map viewOption hiddenWords_
        ]
Enter fullscreen mode Exit fullscreen mode

We now pass HiddenWord to viewOption and calculate answerString inside it. We also can use destructuring in the viewOption declaration to get hiddenWord_.answer immediately:

        viewOption : HiddenWord -> Html Msg
        viewOption { answer } =
            let
                answerString : String
                answerString =
                    answerToString answer
            in
            option
                [ value answerString, selected (answerString == playerChoiceString) ]
                [ text <| String.toLower answerString ]
Enter fullscreen mode Exit fullscreen mode

We still have hiddenIndex in our viewHiddenWord that we haven't handled. This should be the index of the word in the sentence, so we can know what word to update with the player's choice. As we discussed in the beginning of the refactor, we can have this index by using List.indexedMap instead of List.map in viewSentence, and call viewHiddenWord with that index.

Let's update viewHiddenWord to use List.indexedMap, get the index in the inner function and pass it to viewHiddenWord:

viewSentence : Words -> Html Msg
viewSentence sentence =
    let
        hiddenWords_ : List HiddenWord
        hiddenWords_ =
            hiddenWords sentence
    in
    div [ class "has-text-centered" ]
        (List.indexedMap
            (\index sentenceWord ->
                case sentenceWord of
                    SentenceWrd word ->
                        span [ class "sentence-word" ] [ text word ]

                    HiddenWrd hiddenWord ->
                        viewHiddenWord index hiddenWord hiddenWords_
            )
            sentence
        )
Enter fullscreen mode Exit fullscreen mode

Now let's update viewHiddenWord to take the index as its first argument and use it:

viewHiddenWord : Int -> HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord index hiddenWord hiddenWords_ =

-- omitted 

    in
    div [ class "select" ]
        [ select [ class "hidden-word", onInput (WordChanged index) ] <|
            option []
                [ text "" ]
                :: List.map viewOption hiddenWords_
        ]
Enter fullscreen mode Exit fullscreen mode

The game compiles and works!

But the <option>s in the drop down again come in the same order as they appear in the sentence. We still need to sort them by sortKey as we did in viewHiddenWords.

We are repeating ourselves with hiddenWordsSorted, so lets refactor it to its own function:

hiddenWordsSorted : List HiddenWord -> List HiddenWord
hiddenWordsSorted hiddenWordList =
    List.sortBy .sortKey hiddenWordList
Enter fullscreen mode Exit fullscreen mode

and use it inside viewHiddenWords:

viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
    let
        hiddenWordsSorted_ : List HiddenWord
        hiddenWordsSorted_ =
            hiddenWordsSorted hiddenWordList

--- omitted

    in
    ul [] <| List.map viewHiddenWord_ hiddenWordsSorted_
Enter fullscreen mode Exit fullscreen mode

and in viewHiddenWord:

viewHiddenWord : Int -> HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord index hiddenWord hiddenWords_ =
    let
        hiddenWordsSorted_ : List HiddenWord
        hiddenWordsSorted_ =
            hiddenWordsSorted hiddenWords_

-- omitted

    in
    div [ class "select" ]
        [ select [ class "hidden-word", onInput (WordChanged index) ] <|
            option []
                [ text "" ]
                :: List.map viewOption hiddenWordsSorted_
        ]
Enter fullscreen mode Exit fullscreen mode

And now we have the hidden words in the correct random order also in the drop down:

Hidden words in the correct order in the drop down

Phew! That was a lot! And we gained a lot! Much less places to get our code into invalid state.

The current progress is saved in the repo under a Tag v2.0 https://github.com/mickeyvip/words-memory-game/tree/v2.0.

In the next post we will add randomness to select different words for each new game. Stay tuned!

Acknowledgements

Special thanks to Joël Quenneville for reviewing the code and suggesting the refactoring presented in this post.

Top comments (0)