Introduction
Hangman is a simple word game where the user must guess the hidden word. The catch is that there are a limited number of guesses. This is a functional implementation of Hangman in 100 lines. I'll go through each section of the source and explain what's going on.
H a n g m a
Board: h o m e b o d y
Guessed: a g i r s t
Congrats!
You can find the complete runnable source here.
Setup
We're going to be working with text, so Data.Char
and Data.List
will be
useful. The System
imports will let us control user input and our output to
the screen. Data.Maybe
will help us with the 'board' part of our Puzzle.
These will make more sense as we see them in context.
module Main where
import Data.Char (isAlpha, ord, toLower)
import Data.List (intersperse, sort)
import Data.Maybe (catMaybes, fromMaybe)
import System.IO (BufferMode (..), hSetBuffering, stdin, stdout)
import System.Process (callCommand)
Types
We need a structure to contain the state of the world while we play the game. There are three things important to us:
-
secret
the secret word -
board
the list of letters that we've guessed correctly, but also a list of blanks for the letters we haven't guessed yet -
guessed
the letters we've guessed, but aren't in the secret word
In Haskell, one way to represent this is:
data Puzzle = Puzzle
{ secret :: String
, board :: [Maybe Char]
, guessed :: String
}
The 'Puzzle' type packages up these elements and is what we'll thread through the game to maintain state. Looking ahead, we can imagine that the core part of our game will be something to the effect of:
game :: Puzzle -> UserInput -> Puzzle
We'll ask for the user input, look at the current state of the world, and build a new state of the world from the input.
Defining a way to 'string-ify' our puzzle gives us an easy way to display the state of the game as we run. Pattern matching lets us extract the components of our Puzzle for use in the rest of the function.
-
The title doubles as an indicator of how many incorrect guesses the player can make. Each incorrect guess will uncover more of
H A N G M A N
. -
The board is a series of letters or blanks.
Nothing
is a handy way to represent that the board is empty in that position.fromMaybe '_' <$> w
says: for each character, if it's Nothing return '_', otherwise return the letter. -
The guessed line is straight forward, it's just a sorted list of incorrect letters.
instance Show Puzzle where
show (Puzzle s b g) =
concat
[ "\n"
, " ", space showTitle, "\n"
, "\n"
, "Board: ", space showBoard, "\n"
, "\n"
, "Guessed: ", space showGuessed
, "\n"
]
where showBoard = fromMaybe '_' <$> b
showTitle = take (length g) "Hangman"
showGuessed = sort g
space = intersperse ' '
Working with the data structures
Now that we have our game state, we need ways to build and mutate it.
newPuzzle
takes a secret word and builds a Puzzle around it. The board begins
as a list of Nothing
the same length as the secret word. It'll be filled in
by user input. The guessed list starts empty.
newPuzzle :: String -> Puzzle
newPuzzle s = Puzzle secret board guessed
where
board = const Nothing <$> s
secret = toLower <$> s
guessed = []
play
is the actual implementation of the game we guessed earlier while
defining Puzzle
. It takes previous game state and user input, and returns a
new game state. There are essentially three cases to consider:
-
The input character is already on the board or in guessed, do nothing
-
The input character is in the secret word, update the board
-
The input character is not in the secret word, add it to the guessed letters
play :: Puzzle -> Char -> Puzzle
play p@(Puzzle secret board guessed) c
| Just c `elem` board = p
| c `elem` guessed = p
| c `elem` secret = correct
| otherwise = incorrect
where
correct = Puzzle secret updatedBoard guessed
incorrect = Puzzle secret board (c : guessed)
updatedBoard = updateBoard board c secret
The tricky part is when the player is correct. We need to find the correct
position in the board (a list of Maybe Char
) and fill it in, without mutating
the other characters - blank or not. updateBoard
takes the current board, the
player input, the secret word, and returns a new board.
We need to step through the board and secret word together. We could use a
combination of zip
and map
here, but explicit recursion and pattern
matching feels more straight forward. When we encounter a blank (Nothing
), if
the current letter in the secret word is the player's input, we add it to the
board. Otherwise, keep Nothing
in that position.
updateBoard :: [Maybe Char] -> Char -> String -> [Maybe Char]
updateBoard [] _ _ = []
updateBoard (Just m : xs) c (k : ks) = Just m : updateBoard xs c ks
updateBoard (Nothing : xs) c (k : ks)
| c == k = Just c : updateBoard xs c ks
| otherwise = Nothing : updateBoard xs c ks
Getting a secret word
We won't have much of a game if we ask the player to give us the secret word. Instead, we'll pull a random word out of the system dictionary (assuming a Debian-like installation). The word won't be completely random, but pseudo random based on a 'seed word' we ask the user.
First, we'll read in the words from disk and filter out invalid words. The system dictionary contains words with apostrophes and very short words (which are very difficult to guess), we don't want to include those.
To get a 'random' word from our list, we'll convert the seed word to a number, mutate it a bit, and use it as an index into the list.
randomWord :: String -> IO String
randomWord seed = do
words <- filter valid . lines <$> readFile english
return $ random words
where
valid :: String -> Bool
valid word = all isAlpha word && length word > 5
random :: [String] -> String
random xs = xs !! mod (index * index) (length xs)
index = sum $ ord <$> seed
english = "/usr/share/dict/american-english"
Playing the game, or IO
Time to pull it all together and run the main game loop. main
sets up
buffering for stdin and stdout (allows us to skip the enter key while entering
letters), builds the random word and starts the game.
game
is the main loop, and handles the game logic:
-
did we win?
-
did we lose?
-
play a turn and continue the game
To keep things pretty and easy to read, we call clear
before printing the
game state each turn. This could be done with System.Console.Terminfo
, but
that's quite a can of worms and unnecessary here.
By placing the winner
check before the loser
check, we allow the player to
win the game after using all of their guesses.
game :: Puzzle -> IO ()
game puzzle = do
clearScreen
print puzzle
if winner puzzle
then putStrLn "Congrats!"
else if loser puzzle
then putStrLn lostMsg
else play puzzle <$> getChar >>= game
where
clearScreen = callCommand "clear"
winner (Puzzle s b _) = s == catMaybes b
loser (Puzzle _ _ g) = length g == length "hangman"
lostMsg = "You lose! The word was " ++ secret puzzle
main :: IO ()
main = do
mapM_ (`hSetBuffering` NoBuffering) [stdout, stdin]
putStr "Enter a seed word: "
hSetBuffering stdout LineBuffering
getLine >>= randomWord >>= game . newPuzzle
Summary
We built hangman by defining the game state, a couple functions for mutating it, and the wrappers to talk with the outside world. Haskell let us focus on the logic of our game instead of low level details, since the type system and pattern matching do the heavy lifting for us.