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?

200_success
145k22 gold badges190 silver badges478 bronze badges
asked Nov 7, 2017 at 3:14
\$\endgroup\$

2 Answers 2

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.

answered Nov 8, 2017 at 7:37
\$\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
answered Nov 7, 2017 at 20:23
\$\endgroup\$
2
  • 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\$ Commented Nov 8, 2017 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\$ Commented Nov 8, 2017 at 1:59

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.