DEV Community

Cover image for Writing A Word Memory Game In Elm - Part 4: Spicing Things Up With Randomness
Mickey
Mickey

Posted on • Edited on

Writing A Word Memory Game In Elm - Part 4: Spicing Things Up With Randomness

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


Choosing the same words every time is pretty boring. Let's make our game choose random words each time.

Randomness is a side effect and does not fit into functional programming concepts, since generating a random number returns something different each time.

In order to make our application handle side effects we need to upgrade it from Browser.sandbox to Browser.element which will add commands and subscriptions for interacting with the outside world.

Let's change our main definition to use Browser.element:

main : Program () Model Msg
main =
    Browser.element
        { view = view
        , init = init
        , update = update
        , subscriptions = subscriptions
        }

Enter fullscreen mode Exit fullscreen mode

There are now several changes we need to make in order for our current application to compile:

  • init expects a function instead of Model, so we'll add the init function
  • subscriptions expects a function that we'll add
  • update returns a Tuple (Model, Cmd Msg) instead of a Model

Let's follow the compiler and fix the errors.

We'll start by adding the subscriptions function. We actually will not need subscriptions for now, so we can return Sub.none, meaning no subscriptions:

---- SUBSCRIPTIONS ----


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none

Enter fullscreen mode Exit fullscreen mode

Now update - let's change the signature to

update : Msg -> Model -> ( Model, Cmd Msg )
Enter fullscreen mode Exit fullscreen mode

and return the expected tuple (last line):

update : Msg -> Model -> ( Model, Cmd Msg )
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 }, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

The last one is the init function. It takes flags which we will not be using and can just ignore by declaring the first argument as a unit type (). It returns a Tuple (Model, Cmd Msg) similar to update. We will use our initialModel and Cmd.none meaning no commands to run:

