Making text adventures in C

2024-05-22

This article explains my history & process behind making text adventures. I apologize in advance for the various code dumps and drafty paragraphs, but I hope you'll enjoy it if you also make old-style interactive fiction.

A bit of background

I make games since middle school. My first games were, for the most part, Mario-style 2D platformers in Game Maker, but then in high school we had to get a programmable calculator. Not that it was impossible to make a full action game on it, but I just wasn't experienced enough to go that low-level.

So I made character-based games in Casio Basic.

Apart from a pong clone and a game where you shoot rising bubbles, most games I made on calculator were choice-based text adventures ("choose-your-own-adventure" games). It wasn't very elaborate or even logical, filled with absurd moon-logic humor, but at least it was a good start, and an entry point to games made in college where I had to learn...

The C programming language!

The next part will explain the basic structure of those games, in C.

State machine games

When I say "not very elaborate", I mean it. It was a series of rooms, and for each room, a list of choices that either:

If you treat "GAME OVER" as a special room, you basically have a state machine. This is what I made in Plouffe: The Game, a wacky tale featuring my wacky linear algebra teacher.

The main function is a bit like this (simplified, both in structure and narration):

typedef enum { GAMEOVER, DOOR, AMPHITHEATER, ... } Room;
Room room = DOOR;
puts("You are in front of a door.");

while (room != GAMEOVER) {
	switch (room) {
	case DOOR:
		puts("1 Open door");
		puts("2 Knock knock!");
		puts("3 Dance");
		// etc.

		switch (getchar()) {
		case '1':
			puts("You open the door and enter it.");
			puts("You are now in the amphitheater. Your students are here.");
			room = AMPHITHEATER; // go to next room
			break;
		case '2':
			puts("You knock on the door.");
			puts("The door doesn't answer.");
			// don't change rooms
			break;
		case '3':
			puts("You dance the macarena.");
			puts("Mesmerized students look at you through the windows.");
			puts("The dance gets faster and faster...");
			puts("You die, trampled by yourself.");
			room = GAMEOVER;
			break;
		}
		break;

	case AMPHITHEATER:
		puts("1 Teach very important things");
		puts("2 Ring bell");
		puts("3 Throw bell at random student");
		...
	}
}
puts("GAME OVER");

The structure is nested switches: a room switch and an input switch for each room.

Apart from small additions like wrapping getchar() into a nice function which displays "> " before and ignores newlines, that's it. That's the game's structure.

But as I went back to it after almost 6 years of making other types of games, I pondered: can I go further?

Adding items

I was tinkering with a hypertext version of Plouffe: The Game, and decided that our didactic protagonist needed some kind of "item" in some situations.

Here is what I did (translated in C for consistency):

switch (room) {
case DOOR_WITHOUT_PENCIL:
	puts("1 Try to open door");
	puts("2 Dance");

	switch (getchar()) {
	case '1':
		puts("The door is locked!");
		break;
	case '2':
		puts("You dance the macarena...");
		puts("...but you carelessly slip and you trip on the mat.");
		puts("You find a pencil that was under it! You take it.");
		room = DOOR_WITH_PENCIL;
		break;
	}
	break;

case DOOR_WITH_PENCIL:
	puts("1 Lockpick door with pencil");
	puts("2 Dance");
	puts("3 Invert the door's transform using Gauss method & pencil");

	switch (getchar()) {
	case '1':
		puts("You try to unlock the door, but your pencil breaks.");
		puts("Oh, GOOD JOB. That's just GREAT.");
		room = DOOR_WITHOUT_PENCIL;
		break;
	case '2':
		puts("You dance the flamenco, using the pencil as a matador sword,");
		puts("but nothing happens.");
		break;
	case '3':
		puts("You write a Gauss pivot on the door.");
		puts("It opens flawlessly.");
		room = AMPHITHEATER;
		break;
	}
	break;
	...
}

Essentially, one room per game state.

In the best case scenario, your game is small enough (and wacky enough) that there is no scaling problem or consistency problem. You write unique text for every situation, boosting your creativity, until you become the god of coincidental events and unlikely serendipities.

In the worst case scenario, you have scaling and consistency problems.

