~matthilde.blog

Fun little blog about various things

Programming a Roguelike in arround 350 lines of C.

September 30, 2023 — matthilde

Original publishing date: Wed 29 Jul 2020 02:52 PM

NOTE: Contains broken english.

Introduction

Roguelike is a popular arcade video game genre where you play as an hero that explores randomly generated dungeons. These are very cool games to play but it's also auite easy to program a simple, small one. This is why I made a roguelike as my first actual C project.

The program stands in a single c file of arround 350 lines (excluding blank lines and comments). Source is available in this Git repository.
So today I am going to explain how I have made this game.


Quick note before we begin: This is NOT a tutorial. This post is just a writeup of the design and programming process of this game. If you expected a tutorial out of this post, then you are not at the right place.

Design

The design of the roguelike is very simple:

  • The only elements of the game are walls, the player and ennemies.
  • This is a TUI game, you play it in a terminal emulator.
  • The game will be a grid of 20x10 characters. One character represents the player, a wall or an ennemy.
  • The player has 3 lives to kill all the ennemies in the game area. If the player loses it's all 3 lives, it's Game Over. If the player kills all the ennemies, they win.
  • This is turn-based, the ennemies moves when the player moves. If the player steps on the ennemy, the ennemy dies. If the ennemy steps on the player, the ennemy dies and the player lose a live.

Given all those specific rules, I kinda know what do I need to define in my program.

Defining functions and constants

To know what do I have to implement in order to make this game real, let's tear down the previous part of the blog...

  • The game area is randomly generated. Which means I need to implement a map generator.
  • The user will have to control the player using arrow keys. I am gonna have to implement a key press handler.
  • The ennemies will have to move after player's turn. Need to implement a simple move pattern.
  • The program must detect when the player runs out of life.
  • The program must detect when there are no more ennemies in the game area.
  • The program must detect when an object touches an object or when there is a wall on their way.

Given this, I am going to design a few definitions.

We are gonna need to add a few constants and struct defitions so we don't have a big mess in all these needed data. There is a lot and I am gonna explain a few. Many will be progressively added while I program the game but it will be seen later in the blog.

To not keep writing the same value in the whole program and avoid time wasting in case I need to change a value, I have set a few constants.

#define G_WIDTH     20  // Game area width
#define G_HEIGHT    20  // Game area height
#define E_LIMIT     10  // Number of ennemies in one game
#define P_HEALTH    3   // Number of lives the player has
#define GENRATE     20  // Will place a wall on one case out of 5

Each element in the game has an ID, which are the air, the walls, the player and the ennemies. I have wrote two constants for this.

// Object IDs
typedef enum {
    O_NOTHING,
    O_WALL,
    O_PLAYER,
    O_ENNEMY
}

// Character array with the matching
// characters.
const char* objectChars = ".#@%";

Then I have made a few structs so all the game data is kept in one struct so it makes the data manipulation way much more convenient between functions.

The 2 first ones are the player and ennemies. They define their position and their health.

// Player struct
struct player
{
    struct  point p;
    int     health;
};

// Ennemy struct
struct ennemy
{
    struct  point p;
    int     aive;
};

The point struct is simply the position of the actor. it contains an x and an y integer. I have defined it instead of adding x and y directly in case I need to write common movement functions.

Then I put those structs into a single struct that contains all the game data.

struct rogueGame
{
    struct  player g_player;
    struct  ennemy g_ennemies[E_LIMIT];
    char    map[G_HEIGHT][G_WIDTH];
};

Escape sequence handling.

Writing a key press handling function is pretty much simple to do. I just had to enter the raw mode using the termios.h header. I am not using ncurses, there are no point to use it for a such application. The only keys the handler needs to understand are the arrow keys and the SIGINT key (Ctrl + C) to quit the application. Everything should stand in a single while loop.

while (1)
{
    if (read(STDIN_FILENO, &c, 1) == -1 && (errno != EAGAIN && errno != EINTR))
        // See the source file. Basically it's an error handling function.
        die("read");
    if (c == 0)
        continue;

    if (c == 27)
    {
        read(STDIN_FILENO, &c, 1);
        if (c == 91)
        {
            read(STDIN_FILENO, &c, 1);
            switch (...)
            {
                // ...
            }
        }
    }
    if (c == 3) break;
}

Map generation

As you could see before, the map is stored in a matrix array. So I wrote a function that places walls randomly with a defined rate. (the GENRATE constant, defined before). This isn't difficult to do, it fits in a for loop.

// Example: generate_walls(&rogue, GENRATE);
void generate_walls(struct rogueGame *g, int rate)
{
    int x, y;
    for (y = 0; y < G_HEIGHT; ++y)
        for (x = 0; x < G_WIDTH; ++x)
        {
            // Since the player appears on the case (1, 1),
            // no wall will appear here.
            if (x == y == 1)
                continue;
            if (rand() % 100 < rate)
                g->map[y][x] = O_WALL;
        }
}

Yea, the generation of the game area is kinda bad but it's still better than just an empty space!

Handling movement

Since it's turn based, the ennemy will move after the player moves. So when the users presses a key, the program should work this way :

If the key is an arrow key, then execute a function named move_handler.
The function will first run move_player to move the player. If the player doesn't move due a wall, stop from here and don't run the function any further, else continue. Then if the player steps on an ennemy, kill the ennemy then check if all the ennemies are dead, if they all are, stop from there and tell the player they won the game.

Else, continue the program. The function will then run move_ennemy for each of the alive ennemies. If it's too far, just roam arround. Else go towards the player. To calculate the distance, I just used this simple calculation.

// NOTE: This is pseudo-code.
int dx, dy, distance;

dx = player.x - ennemy.x;
dy = player.y - ennemy.y;
distance = abs(dx) + abs(dy);

If distance is higher than 6, then it's too far from the player.
Then if the ennemy touches the player, the player loses a life. If the player have lost a life, the program will check if it's equal or inferior to 0. If it is, the game will return a game over.

Communication between the handlers and the main function.

To let the main function know whenever the player moves or not, if it's a game over or whatever, the move_handler function will return one of those integers in this enum:

typedef enum {
    MH_NOMOVE,      // Object hasn't moved
    MH_MOVE,        // Object has moved
    MH_STOMP,       // The object stepped on another object
    MH_COMPLETE,    // The level is complete
    MH_GAMEOVER,    // Game Over
    MH_ERROR        // Something wrong happened
}

Some of those values in this enum is also used between the move_* functions to know the state of an object once moved.

TUI

Like I have said before, there is no ncurses library, which means I have to write the functions to center the interface and handle resizing on my own. This fits in one struct variable and 2 functions:

struct winsize terminalwin;

void handle_resize();
int calc_center(int area, int square);
void render_map(struct rogueGame *g, const char* log);

Everytime move_handler returns MH_MOVE, the main function will call render_map to update the map.
When a terminal emulator is being resized, the terminal sends the SIGWINCH signal to the running process to tell it has changed size. We are gonna use the signal() function from signal.h to handle this:

signal(SIGWINCH, handle_resize);

Conclusion

This blog is just a little write-up of what I have done in the program. It doesn't cover all the programming and designing process but just a few things.

I have done this little program as an exercice to learn C. The more I will program, the best I will become. Anyways, thanks you for reading.

Tags: repost, programming, c