init : () -> ( Model, Cmd Msg )
init _ =
    ( initialModel, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

This should make the application compile and the game should function as before.

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

How do we choose random words from the list of words? Elm guide has an example of Random usage: https://guide.elm-lang.org/effects/random.html, and elm/random package has a nice API for generating random numbers.

I have found that Random.list might be useful: we can produce 3 random values from 0 to the length of words and take words at those indexes.

indexes : Int -> Int -> Random.Generator (List Int)
indexes quantity listLength =
    Random.list quantity (Random.int 0 (listLength - 1))
Enter fullscreen mode Exit fullscreen mode

Unfortunately, it may produces a list with repeated values. We can overcome this problem by, for example, keeping generating new random values until we have the required number of unique values. But in a search for simpler or "already made" solution I have found elm-community/random-extra package, which, as elm/random documentation says, may some day be merged with elm/random.

elm-community/random-extra has Random.List module which exposes a shuffle function which, as its name states, shuffles a list. So we can apply it on our list of words and take the first 3. Sounds simple enough.

Let's install it (stop the app if it is running):

$ elm install elm-community/random-extra
Enter fullscreen mode Exit fullscreen mode

Accept the installation prompt by hitting y (or "enter").

We also need to have elm/random as a direct dependency, while up until now had been installed as an indirect dependency. When we install it, Elm installer will prompt us to move it from indirect to direct dependencies. Choose 'y':

$ elm install elm/random
I found it in your elm.json file, but in the "indirect" dependencies.
Should I move it into "direct" dependencies for more general use? [Y/n]:
Dependencies loaded from local cache.
Dependencies ready!
Enter fullscreen mode Exit fullscreen mode

As wee see in the documentation, calling shuffle produces a Generator (List a), which is only a description of what we want to do. To actually execute the Generator we need to call Random.generate which in turn produces a Command, as described in the Random example in the Elm guide we saw earlier.

What we can do is take a list of indexes [0, 1, 2, 3...], shuffle this list and take the first 3 indexes. Those will be the indexes of the hidden words; their placement in the new list will be the sortKeys.

Let's add a function that will produce a Command:

import Random
import Random.List

--- COMMANDS ----


chooseWords : Words -> Cmd Msg
chooseWords chosenSentence =
    List.range 0 (List.length chosenSentence - 1)
        |> Random.List.shuffle
        |> Random.map (List.take 3)
        |> Random.map (List.indexedMap Tuple.pair)
        |> Random.generate WordsChosen
Enter fullscreen mode Exit fullscreen mode

The List.range 0 (List.length chosenSentence - 1) will give us the [0, 1, 2, 3, 4, 5, 6] (since we have 7 words in our sentence).
The shuffle will reorder the list.
The List.take 3 will take first 3 items from the reordered list.
The List.indexedMap Tuple.pair will map the list of word indexes to a List of Tuples where the first index is the sortKey and the second index is the index of the word. For example [(0, 1), (1, 6), (2, 3)].
Then we execute the command by calling Random.generate and passing it the new message WordsChosen.

To manipulate the result of the Random we use Random.map - similar to map of other types.

Let's add a new message, WordsChosen, that gets a list of chosen indexes:

type Msg
    = WordChanged Int String
    | WordsChosen (List (Int, Int))
Enter fullscreen mode Exit fullscreen mode

Now the compiler will guide us to handle the new message in the update function:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of

        -- omitted

        WordsChosen sortIndexes ->
Enter fullscreen mode Exit fullscreen mode

I didn't find any easier way to update the sentence so what I did is as follows: List.indexMap the current sentence, so we have a word index and a word.

        WordsChosen sortIndexes ->
            let
                sentence =
                    List.indexedMap
                        (\wordIndex word ->

-- omitted

                    model.sentence
            in
            ( { model | sentence = sentence }, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

Look if there is a sortIndex pair for a current word (wordIndex equals the second member of the Tuple):

-- omitted
                sentence =
                    List.indexedMap
                        (\wordIndex word ->
                            let
                                hiddenIndex =
                                    List.filter
                                        (\( _, originalWordIndex ) ->
                                            originalWordIndex == wordIndex
                                        )
                                        sortIndexes

-- omitted
Enter fullscreen mode Exit fullscreen mode

If hiddenIndex is not empty, we need to replace current word with HiddenWord, and the sortKey will be the first member of the Tuple. Else we need to leave the current word as is:

-- omitted

                                newWord =
                                    case List.head hiddenIndex of
                                        Just ( sortKey, _ ) ->
                                            HiddenWrd
                                                { sortKey = sortKey
                                                , answer = Answer (wordToString word)
                                                , playerChoice = PlayerChoice ""
                                                }

                                        Nothing ->
                                            word
-- omitted
Enter fullscreen mode Exit fullscreen mode

Here is the full code for the WordsChosen message handler:

        WordsChosen sortIndexes ->
            let
                sentence =
                    List.indexedMap
                        (\wordIndex word ->
                            let
                                hiddenIndex =
                                    List.filter
                                        (\( _, originalWordIndex ) ->
                                            originalWordIndex == wordIndex
                                        )
                                        sortIndexes

                                newWord =
                                    case List.head hiddenIndex of
                                        Just ( sortKey, _ ) ->
                                            HiddenWrd
                                                { sortKey = sortKey
                                                , answer = Answer (wordToString word)
                                                , playerChoice = PlayerChoice ""
                                                }

                                        Nothing ->
                                            word
                            in
                            newWord
                        )
                        model.sentence
            in
            ( { model | sentence = sentence }, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

Let's also set all the words as SentenceWord:

initialModel : Model
initialModel =
    { sentence =
        [ SentenceWrd "The"
        , SentenceWrd "pen"
        , SentenceWrd "is"
        , SentenceWrd "mightier"
        , SentenceWrd "than"
        , SentenceWrd "the"
        , SentenceWrd "sword"
        ]
    }
Enter fullscreen mode Exit fullscreen mode

We also need to update our init function to execute the chooseWords command:

init : () -> ( Model, Cmd Msg )
init _ =
    ( initialModel, chooseWords initialModel.sentence )
Enter fullscreen mode Exit fullscreen mode

Refresh the page - we should see our game is working and each refresh makes it choose different words!

Let's add a nice "New Game" button so we don't need to refresh the page for a new game:

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)
-- new code
                , div [ class "is-clearfix" ]
                    [ button
                        [ class "button is-info is-pulled-right"
                        , onClick NewGame
                        ]
                        [ text "New Game" ]
                    ]
                ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

Let's add NewGame message:

type Msg
    = WordChanged Int String
    | WordsChosen (List ( Int, Int ))
    | NewGame
Enter fullscreen mode Exit fullscreen mode

And handle it in the update function:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of

        -- omitted

        NewGame ->
            ( initialModel, chooseWords initialModel.sentence )
Enter fullscreen mode Exit fullscreen mode

And after the page is refreshed, we can click "New Game" to restart the game.

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

Indicating Winning

It would be a good time to show the player that the sentence is correct.

After each change, we can check if every HiddenWord has a playerChoice that is equal to answer. If it is the case - we can render the "You Win" view.

Let's add a function that will check if there is a win:

isWin : Words -> Bool
isWin sentence =
    List.all
        (\word ->
            case word of
                SentenceWrd _ ->
                    True

                HiddenWrd { answer, playerChoice } ->
                    answerToString answer == playerChoiceToString playerChoice
        )
        sentence
Enter fullscreen mode Exit fullscreen mode

List.all will return True if for all of its members the predicate function returns True. If the word is SentenceWrd, there is nothing to check and we just return True. If the word is HiddenWrd, we return the result of comparing answer and playerChoice.

We can shorten isWin: we only interested in HiddenWords and we already have a function that takes a Words and returns List HiddenWord, which is hiddenWords. Let's use it:

isWin : Words -> Bool
isWin sentence =
    List.all
        (\{ answer, playerChoice } ->
            answerToString answer == playerChoiceToString playerChoice
        )
        (hiddenWords sentence)
Enter fullscreen mode Exit fullscreen mode

And if isWin handles HiddenWords only, we don't need to send it Words at all:

isWin : List HiddenWord -> Bool
isWin hiddenWordList =
    List.all
        (\{ answer, playerChoice } ->
            answerToString answer == playerChoiceToString playerChoice
        )
        hiddenWordList
Enter fullscreen mode Exit fullscreen mode

That looks much better.

We can get even fancier and remove the argument altogether and use currying ability of Elm, similar to how JSON decoders are declared:

isWin : List HiddenWord -> Bool
isWin =
    List.all
        (\{ answer, playerChoice } ->
            answerToString answer == playerChoiceToString playerChoice
        )
Enter fullscreen mode Exit fullscreen mode

We are now returning a function that waits for List HiddenWord argument. This can be a little confusing for newcomers (like myself) though.

When the player wins, let's show the sentence and the "You Win!" message, and hide the sentence with drop-downs and the list of words. To make this easier we can refactor the main view to show viewGame or viewWin views depending on whether the win is True or False:

view : Model -> Html Msg
view model =
    let
        win : Bool
        win =
            isWin <| hiddenWords model.sentence


        currentView : Html Msg
        currentView =
            if win then
                viewWin model.sentence

            else
                viewGame model.sentence
    in
    main_ [ class "section" ]
        [ div [ class "container" ]
            [ viewTitle
            , div [ class "box" ]
                [ currentView ]
            ]
        ]


viewGame : Words -> Html Msg
viewGame sentence =
    div []
        [ viewSentence sentence
        , viewHiddenWords (hiddenWords sentence)
        ]


viewWin : Words -> Html Msg
viewWin sentence =
    div []
        [ div [ class "notification is-primary" ]
            [ h3 [ class "title is-3 has-text-centered" ] [ text "You Win!" ]
            ]
        , h4
            [ class "title is-4 has-text-centered" ]
            [ viewOriginalSentence sentence ]
        , div [ class "is-clearfix" ]
            [ button
                [ class "button is-info is-pulled-right"
                , onClick NewGame
                ]
                [ text "New Game" ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

This should be pretty straightforward and need no explanation. On the viewWin view we have a "You Win!" message, the sentence and the "New Game" button. I have also added is-big class to the title to make it bigger. On the viewGame view we show the game as before.

Game in progress

win

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

Generating Sentence Automatically

Until now we had manually set sentence, but in the actual game there will be many sentences as Strings and we want to be able to generate sentence from them.

How can we do it? We can use String.words. That will return a list of words that we can turn into what we need.

Let's first change our Model to have currentSentence : String, set it to "The pen is mightier than the sword" and set sentence to be an empty list:

type alias Model =
    { currentSentence : String
    , sentence : Words
    }


initialModel : Model
initialModel =
    { currentSentence = "The pen is mightier than the sword"
    , sentence = []
    }
Enter fullscreen mode Exit fullscreen mode

Now we'll add a generateSentence function that will generate the sentence with the help of getWords:

getWords : String -> List String
getWords sentence =
    sentence
        |> String.words

generateSentence : String -> List Word
generateSentence sentenceString =
    sentenceString
        |> String.words
        |> List.map SentenceWrd
Enter fullscreen mode Exit fullscreen mode

Let's update our init function to invoke generateSentence:

init : () -> ( Model, Cmd Msg )
init _ =
    let
        sentence : Words
        sentence =
            generateSentence initialModel.currentSentence

        model : Model
        model =
            { initialModel | sentence = sentence }
    in
    ( model, chooseWords sentence )
Enter fullscreen mode Exit fullscreen mode

The game should work as before, only now the generating of the sentence is automatic.

However, when winning the game and clicking "New Game" we see an empty box. This is because we reset the model to initialModel which has sentence set to []. Let's change the update function to call init that generates the sentence and executes chooseWords command:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of

        -- omitted
        NewGame ->
            init ()
Enter fullscreen mode Exit fullscreen mode

And now we can start a new game!

Refactoring Model Into Game States

We again got to a state that can get out of sync. The currentSentence is needed only for generating the sentence and we have to set sentence to [] at the beginning of each game because it is on our Model, but actually it is not needed for the first step.

Let's redefine our Model so it has 2 states:

  1. Started - will have only the currentSentence : String
  2. Playing - will have only the sentence : Words

The game will start in Started state and call generateSentence. After that the state will be changed to Playing.

type alias StateStarted =
    { currentSentence : String }


type alias StatePlaying =
    { sentence : Words
    }


type GameState
    = Started StateStarted
    | Playing StatePlaying
Enter fullscreen mode Exit fullscreen mode

Now let's update our Model to be of type GameState:

type alias Model =
    GameState
Enter fullscreen mode Exit fullscreen mode

Our initialModel can be set to:

initialModel : Model
initialModel =
    Started (StateStarted "The pen is mightier than the sword")
Enter fullscreen mode Exit fullscreen mode

No unnecessary sentence in the initial state!

The init function becomes a little bit more verbose:

init : () -> ( Model, Cmd Msg )
init _ =
    let
        ( model, cmd ) =
            case initialModel of
                Started { currentSentence } ->
                    let
                        sentence : Words
                        sentence =
                            generateSentence currentSentence
                    in
                    ( Playing (StatePlaying sentence), chooseWords sentence )

                Playing _ ->
                    ( initialModel, Cmd.none )
    in
    ( model, cmd )
Enter fullscreen mode Exit fullscreen mode

We now need to pattern match our Model to know in which game state the game is right now. If it's in Started state, we will generate the sentence and execute chooseWords. In the other case (which at this point should not happen), we will return the initialModel as is and not execute any command.

In the update function we also need to take the game state into account:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        WordChanged index wordString ->
            let
                model_ =
                    case model of
                        Playing { sentence } ->
                            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 sentence
                            in
                            Playing (StatePlaying newSentence)

                        Started _ ->
                            model
            in
            ( model_, Cmd.none )

-- omitted

Enter fullscreen mode Exit fullscreen mode

This change should be pretty straightforward: if the game is in Playing state - extract the sentence from the state, update it and return Playing state with the updated newSentence, otherwise - leave the model as is.

The same thing for WordsChosen:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of

-- omitted

        WordsChosen sortIndexes ->
            let
                model_ =
                    case model of
                        Playing { sentence } ->
                            let
                                sentence_ =
                                    List.indexedMap
                                        (\wordIndex word ->
                                            let
                                                hiddenIndex =
                                                    List.filter
                                                        (\( _, originalWordIndex ) ->
                                                            originalWordIndex == wordIndex
                                                        )
                                                        sortIndexes

                                                newWord =
                                                    case List.head hiddenIndex of
                                                        Just ( sortKey, _ ) ->
                                                            HiddenWrd
                                                                { sortKey = sortKey
                                                                , answer = Answer (wordToString word)
                                                                , playerChoice = PlayerChoice ""
                                                                }

                                                        Nothing ->
                                                            word
                                            in
                                            newWord
                                        )
                                        sentence
                            in
                            Playing (StatePlaying sentence_)

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

The view has to be updated also:

view : Model -> Html Msg
view model =
    let
        currentView =
            case model of
                Playing { sentence } ->
                    let
                        win : Bool
                        win =
                            isWin <| hiddenWords sentence
                    in
                    if win then
                        viewWin sentence

                    else
                        viewGame sentence

                Started { currentSentence } ->
                    viewStarted currentSentence
    in
    main_ [ class "section" ]
        [ div [ class "container" ]
            [ viewTitle
            , div [ class "box" ]
                [ currentView ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

When the game state is Started - we will render viewStarted view that will just show the current sentence:

viewStarted : String -> Html msg
viewStarted currentSentence =
    p []
        [ text currentSentence ]
Enter fullscreen mode Exit fullscreen mode

Although we will not see it, since the game goes into Playing state after the init function, but we will use it in the future.

Now the game compiles and works as before, while the Model is much better designed.

It actually looks logical to have another playing state - Win. Thus the logic of winning will reside in the update function and not in the view like it is now, which feels odd.

type alias StateWin =
    { currentSentence : String
    }


type GameState
    = Started StateStarted
    | Playing StatePlaying
    | Win StateWin
Enter fullscreen mode Exit fullscreen mode

The init:

init : () -> ( Model, Cmd Msg )
init _ =
    let
        ( model, cmd ) =
            case initialModel of
                Started { currentSentence } ->
                    let
                        sentence : Words
                        sentence =
                            generateSentence currentSentence
                    in
                    ( Playing (StatePlaying sentence), chooseWords sentence )

                Playing _ ->
                    ( initialModel, Cmd.none )

                Win _ ->
                    ( initialModel, Cmd.none )
    in
    ( model, cmd )
Enter fullscreen mode Exit fullscreen mode

Then update:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        WordChanged index wordString ->
            let
                model_ =
                    case model of
                        Playing { sentence } ->
                            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 sentence

                                isWin_ =
                                    isWin (hiddenWords newSentence)
                            in
                            if isWin_ then
                                Win (StateWin (sentenceToString newSentence))

                            else
                                Playing (StatePlaying newSentence)

                        Started _ ->
                            model

                        Win _ ->
                            model
            in
            ( model_, Cmd.none )

        WordsChosen sortIndexes ->
            let
                model_ =
                    case model of
                        Playing { sentence } ->

-- omitted

                        Win _ ->
                            model
            in
            ( model_, Cmd.none )


Enter fullscreen mode Exit fullscreen mode

We added the winning check to the Playing case. If there is a winning, we will change the state to Win. If the game is in Win state already - just return current model similarly to Started case.

We added sentenceToString function that converts Words to String:

sentenceToString : Words -> String
sentenceToString sentence =
    sentence
        |> List.map wordToString
        |> String.join " "
Enter fullscreen mode Exit fullscreen mode

An we can reuse it in viewOriginalSentence:

viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
    p
        [ class "has-text-centered" ]
        [ text <| sentenceToString words ]
Enter fullscreen mode Exit fullscreen mode

The view is now much simpler:

view : Model -> Html Msg
view model =
    let
        currentView =
            case model of
                Playing { sentence } ->
                    viewGame sentence

                Started { currentSentence } ->
                    viewStarted currentSentence

                Win { currentSentence } ->
                    viewWin currentSentence
    in

--- omitted

Enter fullscreen mode Exit fullscreen mode

Now we see that we need to refactor viewWin to accept String and not Words:

viewWin : String -> Html Msg
viewWin sentence =
    div []
        [ div [ class "notification is-primary" ]
            [ h3 [ class "title is-3 has-text-centered" ] [ text "You Win!" ]
            ]
        , h4
            [ class "title is-4 has-text-centered" ]
            [ p
                [ class "has-text-centered" ]
                [ text sentence ]
            ]
        , div [ class "is-clearfix" ]
            [ button
                [ class "button is-info is-pulled-right"
                , onClick NewGame
                ]
                [ text "New Game" ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

And there is no need for viewOriginalSentence, since we just use the same code that generates HTML inside `viewWin, so just delete it.

The game should compile and work as before, but now we have a 3 specific game states with data relevant to each state.

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

Stay tuned for the upcoming post, where we will have more sentences to choose from for each new game!

Top comments (4)

Collapse
 
digitalsatori profile image
Tony Gu

The pace of your writing is such a joy! Thank you!

Collapse
 
mickeyvip profile image
Mickey

Thank you so much for the kind words.

Now I feel kind of bad for not finishing the series... Maybe some day I will.

Collapse
 
aninternetof profile image
Brady Hurlburt

Thank you! This is exactly the example of Random.List that I needed.

Collapse
 
mickeyvip profile image
Mickey

Glad you have found this helpful, Brady!

This is the best response for me as an author.