home | O'Reilly's CD bookshelfs | FreeBSD | Linux | Cisco | Cisco Exam  


Writing Apache Modules with Perl and C
By:   Lincoln Stein and Doug MacEachern
Published:   O'Reilly & Associates, Inc.  - March 1999

Copyright © 1999 by O'Reilly & Associates, Inc.


 


   Show Contents   Previous Page   Next Page

Chapter 5 - Maintaining State
Maintaining State in Hidden Fields

Figure 5-1 shows the main example used in this chapter, an online hangman game. When the user first accesses the program, it chooses a random word from a dictionary of words and displays a series of underscores for each of the word's letters. The game prompts the user to type in a single letter guess or, if he thinks he knows it, the whole word. Each time the user presses return (or the "Guess" button), the game adds the guess to the list of letters already guessed and updates the display. Each time the user makes a wrong guess, the program updates the image to show a little bit more of the stick figure, up to six wrong guesses total. When the game is over, the user is prompted to start a new game. A status area at the top of the screen keeps track of the number of words the user has tried, the number of games he's won, and the current and overall averages (number of letters guessed per session).2

This hangman game is a classic case of a web application that needs to maintain state across an extended period of time. It has to keep track of several pieces of information, including the unknown word, the letters that the user has already guessed, the number of wins, and a running average of guesses. In this section, we implement the game using hidden fields to record the persistent information. In later sections, we'll reimplement it using other techniques to maintain state.

Figure 5-1. The script described in this chapter generates an online hangman game.

The complete code for the first version of the hangman game is given in Example 5-1. It is an Apache::Registry script and therefore runs equally well as a vanilla CGI script as under mod_perl (except for being much faster under mod_perl, of course). Much of the code is devoted to the program logic of choosing a new word from a random list of words, processing the user's guesses, generating the HTML to display the status information, and creating the fill-out form that prompts the user for input.

This is a long script, so we'll step through the parts that are relevant to saving and retrieving state a section at a time:

# file: hangman1.cgi
# hangman game using hidden form fields to maintain state
use IO::File ();
use CGI qw(:standard);
use strict;
use constant WORDS => '/usr/games/lib/hangman-words';
use constant ICONS => '/icons/hangman';
use constant TRIES => 6;

In order to compartmentalize the persistent information, we keep all the state information in a hash reference called $state. This hash contains six keys: WORD for the unknown word, GUESSED for the list of letters the user has already guessed, GUESSES_LEFT for the number of tries the user has left in this game, GAMENO for the number of games the user has played (the current one included), WON for the number of games the user has won, and TOTAL for the total number of incorrect guesses the user has made since he started playing.

We're now ready to start playing the game:

# retrieve the state
my $state = get_state();
# reinitialize if we need to
$state    = initialize($state) if !$state or param('restart');
# process the current guess, if any
my($message, $status) = process_guess(param('guess') || '', $state);

We first attempt to retrieve the state information by calling the subroutine get_state(). If this subroutine returns an undefined value or if the user presses the "restart" button, which appears when the game is over, we call the initialize() subroutine to pick a new unknown word and set the state variables to their defaults. Next we handle the user's guess, if any, by calling the subroutine process_guess(). This implements the game logic, updates the state information, and returns a two-item list consisting of a message to display to the user (something along the lines of "Good guess!") and a status code consisting of one of the words "won", "lost", "continue", or "error."

The main task now is to generate the HTML page:

# start the page
print header,
   start_html(-Title   => 'Hangman 1',
              -bgcolor => 'white',
              -onLoad  => 'if (document.gf) document.gf.guess.focus()'),
   h1('Hangman 1: Fill-Out Forms');
# draw the picture
picture($state);
# draw the statistics
status($message, $state);
# Prompt the user to restart or to enter the next guess.
if ($status =~ /^(won|lost)$/) {
   show_restart_form($state);
}
else {
   show_guess_form($state);
}
print hr,
   a({-href => '/'}, "Home"),
   p(cite({-style => "fontsize: 10pt"}, 'graphics courtesy Andy Wardley')),
   end_html();

Using CGI.pm functions, we generate the HTTP header and the beginning of the HTML code. We then generate an <IMG> tag using the state information to select which "hanged man" picture to show and display the status bar. If the status code returned by process_guess() indicates that the user has completed the game, we display the fill-out form that prompts the user to start a new game. Otherwise, we generate the form that prompts the user for a new guess. Finally we end the HTML page and exit.

