Creating a Typing Game Using Bash

LinuxLinuxBeginner
Practice Now

Introduction

In this project, you will learn how to create a simple typing game using a shell script. The game displays random characters on the screen, and your goal is to type them before they disappear. The game offers different modes with varying difficulty levels. You can choose to practice typing numbers, letters, a mixture of both, or even your own custom words.

👀 Preview

Shell Typing Game

ðŸŽŊ Tasks

In this project, you will learn:

  • How to create a project file and open it in a code editor
  • How to display a welcome interface using special characters and colors
  • How to implement a mode selection menu for choosing the difficulty level
  • How to implement a typing category selection menu for choosing the type of characters to practice
  • How to create functions for drawing a border and filling the background color of the typing interface
  • How to generate random letters and numbers for the typing game
  • How to implement the typing functionality, including handling user input and calculating accuracy
  • How to create a graceful exit function to handle special signals

🏆 Achievements

After completing this project, you will be able to:

  • Demonstrate shell scripting fundamentals
  • Use special characters and colors in terminal output
  • Read input from the user in shell scripts
  • Implement menus and user interfaces in shell scripts
  • Handle special signals in shell scripts

Create Project File

To Start, Please Create a New File Named shell_typing_game.sh and Open It in Your Preferred Code Editor.

cd ~/project
touch shell_typing_game.sh
âœĻ Check Solution and Practice

Display Welcome Interface

