Capstone: Text Adventure
Welcome to the final project. This is it.
Every lesson, from printf to pointers, from malloc to Makefiles, has been
leading to this. You are about to build a complete, interactive application
from the ground up. This project doesn’t introduce many new C features;
instead, it challenges you to synthesize everything you’ve already learned.
CONCEPTS YOU WILL USE IN THIS PROJECT:
- Foundational Logic: Variables,
if/else,for/whileloops, functions. - Data Structures:
structs to model the game world,enums for clarity. - Memory Management:
mallocandfreeto dynamically create the world. - Pointers: The entire game world is connected by POINTERS. This is the ultimate demonstration of their power.
- File I/O: The game world is loaded from an external data file (
world.map). - Parsing:
sscanfis used to parse the world data file. - Command-Line Arguments: The program takes the map file as an argument.
- External Libraries: We use the
ncurseslibrary for an advanced terminal UI. - Build Tooling: Lesson 31 introduced Makefiles, but this capstone stays in one source file so you can build it directly with a compiler command.
- Advanced Techniques: We’ll even use an array of structs with function pointers for a clean command handling system.
You are no longer a beginner. You are a C developer. Let’s build something awesome.
This function demonstrates dynamic allocation, file I/O, and robust parsing.
and calls it.
This is CRITICAL for preventing memory leaks. A C developer must always clean up their own mess.
Full Source
/**
* @file 35_capstone_awesome_text_adventure.c
* @brief Part 5, Lesson 35: Capstone - Awesome Text Adventure
* @author dunamismax
* @date 06-15-2025
*
* This file contains the source code for the final capstone lesson.
* It pulls together many earlier topics into a single ncurses-based
* text adventure that loads a map file at runtime.
*/
/*
* =====================================================================================
* | - LESSON START - |
* =====================================================================================
*
* Welcome to the final project. This is it.
*
* Every lesson, from `printf` to pointers, from `malloc` to Makefiles, has been
* leading to this. You are about to build a complete, interactive application
* from the ground up. This project doesn't introduce many new C features;
* instead, it challenges you to synthesize everything you've already learned.
*
* CONCEPTS YOU WILL USE IN THIS PROJECT:
* - Foundational Logic: Variables, `if`/`else`, `for`/`while` loops, functions.
* - Data Structures: `struct`s to model the game world, `enum`s for clarity.
* - Memory Management: `malloc` and `free` to dynamically create the world.
* - Pointers: The entire game world is connected by POINTERS. This is the ultimate
* demonstration of their power.
* - File I/O: The game world is loaded from an external data file (`world.map`).
* - Parsing: `sscanf` is used to parse the world data file.
* - Command-Line Arguments: The program takes the map file as an argument.
* - External Libraries: We use the `ncurses` library for an advanced terminal UI.
* - Build Tooling: Lesson 31 introduced Makefiles, but this capstone stays in
* one source file so you can build it directly with a compiler command.
* - Advanced Techniques: We'll even use an array of structs with function
* pointers for a clean command handling system.
*
* You are no longer a beginner. You are a C developer. Let's build something awesome.
*/
// --- Standard and External Library Includes ---
#include <ctype.h> // For tolower()
#include <errno.h> // For robust numeric parsing
#include <limits.h> // For INT_MIN and INT_MAX
#include <ncurses.h> // For the advanced Terminal User Interface (TUI)
#include <stdio.h>
#include <stdlib.h> // For malloc, free, exit
#include <string.h> // For string manipulation functions
// --- Game Constants and Enums ---
#define MAX_DIRECTIONS 4
#define MAX_ROOMS 100
#define MAX_LOG_MESSAGES 10
#define INPUT_BUFFER_SIZE 100
#define ROOM_DESCRIPTION_SIZE 512
#define WORLD_LINE_BUFFER_SIZE 1024
// Using an enum makes direction-related code much more readable and safe.
typedef enum
{
NORTH,
SOUTH,
EAST,
WEST
} Direction;
// --- Core Data Structures ---
// These structs are the blueprint for our entire game world.
// Forward declaration is needed because GameState and Room reference each other.
struct GameState;
typedef struct Room
{
int id;
char description[ROOM_DESCRIPTION_SIZE];
// This is the key: an array of POINTERS to other Room structs.
// This is how we form the "graph" of our world map.
struct Room *exits[MAX_DIRECTIONS];
} Room;
typedef struct Player
{
Room *current_room; // A pointer to the room the player is currently in.
} Player;
typedef struct
{
char *command;
// A function pointer! This lets us create a clean, data-driven command system.
void (*handler)(struct GameState *game, char *argument);
} Command;
// The main struct to hold the entire state of our running game.
typedef struct GameState
{
Player player;
Room *all_rooms[MAX_ROOMS];
int num_rooms;
int game_should_close;
// For the ncurses UI
WINDOW *main_win;
WINDOW *status_win;
WINDOW *input_win;
char *log_messages[MAX_LOG_MESSAGES];
int log_count;
} GameState;
// --- Function Prototypes ---
// Grouping prototypes makes the code easier to navigate.
// Game Lifecycle
void game_loop(GameState *game);
void cleanup(GameState *game);
// World Loading & Parsing
GameState *load_world(const char *filename);
int parse_room(char *line, GameState *game);
int parse_link(char *line, GameState *game);
Room *find_room_by_id(GameState *game, int id);
const char *direction_to_string(Direction d);
int read_world_line(FILE *file, char *buffer, size_t size);
char *skip_whitespace(char *text);
int parse_int_token(char **cursor, int *value);
int has_only_trailing_whitespace(char *text);
char *duplicate_string(const char *text);
// Command Handling
void parse_and_execute_command(GameState *game, char *input);
void handle_quit(GameState *game, char *argument);
void handle_look(GameState *game, char *argument);
void handle_go(GameState *game, char *argument);
void handle_help(GameState *game, char *argument);
// UI Functions (ncurses)
void ui_init(GameState *game);
void ui_draw(GameState *game);
void ui_get_input(GameState *game, char *buffer);
void ui_log(GameState *game, const char *message);
void ui_cleanup(GameState *game);
// --- Main Program Entry ---
int read_world_line(FILE *file, char *buffer, size_t size)
{
size_t length;
if (fgets(buffer, size, file) == NULL)
{
return 0;
}
length = strcspn(buffer, "\n");
if (buffer[length] == '\n')
{
buffer[length] = '\0';
return 1;
}
if (!feof(file))
{
int ch;
while ((ch = fgetc(file)) != '\n' && ch != EOF)
{
// Discard the rest of an overlong line so the next read starts cleanly.
}
buffer[0] = '\0';
return -1;
}
return 1;
}
char *skip_whitespace(char *text)
{
while (isspace((unsigned char)*text))
{
text++;
}
return text;
}
int parse_int_token(char **cursor, int *value)
{
char *endptr;
long parsed;
*cursor = skip_whitespace(*cursor);
if (**cursor == '\0')
{
return 0;
}
errno = 0;
parsed = strtol(*cursor, &endptr, 10);
if (errno != 0 || endptr == *cursor || parsed < INT_MIN || parsed > INT_MAX)
{
return 0;
}
*value = (int)parsed;
*cursor = endptr;
return 1;
}
int has_only_trailing_whitespace(char *text)
{
text = skip_whitespace(text);
return *text == '\0';
}
char *duplicate_string(const char *text)
{
size_t length = strlen(text) + 1;
char *copy = malloc(length);
if (!copy)
{
return NULL;
}
memcpy(copy, text, length);
return copy;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
// We use fprintf to stderr for error messages.
fprintf(stderr, "Usage: %s <world_map_file>\n", argv[0]);
return 1;
}
// --- Initialization ---
// Load the world from the file specified on the command line.
GameState *game = load_world(argv[1]);
if (game == NULL)
{
// load_world will have already printed an error.
return 1;
}
ui_init(game); // Initialize the ncurses interface
// Set the player's starting location.
game->player.current_room = find_room_by_id(game, 0);
if (game->player.current_room == NULL)
{
ui_cleanup(game);
cleanup(game);
fprintf(stderr, "Error: Map has no starting room (ID 0).\n");
return 1;
}
// --- Main Game Loop ---
ui_log(game, "Welcome to the Awesome Text Adventure! Type 'help' for commands.");
handle_look(game, NULL); // Give the player their initial view.
game_loop(game);
// --- Cleanup ---
// This is crucial for both ncurses and memory management.
ui_cleanup(game);
cleanup(game);
printf("Thank you for playing!\n");
return 0;
}
// =====================================================================================
// | - WORLD LOADING & PARSING - |
// =====================================================================================
/**
* @brief Loads the entire game world from a data file.
* This function demonstrates dynamic allocation, file I/O, and robust parsing.
*/
GameState *load_world(const char *filename)
{
// Dynamically allocate the main GameState struct.
GameState *game = malloc(sizeof(GameState));
int line_number = 0;
int line_status;
if (!game)
return NULL;
// ALWAYS initialize allocated memory.
memset(game, 0, sizeof(GameState));
FILE *file = fopen(filename, "r");
if (!file)
{
perror("Error opening world file");
free(game);
return NULL;
}
char line[WORLD_LINE_BUFFER_SIZE];
// --- TWO-PASS LOADING ---
// This is a common and robust technique for loading interconnected data.
// Pass 1: Read all room definitions and allocate memory for them.
while ((line_status = read_world_line(file, line, sizeof(line))) != 0)
{
line_number++;
if (line_status < 0)
{
fprintf(stderr, "Error: world file line %d is too long.\n", line_number);
fclose(file);
cleanup(game);
return NULL;
}
if (strncmp(line, "room", 4) == 0)
{
if (!parse_room(line, game))
{
fprintf(stderr, "Error parsing room definition on line %d.\n", line_number);
fclose(file);
cleanup(game);
return NULL;
}
}
}
// Rewind the file pointer to the beginning for the second pass.
rewind(file);
line_number = 0;
// Pass 2: Read all link definitions and connect the already-created rooms.
while ((line_status = read_world_line(file, line, sizeof(line))) != 0)
{
line_number++;
if (line_status < 0)
{
fprintf(stderr, "Error: world file line %d is too long.\n", line_number);
fclose(file);
cleanup(game);
return NULL;
}
if (strncmp(line, "link", 4) == 0)
{
if (!parse_link(line, game))
{
fprintf(stderr, "Error parsing link definition on line %d.\n", line_number);
fclose(file);
cleanup(game);
return NULL;
}
}
}
fclose(file);
return game;
}
/**
* @brief Parses a 'room' line and creates a Room struct.
*/
int parse_room(char *line, GameState *game)
{
char *cursor = line;
if (game->num_rooms >= MAX_ROOMS)
return 0; // Guard against overflow.
int id;
char desc[ROOM_DESCRIPTION_SIZE];
size_t desc_length = 0;
if (strncmp(cursor, "room", 4) != 0 || !isspace((unsigned char)cursor[4]))
{
return 0;
}
cursor += 4;
if (!parse_int_token(&cursor, &id))
{
return 0;
}
cursor = skip_whitespace(cursor);
if (*cursor != '"')
{
return 0;
}
cursor++;
while (*cursor != '"' && *cursor != '\0')
{
if (desc_length >= sizeof(desc) - 1)
{
return 0;
}
desc[desc_length++] = *cursor;
cursor++;
}
if (*cursor != '"')
{
return 0;
}
desc[desc_length] = '\0';
cursor++;
if (!has_only_trailing_whitespace(cursor) || find_room_by_id(game, id) != NULL)
{
return 0;
}
Room *new_room = malloc(sizeof(Room));
if (!new_room)
return 0;
memset(new_room, 0, sizeof(Room)); // Initialize room exits to NULL.
new_room->id = id;
memcpy(new_room->description, desc, desc_length + 1);
game->all_rooms[game->num_rooms++] = new_room;
return 1;
}
/**
* @brief Parses a 'link' line and connects two existing rooms.
*/
int parse_link(char *line, GameState *game)
{
char *cursor = line;
int from_id, to_id;
char dir_str[10];
size_t dir_length = 0;
if (strncmp(cursor, "link", 4) != 0 || !isspace((unsigned char)cursor[4]))
{
return 0;
}
cursor += 4;
if (!parse_int_token(&cursor, &from_id))
{
return 0;
}
cursor = skip_whitespace(cursor);
if (*cursor == '\0')
{
return 0;
}
while (*cursor != '\0' && !isspace((unsigned char)*cursor))
{
if (dir_length >= sizeof(dir_str) - 1)
{
return 0;
}
dir_str[dir_length++] = *cursor;
cursor++;
}
dir_str[dir_length] = '\0';
if (dir_length == 0 || !parse_int_token(&cursor, &to_id) || !has_only_trailing_whitespace(cursor))
{
return 0;
}
Room *from_room = find_room_by_id(game, from_id);
Room *to_room = find_room_by_id(game, to_id);
if (!from_room || !to_room)
return 0; // One of the rooms doesn't exist.
Direction dir;
if (strcmp(dir_str, "n") == 0)
dir = NORTH;
else if (strcmp(dir_str, "s") == 0)
dir = SOUTH;
else if (strcmp(dir_str, "e") == 0)
dir = EAST;
else if (strcmp(dir_str, "w") == 0)
dir = WEST;
else
return 0; // Invalid direction.
// This is the magic: we store a POINTER to the 'to' room in the 'from' room's exit array.
from_room->exits[dir] = to_room;
return 1;
}
/**
* @brief A helper function to find a room pointer from its integer ID.
*/
Room *find_room_by_id(GameState *game, int id)
{
for (int i = 0; i < game->num_rooms; i++)
{
if (game->all_rooms[i]->id == id)
{
return game->all_rooms[i];
}
}
return NULL; // Not found
}
const char *direction_to_string(Direction d)
{
switch (d)
{
case NORTH:
return "north";
case SOUTH:
return "south";
case EAST:
return "east";
case WEST:
return "west";
}
return "unknown";
}
// =====================================================================================
// | - COMMAND HANDLING - |
// =====================================================================================
// This array of structs is our command table. It pairs a command string with
// the function pointer that handles it. This is a very clean, scalable design.
Command command_table[] = {
{"quit", handle_quit},
{"exit", handle_quit},
{"look", handle_look},
{"l", handle_look},
{"go", handle_go},
{"north", handle_go},
{"south", handle_go},
{"east", handle_go},
{"west", handle_go},
{"n", handle_go},
{"s", handle_go},
{"e", handle_go},
{"w", handle_go},
{"help", handle_help},
{NULL, NULL} // Sentinel to mark the end of the array.
};
/**
* @brief Processes the user's raw input string, finds the correct command handler,
* and calls it.
*/
void parse_and_execute_command(GameState *game, char *input)
{
char *verb, *argument;
// Convert input to lowercase for case-insensitive matching.
for (int i = 0; input[i]; i++)
{
input[i] = (char)tolower((unsigned char)input[i]);
}
verb = strtok(input, " \n"); // Tokenize the string by space or newline.
if (!verb)
return; // Empty input
argument = strtok(NULL, " \n");
// Handle single-word movement commands like "north"
if (strcmp(verb, "n") == 0 || strcmp(verb, "north") == 0 ||
strcmp(verb, "s") == 0 || strcmp(verb, "south") == 0 ||
strcmp(verb, "e") == 0 || strcmp(verb, "east") == 0 ||
strcmp(verb, "w") == 0 || strcmp(verb, "west") == 0)
{
argument = verb; // The argument to "go" is the verb itself.
verb = "go";
}
for (int i = 0; command_table[i].command != NULL; i++)
{
if (strcmp(verb, command_table[i].command) == 0)
{
// We found a match! Call the associated function pointer.
command_table[i].handler(game, argument);
return;
}
}
ui_log(game, "I don't understand that command.");
}
void handle_quit(GameState *game, char *argument)
{
(void)argument;
game->game_should_close = 1;
}
void handle_look(GameState *game, char *argument)
{
(void)argument;
Room *room = game->player.current_room;
ui_log(game, room->description);
char exits_str[100] = "Exits: ";
int found_exit = 0;
for (int i = 0; i < MAX_DIRECTIONS; i++)
{
if (room->exits[i])
{
strcat(exits_str, direction_to_string(i));
strcat(exits_str, " ");
found_exit = 1;
}
}
if (found_exit)
{
ui_log(game, exits_str);
}
else
{
ui_log(game, "There are no obvious exits.");
}
}
void handle_go(GameState *game, char *argument)
{
if (!argument)
{
ui_log(game, "Go where?");
return;
}
int dir = -1;
if (strcmp(argument, "n") == 0 || strcmp(argument, "north") == 0)
dir = NORTH;
else if (strcmp(argument, "s") == 0 || strcmp(argument, "south") == 0)
dir = SOUTH;
else if (strcmp(argument, "e") == 0 || strcmp(argument, "east") == 0)
dir = EAST;
else if (strcmp(argument, "w") == 0 || strcmp(argument, "west") == 0)
dir = WEST;
if (dir == -1)
{
ui_log(game, "That's not a valid direction.");
return;
}
Room *next_room = game->player.current_room->exits[dir];
if (next_room)
{
game->player.current_room = next_room;
handle_look(game, NULL); // Automatically look after moving.
}
else
{
ui_log(game, "You can't go that way.");
}
}
void handle_help(GameState *game, char *argument)
{
(void)argument;
ui_log(game, "--- Available Commands ---");
ui_log(game, "look (l): Describe the current room and exits.");
ui_log(game, "go <dir>: Move in a direction (north, south, east, west, or n,s,e,w).");
ui_log(game, "help: Show this help message.");
ui_log(game, "quit/exit: Leave the game.");
}
// =====================================================================================
// | - NCURSES UI CODE - |
// =====================================================================================
void ui_init(GameState *game)
{
initscr();
cbreak();
noecho();
keypad(stdscr, TRUE);
int height, width;
getmaxyx(stdscr, height, width);
// Create a main window for game text, a status window, and an input window.
game->main_win = newwin(height - 5, width, 0, 0);
game->status_win = newwin(3, width, height - 5, 0);
game->input_win = newwin(2, width, height - 2, 0);
scrollok(game->main_win, TRUE); // Allow the main window to scroll.
wborder(game->status_win, 0, 0, 0, 0, 0, 0, 0, 0);
wborder(game->input_win, ' ', ' ', 0, 0, '>', '<', 0, 0);
refresh();
}
void ui_draw(GameState *game)
{
// Draw status window
box(game->status_win, 0, 0);
mvwprintw(game->status_win, 1, 2, "Location: Room %d", game->player.current_room->id);
// Draw input window
box(game->input_win, 0, 0);
mvwprintw(game->input_win, 1, 2, "> ");
// Draw main window log messages
werase(game->main_win);
for (int i = 0; i < game->log_count; i++)
{
mvwprintw(game->main_win, i + 1, 2, "%s", game->log_messages[i]);
}
// Refresh all windows to show changes
wrefresh(game->main_win);
wrefresh(game->status_win);
wrefresh(game->input_win);
}
void ui_log(GameState *game, const char *message)
{
char *message_copy = duplicate_string(message);
if (!message_copy)
{
return;
}
// If the log is full, shift all messages up.
if (game->log_count >= MAX_LOG_MESSAGES)
{
free(game->log_messages[0]);
for (int i = 0; i < MAX_LOG_MESSAGES - 1; i++)
{
game->log_messages[i] = game->log_messages[i + 1];
}
game->log_count--;
}
game->log_messages[game->log_count++] = message_copy;
}
void ui_get_input(GameState *game, char *buffer)
{
// Move cursor to input window and get string from user.
wmove(game->input_win, 1, 4);
wgetnstr(game->input_win, buffer, INPUT_BUFFER_SIZE - 1);
}
void ui_cleanup(GameState *game)
{
delwin(game->main_win);
delwin(game->status_win);
delwin(game->input_win);
endwin(); // Restore terminal to normal state. CRITICAL.
}
// =====================================================================================
// | - GAME LIFECYCLE - |
// =====================================================================================
void game_loop(GameState *game)
{
char input_buffer[INPUT_BUFFER_SIZE];
while (!game->game_should_close)
{
ui_draw(game);
ui_get_input(game, input_buffer);
parse_and_execute_command(game, input_buffer);
}
}
/**
* @brief Frees all dynamically allocated memory.
* This is CRITICAL for preventing memory leaks. A C developer must always
* clean up their own mess.
*/
void cleanup(GameState *game)
{
// Free all the room structs
for (int i = 0; i < game->num_rooms; i++)
{
free(game->all_rooms[i]);
}
// Free all the log message strings
for (int i = 0; i < game->log_count; i++)
{
free(game->log_messages[i]);
}
// Finally, free the main GameState struct itself.
free(game);
}
/*
* =====================================================================================
* | - LESSON END - |
* =====================================================================================
*
* HOW TO COMPILE AND RUN THIS PROJECT:
*
* This lesson is still a single-source-file program. You can compile it with
* one command, then point it at any compatible map file you create. Because it
* depends on `ncurses`, it is aimed at Unix-like environments unless you install
* a compatible curses implementation for your platform.
*
* 1. CREATE THE DATA FILE:
* In the same directory, create a file named `world.map`. This file defines
* the rooms and their connections. Copy this content into it:
*
* room 0 "You are in a dusty, forgotten library. Ancient tomes line the walls. A grand wooden door stands to the north."
* room 1 "You've entered a vast, cold cavern. The sound of dripping water echoes around you. A narrow passage leads east."
* room 2 "This is a small, cramped tunnel. It smells of damp earth. The path continues west, and you see a faint light to the south."
* room 3 "You emerge into a treasure room! Piles of gold and jewels glitter in the torchlight. A heavy stone door is to the north."
*
* link 0 n 1
* link 1 s 0
* link 1 e 2
* link 2 w 1
* link 2 s 3
* link 3 n 2
*
* 2. COMPILE THE PROGRAM:
* Open a terminal in the directory containing this file and link `ncurses`:
*
* `cc -Wall -Wextra -std=c11 -o 35_capstone_awesome_text_adventure 35_capstone_awesome_text_adventure.c -lncurses`
*
* 3. RUN THE GAME:
* Execute the program, passing the map file as a command-line argument:
*
* `./35_capstone_awesome_text_adventure world.map`
*
* Your terminal will transform, and the game will begin.
*/
How to Compile and Run
cc -Wall -Wextra -std=c11 -o 35_capstone_awesome_text_adventure 35_capstone_awesome_text_adventure.c -lncurses
./35_capstone_awesome_text_adventure world.map