9
\$\begingroup\$

Here is a simple Guessing Game I implemented in Haskell

import System.Random
import Text.Read 

main :: IO ()
main = do
  putStrLn "I am guessing a value between 1 to 100"
  guess <- (randomIO :: IO Int)
  playWith (guess `mod` 100)
  putStrLn "Play again? (Y/N)"
  response <- getLine
  if response == "Y" then
    main
  else
    return ()

playWith :: Int -> IO ()
playWith n = go 5
  where
  go 0 = putStrLn "You loose!"
  go n = do
    putStr "Enter your guess: "
    guess <- readUntilValid 
    if n == guess then
      putStrLn "You win!"
    else
      if n < guess then
          putStrLn "Too large!" >> go (n-1)
      else
          putStrLn "Too small!" >> go (n-1)

readUntilValid :: IO Int
readUntilValid = do
  s <- getLine
  case (readMaybe s :: Maybe Int) of
    Nothing -> putStr "Please enter a valid guess:" >> readUntilValid
    Just x -> return x

Is there something I can (or should) do to prevent the usage of goto like recursion? Maybe I could use something from Control.Monad?

\$\endgroup\$
6
\$\begingroup\$

Great job on writing readUntilValid as a separate function instead of inlining it in playWith. That being said, we can go a little bit further with splitting the functionality of your code.

At the moment, your main contains five distinct steps:

  • greet the user with the rules
  • choose a random number
  • start the game
  • ask whether the user wants to play again
  • restart

The random number is part of the game, so it's a little bit strange to generate it in main. Your function playWith already suggests a name for a another function that takes care of that step:

play :: IO ()
play = randomRIO (1, 100) >>= playWith

randomRIO takes a range, so we end up with the correct numbers. Your program choosed numbers between 0-99, by the way.

However, now we can also get the rules in there:

import Control.Monad (when)
import System.Random (randomRIO)
import Text.Read (readMaybe)

play :: IO ()
play = do
  putStrLn "I am guessing a value between 1 to 100."
  randomRIO (1, 100) >>= playWith

main :: IO ()
main = do
  play
  response <- prompt "Play again? (Y/N)"
  when (response == "Y") main

prompt :: String -> IO String
prompt xs = putStr (xs ++ " ") >> getLine

The prompt is just a little helper for questions to the user. What did we gain from splitting main? Well, main does not have to care for the game at all. We can replace play by any other game, e.g. playTicTacToe, and we don't have to change our main at all. So try to isolate your code a little bit.

Next we have playWith. There's a bug in there, you use n twice, once as (correct) answer, once as the number of turns the user has. Therefore, they have to guess 5, 4, and so on. The original random number wasn't used at all. Let's fix that first:

playWith :: Int -> IO ()
playWith answer = go 5
  where
  go 0 = putStrLn "You loose!"
  go n = do
    putStr "Enter your guess: "
    guess <- readUntilValid 
    if answer == guess then
      putStrLn "You win!"
    else
      if answer < guess then
          putStrLn "Too large!" >> go (n-1)
      else
          putStrLn "Too small!" >> go (n-1)

Note that GHC will warn you about shadowing if you use -Wall. But we can still do better. If you're going to compare two values and want to act on more than a single outcome, use compare instead of two checks (here == and <):

playWith :: Int -> IO ()
playWith answer = go 5
  where
  go 0 = putStrLn "You lost!"
  go n = do
    putStr "Enter your guess: "
    guess <- readUntilValid

    case guess `compare` answer of
      EQ -> putStrLn "You win!"
      LT -> putStrLn "Too small!" >> go (n-1)
      GT -> putStrLn "Too large!" >> go (n-1)

And now back to readUntilValid As I said, it's completely fine, however the additional type signature isn't necessary, as the compiler can infer readMaybe's type in that context. We end up with:

readUntilValid :: IO Int
readUntilValid = do
  s <- getLine
  case readMaybe s of
    Nothing -> putStr "Please enter a valid guess:" >> readUntilValid
    Just x  -> return x

