Guess the Word Before It's Too Late: Hangman Fun Game using shell script

A classic word game with a macabre metaphor, hangman is nonetheless popular and enjoyable. In the game, you guess letters that might be in the hidden word, and each time you guess incorrectly, the man hanging on the gallows has an additional body part drawn in. Make too many wrong guesses, and the man is fully illustrated, so not only do you lose, but, well, you presumably die too. Not very pleasant!

However, the game itself is fun, and writing it as a shell script proves surprisingly easy.

The Code
#!/bin/sh

# hangman - A rudimentary version of the hangman game. Instead of showing a
# gradually embodied hanging man, this simply has a bad guess countdown.
# You can optionally indicate the initial distance from the gallows as the only
# arg.

wordlib="/usr/lib/games/long-words.txt"
randomquote="$HOME/bin/randomquote.sh" # Script #76
empty="\." # we need something for the sed [set] when $guessed=""
games=0

if [ ! -r $wordlib ] ; then
echo "$0: Missing word library $wordlib" >&2
echo "(online: http://www.intuitive.com/wicked/examples/long-words.txt" >&2
echo "save the file as $wordlib and you're ready to play!)" >&2
exit 1
fi

while [ "$guess" != "quit" ] ; do
match="$($randomquote $wordlib)" # pick a new word from the library

if [ $games -gt 0 ] ; then
echo ""
echo "*** New Game! ***"
fi

games="$(($games + 1))"
guessed="" ; guess="" ; bad=${1:-6}
partial="$(echo $match | sed "s/[^$empty${guessed}]/-/g")"

while [ "$guess" != "$match" -a "$guess" != "quit" ] ; do

echo ""
if [ ! -z "$guessed" ] ; then
echo -n "guessed: $guessed, "
fi
echo "steps from gallows: $bad, word so far: $partial"

echo -n "Guess a letter: "
read guess
echo ""

if [ "$guess" = "$match" ] ; then
echo "You got it!"
elif [ "$guess" = "quit" ] ; then
sleep 0 # a 'no op' to avoid an error message on 'quit'
elif [ $(echo $guess | wc -c | sed 's/[^[:digit:]]//g') -ne 2 ] ; then
echo "Uh oh: You can only guess a single letter at a time"
elif [ ! -z "$(echo $guess | sed 's/[[:lower:]]//g')" ] ; then
echo "Uh oh: Please only use lowercase letters for your guesses"
elif [ -z "$(echo $guess | sed "s/[$empty$guessed]//g")" ] ; then
echo "Uh oh: You have already tried $guess"
elif [ "$(echo $match | sed "s/$guess/-/g")" != "$match" ] ; then
guessed="$guessed$guess"
partial="$(echo $match | sed "s/[^$empty${guessed}]/-/g")"
if [ "$partial" = "$match" ] ; then
echo "** You've been pardoned!! Well done! The word was \"$match\"."
guess="$match"
else
echo "* Great! The letter \"$guess\" appears in the word!"
fi
elif [ $bad -eq 1 ] ; then
echo "** Uh oh: you've run out of steps. You're on the platform... "
echo "** The word you were trying to guess was \"$match\""
guess="$match"
else
echo "* Nope, \"$guess\" does not appear in the word."
guessed="$guessed$guess"
bad=$(($bad - 1))
fi
done
done
exit 0

How It Works
The tests in this script are all interesting and worth examination. Consider this test to see if the player has entered more than a single letter as his or her guess:

elif [ $(echo $guess | wc -c | sed 's/[^[:digit:]]//g') -ne 2 ] ; then


Why test for the value 2 rather than 1? Because the entered value has a carriage return appended by the read statement, and so it has two letters if it's correct, not one. The sed in this statement strips out all nondigit values, of course, to avoid any confusion with the leading tab that wc likes to emit.

Testing for lowercase is straightforward: Remove all lowercase letters from guess and see if the result is zero (empty) or not:

elif [ ! -z "$(echo $guess | sed 's/[[:lower:]]//g')" ] ; then

And, finally, to see if the user has guessed the letter already, transform the guess such that any letters in guess that also appear in the guessed variable are removed, and see if the result is zero (empty) or not:

elif [ -z "$(echo $guess | sed "s/[$empty$guessed]//g")" ] ; then

Apart from all these tests, however, the trick behind getting hangman to work is to translate into dashes all occurrences in the original word of each guessed letter and then to compare the result to the original word. If they're different, the guessed letter is in that word:

elif [ "$(echo $match | sed "s/$guess/-/g")" != "$match" ] ; then

One of the key ideas that made it possible to write hangman was that the partially filled-in word shown to the player, the variable partial, is rebuilt each time a correct guess is made. Because the variable guessed accumulates each letter guessed by the player, a sed transformation that translates into a dash each letter in the original word that is not in the guessed string does the trick:

partial="$(echo $match | sed "s/[^$empty${guessed}]/-/g")"

Running the Script
The hangman game has one optional argument: If you specify a numeric value as a parameter, it will use that as the number of incorrect guesses allowed, rather than the default of 6.

The Results
$ hangman

steps from gallows: 6, word so far: -------------
Guess a letter: e

* Great! The letter "e" appears in the word!

guessed: e, steps from gallows: 6, word so far: -e--e--------
Guess a letter: i
* Great! The letter "i" appears in the word!

guessed: ei, steps from gallows: 6, word so far: -e--e--i-----
Guess a letter: o

* Great! The letter "o" appears in the word!

guessed: eio, steps from gallows: 6, word so far: -e--e--io----
Guess a letter: u

* Great! The letter "u" appears in the word!

guessed: eiou, steps from gallows: 6, word so far: -e--e--iou---
Guess a letter: m

* Nope, "m" does not appear in the word.

guessed: eioum, steps from gallows: 5, word so far: -e--e--iou---
Guess a letter: n

* Great! The letter "n" appears in the word!

guessed: eioumn, steps from gallows: 5, word so far: -en-en-iou---
Guess a letter: r

* Nope, "r" does not appear in the word.

guessed: eioumnr, steps from gallows: 4, word so far: -en-en-iou---
Guess a letter: s

* Great! The letter "s" appears in the word!

guessed: eioumnrs, steps from gallows: 4, word so far: sen-en-ious--
Guess a letter: t

* Great! The letter "t" appears in the word!

guessed: eioumnrst, steps from gallows: 4, word so far: sententious--
Guess a letter: l

* Great! The letter "l" appears in the word!

guessed: eioumnrstl, steps from gallows: 4, word so far: sententiousl-
Guess a letter: y

** You've been pardoned!! Well done! The word was "sententiously".

*** New Game! ***
steps from gallows: 6, word so far: ----------
Guess a letter: quit

Hacking the Script
Obviously it's quite difficult to have the fancy guy-hanging-on-the-gallows graphic if we're working with a shell script, so we use the alternative of counting "steps to the gallows" instead. If you were motivated, however, you could probably have a series of predefined "text" graphics, one for each step, and output them as the game proceeds. Or you could choose a nonviolent alternative of some sort, of course!

Note that it is possible to pick the same word twice, but with the default word list containing 2,882 different words, there's not much chance of that occurring. If this is a concern, however, the line where the word is chosen could also save all previous words in a variable and screen against them to ensure that there aren't any repeats.

Finally, if you were motivated, it'd be nice to have the guessed letters list be sorted alphabetically. There are a couple of approaches to this, but I think I'd try to use sed|sort.