A Maze with Curses

TL;DR

Playing with Curses in Perl is funny. Making a simple game is instructive.

Is there a better way to learn a technology than using it? Let’s code a simple maze game with Curses in Perl.

Our little story

We will display a simple maze with one-character wide corridors delimited by walls made of # characters. Our hero the @ sign will always start from the upper-left corner and will have to reach the exit door marked by the . character.

Data structure(s)

We will keep the data structures to a minimum. A maze will be an anonymous hash with the following keys:

  • exit: the row, column coordinates of the exit, kept in an anonymous array
  • hero: the row, column coordinates of the hero, kept in an anonymous array
  • maze: ascii-art picture of the maze
  • moves: a counter keeping how many moves were made

Boilerplate

Well, we have already covered this in Curses boilerplate starter. You can of course just get the boilerplate starter.

This was easy!

Program structure

The main program structure will be as follows:

# load a maze
my $maze = load_maze();

# display it
maze_display($win, $maze);

# main loop
make_a_move($win, $maze) until hero_reached_exit($maze);

# salutations
goodbye_display($win, $maze);

We are keeping it very high level to make it simple to understand, and also to allow experimenting with different alternative implementations where it might be beneficial.

Load a maze

We delegate loading a maze to a specific load_maze() function. We will start with a simple, fixed maze but this allows us to experiment with a more generative approach in the future (if we want to go down that way).

sub load_maze {
   my $maze = <<'END_OF_MAZE';
##################################################
#      #         #   #          #      #   #     #
# #### # ####### # # # ######## # # #  # # # ### #
#    # # #       # # #        # # # #### # #  #  #
#### #   # ####### # ######## # # #    # # ## # ##
#    ##### #       #          # # # ## # #    #  #
# # ###    ##### # ############ # # #  # ####### #
# ### #######    # #            # # # ## #       #
# #         # #### # ############ # #    # #######
# # ####### # #    #              # # ####   #   #
# # ##    # # # ################### ###  ### # # #
# #    ## # # # # #               #        #   # #
# ####### # # # # ######### ##### ##### ######## #
#         #   #             #         #   #      #
################################################ #
END_OF_MAZE
   return {
      exit => [14, 48], # lower-right corner
      hero => [1, 1],   # upper-left  corner
      maze => $maze,
      moves => 0,
   };
}

Display the maze

This function displays the whole maze, including the hero and the exit. It can be handy to just redraw the whole thing.

sub maze_display ($win, $maze) {
   my $n_row = 0;
   for my $row (split m{\n+}mxs, $maze->{maze}) {
      $win->addstr($n_row++, 0, $row);
   }
   $win->addch($maze->{exit}->@*, '.');
   $win->addch($maze->{hero}->@*, '@');
   $win->refresh;
}

Moving our hero around

Our main loop is quite simple:

make_a_move($win, $maze) until hero_reached_exit($maze);

The function to make a move will wait for an input from the terminal and act accordingly:

sub make_a_move ($win, $maze) {
   my ($ch, $key) = $win->getchar;
   if (defined $key) {
      my $key_up = KEY_UP;
      try_move($win, $maze, -1,  0) if $key == KEY_UP;
      try_move($win, $maze,  0,  1) if $key == KEY_RIGHT;
      try_move($win, $maze,  1,  0) if $key == KEY_DOWN;
      try_move($win, $maze,  0, -1) if $key == KEY_LEFT;
   }
   elsif (defined $ch) {
      exit 0 if $ch eq 'q';
   }
   else {
      die 'getch failed?!?';
   }
   return;
}

The getchar() method returns a character or a key when called in list context, which makes it very easy to tell normal keypresses (i.e. letters) from keys (i.e. arrow keys).

If one of the arrow keys is pressed, the try_move() function is called to attempt a move in the specific direction, represented as a pair of deltas in the row and column position of the hero (e.g. -1, 0 means one row less and no change in column, i.e. a movement in the UP direction).

Additionally, it’s possible to exit the game by pressing the q key. It’s possible to “just exit” because the boilerplate already includes the execution of the Curses exiting sequence by default (i.e. call endwin()).

Implementing try_move() is straightforward:

sub try_move ($win, $maze, $row_delta, $col_delta) {
   my ($row, $col) = $maze->{hero}->@*;
   $row += $row_delta;
   $col += $col_delta;
   my $char = $win->inch($row, $col);
   if ($char ne '#') {
      $maze->{hero}->@* = ($row, $col);
      $maze->{moves}++;
      maze_display($win, $maze);
   }
   return;
}

The new candidate position for the hero is calculated in $row and $col. The assumption is that there will always be a wall around the whole maze, so it’s not necessary to check for always being within bounds.

To make things easy, the character at this candidate position is read directly from the displayed window (we might want to use $maze->{maze} instead, but in this case it would require more coding). If it’s not a wall (i.e. if it’s different from character #) then the move is legal and can be performed, updating the hero position and redrawing the whole maze.

Last, we need to test whether the hero reached the exit or not:

sub hero_reached_exit ($maze) {
   return $maze->{hero}[0] == $maze->{exit}[0]
      &&  $maze->{hero}[1] == $maze->{exit}[1];
}

End of the game

When the hero reaches the exit, we want to show one last message before exiting (waiting for an input from the player):

sub goodbye_display ($win, $maze) {
   $win->addstr(5, 17, '               ');
   $win->addstr(6, 17, '  YOU MADE IT! ');
   $win->addstr(7, 17, '               ');
   $win->refresh;
   $win->getchar;
}

Putting all pieces together

You can find the whole game source here: a-maze-ing. I hope you can find it useful and amusing!

Want more? A RANDOM Maze with Curses contains the evolution of the code above to cope with algorithmic generation of mazes, but you might want to take a look at Removing loops from a path first!


Comments? Octodon, , GitHub, Reddit, or drop me a line!