Happy Learn Haskell Tutorial Vol 1

Buy now at Leanpub

Please Buy now at Leanpub

Written and illustrated by GetContented.

Published on 2024-05-24.


Main Table of Contents
Previous chapter: 17. The People Book
18. Times-Table Train of Terror
... 18.1. Tuples or Pairs
... 18.2. Ranges and the zip function
... 18.3. Determining the Level Number
... 18.4. The game loop
... 18.5. Homework
Next chapter: 19. Skwak the Squirrel

18. Times-Table Train of Terror 🔗

We’re going to introduce you to a tiny toy educational game that introduces quite a few new functions and features of Haskell.

We won’t be using any special algebraic data types in this program. In particular, our main type will be:

-- a game Level is simply a pair
-- of Integer values
type Level = (Integer, Integer)

18.1. Tuples or Pairs 🔗

A Level is simply a pair that has two Integer values. As this is a simple math game, each level will be a pair of numbers that represent the two numbers for a multiplication question. (For example, a level value of (7,9) would represent a question something like “What is 7 times 9?”).

The next definition is where we build the levels for this game:

levels :: [Level]
levels =
    concat $ map pairsForNum [3,5..12]
    pairsForNum num = zip [2..12] $ repeat num

18.2. Ranges and the zip function 🔗

Wow, there is a lot packed into this value expression. Let’s pull it apart piece by piece.

The levels value is a list of Level values. It’s defined using the ($) function, which takes a function on the left, and applies it to the value or expression on the right.

In our case here, the value expression on the right is map pairsForNum [3,5..12]. The main new thing in this expression for us is [3,5..12], which is what’s called a range. The range of numbers between 1 and 100, for example, would be represented as [1..100]. The range above, though, is every number between 3 and 12, in odd numbers. (So, 3,5,7,9,11).

So what we’re doing, then, is mapping the pairsForNum function across this range. This function takes a number, calling it num, and “zips” the range [2..12] together with repeat num. Zipping is creating pairs from one item each of a number of lists as we’ll see. The repeat function gives an infinite list of whatever its argument is. The zip function takes two lists, and builds pairs out of those lists, taking one item from each as it does. So, zip [1,2,3] [4,5,6] will create [(1,4),(2,5),(3,6)]. However, the zip function will stop when either list runs out of values, so zipping an infinite list of repeated items with another that has only a few is just fine, as we’re doing.

So what we’ll end up with by using map pairsForNum [3,5,..12] is a list of lists of pairs of Integer values. This basically means we’re combining each of the elements with each of the others. Then, we use concat to concatenate all the lists into one list. For example, concat [[1],[2],[3]] results in [1,2,3]. In math, this process of creating a big set out of two smaller sets is called a cartesian product. You don’t have to know this, but later we’ll see other, much simpler-looking, easier ways to do this. Unfortunately using them requires knowing more than we currently know, so that will have to wait.

The end result of this expression is that we have a list of 55 levels for our train of terror!

18.3. Determining the Level Number 🔗

Here’s a function we’ll use in our program to work out what number level the player is at:

levelNumber :: [a] -> Int
levelNumber remainingLevels =
    totalLevels - levelsLeft
  where totalLevels = length levels + 1
        levelsLeft  = length remainingLevels

We take a list of remaining levels, then subtract the number of levels left (using the length function) from the number of levels we started with plus one. As the player “moves up” the train, we’ll be reducing the size of the list, so the remaining levels will get less, and the level number will go up. Notice from the type signature that we don’t care what the element type of the list passed into it is, as long as it’s a list.

The main function simply gives the player a greeting message, then starts the trainLoop function that handles all the game-play (by passing in the levels value).

main :: IO ()
main = do
  putStrLn "Suddenly, you wake up. Oh no, you're on..."
  putStrLn "The Times-Table Train of Terror!"
  putStrLn "Try to get to the end. We DARE you!"
  trainLoop levels

You can see we have our old friend the do block in action again, joining a whole bunch of IO actions together to form one.

In a moment, we come to the main game-play function, trainLoop.

18.4. The game loop 🔗

The function trainLoop has two definitions.

The first is for an empty list, which for our program means the player has won, because they got to the end of the train without failing. In this case, we declare their victory to them.

The second definition comes into play when there are still levels passed in. This means the game is still in play. Each time the player gets done with a level, we remove it from the list, then pass the rest of the levels back into the trainLoop function and start it again.

