Introduction
In our previous lesson, we brought your game to life by implementing a complete, turn-based combat loop. You now have a dynamic system where a player and an enemy can exchange blows until one is defeated. However, if you look closely at the code we wrote, you'll see a lot of "magic numbers"—hardcoded values like 100 for health or 15 for attack power scattered throughout the program. While this works, it makes the code harder to read and a nightmare to rebalance.
This lesson tackles that problem head-on. We will explore the C preprocessor, a powerful tool that runs before the compiler. You will learn how to use the #define directive to replace these magic numbers with symbolic constants. By the end of this lesson, you will be able to refactor your game's parameters, making your code significantly more readable, maintainable, and easier to tune—a critical skill for any game developer.
The Problem with "Magic Numbers"
Imagine a complex game like the one in the screenshot below. It tracks player level, health, mana, attack, defense, experience points, and much more.

Now, imagine you want to rebalance your game. You decide the player's starting health is too high and want to change it from 100 to 80. If 100 is hardcoded in multiple places (e.g., for initial health, max health, and maybe in a healing potion's logic), you have to hunt down and change every single instance. If you miss one, you introduce subtle and frustrating bugs.
This is where symbolic constants come in. Instead of using the literal number 100, we can define a constant named PLAYER_INITIAL_HEALTH and use this name throughout our code. To change the value, we only need to modify it in one single location. This practice is fundamental to writing clean, professional code.
The C Preprocessor and #define
In C, the primary tool for creating such constants is a preprocessor directive called #define. The preprocessor is a tool that modifies your source code before it is passed to the compiler. A directive, which always starts with a # symbol, is a command for the preprocessor.
The video below provides a great introduction to defining constants and the two common approaches you'll see in C code.
Defining Constants. Should I use CONST or #DEFINE?
This video from Jacob Sorber's channel clearly explains what constants are and introduces the two main methods for defining them in C: #define and const.
First, watch the introduction from the beginning to understand the core concept of constants. Then, focus on the explanation of #define from how it works. The key takeaway is that #define performs a simple, literal text replacement before compilation.
The syntax is straightforward:#define IDENTIFIER_NAME value
By convention, these identifiers are written in ALL_CAPS to make it clear they are constants, not variables. Let's apply this to the combat code from our last lesson. Instead of:
// Before
Character hero = {"Hero", 100, 100, 15, 5, false};
Character goblin = {"Goblin", 50, 50, 10, 2, true};
We can write:
// After
#define HERO_HEALTH 100
#define HERO_ATTACK 15
#define HERO_DEFENSE 5
#define GOBLIN_HEALTH 50
#define GOBLIN_ATTACK 10
#define GOBLIN_DEFENSE 2
// ... in main()
Character hero = {"Hero", HERO_HEALTH, HERO_HEALTH, HERO_ATTACK, HERO_DEFENSE, false};
Character goblin = {"Goblin", GOBLIN_HEALTH, GOBLIN_HEALTH, GOBLIN_ATTACK, GOBLIN_DEFENSE, true};
The preprocessor will find every instance of HERO_HEALTH and replace it with 100 before the compiler even sees the code. The result is identical, but our source code is now far more descriptive and maintainable.
Why Not const? A Crucial Distinction in C
If you have experience with other languages, you might be asking, "Why not just use a constant variable, like const int HERO_HEALTH = 100;?" This is an excellent question, and the answer reveals a fundamental difference between C and many other languages (including C++).
In C, a const-qualified variable is not a true compile-time constant. It's a variable whose value cannot be changed at runtime. The compiler treats it as a read-only variable that occupies memory and has an address. A #define macro, on the other hand, is resolved to a literal value before compilation.
This distinction is critical in certain contexts. For example, when you declare a standard array (not a Variable Length Array, or VLA), its size must be a compile-time constant.
#define ARRAY_SIZE 10
const int const_array_size = 10;
int main() {
char good_array[ARRAY_SIZE]; // OK! Preprocessor changes this to char good_array[10];
// char bad_array[const_array_size]; // ERROR! 'const_array_size' is not a compile-time constant.
return 0;
}
The Stack Overflow discussion below is a classic resource on this topic and provides excellent, concise answers from experienced C programmers.
Why do most C developers use define instead of const?
This Stack Overflow thread provides deep insight into the technical reasons for preferring #define over const for creating symbolic constants in C.
Read the following three highly-rated answers to get a complete picture: Start with Bart van Ingen Schenau's answer, which states that const means read-only, not truly constant, and thus cannot be used for array sizes. Next, read Vovanium's answer, which explains the difference in terms of storage and optimization. This highlights that #define values are known to the compiler directly. Finally, read John Bode's answer, which clarifies that a const-qualified variable is not a constant expression and its value is established at run-time, not compile-time.
The key takeaway is that for defining true, compile-time constants for things like game parameters or array sizes, #define is the standard and often necessary tool in C.
Best Practices and Advanced Techniques
While #define is powerful, its simple text-replacement nature can lead to unexpected behavior if you're not careful.
Always Use Parentheses for Expressions
What if you wanted to define a constant based on an expression?#define MAX_ITEMS (INVENTORY_SIZE + BACKPACK_SIZE)
It is a crucial best practice to always enclose the value of a macro in parentheses if it's an expression. To see why, study the following resource.
Tutorials - C Preprocessor Tricks
This tutorial from cprogramming.com explains the basics of #define and highlights a common pitfall related to operator precedence.
Please read the section on "Constants," paying close attention to the example under the heading. It starts with the basic syntax and then demonstrates how omitting parentheses in a mathematical expression can lead to incorrect calculations due to the order of operations.
Defining Constants at Compile Time
One of the most powerful features of the preprocessor is that you can define macros from outside your source code, directly on the command line when you compile. This is done using the -D flag with GCC.
For example, instead of #define DEBUG_MODE 1 in your code, you could compile with:gcc -DDEBUG_MODE=1 my_game.c -o my_game
This allows you to create different builds of your program without changing a single line of code. You could have a "debug" build with extra logging, or an "easy mode" build with different game parameters. The next video demonstrates this technique.
Defining Constants. Should I use CONST or #DEFINE?
This segment from the same Jacob Sorber video shows how to use the -D compiler option to define macros externally.
Watch the section from defining macros outside source code. The presenter shows how to compile the same source code with different macro values to produce different program behaviors.
This is a professional technique used widely in software development, and we will build on it in our next lesson.
Refactoring Our Combat Code
Now, let's put everything together and refactor the combat loop from the previous lesson to use symbolic constants. The code below is functionally identical to what we had before, but all game parameters are now defined clearly at the top of the file. This makes reading, understanding, and, most importantly, balancing the game much easier.
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
// --- Game Parameters ---
#define MAX_NAME_LENGTH 50
// Player Stats
#define PLAYER_INITIAL_HEALTH 100
#define PLAYER_ATTACK 15
#define PLAYER_DEFENSE 5
// Enemy Stats
#define ENEMY_INITIAL_HEALTH 50
#define ENEMY_ATTACK 10
#define ENEMY_DEFENSE 2
// --- Data Structures ---
typedef struct {
char name[MAX_NAME_LENGTH];
int health;
int max_health;
int attack;
int defense;
bool autoPilot;
} Character;
// --- Function Prototypes ---
void display_status(const Character *c);
int fight_loop(Character *player, Character *enemy);
void swap_pointers(Character **a, Character **b);
// --- Main Program ---
int main() {
// Setup: Initialize our combatants using constants
Character hero = {"Hero", PLAYER_INITIAL_HEALTH, PLAYER_INITIAL_HEALTH, PLAYER_ATTACK, PLAYER_DEFENSE, false};
Character goblin = {"Goblin", ENEMY_INITIAL_HEALTH, ENEMY_INITIAL_HEALTH, ENEMY_ATTACK, ENEMY_DEFENSE, true};
printf("A wild %s appears!\n\n", goblin.name);
int result = fight_loop(&hero, &goblin);
printf("\n--- Battle Over ---\n");
if (result == 1) {
printf("%s is victorious!\n", hero.name);
} else {
printf("%s has been defeated.\n", hero.name);
}
return 0;
}
// --- Function Definitions ---
/**
* The main combat loop.
* Returns 1 if the player wins, 0 otherwise.
*/
int fight_loop(Character *player, Character *enemy) {
Character *attacker = player;
Character *target = enemy;
while (player->health > 0 && enemy->health > 0) {
printf("--- New Turn ---\n");
display_status(player);
display_status(enemy);
printf("\nIt is %s's turn.\n", attacker->name);
int choice = 1;
if (attacker->autoPilot == false) {
printf("Choose your action:\n");
printf(" 1) Attack\n");
printf(">> ");
char line[10];
fgets(line, sizeof(line), stdin);
sscanf(line, "%d", &choice);
}
switch (choice) {
case 1: {
int damage = attacker->attack - target->defense;
if (damage < 0) damage = 0;
printf("%s attacks %s for %d damage!\n", attacker->name, target->name, damage);
target->health -= damage;
break;
}
default:
printf("%s is confused and does nothing.\n", attacker->name);
break;
}
if (target->health <= 0) {
target->health = 0;
break;
}
printf("\n");
swap_pointers(&attacker, &target);
}
display_status(player);
display_status(enemy);
return player->health > 0;
}
// Displays the status of a single character
void display_status(const Character *c) {
printf("%s | Health: %d/%d\n", c->name, c->health, c->max_health);
}
// Swaps two Character pointers
void swap_pointers(Character **a, Character **b) {
Character *temp = *a;
*a = *b;
*b = temp;
}
I encourage you to compile and run this code. Try changing one of the #define values, like PLAYER_ATTACK to 25, and see how it immediately affects the game's balance.
Conclusion
Today, you've learned a fundamental technique for writing clean and maintainable C code. By replacing "magic numbers" with symbolic constants using #define, you've made your game project easier to read, debug, and—crucially for game development—easier to balance.
Key Takeaways:
- The C preprocessor is a tool that modifies source code before compilation.
- The
#definedirective performs a literal text substitution, creating symbolic constants that make code more readable and maintainable. - In C,
#defineis often required for values that must be known at compile-time (like non-VLA array sizes), asconst-qualified variables are not true compile-time constants. - It's a critical best practice to wrap expressions in
#definedirectives with parentheses to avoid operator precedence bugs. - You can define macros from the command line using the
-Dcompiler flag, enabling different build configurations from the same source code.
In our next lesson, we will continue our exploration of the preprocessor by learning about conditional compilation. You'll see how to use directives like #ifdef and #endif, combined with the -D flag, to include or exclude entire blocks of code—a perfect technique for creating a "debug mode" for your game.