Supplemental Lecture 12a: The Animal Game
In our lectures, we often focus on programming ideas and techniques, but not on programs per se. It's sometimes nice to see the programming ideas applied in more substantial examples than we can work out in class. In this supplemental lecture, we develop a complete program for the Animal Game.
The Game
The animal game is a two player game, in which the answering player thinks of an animal, and the questioning player asks a series of yes/no questions about the animal that the questioning player is thinking of. At some point, the questioning player believes they have enough information to make a guess. If the guess is correct, the questioning player wins; if the guess is incorrect the answering player wins.
The animal game is a standard example in computer science. We write a program that plays the questioning player against a human answering player. What makes this interesting is that the questioning player learns through repeated plays of the game, and so becomes progressively more difficult for the answering player to beat.
Data Representation
We will represent the questioner's knowledge base of the animal kingdom by a binary search tree, in which the internal nodes contain a yes/no question, and two subtrees corresponding to yes and no answers to that question, and leaf nodes that correspond to guesses, i.e., animals.
data YesNoTree
= Question
{ query :: String
, yes, no :: YesNoTree
}
| Answer
{ final :: String }
deriving (Show, Read)
One of the trickier aspects to this program is that we need to be able to create a mutated tree that incorporates new knowledge into an existing YesNoTree
. To that end, we introduce an auxiliary data structure, a YesNoView
, which is an instance of Gerárd Huet's zipper design pattern to the simple case of a binary tree. (You should be able to download the paper if you're browsing from the uchicago.edu
domain, but be aware that it's written in ML rather than Haskell.)
data YesNo = Yes | No
data YesNoView = YesNoView
{ current :: YesNoTree
, context :: [(YesNo,String,YesNoTree)]
}
The idea is that a YesNoView
encodes both a current node of the tree, and the information needed to rebuild the tree from that node. Each of the (YesNo,String,YesNoTree)
triples in the context indicated the direction in which we descended into the tree, i.e., by the 'yes' alternative, or the 'no' alternative, the query at that node, and the other child.
We navigate through such a tree in one of three directions: up
, to our parent; downYes
, to our 'yes' child; and downNo
, to our 'no' child.
downYes :: YesNoView -> YesNoView
downYes (YesNoView (Question query_ yes_ no_) ctx) =
YesNoView yes_ ((Yes,query_,no_) : ctx)
downYes _ =
error "call to downYes on an answer node of a YesNoTree."
downNo :: YesNoView -> YesNoView
downNo (YesNoView (Question query_ yes_ no_) ctx) =
YesNoView no_ ((No,query_,yes_) : ctx)
downNo _ =
error "call to downNo on an answer node of a YesNoTree."
up :: YesNoView -> YesNoView
up (YesNoView focus ((yn,query_,saved) : ctx)) = case yn of
Yes -> YesNoView (Question query_ focus saved) ctx
No -> YesNoView (Question query_ saved focus) ctx
up _ =
error "call to up on an YesNoView with empty context."
Note that we “mutate” the tree by replacing the current
field of a YesNoView
with another value.
We have enter :: YesNoTree -> YesNoView
, which allow us to initialize a YesNoView
from a YesNoTree
, with the root as the current node,
enter :: YesNoTree -> YesNoView
enter tree = YesNoView tree []
and exit :: YesNoView -> YesNoTree
, which returns the full tree from a view:
exit :: YesNoView -> YesNoTree
exit (YesNoView tree []) = tree
exit ynv = exit (up ynv)
IO Interaction
We've already seen a couple of functions that will help us manage IO interaction, withBuffering :: Handle -> BufferMode -> IO a -> IO a
which enables us to perform an IO action within within a context that sets a buffering mode on a particular Handle
, and prompt :: String -> IO String
.
withBuffering :: Handle -> BufferMode -> IO a -> IO a
withBuffering handle mode action = do
savedMode <- hGetBuffering handle
hSetBuffering handle mode
result <- action
hSetBuffering handle savedMode
pure result
prompt :: String -> IO String
prompt msg = do
putStr msg
hFlush stdout
withBuffering stdin LineBuffering getLine
Note that this version of the prompt
command ensures that the inner getLine
call occurs in a context where the buffering mode is set to LineBuffering
.
We're also going to be asking a lot of questions that have yes/no answers, indeed, this is going to be the dominant mode of user interaction. As such, we want it to be as frictionless as possible. This is going to result in a different buffering approach. Once we prompt for an answer, we'll read a single character in NoBuffering
mode, so that it is immediately available to us. If we get an answer we can't interpret, we'll re-prompt.
yesNo :: String -> IO YesNo
yesNo msg = do
putStr $ msg ++ " [y,n] "
hFlush stdout
answer <- withBuffering stdin NoBuffering getChar
when (answer /= '\n') $ putChar '\n'
case toLower answer of
'y' -> pure Yes
'n' -> pure No
'\n' -> yesNo msg
_ -> do
putStrLn "Please answer y for yes, or n for no."
yesNo msg
This kind of code is often quite delicate, and it's worth understanding why each line is present. Note in particular that because we get the character immediately, there's been no line feed (unless, of course, the character is itself the newline character), so we have to supply this ourselves.
Game Mechanics
A basic building block is the playOneGame :: YesNoView -> IO YesNoView
function, which as its name and type suggests, represent the play of a single instance of the animal game, returning a possibly updated game tree.
playOneGame :: YesNoView -> IO YesNoView
playOneGame ynv = case current ynv of
question@Question {} -> yesNo (query question) >>= \case
Yes -> playOneGame (downYes ynv)
No -> playOneGame (downNo ynv)
answer -> yesNo ("Is your animal " ++ final answer ++ "?") >>= \case
Yes -> do
putStrLn "You lose."
pure ynv
No -> do
putStrLn "You win!"
newAnimal <- prompt "Your animal is > "
newQuestion <- prompt $
"Please state a question that is true of "
++ newAnimal
++ " but false of " ++ final answer ++ " > "
let newNode =
Question newQuestion (Answer newAnimal) answer
pure $ ynv { current = newNode }
One reason for this choice of type signature is that it enables playOneGame
to call itself recursively, which it does as at each Question
node. A common pattern in this code is to extract a value from IO
, and then do an immediate pattern match on that variable. Using just standard Haskell syntax, we'd have to write a lot of code like
ans <- yesNoQuestion "Yes or no?"
case ans of
Yes -> ...
No -> ...
or
yesNoQuestion "Yes or no?" >>= \ans -> case ans of
Yes -> ...
No -> ...
Either way, we had to introduce the variable ans
, and we can't get rid of it. By adding the declaration
{-# LANGUAGE LambdaCase #-}
at the top of the module, we introduce the LambdaCase
extension, which allows us to write:
yesNoQuestion "Yes or no?" >>= \case
Yes -> ...
No -> ...
It's a small thing, but we do so much of it in this program that it seems worthwhile to do it well.
To play multiple games, we have playGames :: YesNoTree -> IO YesNoTree
. Note that this function traffics in YesNoTree
, not YesNoView
.
playGames :: YesNoTree -> IO YesNoTree
playGames start = do
restart <- exit <$> playOneGame (enter start)
yesNo "Would you like to play again?" >>= \case
Yes -> playGames restart
No -> pure restart
Note that playGames
essentially implements a “bottom-test loop,” which is an iterative programming construct that is guaranteed to run its body once.
The swizzling back and forth between YesNoTree
and YesNoView
may seem wasteful, but it's important to keep in mind that enter . exit
is not the identity on YesNoTree
, as it resets the current node to the root. Stated metaphorically, it leaves us with the zipper zipped-up.
Serialization
While a program that learns is interesting, we can do this one better by persisting in our learning. Thus, if you play the animal game, quit, and then come back to it later, the knowledge it gained in the first play is still available. The game get harder.
To that end, we use a simple serialization strategy. We use the file .animals
in the user's home directory to store a text representation of the YesNoTree
. Using default Read
and Show
instances makes this very easy.
gameDBPath :: IO FilePath
gameDBPath = do
home <- getHomeDirectory
pure $ home </> ".animals"
loadGameDB :: IO YesNoTree
loadGameDB = do
dbPath <- gameDBPath
doesFileExist dbPath >>= \case
True -> readMaybe <$> S.readFile dbPath >>= \case
Just db -> pure db
Nothing -> do
putStrLn "Stored database corrupt, using default."
pure startTree
False -> pure startTree
saveGameDB :: YesNoTree -> IO ()
saveGameDB tree = do
dbPath <- gameDBPath
writeFile dbPath (show tree)
One bit of trickiness here is that Haskell's readFile
function is lazy. We use the readFile
function from the module System.IO.Strict
. This module isn't a part of the Haskell Platform distribution, and has to be installed using the cabal tool:
$ cabal install strict
One further nuance is that we use readMaybe
rather than read
. This function (which is found in Text.Read
) indicates a parsing failure by returning Nothing
, allowing for more graceful error handling.
Exceptions, and Main
We could finish this code by
main :: IO ()
main = loadGameDB >>= playGames >>= saveGameDB
a particularly simple and pleasing end to the program, but it's possible for the user to cause an ugly crash, by typing ^-D
in response to prompt
. This causes getLine
to throw an exception. We won't cover exceptions in this course, but there are a couple of things to know: (1) exceptions are caught in the IO monad, but an of a number of functions, including catch
, handle
, and try
; (2) these functions rely on an Exception
type class, and their use requires somehow specifying a specific type that implements this type class. Oddly enough, this is often the hard part of the exercise. We use
catchIO :: IO a -> (IOException -> IO a) -> IO a
catchIO = catch
a type-restricted version of catch
, which catches the IOException
type.
main :: IO ()
main = do
putStrLn "Welcome to the Animal Game!\n"
catchIO (loadGameDB >>= playGames >>= saveGameDB) $ \_ ->
putStrLn "\nIO exception occurred, database not saved."
putStrLn "Goodbye."