#/bin/bash
function dis_welcome() {
  declare -r str='
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
1000000010000000101111101000000111100111000000100000001000001111
0100000101000001001000001000001000001000100001010000010100001000
0010001000100010001111101000001000001000100010001000100010001111
0001010000010100001000001000001000001000100100000101000001001000
0000100000001000001111101111100111100111001000000010000000101111
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000001111000000100000000001000000010000001111100000000000
0000000000010000000001010000000010100000101000001000000000000000
0000000000010011000011111000000100010001000100001111100000000000
0000000000010001000100000100001000001010000010001000000000000000
0000000000001111001000000010010000000100000001001111100000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000'
  declare -i j=0
  declare -i row=3   ## Print start line.
  line_char_count=65 ## Define the newline position, 64 characters per line plus newlines, for a total of 65 characters.

  echo -ne "\033[37;40m\033[5;3H" ## Set the color and cursor start position.

  for ((i = 0; i < ${#str}; i++)); do
    ## Line feed.
    if [ "$((i % line_char_count))" == "0" ] && [ "$i" != "0" ]; then
      row=$row+1
      echo -ne "\033["$row";3H"
    fi
    ## Determine foreground and background characters.
    if [ "${str:$i:1}" == "0" ]; then
      echo -ne "\033[37;40m "
    elif [ "${str:$i:1}" == "1" ]; then
      echo -ne "\033[31;40m$"
    fi
  done
}

dis_welcome

The long string at the beginning 010101... is the welcome game interface to be displayed. If you look at it directly, you may not understand what it is. So you can copy this string to a gedit editor and then press the shortcut Ctrl+F to find. When you enter 1, you will understand it immediately. Let's focus on the echo command. We need to use the echo command because we want to display it on the screen (here it refers to the Xfce terminal. The standard input, output, and standard error output under Linux are all connected to the terminal by default). To achieve some special functions, such as printing at a specified position and setting the display character color, and print the background of the entire game, we need to use some special parameters of the corresponding command, as shown in the following code:

## Set the color and cursor start position.
echo -ne "\033[37;40m\033[5;3H"

Based on the comments, we can roughly understand its function. But what exactly does it mean? First, the -n parameter means to print in the current line without line feed after output. Then set the output character color and cursor position. Among them, \033[foreground color;background color m, and the following is \033[row;column H. However, to make echo recognize these special escape sequences, we need the -e parameter, otherwise echo will output these characters as they are.

Foreground colors: 30-39, background colors: 40-49.

Value Foreground Colors Value Background Color Range: 40 - 49
30 Set black foreground 40 Set black background
31 Set red foreground 41 Set red background
32 Set green foreground 42 Set green background
33 Set brown foreground 43 Set brown background
34 Set blue foreground 44 Set blue background
35 Set purple foreground 45 Set purple background
36 Set cyan foreground 46 Set cyan background
37 Set white foreground 47 Set white background
38 Set underline on default foreground color 49 Set default black background
39 Set underline off default foreground color

Didn't expect echo to be used like this! Do you feel like you've reacquainted yourself with it? Okay, now let's test this piece of code in the terminal.

cd ~/project
bash shell_typing_game.sh
Shell Typing Game
âœĻ Check Solution and Practice
## Declare the variable 'time' to represent the typing timeout, where different difficulty levels correspond to different timeout values
declare -i time
function modechoose() {
  ## Select from three different modes
  echo -e "\033[8;30H1) easy mode"
  echo -e "\033[9;30H2) normal mode"
  echo -e "\033[10;30H3) difficult mode"
  echo -ne "\033[22;2HPlease input your choice: "
  read mode
  case $mode in
    "1")
      time=10
      dismenu ## Call the menu selection function
      ;;
    "2")
      time=5
      dismenu
      ;;
    "3")
      time=3
      dismenu
      ;;
    *)
      echo -e "\033[22;2HYour choice is incorrect, please try again"
      sleep 1
      ;;
  esac
}

Before defining this function, we first declared an integer variable 'time' using the command declare -i. Then, in the following case statement, we set different values for the 'time' variable based on the user's difficulty selection. The higher the difficulty, the smaller the value. This variable is actually used in the subsequent typing processing function. You may remember that in shell scripts, variables declared or defined inside or outside a function are treated as global variables with a scope throughout the script file, unless the 'local' keyword is used to declare the variable within a function. As for the display effect of the menu, we still use the echo command and then use the read command to read the user's input into the variable 'mode'.

Shell Typing Game
âœĻ Check Solution and Practice
function display_menu() {
  while [ 1 ]; do
    draw_border
    echo -e "\033[8;30H1) Practice typing numbers"
    echo -e "\033[9;30H2) Practice typing letters"
    echo -e "\033[10;30H3) Practice typing alphanumeric characters"
    echo -e "\033[11;30H4) Practice typing words"
    echo -e "\033[12;30H5) Quit"
    echo -ne "\033[22;2HPlease input your choice: "
    read choice
    case $choice in
      "1")
        draw_border
        ## The next two are function parameters, the first parameter indicates the typing type, and the second is the function for moving characters
        main digit
        echo -e "\033[39;49m"
        ;;
      "2")
        draw_border
        main char
        echo -e "\033[39;49m"
        ;;
      "3")
        draw_border
        main mix
        echo -e "\033[39;49m"
        ;;
      "4")
        draw_border
        echo -ne "\033[22;2H"
        read -p "Which file would you like to use for typing practice: " file
        if [ ! -f "$file" ]; then
          display_menu
        else
          exec 4< $file ## Create a file pipeline
          main word
          echo -e "\033[39;49m"
        fi
        ;;
      "5" | "q" | "Q")
        draw_border
        echo -e "\033[10;25HYou will exit this game now"
        echo -e "\033[39;49m"
        sleep 1
        clear
        exit 1
        ;;
      *)
        draw_border
        echo -e "\033[22;2HYour choice is wrong, please try again"
        sleep 1
        ;;
    esac
  done
}

In this function, let's first explain the two functions called in the case branch. First, draw_border function is used to draw the border of the typing interface, which will be shown later. Then, the main function is called. This main function does not have any special role like the main function in languages such as Java and C, it just simply has the name main, which is used to indicate that it plays a major role in the entire program. You may have noticed that there is an argument after each main, yes, this is the parameter passed to the function. In shell, parameters are not written inside parentheses immediately following the function name, unlike many other languages. It should also be noted that in the fourth branch of the case statement, which is these lines:

