For now we know what is event stream and how to use it. Let’s define a simple game with them. FRP is often associated with UI applications or Graphical games or even Creation of dynamic web pages.
But I’d like to provide a solid understanding of the basic concepts for you with simple example that can be run right in the REPL. For that we can use Rock-Paper-Scissors game and build an application for it as a Haskell function.
We all used to play Rock-Paper-Scissors (RPS) when we were kids to resolve complicated questions. It was universal judge and authority. There is a question and we don’t know who is right. Let’s just solve it with RPS game.
If you somehow don’t know about it. Here is a description. It’s a hand game. Two or more players say a some proverb and as they say it they hold hands and on the last word the show one of three figures with the hands: Rock (closed fist), paper (a flat hand), scissors (V-shape with index and middle fingers). Rock wins over scissors, paper wins over rock, scissors win over paper and if two players show the same figure it’s a draw.
Let’s implement that game. We are going to play with AI which randomly chooses
one of the figures. And we input the figure with getLine
event stream.
Let’s look at the types first:
data Move = Rock | Paper | Scissors
deriving (Show, Read, Eq)
So the move is one of the 3 hand figures. Let’s describe the result of the game:
-- | Result of the rounds
data Score = Score
{ game'first :: !Int -- ^ first player wins so many times
, game'second :: !Int -- ^ second player wins
, game'draw :: !Int -- ^ it's a draw
, game'moves :: [(Move, Move)] -- ^ list of moves
}
The list of imports that we going to use:
import Dyna
import System.IO
import Text.Read
import Data.Tuple (swap)
We are going to play several rounds and we will have only two players. As we go along we will count the number of each player’s victories and also we will keep the record of the moves.
Let’s calculate the results for a single round:
-- | Collect scores
toScore :: Move -> Move -> Score
toScore a b = case (a, b) of
(Rock, Paper) -> secondWin
(Rock, Scissors) -> firstWin
(Paper, Scissors) -> secondWin
_ | a == b -> draw
otherwise -> swapWin $ toScore b a
where
firstWin = Score 1 0 0 [(a, b)]
secondWin = Score 0 1 0 [(a, b)]
draw = Score 0 0 1 [(a, b)]
swapWin (Score a b draw moves) = Score b a draw (fmap swap moves)
To make it easy to keep and accumulate the scores we define the obvious monoid instance for it:
instance Semigroup Score where
(<>) (Score a1 b1 c1 d1) (Score a2 b2 c2 d2) = Score (a1 + a2) (b1 + b2) (c1 + c2) (d1 ++ d2)
instance Monoid Score where
mempty = Score 0 0 0 []
Also we need some nice print of the results:
-- | Print current state
printScore :: Int -> (Int, Score) -> String
printScore totalRounds (roundId, Score firstWin secondWin draw moves) = unlines
[ "Score: round " <> show roundId
, " first : " <> show firstWin
, " second : " <> show secondWin
, " draw : " <> show draw
, if totalRounds == roundId
then unlines [" last move: " <> show (last moves), status]
else " last move: " <> show (last moves)
]
where
status
| firstWin > secondWin = "You win!"
| firstWin < secondWin = "You lose!"
| otherwise = "It's a Draw"
We keep showing the state for all rounds and for the last round we calculate the final verdict who won the whole game.
With this in place we are ready to define a game in FRP-style. Let’s describe first the algorithm of the game.
- read user input
- take only valid moves
- take only so many moves as the number of rounds
- add random AI move as first element of the tuple
- get the score and accumulate scores
- append the round number
- print the current score for each round
Each of the step can be expressed as an event stream processing function. For convenience we will use the pipe operator (hello Elixir). It’s just reversed Haskell’s application operator:
(|>) :: a -> (a -> b) -> b
(|>) x f = f x
Let’s turn our description into application line by line:
-
read user input
once getLine |> forevers |>
We repeatedly query user for input.
-
take only valid moves. We use the same trick as we used with
readInts
. The functionreadMaybe
comes from the standard moduleText.Read
mapMay (readMaybe @Move) |>
-
take only so many moves as the number of rounds
takes total |>
Note the clever trick to skip all invalid user inputs prior to
takes
function. Underlying stream will query for the next move forever but for the game we will count only valid moves. -
add random AI move as a first element of the tuple
withOneOf [Rock, Paper, Scissors] |>
We append to the user’s move some random move. Note that random move goes first in the result tuple pair.
-
get the score and accumulate scores
foldMaps (uncurry $ flip toScore) |> -- get the score and accumulate scores
The
foldMaps
function is a combo offmap
andappends
. It transforms the event values to something monoidable and appends them all. For our example we calculate the score for a single round and accumulate the score across all the rounds. We use flip to reverse user’s and AI’s moves. As we want to show user as a first player and AI as second one. -
append the round number
withCount |>
To print the number of the current round we count all the events.
-
Show the results:
fmap (printScore total) -- print the current score
The final step.
Here is the complete code for the FRP-part of the application:
game :: Evt IO String
game =
once getLine |> forevers |> -- read user input
mapMay (readMaybe @Move) |> -- take only valid moves
takes total |> -- take only so many moves
withOneOf [Rock, Paper, Scissors] |> -- add random AI move as first element of the tuple
foldMaps (uncurry $ flip toScore) |> -- get the score and accumulate scores
withCount |> -- append the round number
fmap (printScore total) -- print the current score
The pipe operator is defined in the dyna
lib.
The final parts to give our game a shape:
import Dyna
import System.IO
import Text.Read
import Data.Tuple (swap)
main :: IO ()
main = do
hSetBuffering stdout LineBuffering
greet
putStrLns game
greet :: IO ()
greet = putStrLn $ unlines
[ "Welcome to Rock-paper-scissors game!"
, unwords ["Let's play for", show total, "rounds"]
, "Type: Rock, Paper or Scissors to make a move."
]
We can find out the complete code in the directory dyna/examples/
.
Wow the FRP-part of the game was just 7 lines of code.
And it looks very declarative although we have used imperative
functions under the hood. We combined callback processor functions in neat way
to achieve that.