Let's look at the relevant subroutines now, starting with the initialize() function:

sub initialize {
   my $state = shift;
   $state = {} unless $state;
   $state->{WORD}     = pick_random_word();
   $state->{GUESSES_LEFT}  = TRIES;
   $state->{GUESSED}  = '';
   $state->{GAMENO}   += 1;
   $state->{WON}      += 0;
   $state->{TOTAL}    += 0;
   return $state;
}

All the state maintenance is performed in the subroutines initialize(), get_state(), and set_state(). initialize() creates a new empty state variable if one doesn't already exist, or resets just the per-game fields if one does. The per-game fields that always get reset are WORD, GUESSES_LEFT, and GUESSED. The first field is set to a new randomly chosen word, the second to the total number of tries that the user is allowed, and the third to an empty hash reference. GAMENO and TOTAL need to persist across user games. GAMENO is bumped up by one each time initialize() is called. TOTAL is set to zero only if it is not already defined. The (re)initialized state variable is now returned to the caller.

sub save_state {
   my $state = shift;
   foreach (qw(WORD GAMENO GUESSES_LEFT WON TOTAL GUESSED)) {
      print hidden(-name => $_, -value => $state->{$_}, -override => 1);
   }
}

The save_state() routine is where we store the state information. Because it stashes the information in hidden fields, this subroutine must be called within a <FORM> section. Using CGI.pm's hidden() HTML shortcut, we produce a series of hidden tags whose names correspond to each of the fields in the state hash. For the variables WORD, GAMENO, GUESSES_LEFT, and so on, we just call hidden() with the name and current value of the variable. The output of this subroutine looks something like the following HTML:

<INPUT TYPE="hidden" NAME="WORD" VALUE="tourists">
<INPUT TYPE="hidden" NAME="GAMENO" VALUE="2">
<INPUT TYPE="hidden" NAME="GUESSES_LEFT" VALUE="5">
<INPUT TYPE="hidden" NAME="WON" VALUE="0">
<INPUT TYPE="hidden" NAME="TOTAL" VALUE="7">
<INPUT TYPE="hidden" NAME="GUESSED" VALUE="eiotu">

get_state() reverses this process, reconstructing the hash of state information from the hidden form fields:

sub get_state {
   return undef unless param();
   my $state = {};
   foreach (qw(WORD GAMENO GUESSES_LEFT WON TOTAL GUESSED)) {
      $state->{$_} = param($_);
   }
   return $state;
}

This subroutine loops through each of the scalar variables, calls param() to retrieve its value from the query string, and assigns the value to the appropriate field of the state variable.

The rest of the script is equally straightforward. The process_guess() subroutine (too long to reproduce inline here; see Example 5-1) first maps the unknown word and the previously guessed letters into hashes for easier comparison later. Then it does a check to see if the user has already won the game but hasn't moved on to a new game (which can happen if the user reloads the page).

The subroutine now begins to process the guess. It does some error checking on the user's guess to make sure that it is a valid series of lowercase letters and that the user hasn't already guessed it. The routine then checks to see whether the user has guessed a whole word or a single letter. In the latter case, the program fails the user immediately if the guess isn't an identical match to the unknown word. Otherwise, the program adds the letter to the list of guesses and checks to see whether the word has been entirely filled in. If so, the user wins. If the user has guessed incorrectly, we decrement the number of turns left. If the user is out of turns, he loses. Otherwise, we continue.

The picture() routine generates an <IMG> tag pointing to an appropriate picture. There are six static pictures named h0.gif through h5.gif. This routine generates the right filename by subtracting the total number of tries the user is allowed from the number of turns he has left.

The status() subroutine is responsible for printing out the game statistics and the word itself. The most interesting part of the routine is toward the end, where it uses map() to replace the not-yet-guessed letters of the unknown word with underscores.

pick_random_word() is the routine that chooses a random word from a file of words. Many Linux systems happen to have a convenient list of about 38,000 words located in /usr/games/lib (it is used by the Berkeley ASCII terminal hangman game). (If you don't have such a file on your system, check for /usr/dict/words, /usr/share/words, /usr/words/dictionary, and other variants.) Each word appears on a separate line. We work our way through each line, using a clever algorithm that gives each word an equal chance of being chosen without knowing the length of the list in advance. For a full explanation of how and why this algorithm works, see Chapter 8 of Perl Cookbook, by Tom Christiansen and Nathan Torkington (O'Reilly & Associates, 1998).