Note that you can even write it as

readUntilValid :: Read a => IO a

but that's an aside. We end up with

import Control.Monad (when)

main :: IO ()
main = do
  play
  response <- prompt "Play again? (Y/N)"
  when (response == "Y") main

play :: IO ()
play = do
  putStrLn "I am guessing a value between 1 to 100."
  randomRIO (1, 100) >>= playWith

playWith :: Int -> IO ()
playWith answer = go 5
  where
  go 0 = putStrLn "You lost!"
  go n = do
    guess <- askNumber

    case guess `compare` answer of
      EQ -> putStrLn "You win!"
      LT -> putStrLn "Too small!" >> go (n-1)
      GT -> putStrLn "Too large!" >> go (n-1)

prompt :: String -> IO String
prompt xs = putStr (xs ++ " ") >> getLine

askNumber :: IO Int
askNumber = do
  response <- prompt "Please enter your guess (1-100): "
  case readMaybe response of
    Nothing -> putStrLn "Not a valid guess, try again." >> askNumber
    Just x  -> return x

I've renamed readUntilValid and also let it ask the number, so that again everything that is concerned with getting input from the user resides in a single function.

All in all, well done. Don't be afraid to split your functions, make sure to enable compiler warnings, and use hlint (it suggested when from Control.Monad, by the way).

Exercises

  • at the moment, the user will only replay the game if they enter Y. Also accept other inputs, such as "yes", "YES" and "y", and ask again if they don't say "yes" or "no" in a clear way
  • the number of guesses is currently fixed in playWith. Make it variable
  • the range is currently fixed (1-100). Make it variable
  • have the user choose a difficulty. The difficulty should determine the original range as well as the number of guesses. Keep the lower difficulty levels fair, e.g.

     difficulty   | range      | guesses
    --------------+------------+-------------------------------
     trivial      | 1-10       | 10
     easy         | 1-100      | 10
     medium       | 1-100      | 7
     hard         | 1-1000     | 10 -- still fair
     harder       | 1-10000    | 13 -- beginning to get unfair
     hardest      | 1-1000000  | 17 -- missing three guesses
     lucky        | 1-20       | 1
    
  • the user doesn't know how many guesses they have. You should probably tell them at some point, especially with difficulty levels

  • instead of (), playWith could return data GameEnd = Won | Lost, but that's just for style and not necessary. You could use that information for statistics and so on, but that's an overkill

Have fun.

\$\endgroup\$
0
\$\begingroup\$

playWith and go both call their argument n, leading to mixups.

mod maps the integers to [0.99], not [1..100].

You are looking for Control.Monad.Loops.

import Control.Monad.Loops (allM, orM, untilJust)

main = void $ allM (== "Y") $ repeat $ do
  putStrLn "I am guessing a value between 1 to 100"
  value <- randomRIO (1,100) :: IO Int
  win <- orM $ replicate 5 $ do
    putStr "Enter your guess: "
    guess <- untilJust $ do
      mguess <- readMaybe <$> getLine :: IO (Maybe Int)
      unless (isJust mguess) $ putStr "Please enter a valid guess: "
      return mguess
    when (guess < value) $ putStrLn "Too large!"
    when (guess > value) $ putStrLn "Too small!"
    return $ guess == value
  putStrLn $ "You " ++ if win then "win!" else "lose!"
  putStrLn "Play again? (Y/N)"
  getLine
\$\endgroup\$
  • 5
    \$\begingroup\$ While the code you present does have the same behaviour, it's an entirely different ballpark of complex. I don't like how you've inlined both extracted functions, while rewriting them significantly without any comment. I think it would help OP a lot if you could be significantly more explicit in your rewriting process \$\endgroup\$ – Vogel612 Nov 8 '17 at 0:08
  • \$\begingroup\$ If extracting the inner do blocks would help, the reader can simply ignore all code that starts successively further from the left for the same effect. \$\endgroup\$ – Gurkenglas Nov 8 '17 at 1:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.