For example, what if you exit the amphitheater after you enter it? You had to unlock the door before, so do you automatically slam it so hard that it relocks itself?

That in itself would be a funny event, but what if you added a second path to (or out of) the amphitheater, for which you don't need a pencil? Does it mean that because of consistency, you can't use the pencil except in front of the door, even for critical things, like stabbing a dragon's eye, building a hammer, or writing equations?

Flags like "bool has_pencil = false;" is one approach to it, but unsurprisingly, it doesn't scale very well, and if you add the ability to drop the pencil somewhere, you have to store its location in a more capable way than just "in your inventory" or "not in your inventory".

It'd be nice if there was some kind of global item location system.

The item system

So I added an array of positions. At first I made each item a struct, with a name and a description and all, but I quickly found a "simpler" way.

You might notice that these games are 1-character parsers, and choices already use the digits (a remnant of my calculator days). So, how about using letters for items? That way, even with only lowercase, you have 26 items in the world, which is plenty.

By the way, if you have less than 48 characters (by "characters" I mean the hero and NPCs, not the glyphs), then it's safe to store them the same way as your items. I picked "48" because that's the ASCII code for the character 0 (zero). Characters are like characters, isn't that fun? Anyway.

As a result, one array is the ENTIRE game state:

enum { YOU }; // characters
typedef enum { NOWHERE, INVENTORY, DOOR, AMPHITHEATER, ... } Room;
Room where[128]; // where items and characters are
Room zero is "NOWHERE" instead of "GAMEOVER", but if your player character is still mortal, you can put GAMEOVER back in.
You can also put location-based flags in those slots. Hell, you can even add more meta-rooms like INVENTORY. I didn't try to fuse items and rooms such that you can have containers, but it would be easy to do in this system.

It's more complex than one room ID being the entire game state, but it's more scalable. Watch this:

// init
where[YOU] = DOOR; // you are in front of the door
where['p'] = DOOR; // pencil is in front of the door