read -p "Which file would you like to use for typing practice: " file
if [ ! -f "$file" ]; then
  display_menu
else
  exec 4< $file ## Create a file pipeline
  main word
  echo -e "\033[39;49m"
fi

According to the menu prompt, we can know that this branch is for practicing typing words. This program allows users to use custom word files (text files with a certain formatting requirement, with one word per line, you can download a word list file online and use the awk command to extract) for practice. So first we need to read in a user input file name to select the word file. Then we use the exec command to create a pipeline that points to the file, i.e., redirecting the output of the file to file descriptor 4 (fd). Since exec is a built-in command in bash, you won't be able to see the documentation of exec using the man command. I/O redirection with exec is usually related to fd, and shell usually has 10 fd, ranging from 0 to 9. The common used fd are 3, which are 0 (stdin, standard input), 1 (stdout, standard output), and 2 (stderr, standard error output). Just understand its meaning for now.

Shell Typing Game
âœĻ Check Solution and Practice

Drawing a Border for a Typing Interface

function draw_border() {
  declare -i width
  declare -i high
  width=79 ## terminal default width - 1
  high=23  ## terminal default height - 1

  clear

  ## Set display color to white on black background
  echo -e "\033[37;40m"
  ## Set background color
  for ((i = 1; i <= $width; i = i + 1)); do
    for ((j = 1; j <= $high; j = j + 1)); do
      ## Set display position
      echo -e "\033["$j";"$i"H "
    done
  done
  ## Draw background border
  echo -e "\033[1;1H+\033["$high";1H+\033[1;"$width"H+\033["$high";"$width"H+"
  for ((i = 2; i <= $width - 1; i = i + 1)); do
    echo -e "\033[1;"$i"H-"
    echo -e "\033["$high";"$i"H-"
  done
  for ((i = 2; i <= $high - 1; i = i + 1)); do
    echo -e "\033["$i";1H|"
    echo -e "\033["$i";"$width"H|\n"
  done
}
  • In the draw_border() function, two integer variables width and high are declared, which represent the width and height of the terminal window respectively.
  • The command echo -e "\033[37;40m" is used to set the display color. Nested for loops are used to iterate through each row and column of the terminal to fill the background.
  • Within the inner loop, ANSI escape codes are used to position the cursor at the desired coordinates within the terminal. For example, echo -e "\033["$j";"$i"H" sets the cursor position to row j and column i.
  • After filling the terminal background, decorative borders are drawn using specific characters. The characters used include +, -, and |, which are commonly used for drawing borders. The positions of these characters are determined by their row and column values, and printed using ANSI escape codes. For example, echo -e "\033[1;1H+" places the + character at the top left corner of the terminal.

In summary, the draw_border() function clears the terminal, sets the background color to black, fills the terminal with spaces to create a background. Finally, it draws a visually appealing border by placing characters (such as + for corners, - and | for lines) at specific positions.

Shell Typing Game
âœĻ Check Solution and Practice

Filling the Background Color of the Typing Interface

## Clear the entire character landing area
function clear_all_area() {
  local i j
  ## Fill the typing area
  for ((i = 5; i <= 21; i++)); do
    for ((j = 3; j <= 77; j = j + 1)); do
      ## Set the display position
      echo -e "\033[44m\033["$i";"$j"H "
    done
  done
  echo -e "\033[37;40m"
}

