In this brief article, we look at using Tasks, Generators and Msg constructors in Elm to similate the effect of a computer opponent in a game, "thinking" about a choice.
Although the implications and paradigms referred to are relatively language agnostic, the actual solution implementation is written in the Elm, and the article assumes some knowledge of the programming language and the basics of the Model-View-Update style of User Interface design.
Tl;dr - If you'd rather just go straight to the working solution, just check out this Ellie.
Sometimes in programming, we want to intentionally delay the effect, or response from a particular computation. One such case is when you want to simulate to the user than the computer/app device they're interacting with is thinking. This concept of a thinking or pondering machine has been especially important to me as I've been working on my implementation of Quarto, a sort've advanced tic tac toe variant.
In Quarto, players can challenge themselves against a computer opponent, who at the moment, plays randomly available pieces randomly on the board.
First, let's look at the stardard random generator.
To illustrate our example today, we're going to be using the concept of playing cards. Below I've added a snippet of code from the elm-lang Random cards example, trimming way the parts unnecessary to our exploration today.
Unlike in other less strict languages like python or javascript, Elm can't simply produce a random number on the fly. All Elm functions are pure, meaning the same input should produce the same output every time. By definiton, the concept of randomness is inherently impure, since a random when given the same inputs, produces many different outputs. So we need a slightly more sophisticated method to get our random numbers.
-- MODEL
type Card
= Ace
| Two
| Three
| Four
| Five
| Six
| Seven
| Eight
| Nine
| Ten
| Jack
| Queen
| King
type alias Model =
{ card : Card
}
Above we see that we define a union type called Card
, with each possible value of the Card
type representing the potential value of a real life playing card.
Our model, the state of our application, is a record with a single field, card
, which stores any one possible value of our Card
type.
-- UPDATE
type Msg
= Draw
| NewCard Card
Next we define the messages that can be passed around our app with a type called Msg
. In Elm, passing messages are the only way to trigger updates to the app.
- Our first Msg,
Draw
is what we'll use to trigger our card generator function. - Our second Msg,
NewCard Card
is the message that our random card generator will return once it's found a card from the list. Note that this message has Card as an associated type.
Next let's look at our actual randomness function.
-- Our generator function
cardGenerator : Random.Generator Card
cardGenerator =
Random.uniform Ace
[ Two
, Three
, Four
, Five
, Six
, Seven
, Eight
, Nine
, Ten
, Jack
, Queen
, King
]
Here we define a function cardGenerator that uses the elm/Random API to make a random generator. Now, we don't really need to get into how generators work behind the scenes (fancy maths), we just have to understand that the Random.uniform
function is a function that accept some data type a
, where a can be anything, and a List of a
and will return a random Generator a
. So in this scenario, a
is our Card
type.
Now so far, we have a Model
that holds cards, a Generator
that produces cards, and a Msg
that passes cards, but now we need to put them together.
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Draw ->
( model
, Random.generate NewCard cardGenerator
)
NewCard newCard ->
( Model newCard
, Cmd.none
)
So finally we have the update
function. In Elm, the update function is the only way we can update the model. Note the type signature (always start with the type signature), Msg -> Model -> (Model, Cmd Msg)
.
Update
accepts a Msg
and the existing Model
, and returns an updated model and a Cmd
, which is basically some external task for the app to perform.
So let's break down our update function
- If a
Draw
message is passed to the application, the model doesn't change but a command is sent using theRandom.generate
function.Random.generate
accepts a function that takes some typea
and returns a Msg, then aGenerator
of thata
, runs the generator, and passes the returns value to the msg constructor.- The reason why
Random.generate
is a cmd is because the Elm runtime has to reach outside the app state to get a number toseed
the random function.
- Once the generator produces it's value, it turns it to the
NewCard
branch of the update function, where our model is updated with that newly generated value.
If you want to see a the full version of how this works with the rest of the bells and whistles, you can check it out here.
So now we have a working random generator, and with the speed of modern computers, the generation happens pretty quick. Too quick. Our computer is too smart! Or more honestly, it's really troubling for the human brain to process that a sequence has happened that it can't even see. So how do we pause an application?
We put it to sleep!
Let's make a delay function that can delay the progression of the application. What do we need to do this.
Firstly, we need the Process.sleep function. Sleep
accepts a Float
and returns a Task
. For now, let's treat a task as just a promise to do a thing. We can request that Elm perform tasks, and tasks could either succeed or fail. Typically when they succeed, they return a value to the app through our delightful Msg
type again. Sleep
however, returns the type () unit
, or essentially nothing. It just y'know, sleeps.
So sleep 1000.00
produces a task that "sleeps" for 1000 milliseconds.
The next thing we'll need is the Time.now function, which like, sleep, produces a task, but also returns a Posix time
value, representing the current time. Posix time isn't necessary useful for us, so we'll also use Time.posixToMillis
to convert the time from Time.now
to an integer.
The final unique function we're gonna look at is the Random.step.
Earlier on, we used Random.generate
function to run our generator. But now, we want to chain a generator into a sleep process. Unfortunately, because of this we can't use generate anymore, since Elm commands are run in parallel, and cant be chained the way we want.
Fortunately, there's a way to produce a random function without using a command, and that's by passing in the inital seed ourserlves. Given a generator and an initial seed, the step
function can run our generator and produce a random value (as well as a new seed in case we need to keep generating random numbers).
Using all this knowledge, we can define first, a function generateCard
generateCard : Int -> Msg
generateCard seedNum =
seedNum
|> Random.initialSeed
|> Random.step cardGenerator
|> (\( value, _ ) -> NewCard value)
- This function accepts an
Int
and uses the step function to make a new value (no commands required) and then creates ourNewCard
msg with that value. - Note we make use of another
Random
function,initialSeed
, which when given anInt
, returns aSeed
type that can be consumed by the step function. - Step returns a tuple of the generated value and a new seed. We keep the value, and pass it into the
NewCard
constructor.
And then we can finally define a delay
function:
delayGenerator : Cmd Msg
delayGenerator =
Process.sleep 1000.00
|> Task.andThen (\_ -> Time.now)
|> Task.perform (Time.posixToMillis >> generateCard)
- Our
delayGenerator
function chains oursleep
Task
into theTime.now
Task
using a helper functonandThen
, who's implementation is beyond the scope of this article.
We can essentially read this function as
- Run the sleep task and wait for three seconds
- Ignore the value sleep passes back, and run the
now
function to get the current time - call
Time.perform
(similar toRandom.generate
,perform
can resolve these promises to do tasks into actual values - perform will pass the posix value from
now
toposixToMillis
to get anInt
. - And finally that integer is passed to our
generateCard
function, and pops out at the end a random card, 1000.00 milliseconds after delay is called.
And the best part is, our new update function doesn't change much at all.
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Draw ->
( { model | status = "Choosing a card..." }
, delayGenerator
)
NewCard newCard ->
( Model newCard "Card chosen. Draw again?"
, Cmd.none
Note that all we had to do was change our Cmd Msg
in the Draw
branch from Random.generate
to our new delayGenerator
Task
maker.
And that's it. I recognize that this might not all make perfect sense from the beginning, and that's okay. But hopefully I at least brought some context to how you can build these types of chains of commands in a logical fashion, combining matching functions and types.
I've attached a full example here that shows the whole thing in action. It's also available in this gist
I also highly recommend reading the documentation on random functions and tasks to get more context.
Also, if you want to see this type of concept out in the wild, I'm going to shamelessly plug my small game app Quarto where you can look at this type of code in action, and maybe even contribute if you'd like. I've been offering pair programming sessions whenever I can to folks looking to learn elm and contribute to open source.
Thank you for reading :)