Retro Rogue-Like: Modular Programming Techniques in C

Retro Game Coders

Today, I dedicated some time to reorganizing the Dungeon game codebase into a modular framework.

I wanted to document my progress here, highlighting key C programming concepts that I utilized during this process.

This update is part of my devlog for the Retro Rogue-Like game I’m developing, aiming for compatibility across a range of retro computers from the 1970s and 1980s, as well as modern architectures.

During my reorganization of the Dungeon codebase into a modular structure, I learned quite a bit.

I’m chronicling it here (mostly for future Chris), along with essential C programming concepts applied during the endeavor.

Note: I am NOT claiming expertise or that this is the correct approach; this is simply my current method. It may very well be overkill or under-organized – I’m not certain!

Project Structure

My repository has been restructured as follows:

src/ ├── display/ # Functions for display and rendering │ ├── display.c │ └── display.h ├── include/ # Common includes and global definitions │ ├── globals.c │ ├── globals.h │ ├── maze.c │ ├── maze.h │ ├── notconio.c │ └── notconio.h ├── input/ # Functions for handling input │ ├── input.c │ └── input.h ├── logic/ # Game logic and mechanics │ ├── game_logic.c │ └── game_logic.h ├── screens/ # Title and game over screens │ ├── screens.c │ └── screens.h ├── main.c # Entry point of the program └── Makefile # Build system configuration

This structure divides the code into logical components, enhancing understandability, maintainability, and extensibility. Hopefully, this will also facilitate the addition of gamepad/joystick controls and graphics down the line.

Header (.h) vs Implementation (.c) Files

In C, code is generally separated into header files (.h) and implementation files (.c):

Previously, I relied on main.c with several .h files, but that approach becomes unsustainable when dealing with numerous shared variables and functions.

For instance, I used a function for displaying game over messages that required an include statement to be accessible to the compiler.

Header (Library) Files (.h)

Header files typically include:

  • Function declarations (prototypes)
  • Type definitions (structs, enums, typedefs)
  • Constant definitions
  • Macro definitions
  • Declarations for external variables

These act as an interface for a library's functionality.

For instance, in display.h:

// Function declarations void output_message(void); void draw_screen(void); void draw_momentary_object(unsigned int obj_old_x, unsigned int obj_old_y, unsigned int obj_x, unsigned int obj_y, unsigned int obj_tile, unsigned int delay);

Code (Implementation) Files (.c)

Implementation files contain:

  • The actual code implementing the declared functions
  • Definitions of static (file-scope) variables
  • Definitions of global variables

An example from display.c would be:

void draw_screen(void) { // Draw the whole screen int row, col; if (draw_whole_screen && screen_drawn == false) { for (row = 0; row < PLAYABLE_HEIGHT; row++) { for (col = 0; col < MAZE_WIDTH; col++) { cputcxy(col, row, get_map(col, row)); } } screen_drawn = true; } else { // Update the screen around the player update_fov(player_x, player_y, 2); } }

Why Bother? (Benefits of Separation)

While spaghetti code may seem convenient, it tends to break down over time. Adopting a modular approach proves beneficial in the long run:

  1. Encapsulation: Keeps implementation details hidden.
  2. Compilation Efficiency: Changes in implementation don’t necessitate recompiling all files that utilize a module.
  3. Clarity: Establishes a clear distinction between interface and implementation.
  4. Reduced Conflicts: Minimizes naming conflicts and unintended dependencies.
  5. Reuse: Promotes the principle of solving a problem once for all systems.

Global Variables and the extern Keyword

One challenge I faced was managing global variables due to the needs of older retro target systems. Global variables can be accessed throughout the program.

In a modular codebase, global variables must be:

  1. Defined in one specific .c file
  2. Declared with the extern keyword in a header file

Example

In globals.h, global variables are declared as follows:

// Game state variables extern bool run; extern bool in_play; extern bool obstruction; extern bool screen_drawn; extern bool draw_whole_screen; // Player variables extern unsigned char player_x; extern unsigned char player_y; // ... more variables ...

In globals.c, these variables are defined:

// Game state variables
bool run = true;
bool in_play = false;
bool obstruction = false;
bool screen_drawn = false;
bool draw_whole_screen = false;
// Player variables
unsigned char player_x = 19;
unsigned char player_y = 8;
// ... more variables ...
        

The extern Keyword

The extern keyword informs the compiler that "this variable is defined elsewhere." It signals that the linker will locate the actual definition in another compilation unit.

Benefits include:

  • Avoids multiple definitions of the same variable
  • Facilitates variable sharing across multiple files
  • Ensures variable initialization is sourced from a single point

Include Guards

Include guards prevent a header file from being included multiple times within the same compilation unit, which can lead to redefinition errors.

Example from globals.h:

ifndef GLOBALS_H

define GLOBALS_H

// Header content goes here...

endif / GLOBALS_H /

    

How it works:

  1. On the first inclusion, GLOBALS_H isn't defined, so the preprocessor includes the content.
  2. GLOBALS_H is now defined.
  3. On subsequent inclusions, GLOBALS_H is already defined, and the content is skipped.

Make Files (The Build System)

A Makefile is a script used by the make utility to compile the program. The contents of our Makefile are as follows:

Compiler definitions

CC = gcc CL65 = cl65 CFLAGS = -Wall -Wextra -g LDFLAGS = -lncurses

Common source files

COMMON_SRC = main.c \ include/globals.c \ include/maze.c \ display/display.c \ input/input.c \ logic/game_logic.c \ screens/screens.c

Desktop-specific source files

DESKTOP_SRC = $(COMMON_SRC) include/notconio.c

Object files for desktop

DESKTOP_OBJ = $(DESKTOP_SRC:.c=.o)

Target names

DESKTOP_TARGET = dungeonDesktop PET_TARGET = dungeonPET.prg C64_TARGET = dungeon64.prg

Default target - build all platforms

all: desktop pet c64

Desktop target using GCC

desktop: $(DESKTOP_OBJ) $(CC) $(CFLAGS) -o $(DESKTOP_TARGET) $^ $(LDFLAGS)

PET target using CL65

pet: $(COMMON_SRC) $(CL65) -t pet -v -Cl -O -DCC65_NO_RUNTIME_TYPE_CHECKS -o $(PET_TARGET) $(COMMON_SRC)

C64 target using CL65

c64: $(COMMON_SRC) $(CL65) -t c64 -v -Cl -O -DCC65_NO_RUNTIME_TYPE_CHECKS -o $(C64_TARGET) $(COMMON_SRC)

Object file rule for desktop builds only

$(DESKTOP_OBJ): %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(DESKTOP_OBJ) $(DESKTOP_TARGET) $(PET_TARGET) $(C64_TARGET) .PHONY: all clean desktop pet c64

Compilation Process

The compilation procedure involves multiple stages:

  1. Preprocessing: Expands macros and includes header files.
  2. Compilation: Converts C code to assembly language.
  3. Assembly: Transforms assembly code into machine code (object files).
  4. Linking: Merges object files and libraries to form an executable.

In our Makefile:

  • %.o: %.c handles the first three steps for each source file.
  • $(TARGET): $(OBJ) manages the linking stage.

Special Variables in Makefiles

  • $@: Represents the target of the rule (left side of the colon).
  • $^: All prerequisites (right side of the colon).
  • $: The first prerequisite.

Building the Project

To build the project:

To clean generated files:

New Code Organization

The codebase is now organized into logical components:

  1. Global Variables and Structs (include/globals.h, include/globals.c):

    • Contains all global variables used across the game.
    • Defines common structures, such as enemy structs.
  2. Display/Output (display/display.h, display/display.c):

    • Functions for rendering the game world.
    • Screen updates and message displays.
  3. Input/Controls (input/input.h, input/input.c):

    • Handles keyboard input.
    • Includes timing functions.
  4. Game Logic (logic/game_logic.h, logic/game_logic.c):

    • Core game mechanics.
    • Enemy AI and combat.
    • Map manipulation.
  5. Screens (screens/screens.h, screens/screens.c):

    • Title screen.
    • Game over screen.
  6. Maze Generation (include/maze.h, include/maze.c):

    • Procedural maze generation.
    • Object placement.
  7. Console I/O (include/notconio.h, include/notconio.c):

    • Platform-independent console functions.
    • Abstraction layer for terminal operations.

Dependencies Between Components

Components depend on one another, as shown by the #include statements:

  • Main relies on all components.
  • Display is dependent on Game Logic and Input.
  • Game Logic depends on Display, Input, and Maze components.
  • Input relies on Game Logic and Display.
  • Screens depend on both Display and Input.

This dependency graph clarifies component interactions and ensures proper initialization order.

Next Steps

The reorganized repository is now far more modular and maintainable, significantly improving clarity.

Each component has distinct responsibilities, with explicit dependencies among them.

This structure allows for easier feature expansions or bug fixes without impacting unrelated code segments. Additionally, I have a build system in place, enabling me to begin addressing dependencies across various target platforms.

Moving forward, I aim to develop the game to a point where I can gather early feedback from testers!


Original article by retrogamecoders.com

Main Menu