## Function: Clear a specific column of characters
## Input: The column number to be cleared
## Return: None
function clear_line() {
  local i
  ## Fill the typing area
  for ((i = 5; i <= 21; i++)); do
    for ((j = $1; j <= $1 + 9; j = j + 1)); do
      echo -e "\033[44m\033["$i";"$j"H "
    done
  done
  echo -e "\033[37;40m"
}
  1. The clear_all_area() function:
  • It is used to clear the entire character landing area. Its function is to fill the characters in the character landing area with the background color (black) to clear the characters in the terminal window.
  • It uses nested loops to iterate over all combinations of i and j, where i represents the row and j represents the column.
  • Inside the inner loop, it sets the cursor position to the specified row and column using ANSI escape codes, and then uses echo -e "\033[44m\033["$i";"$j"H " to print a space character at that position, but with the background color set to black (code 44).
  • After the loops, the function uses echo -e "\033[37;40m" to restore the text color and background color to their default values (white text, black background).
  1. The clear_line() function:
  • This function is used to clear a specific column in the character landing area. It is typically used to clear the character path in that column after the user enters the correct character.
  • The function takes one argument $1, which represents the column number to be cleared.
  • It uses a loop to iterate over the rows and columns in the character landing area and fills the character path of that column with the background color (black).
  • Similar to clear_all_area(), the function sets the cursor position to the specified row and column using ANSI escape codes, and then uses echo -e "\033[44m\033["$i";"$j"H " to print a space character at that position, but with the background color set to black.
  • Finally, the function uses echo -e "\033[37;40m" to restore the text color and background color to their default values.
âœĻ Check Solution and Practice

Generating Random Letters and Numbers

## Function:  Move the character along the falling path.
## Input:     Parameter 1: The current row of the character (related to the time length away from the character).
##            Parameter 2: The current column of the character.
##            Parameter 3: (Unused parameter).
## Return:    None
function move() {

  local locate_row lastloca
  locate_row=$(($1 + 5))
  ## Display the character to be input.
  echo -e "\033[30;44m\033["$locate_row";"$2"H$3\033[37;40m"
  if [ "$1" -gt "0" ]; then
    lastloca=$(($locate_row - 1))
    ## Clear the previous position.
    echo -e "\033[30;44m\033["$lastloca";"$2"H \033[37;40m"
  fi
}

function putarray() {
  local chars
  case $1 in
    digit)
      chars='0123456789'
      for ((i = 0; i < 10; i++)); do
        array[$i]=${chars:$i:1}
      done
      ;;
    char)
      chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
      for ((i = 0; i < 52; i++)); do
        array[$i]=${chars:$i:1}
      done
      ;;
    mix)
      chars='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
      for ((i = 0; i < 62; i++)); do
        array[$i]=${chars:$i:1}
      done
      ;;
    *) ;;

  esac
}

## Function:  Generate a random character of the corresponding type to be converted into a random character.
## Input:     The type of character to be generated.
## Global Var: random_char, array[]
## Return:    None
function get_random_char() {
  local typenum
  declare -i typenum=0
  case $1 in
    digit)
      typenum=$(($RANDOM % 10))
      ;;
    char)
      typenum=$(($RANDOM % 52))
      ;;
    mix)
      typenum=$(($RANDOM % 62))
      ;;
    *) ;;

  esac
  random_char=${array[$typenum]}
}

Due to the fact that shell's string variables do not directly support indexing, we need to put all letters and numbers into an indexed array. This is the purpose of the putarray function. The get_random_char function below is used to generate random letters and numbers. It uses the system's random number environment variable RANDOM to obtain a random index, and then reads the corresponding character from the array.

âœĻ Check Solution and Practice

Implementation of Typing

After all the preparations, we can finally start implementing the typing function. Let's take a look at the following code:

