Re: Factor

Factor: the language, the theory, and the practice.

Building Hangman

Tuesday, December 26, 2023

Recently, Jon Fincher published an interesting Python tutorial describing steps to build a hangman game for the command line in Python. It provides for a nice demo of different programming language features including taking user input, printing to the screen, storing game state, and performing some logic until the game is completed.

A game in progress might look like this – with hangman as the word being guessed:


      ┌───┐
      │   │
      O   │
     ─┼─  │
    / │   │
      │   │
   └──────┘

Your word is: _ a n _ _ a n
Your guesses: a e r s n

I thought it would be fun to show how to build a similar hangman game in Factor, using similar steps.

Step 1: Set Up the Hangman Project

We need to create the hangman vocabulary to store our work. We can use the scaffold-vocab word to create a new vocabulary. It will prompt for which vocab-root to place the new vocabulary into.

IN: scratchpad USE: tools.scaffold

IN: scratchpad "hangman" scaffold-vocab

And then open the vocab in your favorite text editor:

IN: scratchpad "hangman" edit-vocab

We can start the file with all these imports, which we will be using in the implementation below and two symbols that we will use to hold the state of our game.

USING: combinators.short-circuit io io.encodings.utf8 io.files
kernel make math multiline namespaces random sequences
sequences.interleaved sets sorting unicode ;

IN: hangman

SYMBOL: target-word  ! the word being guessed
SYMBOL: guesses      ! all of the guessed letters

Step 2: Select a Word to Guess

Let’s create a vocab:hangman/words.txt file containing all of the possible word choices. The original tutorial had a list of words that you can reference – you are welcome to copy my file or create your own:

IN: scratchpad USE: http.client

IN: scratchpad "https://raw.githubusercontent.com/mrjbq7/re-factor/master/hangman/words.txt"
               "vocab:hangman/words.txt" download-to

Now we can add this word to read the file into memory and then choose a random line.

: random-word ( -- word )
    "vocab:hangman/words.txt" utf8 file-lines random ;

Step 3: Get and Validate the Player’s Input

The user will use the readln word to provide input, and we will validate it by making sure the line contains a single character that is not already in our guesses:

: valid-guess? ( input -- ? )
    {
        [ length 1 = ]
        [ lower? ]
        [ first guesses get ?adjoin ]
    } 1&& ;

Reading the player input is then just looping until we get a valid guess:

: player-guess ( -- ch )
    f [ dup valid-guess? ] [ drop readln ] do until first ;

Step 4: Display the Guessed Letters and Word

We can display the guessed letters as a sorted, space-separated list:

: spaces ( str -- str' )
    CHAR: \s <interleaved> ;

: guessed-letters ( -- str )
    guesses get members sort spaces ;

And the target word is also space-separated with blanks for letters we have not guessed, or the actual letters if they have been guessed successfully:

: guessed-word ( -- str )
    target-word get guesses get '[
        dup _ in? [ drop CHAR: _ ] unless
    ] map spaces ;

Step 5: Draw the Hanged Man

We first calculate the number of wrong guesses, by set difference between the guesses and our target word:

: #wrong-guesses ( -- n )
    guesses get target-word get diff cardinality ;

Displaying the “hanged man” requires a bit more lines of code that the rest of the program, using the number of wrong guesses to pick which to output:

CONSTANT: HANGED-MAN {
[[
      ┌───┐
      │   │
   └──────┘
]] [[
      ┌───┐
      │   │
      O   │
   └──────┘
]] [[
      ┌───┐
      │   │
      O   │
     ─┼─  │
      │   │
      │   │
   └──────┘
]] [[
      ┌───┐
      │   │
      O   │
     ─┼─  │
    / │   │
      │   │
   └──────┘
]] [[
      ┌───┐
      │   │
      O   │
     ─┼─  │
    / \ │
      │   │
   └──────┘
]] [[
      ┌───┐
      │   │
      O   │
     ─┼─  │
    / \ │
      │   │
     ─┴─  │
    /     │     │
   └──────┘
]] [[
      ┌───┐
      │   │
      O   │
     ─┼─  │
    / \ │
      │   │
     ─┴─  │
    /   \ │
    │   │ │
   └──────┘
]]
}

: hanged-man. ( -- )
    #wrong-guesses HANGED-MAN nth print ;

Step 6: Figure Out When the Game Is Over

The game is lost when the player has too many wrong guesses:

: lose? ( -- ? )
    #wrong-guesses HANGED-MAN length 1 - >= ;

The game is won when the word has no unknown letters:

: win? ( -- ? )
    target-word get guesses get diff null? ;

And the game is over when it is won or lost:

: game-over? ( -- ? )
    { [ win? ] [ lose? ] } 0|| ;

Step 7: Run the Game Loop

It is frequently useful in Factor to build helper words that, for example, set up some of the state that our program will use and then run a provided quotation:

: with-hangman ( quot -- )
    [
        random-word target-word ,,
        HS{ } clone guesses ,,
    ] H{ } make swap with-variables ; inline

And then we can use that to build and run the game:

: play-hangman ( -- )
    [
        "Welcome to Hangman!" print

        [ game-over? ] [
            hanged-man.
            "Your word is: " write guessed-word print
            "Your guesses: " write guessed-letters print

            nl "What is your guess? " write flush

            player-guess target-word get in?
            "Great guess!" "Sorry, it's not there." ? print
        ] until

        hanged-man.
        lose? "Sorry, you lost!" "Congrats! You did it!" ? print
        "Your word was: " write target-word get print
    ] with-hangman ;

One last thing we can do is set this word as the main entry point of our vocabulary:

MAIN: play-hangman

Next Steps

Well, that’s kind of fun. You can run this in the listener:

IN: scratchpad "hangman" run

Or at the command-line:

$ ./factor -run=hangman

The source code is available on my GitHub.