Because the state information is saved in the document body, the save_state() function has to be called from the part of the code that generates the fill-out forms. The two places where this happens are the routines show_guess_form() and show_restart_form():

sub show_guess_form {
   my $state = shift;
   print start_form(-name => 'gf'),
         "Your guess: ",
         textfield(-name => 'guess', -value => '', -override => 1),
         submit(-value => 'Guess');
   save_state($state);
   print end_form;
}

show_guess_form() produces the fill-out form that prompts the user for his guess. It calls save_state() after opening a <FORM> section and before closing it.

sub show_restart_form {
   my $state = shift;
   print start_form,
         "Do you want to play again?",
         submit(-name => 'restart', -value => 'Another game');
   delete $state->{WORD};
   save_state($state);
   print end_form;
}

show_restart_form() is called after the user has either won or lost a game. It creates a single button that prompts the user to restart. Because the game statistics have to be saved across games, we call save_state() here too. The only difference from show_guess_form() is that we explicitly delete the WORD field from the state variable. This signals the script to generate a new unknown word on its next invocation.

Astute readers may wonder at the -onLoad argument that gets passed to the start_html() function toward the beginning of the code. This argument points to a fragment of JavaScript code to be executed when the page is first displayed. In this case, we're asking the keyboard focus to be placed in the text field that's used for the player's guess, avoiding the annoyance of having to click in the text field before typing into it. We promise we won't use JavaScript anywhere else in this book!

Example 5-1. A Hangman Game Using Fill-out Forms to Save State

# file: hangman1.cgi
# hangman game using hidden form fields to maintain state
use IO::File ();
use CGI qw(:standard);
use strict;
use constant WORDS => '/usr/games/lib/hangman-words';
use constant ICONS => '/icons/hangman';
use constant TRIES => 6;
# retrieve the state
my $state = get_state();
# reinitialize if we need to
$state    = initialize($state) if !$state or param('restart');
# process the current guess, if any
my($message, $status) = process_guess(param('guess') || '', $state);
# start the page
print header,
   start_html(-Title  => 'Hangman 1',
             -bgcolor => 'white',
             -onLoad  => 'if (document.gf) document.gf.guess.focus()'),
   h1('Hangman 1: Fill-Out Forms');
# draw the picture
picture($state);
# draw the statistics
status($message, $state);
# Prompt the user to restart or for his next guess.
if ($status =~ /^(won|lost)$/) {
   show_restart_form($state);
}
else {
   show_guess_form($state);
}
print hr,
   a({-href => '/'}, "Home"),
   p(cite({-style => "fontsize: 10pt"}, 'graphics courtesy Andy Wardley')),
   end_html();