function main() {
  declare -i gamestarttime=0
  declare -i gamedonetime=0

  declare -i starttime
  declare -i deadtime
  declare -i curtime
  declare -i donetime

  declare -i numright=0
  declare -i numtotal=0
  declare -i accuracy=0

  ## Store the corresponding characters into an array, $1 represents the user-selected character type
  putarray $1

  ## Initialize the game start time
  gamestarttime=$(date +%s)

  while [ 1 ]; do
    echo -e "\033[2;2H  Please enter the letter on the screen before it disappears!"

    echo -e "\033[3;2H Game time:     "
    curtime=$(date +%s)
    gamedonetime=$curtime-$gamestarttime
    echo -e "\033[31;40m\033[3;15H$gamedonetime s\033[37;40m"
    echo -e "\033[3;60H Total: \033[31;26m$numtotal\033[37;40m"
    echo -e "\033[3;30H Accuracy: \033[31;40m$accuracy %\033[37;40m"
    echo -ne "\033[22;2H Your input:                         "
    clear_all_area
    ## Loop 10 times to check if a column of characters times out or is hit
    for ((line = 20; line <= 60; line = line + 10)); do
      ## Check if the column of characters has been hit
      if [ "${ifchar[$line]}" == "" ] || [ "${donetime[$line]}" -gt "$time" ]; then
        ## Clear the display in that column
        clear_line $line
        ## Generate a random character
        if [ "$1" == "word" ]; then
          read -u 4 word
          if [ "$word" == "" ]; then ## End of file reading
            exec 4< $file
          fi
          putchar[$line]=$word
        else
          get_random_char $1
          putchar[$line]=$random_char
        fi
        numtotal=$numtotal+1
        ## Set the flag to 1
        ifchar[$line]=1
        ## Reset the timer
        starttime[$line]=$(date +%s)
        curtime[$line]=${starttime[$line]}
        donetime[$line]=$time
        ## Reset the column position to 0
        column[$line]=0
        if [ "$1" == "word" ]; then
          move 0 $line ${putchar[$line]}
        fi
      else
        ## If it has not timed out or been hit, update the timer and current position
        curtime[$line]=$(date +%s)
        donetime[$line]=${curtime[$line]}-${starttime[$line]}
        move ${donetime[$line]} $line ${putchar[$line]}
      fi
    done

    if [ "$1" != "word" ]; then
      echo -ne "\033[22;14H" ## Clear the input line characters
      ## Check user input characters and act as a one-second timer
      if read -n 1 -t 0.5 tmp; then
        ## Successful input, loop checks if the input matches a column
        for ((line = 20; line <= 60; line = line + 10)); do
          if [ "$tmp" == "${putchar[$line]}" ]; then
            ## Clear the display in that column
            clear_line $line
            ## If matched, clear the flag
            ifchar[$line]=""
            echo -e "\007\033[32;40m\033[4;62H         right !\033[37;40m"
            numright=$numright+1
            ## Exit the loop
            break
          else
            ## Otherwise, always display an error until timeout
            echo -e "\033[31;40m\033[4;62Hwrong,try again!\033[37;40m"
          fi
        done
      fi
    else
      echo -ne "\033[22;14H" ## Clear the input line characters
      ## Check user input characters and act as a timer
      if read tmp; then
        ## Successful input, loop checks if the input matches a column
        for ((line = 20; line <= 60; line = line + 10)); do
          if [ "$tmp" == "${putchar[$line]}" ]; then
            ## Clear the display in that column
            clear_line $line
            ## If matched, clear the flag
            ifchar[$line]=""
            echo -e "\007\033[32;40m\033[4;62H         right !\033[37;40m"
            numright=$numright+1
            ## Exit the loop
            break
          else
            ## Otherwise, always display an error until timeout
            echo -e "\033[31;40m\033[4;62Hwrong,try again!\033[37;40m"
          fi
        done
      fi
    fi
    trap " doexit " 2 ## Capture special signals
    ## Calculate accuracy
    accuracy=$numright*100/$numtotal
  done
}

Now let's explain the method of typing single characters and words separately, as they are slightly different.

For typing single characters, we want five characters to appear at the same time, and they will continue to fall at a set time interval until they disappear when they reach a certain height (determined by the timeout period chosen at the beginning). If they are not hit (the user does not input the corresponding correct character) before disappearing, a new character will appear in that column. Therefore, first, we loop to initialize a character sequence and store it in an array. The index of the array represents the column number of the terminal, which has the advantage of independently managing each column of characters, whether it has timed out or been hit, and where it should be placed. The disadvantage is that we will create a relatively large array, but it will not be so large that it cannot be handled. This doesn't matter for the shell since it doesn't allocate memory based on the size of the array index. The first for loop is responsible for this, and it also checks at the end of each major loop if a column is empty and then a new character will appear in that column.