while (...) {

switch (where[YOU]) {
case DOOR:
	puts("1 Try to open door");
	puts("2 Dance");
	if (where['p'] == INVENTORY) puts("p Use Gauss method");
	
	// in this specific room, the pencil is hidden under the mat.
	// in other rooms, we have to print (and handle) this:
	//if (where['p'] == where[YOU]) puts("p Take pencil");

	switch (getchar()) {
	case '1':
		if (where['p'] == INVENTORY) {
			puts("You try to unlock the door, but your pencil breaks.");
			where['p'] = NOWHERE;
			break;
		}
		puts("The door is locked!");
		break;
	case '2':
		puts("You dance the macarena...");
		if (where['p'] == where[YOU]) {
			puts("...but you carelessly slip and you trip on the mat.");
			puts("You find a pencil that was under it! You take it.");
			where['p'] = INVENTORY;
			break;
		}
		puts("...but nothing happens, except your otherwordly gracefulness.");
		break;
	case 'p':
		if (where['p'] == INVENTORY) {
			puts("You write a Gauss pivot on the door.");
			puts("It opens flawlessly.");
			room = AMPHITHEATER;
			break;
		}
	...

Wow! Item handling! So fancy.

But then, problems happen:

  1. Say, you have an orange (letter o). You can eat the orange anywhere. Good luck copy-pasting that action in each room switch.
    • It gets even worse with items you can drop and take anywhere, because now you have two actions to copy-paste. One for the room (TAKE), one for your inventory (USE/DROP).
  2. Because you associated an item with a letter, when there is no special action for it in the current room, the parser needs to handle it more gracefully than "I can't understand this character", at least when the item is on you or visible in the room.
  3. You either need an inventory command (letter i for example), which I think kinda breaks the flow of the game, or EVERY room will have to remind you through choices that you have a pencil in hand.
    • There's a lot of possibilities for that. I probably just skimmed the problem's surface.
  4. You can soft-lock the game if you Dance and Try to open door. Can be fixed easily, but because the game is more complex than a state machine you can't just draw a graph and notice it immediately.

So to address those points (except the last one), we have to think about...

Generic item actions

Those are actions that you can do in any room, sometimes overridden by specific actions.

Not tied to any particular item are the help (?) and quit (q) commands. Now there is a "chain of command" of sorts, and we run into continue statements when the command is handled. Here is the whole stuff:

char input;

do {

	switch (where[YOU]) {
	case DOOR:
		puts("1 First choice");
		puts("2 Second choice");
		if (where['o'] == where[YOU]) puts("o Take orange"); // generic
		if (where['o'] == INVENTORY) puts("o Eat orange"); // generic

		input = getchar();

		switch (input) {
			// handle door choices...
			// no need to handle the orange here :-)
			// IMPORTANT: when action is handled, continue instead of break
		}

		// HERE you break so that:
		// - you don't skip the generic handlers
		// - you don't break (heh) into other rooms
		break;
	// other rooms...
	}

	// Generic actions

	// Orange
	if (input == 'o') {
		if (where['o'] == where[YOU]) {
			puts("You firmly grasp the orange, gorged of sunlight,");
			puts("meticulously crafted by nature in its warmest months.");
			where['o'] = INVENTORY;
			continue;
		}
		if (where['o'] == INVENTORY) {
			puts("Omnomnomnom!");
			where['o'] = NOWHERE;
			continue;
		}
	}

	// HELP!
	if (input == '?') {
		puts("This game only reads the first character you type.");
		puts("Universal commands: ? (read this help), q (quit)");
	}
} while (input != 'q'); // QUIT!

Simple and elegant.

Except:

If only there were some cool and clever developers who thought about it in the past twenty years! That leads to...

IMGUI-style code

If you don't know, IMGUI (or Immediate-Mode Graphical User Interface) is a paradigm that, in short, puts interface layout into the code's structure. At least that's what is interesting about it here. For a more decent summary of the thing, please read a competent article instead of me.

For example, let's say there is an opt() function that not only prints the choice/option, but also returns whether that choice was selected. Something like:

// in the room switch, case DOOR:

if (opt("1 First choice")) {
	puts("You make the first choice.");
	continue;
}
if (where['p'] == INVENTORY && opt("p Use pencil to compute Gauss method")) {
	puts("The door opens. You're a clever little shit you know that?");
	where[YOU] = AMPHITHEATER;
	continue;
}

// far, far away, outside the room switch, a generic item action is handled:

if (where['p'] == INVENTORY && opt("p Write gibberish with pencil")) {
	puts("You invent a word, but apart from that nothing happens.");
	continue;
}
if (where['p'] == where[YOU] && opt("p Take pencil")) {
	puts("You feel the weight of your sins, I mean, of the pencil.");
	where['p'] = INVENTORY;
	continue;
}

That looks elegant, because now you print a choice and check for it in the same line of code.

Unfortunately, you can't display all the choices and check if they're selected in the same single pass, at least not in a command line interface. You have to use a trick, like perform two passes.

bool is_exec_pass = true;
char input;

bool opt(char* option) {
	if (is_exec_pass) {
		// check if this option was selected
		return input == option[0];
	} else {
		// only print it
		puts(option);
		return false;
	}
}

// in main:

do {
	is_exec_pass = !is_exec_pass;
	if (is_exec_pass) input = getchar();

	// here write the rest of the game

} while (input != 'q');

Cool! Your game works well.

Until you run it. It displays both the specific action and the generic action. So for the non-exec pass, you need to store the handled actions somewhere until it's time to print them.

Not only that, but your items won't be in alphabetical order anymore! Specific items will appear before generic items, and when you move between rooms, one of your items jumps across the list!

The solution? Store all these options in a big ol' global array.

Ironically, immediate-mode GUI (or in this case CLI, "IMCLI" anyone?) means that you have to retain more state than a naive implementation, at least when you implement it "properly" (i.e. to address paragraphs above).

So the final code is this, more or less:

unsigned char input;
bool is_exec_pass = true;
char* options[128] = { "" };

bool opt(char* option) {
	unsigned char letter = option[0];
	if (is_exec_pass) return input == letter; // actual check
	if (options[letter] != NULL) return false; // option was already overridden
	options[letter] = option; // ready for print
	return false;
}

enum { YOU }; // characters
typedef enum { NOWHERE, DOOR, AMPHITHEATER, INVENTORY } Room;
Room where[128];

int main() {
	where[YOU] = DOOR;
	where['o'] = DOOR;

	do {
		is_exec_pass = !is_exec_pass;
		if (is_exec_pass) {
			for (unsigned char i = 32; i < 128; i++) {
				if (options[i] == NULL) continue;
				printf("%s\n", options[i]);
				options[i] = NULL;
			}
			input = ask();
		}

		switch (where[YOU]) {
		case DOOR:
			if (opt("1 First choice")) {
				// etc
				continue;
			}
			if (opt("2 Second choice")) {
				// etc
				continue;
			}
			break;
		case AMPHITHEATER:
			// etc
			if (where['o'] == INVENTORY && opt("o Throw orange at student")) {
				puts("You fuckin KILL a student. What the fuck man??");
				puts("Anyway. The lecture resumes normally.");
				continue;
			}
			break;
		}

		// generic actions

		if (where['o'] == INVENTORY && opt("o Eat orange")) {
			puts("You eat the orange like a normal person.");
			puts("Thank god! If you were in the amphitheater,");
			puts("you would have done something truly terrible instead!");
			where['o'] = NOWHERE;
			continue;
		}
		if (where['o'] == where[YOU] && opt("o Take orange")) {
			puts("You take the orange. Yummy!");
			where['o'] = INVENTORY;
			return true;
		}
		// etc (other items)

		// universal commands are hidden,
		// so they still have to be handled the good ol' way
		if (is_exec_pass) {
			if (input == '?') {
				// help
				continue;
			}
			printf("What is '%c'? Type ? for help.\n", input);
		}
	} while (input != 'q');
	return 0;
}

So yeah, we went from a state machine... to this.

That's really cool, but at the same time if you like simplicity, right now you must feel the weight of all the sacrifices you had to make.

In conclusion: make whatever kind of game you want. I haven't even talked about attributes, which would be a great way to go further in code scalability, create emergent gameplay, and allow for many solutions to one problem. Beyond that is procedural generation, and language processing. Have fun!

Before we part, let's wear our UI designer hat for a bit.

Bonus: visual layout
Or the practical uses of subtractive printing characters

You might have noticed that your game constantly hugs the left side of your terminal when you run it, and that's not very elegant.

|You are in front of the door.
|By the way, left line is added for clarity.
|
|1 Open door
|2 Do something else
|3 Etc.
|
|> 1
|
|You try to open the door but it's locked.

One thing you can do is to add a tab (\t) at the start of every line.

|	You are in front of the door.
|	By the way, left line is added for clarity.
|
|	1 Open door
|	2 Do something else
|	3 Etc.
|
|	> 1
|
|	You try to open the door but it's locked.

Okay, cool, but your choices and the input prompt have a kind of structure. It'd be cool to align them with the rest of your text (here it is pictured with 8 spaces wide tabs):

|       You are in front of the door.
|       By the way, left line is added for clarity.
|
|     1 Open door
|     2 Do something else
|     3 Etc.
|
|     > 1
|
|       You try to open the door but it's locked.

If you use spaces to do that, you might run into issues if some terminals have variable tabs. You can use spaces everywhere, but that's a lot more typing depending on the width and also another debate.

Fortunately, one thing you can do in the terminal (but not everywhere else) is to print backspaces (\b), two in this case.

And voilà! Your prompts must now be aligned with your descriptions, like in the figure above. I can't really show it authentically in HTML so you have to trust me. You can also make pretty lists with that technique.

Another character like that is the carriage return (\r) which jumps to the start of the current line. It's handy for when you print many many choices, so that you can display them in multiple columns independently. Something like:

printf("\t\b\b"         "1 First column\r");
printf("\t\t\t\b\b"     "2 Second column\r");
printf("\t\t\t\t\t\b\b" "3 Third column\r");
printf("\n");
printf("\t\b\b"         "4 Col 4\r");
printf("\t\t\t\b\b"     "5 Col 5\r");
printf("\t\t\t\t\t\b\b" "6 Your final choice yeeha!\r");

will print the following (if tabs are 8 spaces wide):

      1 First column  2 Second column 3 Third column
      4 Col 4         5 Col 5         6 Your final choice yeeha!

Notice how you don't have to compute widths when you print options.

That is all. See ya in the next one!


© cagibidev 2024 - Other articles - Back to top