This is part 4 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
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
}
There are now several changes we need to make in order for our current application to compile:
-
init
expects a function instead ofModel
, so we'll add theinit
function -
subscriptions
expects a function that we'll add -
update
returns aTuple
(Model, Cmd Msg)
instead of aModel
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
Now update
- let's change the signature to
update : Msg -> Model -> ( Model, Cmd Msg )
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 )
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 )
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))
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
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!
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 sortKey
s.
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
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 Tuple
s 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))
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 ->
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 )
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
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
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 )
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"
]
}
We also need to update our init
function to execute the chooseWords
command:
init : () -> ( Model, Cmd Msg )
init _ =
( initialModel, chooseWords initialModel.sentence )
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" ]
]
]
]
]
Let's add NewGame
message:
type Msg
= WordChanged Int String
| WordsChosen (List ( Int, Int ))
| NewGame
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 )
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
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 HiddenWord
s 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)
And if isWin
handles HiddenWord
s 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
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
)
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" ]
]
]
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.
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 String
s 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 = []
}
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
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 )
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 ()
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:
-
Started
- will have only thecurrentSentence : String
-
Playing
- will have only thesentence : 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
Now let's update our Model
to be of type GameState
:
type alias Model =
GameState
Our initialModel
can be set to:
initialModel : Model
initialModel =
Started (StateStarted "The pen is mightier than the sword")
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 )
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
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 )
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 ]
]
]
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 ]
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
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 )
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 )
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 " "
An we can reuse it in viewOriginalSentence
:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
[ text <| sentenceToString words ]
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
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" ]
]
]
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)
The pace of your writing is such a joy! Thank you!
Thank you so much for the kind words.
Now I feel kind of bad for not finishing the series... Maybe some day I will.
Thank you! This is exactly the example of Random.List that I needed.
Glad you have found this helpful, Brady!
This is the best response for me as an author.