########### subroutines ##############
# This is called to process the user's guess
sub process_guess {
   my($guess, $state) = @_;
    # lose immediately if user has no more guesses left
   return ('', 'lost') unless $state->{GUESSES_LEFT} > 0;
    my %guessed = map { $_ => 1 } $state->{GUESSED} =~ /(.)/g;
   my %letters = map { $_ => 1 } $state->{WORD} =~ /(.)/g;
    # return immediately if user has already guessed the word
   return ('', 'won') unless grep(!$guessed{$_}, keys %letters);
    # do nothing more if no guess
   return ('', 'continue') unless $guess;
    # This section processes individual letter guesses
   $guess = lc $guess;
   return ("Not a valid letter or word!", 'error')
      unless $guess =~ /^[a-z]+$/;
   return ("You already guessed that letter!", 'error')
      if $guessed{$guess};
    # This section is called when the user guesses the whole word
   if (length($guess) > 1 and $guess ne $state->{WORD}) {
      $state->{TOTAL} += $state->{GUESSES_LEFT};
      return (qq{You lose.  The word was "$state->{WORD}."}, 'lost')
   }
    # update the list of guesses
   foreach ($guess =~ /(.)/g) { $guessed{$_}++; }
   $state->{GUESSED} = join '', sort keys %guessed;
    # correct guess -- word completely filled in
   unless (grep(!$guessed{$_}, keys %letters)) {
      $state->{WON}++;
      return (qq{You got it!  The word was "$state->{WORD}."}, 'won');
   }
    # incorrect guess
   if (!$letters{$guess}) {
      $state->{TOTAL}++;
      $state->{GUESSES_LEFT}--;
      # user out of turns
      return (qq{The jig is up.  The word was "$state->{WORD}".}, 'lost')
          if $state->{GUESSES_LEFT} <= 0;
      # user still has some turns
      return ('Wrong guess!', 'continue');
   }
    # correct guess but word still incomplete
   return (qq{Good guess!}, 'continue');
}
# create the cute hangman picture
sub picture {
   my $tries_left = shift->{GUESSES_LEFT};
   my $picture = sprintf("%s/h%d.gif", ICONS, TRIES-$tries_left);
   print img({-src   => $picture,
             -align => 'LEFT',
             -alt   => "[$tries_left tries left]"});
}
# print the status
sub status {
   my($message, $state) = @_;
   # print the word with underscores replacing unguessed letters
   print table({-width => '100%'},
              TR(
                 td(b('Word #:'), $state->{GAMENO}),
                 td(b('Guessed:'), $state->{GUESSED})
                 ),
              TR(
                 td(b('Won:'), $state->{WON}),
                 td(b('Current average:'),
                    sprintf("%2.3f", $state->{TOTAL}/$state->{GAMENO})), 
td(b('Overall average:'), $state->{GAMENO} > 1 ? sprintf("%2.3f", ($state->{TOTAL}-(TRIES-$state->{GUESSES_LEFT}))/ ($state->{GAMENO}-1)) : '0.000') ) ); my %guessed = map { $_ => 1 } $state->{GUESSED} =~ /(.)/g; print h2("Word:", map {$guessed{$_} ? $_ : '_'} $state->{WORD} =~ /(.)/g); print h2(font({-color => 'red'}, $message)) if $message; }
# print the fill-out form for requesting input
sub show_guess_form {
   my $state = shift;
   print start_form(-name => 'gf'),
         "Your guess: ",
         textfield(-name => 'guess', -value => '', -override => 1),
         submit(-value => 'Guess');
   save_state($state);
   print end_form;
}
# ask the user if he wants to start over
sub show_restart_form {
   my $state = shift;
   print start_form,
         "Do you want to play again?",
         submit(-name => 'restart', -value => 'Another game');
   delete $state->{WORD};
   save_state($state);
   print end_form;
}
# pick a word, any word
sub pick_random_word {
   my $list = IO::File->new(WORDS)
      || die "Couldn't open ${\WORDS}: $!\n";
   my $word;
   rand($.) < 1 && ($word = $_) while <$list>;
   chomp $word;
   $word;
}
################### state maintenance ###############
# This is called to initialize a whole new state object
# or to create a new game.
sub initialize {
   my $state = shift;
   $state = {} unless $state;
   $state->{WORD}     = pick_random_word();
   $state->{GUESSES_LEFT}     = TRIES;
   $state->{GUESSED}  = '';
   $state->{GAMENO}   += 1;
   $state->{WON}      += 0;
   $state->{TOTAL}    += 0;
   return $state;
}
# Retrieve an existing state
sub get_state {
   return undef unless param();
   my $state = {};
   foreach (qw(WORD GAMENO GUESSES_LEFT WON TOTAL GUESSED)) {
      $state->{$_} = param($_);
   }
   return $state;
}
# Save the current state
sub save_state {
   my $state = shift;
   foreach (qw(WORD GAMENO GUESSES_LEFT WON TOTAL GUESSED)) {
      print hidden(-name => $_, -value => $state->{$_}, -override => 1);
   }
}

Although this method of maintaining the hangman game's state works great, it has certain obvious limitations. The most severe of these is that it's easy for the user to cheat. All he has to do is to choose the "View Source" command from his browser's menu bar and there's the secret word in full view, along with all other state information. The user can use his knowledge of the word to win the game, or he can save the form to disk, change the values of the fields that keep track of his wins and losses, and resubmit the doctored form in order to artificially inflate his statistics.

These considerations are not too important for the hangman game, but they become real issues in applications in which money is at stake. Even with the hangman game we might worry about the user tampering with the state information if we were contemplating turning the game into an Internet tournament. Techniques for preventing user tampering are discussed later in this chapter.

   Show Contents   Previous Page   Next Page
Copyright © 1999 by O'Reilly & Associates, Inc.