📜 Jelle Pelgrims

Building a roguelike in awk

Some time ago I started working on a roguelike in awk, the text processing tool/language. This game was also my entry in the 2018 version of the "Roguelikedev Does The Complete Roguelike Tutorial" event, organized by the RoguelikeDev subreddit. This article serves as a post-mortem for the game, since I thought that the interesting choice of programming language and the resulting limitations and workarounds deserved some further explanation. The game is is called awkventure.

To give an idea of what it looks like, here's the intro screen:

And here's a bit of gameplay:

Why awk

I probably should explain why I chose to write a game completely in awk. Awk is a scripting language meant for processing and manipulating text files. It's syntax is mostly C-based. Most unix systems will include some version of it. It is not really meant for building games, or pretty much anything that is not processing text files. Which is why I thought it would be fun to build a roguelike with awk. I had also just read the excellent awk book - The AWK Programming Language - and I wanted to apply some of the things I read about.

There are multiple awk implementations. I chose to use gawk, which is the GNU alternative to the 'original' awk, with a lot more features and language extensions.

Project structure

Soon I had to start structuring the source code due to the project quickly increasing in size (currently about 2000 lines of code). I managed to divide the source code into multiple files by using gawk's -f command line option:

# Ouput taken from 'man gawk'

-f program-file
--file program-file
    Read the AWK program source from the file program-file, instead of from the 
    first command line argument. Multiple -f (or --file) options may be used.

Now I could package related code in separate files, but I still had to add each file manually to the command line arguments. To avoid this I wrote a shell script that would find every awk source code file in the game folder and automatically load those into the CLI arguments. The startup script currently also loads any .dat files (game data files) or .sav files (save files), if present.

GAMEFILES=$(ls | grep -o '^[a-zA-Z]*.dat$')
LIBRARIES=$(ls | grep -o '^[a-zA-Z]*.awk$')
SAVEFILE=$(ls | grep -o '^[a-zA-Z]*.sav$' | head -n 1)

gawk $(for lib in $LIBRARIES; do echo "-f "; echo $lib; done;) \
    $(for gamefile in $GAMEFILES; do echo $gamefile; done;) \
    $SAVEFILE

Loading game data

The game files mentioned above contain all game data. They are loaded into the game during startup using awk's built-in regular expression support.

Awk allows regular expression patterns to be defined that are then matched against all files passed as arguments to awk. The interesting thing about these patterns is that one can also specify a set of commands to be run for every pattern match.

This functionality turns out to be very useful for savefiles. I simply needed to mark all data sections in the data file with their own "title", after which I could create patterns matching each title. The data after the title would then be loaded by the commands specified in the pattern. This meant that I could also mix data formats, e.g. one section could use .ini formatting while another could use csv formatting.

The following is an example data section for defining keybindings:

[KEYBINDINGS]
EXAMINE = e
UP = z
DOWN = s
LEFT = q
RIGHT = d
QUIT = p

This data section would then be processed using the following awk pattern:

/^\[KEYBINDINGS\]/ {
    # specific data processing code goes here
}

Player input

Keyboard input was one of the things that required quite a lot of messing around before it actually worked. I needed a way to read keypresses without showing any output on the screen. The input method also needed to only return the most recent keypress, i.e. it should not keep a queue of recent keypresses and go through those.

Most of this functionality could be implemented with the read command:

jpelgrims@LAPTOP-3CN5DAKP:~$ read --help

read: read [-ers] [-a array] [-d delim] [-i text] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [-u fd] [name ...]
    Read a line from the standard input and split it into fields.

    Reads a single line from the standard input, or from file descriptor FD
    if the -u option is supplied.  The line is split into fields as with word
    splitting, and the first word is assigned to the first NAME, the second
    word to the second NAME, and so on, with any leftover words assigned to
    the last NAME.  Only the characters found in $IFS are recognized as word
    delimiters.

The actual input function currently being used is shown below:

function get_input(echo,   arrow, key) {
    # Turn off console echo if requested
    if (echo == 1) {
        system("stty -echo") 
    }

    # Clear stdin
    system("bash -c 'read -n 1000 -t 0.0001 -s'") 

    # Read up to three characters (arrow keys are 3)
    cmd = "bash -c 'if read -n 1 key; then read -n 2 -t 0.00005 char; fi; echo $key$char'"
    cmd | getline key
    close(cmd)

    return key
}

First of all a system call (stty -echo) is used to stop console echo-ing. The second system call takes care of any keypresses that may have been stored in a queue. It does this by using the read command to simply read a lot of characters with a very small timeout value. This will clear stdin and return immediately if no keypresses are left to read.

The last part of the function uses the read command again to get the player's last keypresses. It does this by reading one character at first, with no timeout value. If the function reads a character, it will then try to read two more characters or time out if nothing can be read. This is done to make sure that special characters, such as arrow key presses can also be read. These keys get turned into special character sequences when outputted to the screen, mostly three characters long.

The characters that were read are then simply captured in a variable and returned.

Screen output

Awk, as most programming languages, has a print statement for basic console output, but not much more. All other screen output functions would have to be built on top of this.

I started out by implementing a few basic functions that could output colored strings at a specified location on the console using ANSI escape sequences.

function putch(char, x, y) {
    printf "\033[%s;%sH%s", y+1, x, char
}

function set_foreground_color(r, g, b) {
    printf "\033[38;2;%s;%s;%sm", r, g, b
}


function set_background_color(r, g, b) {
    printf "\033[48;2;%s;%s;%sm", r, g, b
}

function cls() {
    printf "\033[2J"
}

Using these functions I could now draw frames, using the cls() function to refresh. This however caused a lot of flickering due to the slow redrawing speed of the console.

In order to remove the flickering I had to implement a buffering system. Instead of writing directly to the screen, I now drew to a buffer. Once the frame was ready, I looped over the buffer and checked which cells were different from the cells of the previous frame. Only cells that had changed were then drawn to the screen. This took care of the flickering and also significantly sped up the drawing process.

function flip_buffer(   y, x, cell) {
    for (y=0; y<screen_height;y++) {
        for (x=0;x<screen_width;x++) {
            if (SCREEN_BUFFER[x][y] != CURRENT_SCREEN[x][y]) {
                cell = SCREEN_BUFFER[x][y]
                putch(cell, x, y)
                CURRENT_SCREEN[x][y] = SCREEN_BUFFER[x][y]
            }
        }
    }
    reset_cursor()
}

Telnet server

I somehow managed to set up a very basic telnet server for the game. Because the rendering is completely done in the console, using ANSI escape codes, this wasn't too hard. The server is basically a simple ncat command that uses the -k and -e flags. The -k (--keep-open) flag allows for multiple simultaneous connections, while the -e (--exec) flag takes an executable as a argument. The executable (in this case a bash script that starts up the game) will then receive all input received over the telnet connection, and any output from the executable is also sent back over this connection. Much to my amusement, it is surprisingly stable and works rather well.

#!/usr/bin/env sh
PORT="8888"
if [ $1 ]; then PORT=$1; fi;
while [ 1 ]; do ncat -lkt -p $PORT -e "./telnet_client.sh"; done;