Next is a long if...else statement. Do you remember the parameter passed when calling the main function? The outermost part is used to distinguish whether to type single characters or words. Let's first look at typing single characters, which contains a crucial line:

if read -n 1 -t 0.5 tmp

The -n parameter of the read command specifies the length of the characters to be read. Here, a length of 1 is specified, which means that the input ends immediately after the user enters one character, without the need for pressing the Enter key. The -t parameter specifies the input timeout time. If the user does not input or the input is not completed within the timeout period, the reading will end immediately. Because the read command is a synchronous operation for the same shell when reading user input, the implementation of the character falling relies on this timeout (indirectly implementing a delay in falling). Here, 0.5s is set because most users can complete the input of one character within 0.5s. After reading the user input, it uses a for loop to compare each character array corresponding to each column, and if there is a match, it calls a clear_line function to clear the character in the current column, and sets the flag variable ifchar[$line] to 0 which indicates it has been cleared.

The flow of typing words is basically the same as typing characters, except that because we cannot estimate the time it takes for the user to input a word with an indefinite length, we cannot set the input timeout time for the read command. Without this timeout, the final implementation of typing words may not automatically fall like when typing characters, but instead, the word falls one line after we enter a word and press Enter. Of course, you can consider using other methods to achieve the same or better effects as typing characters.

In addition, the handling of obtaining new words for each column is slightly different because we are reading from a file, rather than generating random words. Do you still remember the file descriptor 4 we created earlier, which points to a word file? We used this file descriptor here:

read -u 4 word
if [ "$word" == "" ]; then ## End of file reading
  exec 4< $file
fi
putchar[$line]=$word

Here, we still use the read command, and with the -u parameter, we can specify from which file descriptor to read the file line by line into the variable. The subsequent empty check statement is to recreate the file descriptor that points to the file when a file reaches its end, so that read can read from the beginning of the file again.

Do you feel that it's a bit complicated? Don't worry, we're not done yet. Next, pay attention to the penultimate line of the main function:

trap " doexit " 2 ## Capture special signals

The trap command is used to capture special signals in the shell, such as Ctrl+C, Ctrl+D, Ctrl+Z, ESC, etc. Originally, these special signals are handled by the shell itself. Because we want the game to exit more gracefully when it exits, we can intercept the 2nd special signal, which is Ctrl+C, to implement some customized processing. For example, here, after capturing the 2nd signal, it calls the doexit function below to exit the program gracefully:

function doexit() {
  draw_border
  echo -e "\033[10;30Hthis game will exit....."
  echo -e "\033[0m"
  sleep 2
  clear
  exit 1
}
âœĻ Check Solution and Practice

Main Code Flow

Since the code for each function module is already in place, to make the program run, we still need a main code flow to call these functions.

draw_border
dis_welcome
echo -ne "\033[3;30Hstart the game. Y/N : "
read yourchoice
if [ "$yourchoice" == "Y" ] || [ "$yourchoice" == "y" ]; then
  draw_border
  modechoose
else
  clear
  exit 1
fi

exit 1

This piece of code creates an entry point for a character falling game. It first displays a welcome screen and a prompt to start the game, then waits for user input on whether to start the game. If the user chooses to start the game, they will be prompted to choose the difficulty mode of the game. If the user chooses to quit or enters an invalid input, the game will not start.

âœĻ Check Solution and Practice

Running and Testing

Next, we can run our shell typing game:

cd ~/project
bash shell_typing_game.sh
Shell Typing Game
âœĻ Check Solution and Practice

Summary

You have just created a project to build a simple typing game in the shell. You can follow these steps to create your own typing game project and choose different game modes and difficulty levels. This project provides practical experience in shell scripting and terminal-based game development. If you are not familiar with some of the commands used in the course, such as the various outputs of echo, the I/O redirection of exec, and the trap for capturing special signals, you can practice them more to become familiar with their usage.

Other Linux Tutorials you may like