trainLoop :: [Level] -> IO ()
trainLoop [] =
  putStrLn "You won! Well done."
trainLoop remainingLevels @ (currentLevel : levelsAfterThisOne) =
    let currentLevelNumber =
            levelNumber remainingLevels
        (num1, num2) =
    putStrLn $
      "You are in a Train Carriage "
      ++ show currentLevelNumber
      ++ " of " ++ (show $ length levels)
    putStrLn "Do you want to:"
    putStrLn "1. Go to the next Carriage"
    putStrLn "2. Jump out of the train"
    putStrLn "3. Eat some food"
    putStrLn "q. Quit"
    activity <- getLine
    case activity of
      "1" ->
          putStrLn $ "You try to go to the next carriage."
                   ++ " The door is locked."
          putStrLn "Answer this question to unlock the door:"
          putStrLn $ "What is " ++ show num1
                    ++ " times " ++ show num2 ++ "?"
          answer <- getLine
          if answer == (show $ num1 * num2)
          then do
            putStrLn "The lock is opened!"
            trainLoop levelsAfterThisOne
          else do
            putStrLn $ "Wrong. You try to open the lock,"
                     ++ " but it won't open."
            trainLoop remainingLevels
      "2" -> jumpingFutility
      "3" -> eatingFutility
      "q" -> putStrLn $ "You decide to quit."
                      ++ " Thanks for playing. Bah-Bye!"
      _   -> do
        putStrLn "That makes NO sense! Try again."
        trainLoop remainingLevels

The construction with an @ symbol means the whole argument is matched as remainingLevels, and then, also, currentLevel is set to the first item of the list, and levelsAfterThisOne is set to the tail of the list. When we use the @ symbol like this, it’s called an as pattern.

The trainLoop passes back an IO action, so we begin a very large do block. This do block has lots of different kinds of things in it, to get you familiar with more complicated looking do blocks.

Ideally, we would pull each of the sections into their own function. For now we’ll look at it like this because it serves our purposes quite nicely to show you a larger do block.

Remember, each of these expressions will evaluate to an IO action (because the final value of this function’s type is IO ()), and the do block simply connects them all up properly so they become one single IO action.

First, we set up some variables we’ll need later using let. A let expression is another way we can define some locally scoped definitions. In a do block, name defined in a let expression will be available for the remainder of the do block. One of these is currentLevelNumber which uses the levelNumber function we just looked at, and the next line is pattern matching the currentLevel variable into two number variables num1 and num2. Remember, currentLevel’s type is Level, which is (Integer, Integer), so num1 and num2 will both be Integer values.

Then we print out a message explaining that the player is on a certain train carriage, using show to turn the number values into strings so they can be fed into (++) with the other strings.

Next, a menu is described, and we receive a line of text from the player with getLine, which goes into the variable called activity.

After this, we have a case expression that matches on the player’s response. If they wrote 2, 3 or q, then we write a message and restart the whole game, or quit out (q quits the game).

If, however, they typed 1 in, they’re asked this level’s multiplication question. If they get it right, it unlocks the current level and they can proceed, which we do by starting the trainLoop again but with only the tail of the levels (by using the levelsAfterThisOne variable).

All that remains is to show the definitions for jumpingFutility and eatingFutility. These actions are executed if the player tries to jump or eat.

All they do is print a message, then start the game at the very beginning by returning trainLoop applied to the initial levels.

jumpingFutility :: IO ()
jumpingFutility = do
  putStrLn "You try to jump out of the train."
  putStrLn "You fail and die."
  trainLoop levels

eatingFutility :: IO ()
eatingFutility = do
  putStrLn "You see a delicious looking cupcake."
  putStrLn "You eat it. It's a time travel cupcake!"
  trainLoop levels

18.5. Homework 🔗

Write a program that prints your birthday on the screen. Write another program that prints two numbers added on the screen, then one for multiplied, then subtracted.

If you’ve enjoyed reading this, please consider purchasing a copy at Leanpub today

Please follow us, and check out our videos Follow @HappyLearnTutes

Also, Volume 2 is now in beta and being written! Show your support and register your interest at its Leanpub site.

Main Table of Contents
Previous chapter: 17. The People Book
18. Times-Table Train of Terror
... 18.1. Tuples or Pairs
... 18.2. Ranges and the zip function
... 18.3. Determining the Level Number
... 18.4. The game loop
... 18.5. Homework
Next chapter: 19. Skwak the Squirrel