Simple tower defense tutorial, part 19: Writing a parser 1/2

I want to prepare the game for setting up levels (spawn points, enemy types, etc) so it can be dynamically loaded. The fuzzy plan is to be able to set up levels within the game and then exporting them to a file or clipboard. Being able to define the levels in game will allow us to test them easily and make changes without having to recompile the game.

The content of this and the next few parts will be relatively dry and technical (I am sorry about that) until we are able to load the levels from a file and see them in action.

Current data sources

The level setup is currently defined in the Level levels[] array. The structure looks like this:

  1 Level levels[] = {
  2   [0] = {
  3     .initialGold = 500,
  4     .waves[0] = {
  5       .enemyType = ENEMY_TYPE_SHIELD,
  6       .wave = 0,
  7       .count = 1,
  8       .interval = 2.5f,
  9       .delay = 1.0f,
 10       .spawnPosition = {2, 6},
 11     },
 12     .waves[1] = {
 13       .enemyType = ENEMY_TYPE_RUNNER,
 14       .wave = 0,
 15       .count = 5,
 16       .interval = 0.5f,
 17       .delay = 1.0f,
 18       .spawnPosition = {-2, 6},
 19     },
 20     ...
 21   },
 22 };

Another data source is the EnemyClassConfig enemyClassConfigs[] array which defines the enemy properties:

  1 EnemyClassConfig enemyClassConfigs[] = {
  2     [ENEMY_TYPE_MINION] = {
  3       .health = 10.0f, 
  4       .speed = 0.6f, 
  5       .radius = 0.25f, 
  6       .maxAcceleration = 1.0f,
  7       .explosionDamage = 1.0f,
  8       .requiredContactTime = 0.5f,
  9       .explosionRange = 1.0f,
 10       .explosionPushbackPower = 0.25f,
 11       .goldValue = 1,
 12     },
 13     ...
 14 };

And yet another data source is the TowerTypeConfig towerTypeConfigs[] array which defines the tower properties:

  1 static TowerTypeConfig towerTypeConfigs[TOWER_TYPE_COUNT] = {
  2     [TOWER_TYPE_BASE] = {
  3         .name = "Castle",
  4         .maxHealth = 10,
  5     },
  6     [TOWER_TYPE_ARCHER] = {
  7         .name = "Archer",
  8         .cooldown = 0.5f,
  9         .maxUpgradeCooldown = 0.25f,
 10         .range = 3.0f,
 11         .maxUpgradeRange = 5.0f,
 12         .cost = 6,
 13         .maxHealth = 10,
 14         .projectileSpeed = 4.0f,
 15         .projectileType = PROJECTILE_TYPE_ARROW,
 16         .hitEffect = {
 17           .damage = 3.0f,
 18           .maxUpgradeDamage = 6.0f,
 19         },
 20     },
 21     ...
 22 };

The plan is to move all this data into a single file that can be loaded and updated at runtime.

Being able to reload this configuration data at runtime will us allow us to edit the game's properties without creating an editor - instead, any text editor will become the game editor.

As for the format, a typical choice would be JSON, but I want to show how to create custom text based file formats. The reason is that for JSON, we would need to include a JSON parser in the game and furthermore, creating a simple format that fits the game's needs is not only a good exercise but it is also not that difficult. Another reason is that while JSON content validation is not too difficult, pointing the user to the location WHERE the content is invalid is only possible if the JSON parser provides line number information for each value (which most parsers don't).

How would the format look like? The easiest approach is to write down the data first into a file and see how it could be represented. For example:

-- this is a comment
Level 0
  initialGold: 500
Wave 
  enemyType: ENEMY_TYPE_SHIELD
  wave: 0
  count: 1
  interval: 2.5
  delay: 1.0
  spawnPosition: 2 6
Wave
  enemyType: ENEMY_TYPE_RUNNER
  wave: 0
  count: 5
  interval: 0.5
  delay: 1.0
  spawnPosition: -2 6

EnemyClassConfig ENEMY_TYPE_MINION
  health: 10.0
  speed: 0.6
  radius: 0.25
  maxAcceleration: 1.0
  explosionDamage: 1.0
  requiredContactTime: 0.5
  explosionRange: 1.0
  explosionPushbackPower: 0.25
  goldValue: 1

TowerTypeConfig TOWER_TYPE_BASE
  maxHealth: 10

TowerTypeConfig TOWER_TYPE_ARCHER
  cost: 6
  cooldown: 0.5
  maxUpgradeCooldown: 0.25
  range: 3.0
  maxUpgradeRange: 5.0
  damage: 3.0
  maxUpgradeDamage: 6.0
  maxHealth: 10
  projectileSpeed: 4.0
  projectileType: PROJECTILE_TYPE_ARROW

It isn't difficult to see that the format is pretty much the same as the C struct definitions minus the various extra syntax. The spaces for indentation and the empty lines are optional but it helps humans reading the file.

The lines without a colon are considered to be the start of a new section. Depending on the section, the data is stored in a different struct. Each line is only allowed to have one key and one value. Linebreaks are not allowed within a key or value.

But if we are a bit more lenient, we could also say that the file is just a sequence of key-value pairs. We could ignore the colons and line breaks and we could still parse the file. This simplification will make it easier to write the parser.

The parser should provide proper errors when unidentified keys are encountered or required keys are missing. For example, not fully defining a Wave struct should result in an error message including the line number and the missing key. This is the advantage over JSON that we want to achieve here.

If it works out, we will be able to balance the game without recompiling it and I want to make this even work in the web version of the game.

Writing a parser

Dealing with strings in C is not so straight forward as in other languages. But our input file is pure ASCII (and we should not add texts that are displayed to the player). That helps a lot. So for this tutorial, let's start fresh with a relatively simple file and build up from there. Let's start with a classic:

  1 #include <stdio.h>
  2 
  3 int main()
  4 {
  5   printf("Hello, World!");
  6   return 0;
  7 }
Hello, World!

So we see the program and the output when running it. The next step is to read a file.

  1 #include <raylib.h>
  2 #include <stdio.h>
  3 
  4 int main()
  5 {
  6   char *fileContent = LoadFileText("data/level.txt");
  7   if (fileContent == NULL)
  8   {
  9     printf("Failed to load file\n");
 10     return 1;
 11   }
 12   printf("File content: %s\n", fileContent);
 13   UnloadFileText(fileContent);
 14   return 0; 
 15 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
File content: -- this is a comment
Level 0
  initialGold: 500
Wave 
  enemyType: ENEMY_TYPE_SHIELD
  wave: 0
  count: 1
  interval: 2.5
  delay: 1.0
  spawnPosition: 2 6
Wave
  enemyType: ENEMY_TYPE_RUNNER
  wave: 0
  count: 5
  interval: 0.5
  delay: 1.0
  spawnPosition: -2 6

EnemyClassConfig ENEMY_TYPE_MINION
  health: 10.0
  speed: 0.6
  radius: 0.25
  maxAcceleration: 1.0
  explosionDamage: 1.0
  requiredContactTime: 0.5
  explosionRange: 1.0
  explosionPushbackPower: 0.25
  goldValue: 1

TowerTypeConfig TOWER_TYPE_BASE
  maxHealth: 10

TowerTypeConfig TOWER_TYPE_ARCHER
  cost: 6
  cooldown: 0.5
  maxUpgradeCooldown: 0.25
  range: 3.0
  maxUpgradeRange: 5.0
  damage: 3.0
  maxUpgradeDamage: 6.0
  maxHealth: 10
  projectileSpeed: 4.0
  projectileType: PROJECTILE_TYPE_ARROW

Even without initializing raylib, we can still use the LoadFileText and UnloadFileText functions, which is lucky for us. For many functions in raylib, this is not the case! So be careful when using raylib functions in a program that doesn't call raylib's InitWindow function.

Strings in C are typically null terminated. This means that the end of the string is marked by a 0 byte. It is a very simple concept that has some flaws but we can work with it. Not terminating a string properly can lead to buffer overflows and other nasty bugs since arrays in C are just simple pointers to a memory location without any information how long the array is. Reading or writing beyond the array's bounds is a common source of security vulnerabilities.

Now that we have the file content, we can start thinking about how to parse it. Let's just read the first line and print it out to get a feeling how this could work:

  1 #include <raylib.h>
  2 #include <stdio.h>
  3 
  4 int main()
  5 {
  6   char *fileContent = LoadFileText("data/level.txt");
  7   if (fileContent == NULL)
  8   {
  9     printf("Failed to load file\n");
 10     return 1;
 11   }
 12 
 13   printf("File content length: %d\n", TextLength(fileContent));
 14 
 15   char buffer[256] = {0};
 16   // reading in a loop until we hit a line break or the end of the file or the buffer limit
 17   for (int i = 0; fileContent[i] != 0 && i < 255; i++)
 18   {
 19     buffer[i] = fileContent[i];
 20     if (buffer[i] == '\n')
 21     {
 22       // found line break, stop reading and terminate buffer string with 0
 23       buffer[i] = 0;
 24       break;
 25     }
 26   }
 27 
 28   printf("First line:\n%s\nEOL", buffer);
 29   
 30   UnloadFileText(fileContent);
 31   return 0; 
 32 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
File content length: 774
First line:
-- this is a comment
EOL

We use a buffer of 256 bytes to read the first line. We stop reading when we encounter a newline or the end of the file or when we reach the buffer limit minus 1. The reason here is that we HAVE to add a null terminator at the end of the string. Not doing so will lead to undefined behavior (means: likely a crash. When lucky.).

Make sure that you understand every line of code above - this kind of code is what is going to be used in the parser to great extent. Overlooking details here leads to bugs that are hard to find. String operations are notoriously error prone in C and the source of many security vulnerabilities. I made plenty of mistakes when just writing this simple code above alone.

Once all is clear, we can modify the code a little to shorten it a little:

  1 #include <raylib.h>
  2 #include <stdio.h>
  3 
  4 int main()
  5 {
  6   char *fileContent = LoadFileText("data/level.txt");
  7   if (fileContent == NULL)
  8   {
  9     printf("Failed to load file\n");
 10     return 1;
 11   }
 12 
 13   printf("File content length: %d\n", TextLength(fileContent));
 14 
 15   char buffer[256] = {0};
 16   // reading in a loop until we hit a line break or the end of the file or the buffer limit
 17   for (int i = 0; fileContent[i] != 0 && fileContent[i] != '\n' && i < 255; i++)
 18   {
 19     buffer[i] = fileContent[i];
 20   }
 21 
 22   printf("First line:\n%s\nEOL", buffer);
 23   
 24   UnloadFileText(fileContent);
 25   return 0; 
 26 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
File content length: 774
First line:
-- this is a comment
EOL

Initializing the buffer with 0 is a good practice and allows us to stop copying the string when we hit the end of the file or the buffer limit or a newline. This way, the loop condition is the only check we need to do, streamlining the code a little. This is greatly improving the readability of the code (in my opinion). Keeping the code simple and compact is well invested time to achieve this.

So what's next? We need now to set up a strategy how to parse the file. Typically the first step is to tokenize the input. This means that we split the input into smaller parts that we can then analyze individually. Let's do the tokenization by hand first; I will insert >| and |< to highlight individual tokens and use «comments» to show meta information:

-- this is a comment «We ignore comments during parsing»
>|Level|< «whitespaces are skipped»>|0|<
  >|initialGold|<: «The colon is detected and attributed to the previous token»>|500|<
>|Wave|< 
  >|enemyType|<: >|ENEMY_TYPE_SHIELD
  >|wave|<: >|0|<
  >|count|<: >|1|<
  >|interval|<: >|2.5|<
  >|delay|<: >|1.0|<
  >|spawnPosition|<: >|2|< >|6|<
...

This is more or less how we want the first step of our parser to split the input for later processing.

In order to do this, let's first create a parser state struct that holds the current state of the parser. This struct will hold the current token and the current position in the input string. We will also add a function to read the next token.

  1 #include <raylib.h>
  2 #include <stdio.h>
  3 
  4 typedef struct ParserState
  5 {
  6   char *input;
  7   int position;
  8   char nextToken[256];
  9 } ParserState;
 10 
 11 int ParserStateReadNextToken(ParserState *state)
 12 {
 13   int i = 0, pos = state->position;
 14   char *input = state->input;
 15 
 16   // skip whitespaces
 17   while (input[pos] != 0 && input[pos] <= ' ')
 18   {
 19     pos++;
 20   }
 21 
 22   // read token
 23   while (input[pos] != 0 && input[pos] > ' ' && i < 256)
 24   {
 25     state->nextToken[i] = input[pos];
 26     pos++;
 27     i++;
 28   }
 29   state->position = pos;
 30 
 31   if (i == 0 || i == 256)
 32   {
 33     state->nextToken[0] = 0;
 34     return 0;
 35   }
 36   // terminate the token
 37   state->nextToken[i] = 0;
 38   return 1;
 39 }
 40 
 41 int main()
 42 {
 43   char *fileContent = LoadFileText("data/level.txt");
 44   if (fileContent == NULL)
 45   {
 46     printf("Failed to load file\n");
 47     return 1;
 48   }
 49 
 50   ParserState state = {fileContent, 0, {0}};
 51   for (int i = 0; i < 10; i++)
 52   {
 53     if (!ParserStateReadNextToken(&state))
 54     {
 55       break;
 56     }
 57     printf("Token: %s\n", state.nextToken);
 58   }
 59 
 60   printf("Parser reading stopped at position %d\n", state.position);
 61   
 62   UnloadFileText(fileContent);
 63   return 0; 
 64 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
Token: --
Token: this
Token: is
Token: a
Token: comment
Token: Level
Token: 0
Token: initialGold:
Token: 500
Token: Wave
Parser reading stopped at position 52

The ParserStateReadNextToken function reads the next token from the input string after skipping whitespaces. It returns 1 if a token was found and 0 if the end of the input string was reached or when the token is too long - we need error handling.

On the output, we also see that the parser does not ignore comments. We can handle this by creating a function that skips comments and whitespaces all at once:

  • 💾
  1 #include <raylib.h>
  2 #include <stdio.h>
  3 
  4 typedef struct ParserState
  5 {
  6   char *input;
  7   int position;
  8   char nextToken[256];
  9 } ParserState;
 10 
11 void ParserStateSkipWhiteSpaces(ParserState *state)
12 {
13 char *input = state->input; 14 int pos = state->position; 15 int skipped = 1; 16 while (skipped) 17 { 18 skipped = 0; 19 if (input[pos] == '-' && input[pos + 1] == '-') 20 { 21 skipped = 1; 22 // skip comments 23 while (input[pos] != 0 && input[pos] != '\n') 24 { 25 pos++; 26 } 27 } 28 29 // skip white spaces
30 while (input[pos] != 0 && input[pos] <= ' ')
31 { 32 skipped = 1; 33 pos++; 34 } 35 36 // repeat until no more white spaces or comments 37 } 38 state->position = pos; 39 } 40 41 int ParserStateReadNextToken(ParserState *state) 42 { 43 ParserStateSkipWhiteSpaces(state); 44 45 int i = 0, pos = state->position; 46 char *input = state->input;
47 48 // read token 49 while (input[pos] != 0 && input[pos] > ' ' && i < 256) 50 { 51 state->nextToken[i] = input[pos]; 52 pos++; 53 i++; 54 } 55 state->position = pos; 56 57 if (i == 0 || i == 256) 58 { 59 state->nextToken[0] = 0; 60 return 0; 61 } 62 // terminate the token 63 state->nextToken[i] = 0; 64 return 1; 65 } 66 67 int main() 68 { 69 char *fileContent = LoadFileText("data/level.txt"); 70 if (fileContent == NULL) 71 { 72 printf("Failed to load file\n"); 73 return 1; 74 } 75 76 ParserState state = {fileContent, 0, {0}}; 77 for (int i = 0; i < 10; i++) 78 { 79 if (!ParserStateReadNextToken(&state)) 80 { 81 break; 82 } 83 printf("Token: %s\n", state.nextToken); 84 } 85 86 printf("Parser reading stopped at position %d\n", state.position); 87 88 UnloadFileText(fileContent); 89 return 0; 90 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
Token: Level
Token: 0
Token: initialGold:
Token: 500
Token: Wave
Token: enemyType:
Token: ENEMY_TYPE_SHIELD
Token: wave:
Token: 0
Token: count:
Parser reading stopped at position 103

The ParserStateSkipWhiteSpaces function is a simple helper function that skips whitespaces and comments until a non-whitespace character is found. This way, we get now tokens that look already quite useful. Some tokens end now with a colon; since we split the tokens by whitespaces, we treat the colon the same as any other character. If we would forget in the format to add a space after the colon, the parser would treat the two parts as one token.

We can however modify the parser to treat the colon like a whitespace character; but then the colon would no longer be mandatory - but maybe that is OK.

  • 💾
  1 #include <raylib.h>
  2 #include <stdio.h>
  3 
  4 typedef struct ParserState
  5 {
  6   char *input;
  7   int position;
  8   char nextToken[256];
  9 } ParserState;
 10 
 11 void ParserStateSkipWhiteSpaces(ParserState *state)
 12 {
 13   char *input = state->input;
 14   int pos = state->position;
 15   int skipped = 1;
 16   while (skipped)
 17   {
 18     skipped = 0;
 19     if (input[pos] == '-' && input[pos + 1] == '-')
 20     {
 21       skipped = 1;
 22       // skip comments
 23       while (input[pos] != 0 && input[pos] != '\n')
 24       {
 25         pos++;
 26       }
 27     }
 28   
29 // skip white spaces and ignore colons 30 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
31 { 32 skipped = 1; 33 pos++; 34 } 35 36 // repeat until no more white spaces or comments 37 } 38 state->position = pos; 39 } 40 41 int ParserStateReadNextToken(ParserState *state) 42 { 43 ParserStateSkipWhiteSpaces(state); 44 45 int i = 0, pos = state->position; 46 char *input = state->input; 47 48 // read token
49 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
50 { 51 state->nextToken[i] = input[pos]; 52 pos++; 53 i++; 54 } 55 state->position = pos; 56 57 if (i == 0 || i == 256) 58 { 59 state->nextToken[0] = 0; 60 return 0; 61 } 62 // terminate the token 63 state->nextToken[i] = 0; 64 return 1; 65 } 66 67 int main() 68 { 69 char *fileContent = LoadFileText("data/level.txt"); 70 if (fileContent == NULL) 71 { 72 printf("Failed to load file\n"); 73 return 1; 74 } 75 76 ParserState state = {fileContent, 0, {0}}; 77 for (int i = 0; i < 10; i++) 78 { 79 if (!ParserStateReadNextToken(&state)) 80 { 81 break; 82 } 83 printf("Token: %s\n", state.nextToken); 84 } 85 86 printf("Parser reading stopped at position %d\n", state.position); 87 88 UnloadFileText(fileContent); 89 return 0; 90 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
Token: Level
Token: 0
Token: initialGold
Token: 500
Token: Wave
Token: enemyType
Token: ENEMY_TYPE_SHIELD
Token: wave
Token: 0
Token: count
Parser reading stopped at position 102

We pretty much have now a working tokenizer that produces tokens that we can use to extract the data from the input file. Our pattern is simple: we read a token, look up what it means and then obtain the tokens that belong to this element - which can be 1, 2 or 3 follow up tokens in our case.

To convert this into meaningful data, we can look at the input like a hierarchical document; the Wave token is a child of the Level token. Other tokens however are unrelated, like for example the EnemyClassConfig token.

Let's start with the Level token:

  • 💾
1 #include "td_main.h"
2 #include <raylib.h> 3 #include <stdio.h> 4 5 typedef struct ParserState 6 { 7 char *input; 8 int position; 9 char nextToken[256]; 10 } ParserState; 11 12 void ParserStateSkipWhiteSpaces(ParserState *state) 13 { 14 char *input = state->input; 15 int pos = state->position; 16 int skipped = 1; 17 while (skipped) 18 { 19 skipped = 0; 20 if (input[pos] == '-' && input[pos + 1] == '-') 21 { 22 skipped = 1; 23 // skip comments 24 while (input[pos] != 0 && input[pos] != '\n') 25 { 26 pos++; 27 } 28 } 29 30 // skip white spaces and ignore colons 31 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':')) 32 { 33 skipped = 1; 34 pos++; 35 } 36 37 // repeat until no more white spaces or comments 38 } 39 state->position = pos; 40 } 41 42 int ParserStateReadNextToken(ParserState *state) 43 { 44 ParserStateSkipWhiteSpaces(state); 45 46 int i = 0, pos = state->position; 47 char *input = state->input; 48 49 // read token 50 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256) 51 { 52 state->nextToken[i] = input[pos]; 53 pos++; 54 i++; 55 } 56 state->position = pos; 57 58 if (i == 0 || i == 256) 59 { 60 state->nextToken[0] = 0; 61 return 0; 62 } 63 // terminate the token
64 state->nextToken[i] = 0; 65 return 1; 66 } 67 68 int ParserStateReadNextInt(ParserState *state, int *value) 69 { 70 if (!ParserStateReadNextToken(state)) 71 { 72 return 0; 73 } 74 // check if the token is a number 75 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+'; 76 for (int i = isSigned; state->nextToken[i] != 0; i++) 77 { 78 if (state->nextToken[i] < '0' || state->nextToken[i] > '9') 79 { 80 return 0; 81 } 82 } 83 *value = TextToInteger(state->nextToken); 84 return 1; 85 } 86 87 typedef struct ParsedGameData 88 { 89 char *parseError; 90 Level levels[32]; 91 int lastLevelIndex; 92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT]; 93 EnemyClassConfig enemyClasses[8]; 94 } ParsedGameData; 95 96 int ParseGameData(ParserState *state, ParsedGameData *gameData) 97 { 98 *gameData = (ParsedGameData){0}; 99 gameData->lastLevelIndex = -1; 100 101 while (ParserStateReadNextToken(state)) 102 { 103 if (TextIsEqual(state->nextToken, "Level")) 104 { 105 // read id of level 106 int levelId; 107 if (!ParserStateReadNextInt(state, &levelId)) 108 { 109 gameData->parseError = "Failed to read level id"; 110 return 0; 111 } 112 if (levelId < 0 || levelId >= 32) 113 { 114 gameData->parseError = "Invalid level id"; 115 return 0; 116 } 117 gameData->lastLevelIndex = levelId; 118 printf("Parsing level %d\n", levelId); 119 } 120 } 121
122 return 1; 123 } 124 125 int main() 126 { 127 char *fileContent = LoadFileText("data/level.txt"); 128 if (fileContent == NULL) 129 { 130 printf("Failed to load file\n"); 131 return 1; 132 } 133 134 ParserState state = {fileContent, 0, {0}};
135 ParsedGameData gameData = {0}; 136 137 if (!ParseGameData(&state, &gameData))
138 {
139 printf("Failed to parse game data: %s\n", gameData.parseError); 140 UnloadFileText(fileContent); 141 return 1; 142 } 143
144 UnloadFileText(fileContent); 145 return 0; 146 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
Parsing level 0

So this version prints out the level id that is parsed and it has correctly detected that we have a Level section with the id 0. So far, it does not do much, but we have now the functions in place to extend this:

  • 💾
  1 #include "td_main.h"
  2 #include <raylib.h>
  3 #include <stdio.h>
  4 
  5 typedef struct ParserState
  6 {
  7   char *input;
  8   int position;
  9   char nextToken[256];
 10 } ParserState;
 11 
 12 void ParserStateSkipWhiteSpaces(ParserState *state)
 13 {
 14   char *input = state->input;
 15   int pos = state->position;
 16   int skipped = 1;
 17   while (skipped)
 18   {
 19     skipped = 0;
 20     if (input[pos] == '-' && input[pos + 1] == '-')
 21     {
 22       skipped = 1;
 23       // skip comments
 24       while (input[pos] != 0 && input[pos] != '\n')
 25       {
 26         pos++;
 27       }
 28     }
 29   
 30     // skip white spaces and ignore colons
 31     while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
 32     {
 33       skipped = 1;
 34       pos++;
 35     }
 36 
 37     // repeat until no more white spaces or comments
 38   }
 39   state->position = pos;
 40 }
 41 
 42 int ParserStateReadNextToken(ParserState *state)
 43 {
 44   ParserStateSkipWhiteSpaces(state);
 45 
 46   int i = 0, pos = state->position;
 47   char *input = state->input;
 48 
 49   // read token
 50   while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
 51   {
 52     state->nextToken[i] = input[pos];
 53     pos++;
 54     i++;
 55   }
 56   state->position = pos;
 57 
 58   if (i == 0 || i == 256)
 59   {
 60     state->nextToken[0] = 0;
 61     return 0;
 62   }
 63   // terminate the token
 64   state->nextToken[i] = 0;
 65   return 1;
 66 }
 67 
 68 int ParserStateReadNextInt(ParserState *state, int *value)
 69 {
 70   if (!ParserStateReadNextToken(state))
 71   {
 72     return 0;
 73   }
 74   // check if the token is a number
 75   int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
 76   for (int i = isSigned; state->nextToken[i] != 0; i++)
 77   {
 78     if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
 79     {
 80       return 0;
 81     }
 82   }
 83   *value = TextToInteger(state->nextToken);
 84   return 1;
 85 }
 86 
 87 typedef struct ParsedGameData
 88 {
 89   char *parseError;
 90   Level levels[32];
 91   int lastLevelIndex;
 92   TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
 93   EnemyClassConfig enemyClasses[8];
 94 } ParsedGameData;
 95 
96 int ParseGameDataLevelSection(ParserState *state, ParsedGameData *gameData)
97 {
98 int levelId; 99 if (!ParserStateReadNextInt(state, &levelId)) 100 { 101 gameData->parseError = "Failed to read level id"; 102 return 0; 103 } 104 105 if (levelId < 0 || levelId >= 32) 106 { 107 gameData->parseError = "Invalid level id"; 108 return 0; 109 } 110 111 gameData->lastLevelIndex = levelId; 112 Level *level = &gameData->levels[levelId];
113
114 while (1)
115 {
116 // try to read the next token and if we don't know how to handle it, 117 // we step back and return 118 ParserState prevState = *state; 119 120 if (!ParserStateReadNextToken(state))
121 {
122 // end of file 123 return 1; 124 } 125 126 if (TextIsEqual(state->nextToken, "initialGold"))
127 {
128 if (!ParserStateReadNextInt(state, &level->initialGold)) 129 { 130 gameData->parseError = "Failed to read initial gold";
131 return 0; 132 }
133 134 continue; 135 } 136 137 // no match, return to previous state and break 138 *state = prevState; 139 break; 140 } 141 142 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold); 143 return 1; 144 } 145 146 int ParseGameData(ParserState *state, ParsedGameData *gameData) 147 { 148 *gameData = (ParsedGameData){0}; 149 gameData->lastLevelIndex = -1; 150 151 while (ParserStateReadNextToken(state))
152 {
153 if (TextIsEqual(state->nextToken, "Level")) 154 { 155 // read id of level 156 if (ParseGameDataLevelSection(state, gameData) == 0) 157 {
158 return 0; 159 }
160 } 161 162
163 } 164 165 return 1; 166 } 167 168 int main() 169 { 170 char *fileContent = LoadFileText("data/level.txt"); 171 if (fileContent == NULL) 172 { 173 printf("Failed to load file\n"); 174 return 1; 175 } 176 177 ParserState state = {fileContent, 0, {0}}; 178 ParsedGameData gameData = {0}; 179 180 if (!ParseGameData(&state, &gameData)) 181 { 182 printf("Failed to parse game data: %s\n", gameData.parseError); 183 UnloadFileText(fileContent); 184 return 1; 185 } 186 187 UnloadFileText(fileContent); 188 return 0; 189 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
Parsed level 0, initialGold=500

There is now a dedicated ParseGameDataLevelSection function that will parse all level related data. Currently, it only reads the initial gold value. When it encounters a token it can't handle, it assumes the level section is done and returns.

We could add now the wave handling, however, there is an improvement we can do: There are now two instances where we first read a token, and if it matches, we read another token in the expectation that this has to be an integer. We can combine this into a single function that reads a token and then reads the next token as an integer. It has however to have a tristate return value: It didn't match, it matched but the next token was not an integer (error) or it matched and the next token was read successfully as an integer.

Implementing such a function should shorten quite regular operations and will help to make the code more readible by making it more concise. It also reduces the chance of introducing bugs when extending the parser.

  • 💾
  1 #include "td_main.h"
  2 #include <raylib.h>
  3 #include <stdio.h>
  4 
  5 typedef struct ParserState
  6 {
  7   char *input;
  8   int position;
  9   char nextToken[256];
 10 } ParserState;
 11 
 12 void ParserStateSkipWhiteSpaces(ParserState *state)
 13 {
 14   char *input = state->input;
 15   int pos = state->position;
 16   int skipped = 1;
 17   while (skipped)
 18   {
 19     skipped = 0;
 20     if (input[pos] == '-' && input[pos + 1] == '-')
 21     {
 22       skipped = 1;
 23       // skip comments
 24       while (input[pos] != 0 && input[pos] != '\n')
 25       {
 26         pos++;
 27       }
 28     }
 29   
 30     // skip white spaces and ignore colons
 31     while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
 32     {
 33       skipped = 1;
 34       pos++;
 35     }
 36 
 37     // repeat until no more white spaces or comments
 38   }
 39   state->position = pos;
 40 }
 41 
 42 int ParserStateReadNextToken(ParserState *state)
 43 {
 44   ParserStateSkipWhiteSpaces(state);
 45 
 46   int i = 0, pos = state->position;
 47   char *input = state->input;
 48 
 49   // read token
 50   while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
 51   {
 52     state->nextToken[i] = input[pos];
 53     pos++;
 54     i++;
 55   }
 56   state->position = pos;
 57 
 58   if (i == 0 || i == 256)
 59   {
 60     state->nextToken[0] = 0;
 61     return 0;
 62   }
 63   // terminate the token
 64   state->nextToken[i] = 0;
 65   return 1;
 66 }
 67 
 68 int ParserStateReadNextInt(ParserState *state, int *value)
 69 {
 70   if (!ParserStateReadNextToken(state))
 71   {
 72     return 0;
 73   }
 74   // check if the token is a number
 75   int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
 76   for (int i = isSigned; state->nextToken[i] != 0; i++)
 77   {
 78     if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
 79     {
 80       return 0;
 81     }
 82   }
 83   *value = TextToInteger(state->nextToken);
 84   return 1;
 85 }
 86 
 87 typedef struct ParsedGameData
 88 {
89 const char *parseError;
90 Level levels[32]; 91 int lastLevelIndex; 92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT]; 93 EnemyClassConfig enemyClasses[8]; 94 } ParsedGameData; 95
96 typedef enum TryReadResult
97 {
98 TryReadResult_NoMatch, 99 TryReadResult_Error, 100 TryReadResult_Success 101 } TryReadResult; 102 103 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange) 104 { 105 if (!TextIsEqual(state->nextToken, key))
106 {
107 return TryReadResult_NoMatch; 108 } 109 110 if (!ParserStateReadNextInt(state, value)) 111 { 112 gameData->parseError = TextFormat("Failed to read %s int value", key); 113 return TryReadResult_Error;
114 }
115 116 // range test, if minRange == maxRange, we don't check the range 117 if (minRange != maxRange && (*value < minRange || *value > maxRange))
118 {
119 gameData->parseError = TextFormat("Invalid value range for %s, range is [%d, %d], value is %d", 120 key, minRange, maxRange, *value); 121 return TryReadResult_Error; 122 } 123 124 return TryReadResult_Success; 125 } 126 127 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state) 128 { 129 int levelId; 130 TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31); 131 if (result != TryReadResult_Success) 132 { 133 return result;
134 } 135 136 gameData->lastLevelIndex = levelId; 137 Level *level = &gameData->levels[levelId]; 138 139 while (1) 140 { 141 // try to read the next token and if we don't know how to handle it,
142 // we rewind and return
143 ParserState prevState = *state; 144 145 if (!ParserStateReadNextToken(state)) 146 { 147 // end of file 148 return 1;
149 } 150 151 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000)) 152 { 153 case TryReadResult_Success: continue; 154 case TryReadResult_Error: return TryReadResult_Error;
155 } 156 157 // no match, return to previous state and break
158 *state = prevState;
159 break; 160 }
161
162 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold); 163 return TryReadResult_Success; 164 } 165 166 int ParseGameData(ParsedGameData *gameData, ParserState *state) 167 {
168 *gameData = (ParsedGameData){0};
169 gameData->lastLevelIndex = -1;
170 171 while (ParserStateReadNextToken(state)) 172 { 173 switch (ParseGameDataTryReadLevelSection(gameData, state))
174 { 175 case TryReadResult_Success: continue; 176 case TryReadResult_Error: return 0; 177 } 178 179 // read other sections later 180 } 181 182 return 1; 183 } 184 185 int main() 186 { 187 char *fileContent = LoadFileText("data/level.txt"); 188 if (fileContent == NULL)
189 {
190 printf("Failed to load file\n"); 191 return 1; 192 } 193 194 ParserState state = {fileContent, 0, {0}}; 195 ParsedGameData gameData = {0}; 196 197 if (!ParseGameData(&gameData, &state)) 198 { 199 printf("Failed to parse game data: %s\n", gameData.parseError); 200 UnloadFileText(fileContent); 201 return 1; 202 } 203 204 UnloadFileText(fileContent); 205 return 0; 206 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully
Parsed level 0, initialGold=500

There are now two `TryRead*` functions: functions: ParseGameDataTryReadKeyInt and ParseGameDataTryReadLevelSection. Both functions will only become active, when the current token matches its expectation. If not, it returns a TryReadResult_NoMatch result. After a successful match, the further reading can either fail with a TryReadResult_Error or succeed with a TryReadResult_Success result.

This simplifies extending the parser to construct the data structures we want to read.

However... it is time to test how the parser reacts to errorneous inputs and how the errors look like.

So let's add a small testing section to our program:

  • 💾
  1 #include "td_main.h"
  2 #include <raylib.h>
  3 #include <stdio.h>
  4 
  5 typedef struct ParserState
  6 {
  7   char *input;
  8   int position;
  9   char nextToken[256];
 10 } ParserState;
 11 
 12 void ParserStateSkipWhiteSpaces(ParserState *state)
 13 {
 14   char *input = state->input;
 15   int pos = state->position;
 16   int skipped = 1;
 17   while (skipped)
 18   {
 19     skipped = 0;
 20     if (input[pos] == '-' && input[pos + 1] == '-')
 21     {
 22       skipped = 1;
 23       // skip comments
 24       while (input[pos] != 0 && input[pos] != '\n')
 25       {
 26         pos++;
 27       }
 28     }
 29   
 30     // skip white spaces and ignore colons
 31     while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
 32     {
 33       skipped = 1;
 34       pos++;
 35     }
 36 
 37     // repeat until no more white spaces or comments
 38   }
 39   state->position = pos;
 40 }
 41 
 42 int ParserStateReadNextToken(ParserState *state)
 43 {
 44   ParserStateSkipWhiteSpaces(state);
 45 
 46   int i = 0, pos = state->position;
 47   char *input = state->input;
 48 
 49   // read token
 50   while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
 51   {
 52     state->nextToken[i] = input[pos];
 53     pos++;
 54     i++;
 55   }
 56   state->position = pos;
 57 
 58   if (i == 0 || i == 256)
 59   {
 60     state->nextToken[0] = 0;
 61     return 0;
 62   }
 63   // terminate the token
 64   state->nextToken[i] = 0;
 65   return 1;
 66 }
 67 
 68 int ParserStateReadNextInt(ParserState *state, int *value)
 69 {
 70   if (!ParserStateReadNextToken(state))
 71   {
 72     return 0;
 73   }
 74   // check if the token is a number
 75   int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
 76   for (int i = isSigned; state->nextToken[i] != 0; i++)
 77   {
 78     if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
 79     {
 80       return 0;
 81     }
 82   }
 83   *value = TextToInteger(state->nextToken);
 84   return 1;
 85 }
 86 
 87 typedef struct ParsedGameData
 88 {
 89   const char *parseError;
 90   Level levels[32];
 91   int lastLevelIndex;
 92   TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
 93   EnemyClassConfig enemyClasses[8];
 94 } ParsedGameData;
 95 
 96 typedef enum TryReadResult
 97 {
 98   TryReadResult_NoMatch,
 99   TryReadResult_Error,
100   TryReadResult_Success
101 } TryReadResult;
102 
103 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange)
104 {
105   if (!TextIsEqual(state->nextToken, key))
106   {
107     return TryReadResult_NoMatch;
108   }
109 
110   if (!ParserStateReadNextInt(state, value))
111   {
112     gameData->parseError = TextFormat("Failed to read %s int value", key);
113     return TryReadResult_Error;
114   }
115 
116   // range test, if minRange == maxRange, we don't check the range
117   if (minRange != maxRange && (*value < minRange || *value > maxRange))
118   {
119     gameData->parseError = TextFormat("Invalid value range for %s, range is [%d, %d], value is %d", 
120       key, minRange, maxRange, *value);
121     return TryReadResult_Error;
122   }
123 
124   return TryReadResult_Success;
125 }
126 
127 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state)
128 {
129   int levelId;
130   TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31);
131   if (result != TryReadResult_Success)
132   {
133     return result;
134   }
135   
136   gameData->lastLevelIndex = levelId;
137   Level *level = &gameData->levels[levelId];
138 
139   while (1)
140   {
141     // try to read the next token and if we don't know how to handle it,
142     // we rewind and return
143     ParserState prevState = *state;
144     
145     if (!ParserStateReadNextToken(state))
146     {
147       // end of file
148 break;
149 } 150 151 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000)) 152 { 153 case TryReadResult_Success: continue; 154 case TryReadResult_Error: return TryReadResult_Error; 155 } 156 157 // no match, return to previous state and break 158 *state = prevState; 159 break; 160 } 161 162 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold); 163 return TryReadResult_Success; 164 } 165 166 int ParseGameData(ParsedGameData *gameData, ParserState *state) 167 { 168 *gameData = (ParsedGameData){0}; 169 gameData->lastLevelIndex = -1; 170 171 while (ParserStateReadNextToken(state)) 172 { 173 switch (ParseGameDataTryReadLevelSection(gameData, state)) 174 { 175 case TryReadResult_Success: continue; 176 case TryReadResult_Error: return 0; 177 } 178 179 // read other sections later 180 } 181
182 return 1; 183 } 184 185 #define EXPECT(cond, msg) if (!(cond)) { printf("Error %s:%d: %s\n", __FILE__, __LINE__, msg); failedCount++; } else { passedCount++; } 186 187 int RunParseTests() 188 { 189 int passedCount = 0, failedCount = 0; 190 ParserState state; 191 ParsedGameData gameData = {0}; 192 193 state = (ParserState) {"Level 7\n initialGold 100\nLevel 2 initialGold 200", 0, {0}}; 194 gameData = (ParsedGameData) {0}; 195 EXPECT(ParseGameData(&gameData, &state) == 1, "Failed to parse level section"); 196 EXPECT(gameData.lastLevelIndex == 2, "lastLevelIndex should be 2"); 197 EXPECT(gameData.levels[7].initialGold == 100, "initialGold should be 100"); 198 EXPECT(gameData.levels[2].initialGold == 200, "initialGold should be 200"); 199 200 state = (ParserState) {"Level 392\n", 0, {0}}; 201 gameData = (ParsedGameData) {0}; 202 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 203 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for Level") >= 0, "Expected invalid value range error"); 204 205 state = (ParserState) {"Level 3 initialGold -34", 0, {0}}; 206 gameData = (ParsedGameData) {0}; 207 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 208 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for initialGold") >= 0, "Expected invalid value range error"); 209 210 printf("Passed %d test(s), Failed %d\n", passedCount, failedCount); 211 212 return failedCount;
213 } 214 215 int main()
216 { 217 printf("Running parse tests\n"); 218 if (RunParseTests()) 219 { 220 return 1; 221 } 222 printf("\n"); 223
224 char *fileContent = LoadFileText("data/level.txt"); 225 if (fileContent == NULL) 226 { 227 printf("Failed to load file\n"); 228 return 1; 229 } 230 231 ParserState state = {fileContent, 0, {0}}; 232 ParsedGameData gameData = {0}; 233 234 if (!ParseGameData(&gameData, &state)) 235 { 236 printf("Failed to parse game data: %s\n", gameData.parseError); 237 UnloadFileText(fileContent); 238 return 1; 239 } 240 241 UnloadFileText(fileContent); 242 return 0; 243 }
Running parse tests
Parsed level 7, initialGold=100
Parsed level 2, initialGold=200
Passed 8 test(s), Failed 0

INFO: FILEIO: [data/level.txt] Text file loaded successfully
Parsed level 0, initialGold=500

Running a few tests did not reveal any big issues. The tests cover some range check errors. We can now extend the parser to also expect certain keys to be mandatory and also prevent repeated keys - like having two initialGold keys in a Level section. Also, multiple Level sections with the same id should be considered an error.

Let's add more tests and improve the parser to honor these requirements:

  • 💾
  1 #include "td_main.h"
  2 #include <raylib.h>
  3 #include <stdio.h>
  4 
  5 typedef struct ParserState
  6 {
  7   char *input;
  8   int position;
  9   char nextToken[256];
 10 } ParserState;
 11 
 12 void ParserStateSkipWhiteSpaces(ParserState *state)
 13 {
 14   char *input = state->input;
 15   int pos = state->position;
 16   int skipped = 1;
 17   while (skipped)
 18   {
 19     skipped = 0;
 20     if (input[pos] == '-' && input[pos + 1] == '-')
 21     {
 22       skipped = 1;
 23       // skip comments
 24       while (input[pos] != 0 && input[pos] != '\n')
 25       {
 26         pos++;
 27       }
 28     }
 29   
 30     // skip white spaces and ignore colons
 31     while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
 32     {
 33       skipped = 1;
 34       pos++;
 35     }
 36 
 37     // repeat until no more white spaces or comments
 38   }
 39   state->position = pos;
 40 }
 41 
 42 int ParserStateReadNextToken(ParserState *state)
 43 {
 44   ParserStateSkipWhiteSpaces(state);
 45 
 46   int i = 0, pos = state->position;
 47   char *input = state->input;
 48 
 49   // read token
 50   while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
 51   {
 52     state->nextToken[i] = input[pos];
 53     pos++;
 54     i++;
 55   }
 56   state->position = pos;
 57 
 58   if (i == 0 || i == 256)
 59   {
 60     state->nextToken[0] = 0;
 61     return 0;
 62   }
 63   // terminate the token
 64   state->nextToken[i] = 0;
 65   return 1;
 66 }
 67 
 68 int ParserStateReadNextInt(ParserState *state, int *value)
 69 {
 70   if (!ParserStateReadNextToken(state))
 71   {
 72     return 0;
 73   }
 74   // check if the token is a number
 75   int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
 76   for (int i = isSigned; state->nextToken[i] != 0; i++)
 77   {
 78     if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
 79     {
 80       return 0;
 81     }
 82   }
 83   *value = TextToInteger(state->nextToken);
 84   return 1;
 85 }
 86 
 87 typedef struct ParsedGameData
 88 {
 89   const char *parseError;
 90   Level levels[32];
 91   int lastLevelIndex;
 92   TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
 93   EnemyClassConfig enemyClasses[8];
 94 } ParsedGameData;
 95 
 96 typedef enum TryReadResult
 97 {
 98   TryReadResult_NoMatch,
 99   TryReadResult_Error,
100   TryReadResult_Success
101 } TryReadResult;
102 
103 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange)
104 {
105   if (!TextIsEqual(state->nextToken, key))
106   {
107     return TryReadResult_NoMatch;
108   }
109 
110   if (!ParserStateReadNextInt(state, value))
111   {
112     gameData->parseError = TextFormat("Failed to read %s int value", key);
113     return TryReadResult_Error;
114   }
115 
116   // range test, if minRange == maxRange, we don't check the range
117   if (minRange != maxRange && (*value < minRange || *value > maxRange))
118   {
119     gameData->parseError = TextFormat("Invalid value range for %s, range is [%d, %d], value is %d", 
120       key, minRange, maxRange, *value);
121     return TryReadResult_Error;
122   }
123 
124   return TryReadResult_Success;
125 }
126 
127 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state)
128 {
129   int levelId;
130   TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31);
131   if (result != TryReadResult_Success)
132   {
133     return result;
134   }
135   
136   gameData->lastLevelIndex = levelId;
137   Level *level = &gameData->levels[levelId];
138 
139 // since we require the initialGold to be initialized with at least 1, we can use it as a flag 140 // to detect if the level was already initialized 141 if (level->initialGold != 0) 142 { 143 gameData->parseError = TextFormat("level %d already initialized", levelId); 144 return TryReadResult_Error; 145 } 146 147 int initialGoldInitialized = 0; 148
149 while (1) 150 { 151 // try to read the next token and if we don't know how to handle it, 152 // we rewind and return 153 ParserState prevState = *state; 154 155 if (!ParserStateReadNextToken(state)) 156 { 157 // end of file 158 break; 159 } 160 161 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000)) 162 {
163 case TryReadResult_Success: 164 if (initialGoldInitialized) 165 { 166 gameData->parseError = "initialGold already initialized"; 167 return TryReadResult_Error; 168 } 169 initialGoldInitialized = 1; 170 continue;
171 case TryReadResult_Error: return TryReadResult_Error; 172 } 173 174 // no match, return to previous state and break 175 *state = prevState;
176 break; 177 } 178 179 if (!initialGoldInitialized) 180 { 181 gameData->parseError = "initialGold not initialized"; 182 return TryReadResult_Error;
183 } 184 185 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold); 186 return TryReadResult_Success; 187 } 188 189 int ParseGameData(ParsedGameData *gameData, ParserState *state) 190 { 191 *gameData = (ParsedGameData){0}; 192 gameData->lastLevelIndex = -1; 193 194 while (ParserStateReadNextToken(state)) 195 { 196 switch (ParseGameDataTryReadLevelSection(gameData, state)) 197 { 198 case TryReadResult_Success: continue; 199 case TryReadResult_Error: return 0; 200 } 201 202 // read other sections later 203 } 204 205 return 1; 206 } 207 208 #define EXPECT(cond, msg) if (!(cond)) { printf("Error %s:%d: %s\n", __FILE__, __LINE__, msg); failedCount++; } else { passedCount++; } 209 210 int RunParseTests() 211 { 212 int passedCount = 0, failedCount = 0; 213 ParserState state; 214 ParsedGameData gameData = {0}; 215 216 state = (ParserState) {"Level 7\n initialGold 100\nLevel 2 initialGold 200", 0, {0}}; 217 gameData = (ParsedGameData) {0}; 218 EXPECT(ParseGameData(&gameData, &state) == 1, "Failed to parse level section"); 219 EXPECT(gameData.lastLevelIndex == 2, "lastLevelIndex should be 2"); 220 EXPECT(gameData.levels[7].initialGold == 100, "initialGold should be 100"); 221 EXPECT(gameData.levels[2].initialGold == 200, "initialGold should be 200"); 222 223 state = (ParserState) {"Level 392\n", 0, {0}}; 224 gameData = (ParsedGameData) {0}; 225 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 226 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for Level") >= 0, "Expected invalid value range error"); 227 228 state = (ParserState) {"Level 3 initialGold -34", 0, {0}}; 229 gameData = (ParsedGameData) {0}; 230 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
231 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for initialGold") >= 0, "Expected invalid value range error"); 232 233 state = (ParserState) {"Level 3 initialGold 2 initialGold 3", 0, {0}}; 234 gameData = (ParsedGameData) {0}; 235 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 236 237 state = (ParserState) {"Level 3", 0, {0}}; 238 gameData = (ParsedGameData) {0}; 239 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 240 241 state = (ParserState) {"Level 7\n initialGold 100\nLevel 7 initialGold 200", 0, {0}}; 242 gameData = (ParsedGameData) {0}; 243 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
244 245 printf("Passed %d test(s), Failed %d\n", passedCount, failedCount); 246 247 return failedCount; 248 } 249 250 int main() 251 { 252 printf("Running parse tests\n"); 253 if (RunParseTests()) 254 { 255 return 1; 256 } 257 printf("\n"); 258 259 char *fileContent = LoadFileText("data/level.txt"); 260 if (fileContent == NULL) 261 { 262 printf("Failed to load file\n"); 263 return 1; 264 } 265 266 ParserState state = {fileContent, 0, {0}}; 267 ParsedGameData gameData = {0}; 268 269 if (!ParseGameData(&gameData, &state)) 270 { 271 printf("Failed to parse game data: %s\n", gameData.parseError); 272 UnloadFileText(fileContent); 273 return 1; 274 } 275 276 UnloadFileText(fileContent); 277 return 0; 278 }
Running parse tests
Parsed level 7, initialGold=100
Parsed level 2, initialGold=200
Parsed level 7, initialGold=100
Passed 11 test(s), Failed 0

INFO: FILEIO: [data/level.txt] Text file loaded successfully
Parsed level 0, initialGold=500

Adding a few test cases and some code to satisfy the expectations is quite straight forward.

Writing tests first is calledtest driven development (TDD) and it has its merits. I don't often use TDD, but there are cases where there are clear benefits: In this case, it simplifies extending the parser functionality and ensures that the parser is working as expected. Since the functions are pure input-output functions where we can easily check the output, applying the TDD principles is quite easy. We also have here very strict requirements how the code should behave while requiring that it does work highly reliable.

Speaking of tests and failing: Our errors are currently only mere indicators what went wrong, but it isn't pointing to location where the error happened, which would mean in practice that we would spend a lot of time to find errors.

So let's modify the errors to include line numbers.

  • 💾
  1 #include "td_main.h"
  2 #include <raylib.h>
  3 #include <stdio.h>
  4 
  5 typedef struct ParserState
  6 {
  7   char *input;
  8   int position;
  9   char nextToken[256];
 10 } ParserState;
 11 
12 int ParserStateGetLineNumber(ParserState *state) 13 { 14 int lineNumber = 1; 15 for (int i = 0; i < state->position; i++) 16 { 17 if (state->input[i] == '\n') 18 { 19 lineNumber++; 20 } 21 } 22 return lineNumber; 23 } 24
25 void ParserStateSkipWhiteSpaces(ParserState *state) 26 { 27 char *input = state->input; 28 int pos = state->position; 29 int skipped = 1; 30 while (skipped) 31 { 32 skipped = 0; 33 if (input[pos] == '-' && input[pos + 1] == '-') 34 { 35 skipped = 1; 36 // skip comments 37 while (input[pos] != 0 && input[pos] != '\n') 38 { 39 pos++; 40 } 41 } 42 43 // skip white spaces and ignore colons 44 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':')) 45 { 46 skipped = 1; 47 pos++; 48 } 49 50 // repeat until no more white spaces or comments 51 } 52 state->position = pos; 53 } 54 55 int ParserStateReadNextToken(ParserState *state) 56 { 57 ParserStateSkipWhiteSpaces(state); 58 59 int i = 0, pos = state->position; 60 char *input = state->input; 61 62 // read token 63 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256) 64 { 65 state->nextToken[i] = input[pos]; 66 pos++; 67 i++; 68 } 69 state->position = pos; 70 71 if (i == 0 || i == 256) 72 { 73 state->nextToken[0] = 0; 74 return 0; 75 } 76 // terminate the token 77 state->nextToken[i] = 0; 78 return 1; 79 } 80 81 int ParserStateReadNextInt(ParserState *state, int *value) 82 { 83 if (!ParserStateReadNextToken(state)) 84 { 85 return 0; 86 } 87 // check if the token is a number 88 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+'; 89 for (int i = isSigned; state->nextToken[i] != 0; i++) 90 { 91 if (state->nextToken[i] < '0' || state->nextToken[i] > '9') 92 { 93 return 0; 94 } 95 } 96 *value = TextToInteger(state->nextToken); 97 return 1; 98 } 99 100 typedef struct ParsedGameData 101 { 102 const char *parseError; 103 Level levels[32]; 104 int lastLevelIndex; 105 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT]; 106 EnemyClassConfig enemyClasses[8]; 107 } ParsedGameData; 108 109 typedef enum TryReadResult 110 { 111 TryReadResult_NoMatch, 112 TryReadResult_Error, 113 TryReadResult_Success
114 } TryReadResult; 115 116 TryReadResult ParseGameDataError(ParsedGameData *gameData, ParserState *state, const char *msg) 117 { 118 gameData->parseError = TextFormat("Error at line %d: %s", ParserStateGetLineNumber(state), msg); 119 return TryReadResult_Error; 120 }
121 122 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange) 123 { 124 if (!TextIsEqual(state->nextToken, key)) 125 { 126 return TryReadResult_NoMatch; 127 } 128 129 if (!ParserStateReadNextInt(state, value))
130 { 131 return ParseGameDataError(gameData, state, TextFormat("Failed to read %s int value", key));
132 } 133 134 // range test, if minRange == maxRange, we don't check the range 135 if (minRange != maxRange && (*value < minRange || *value > maxRange))
136 { 137 return ParseGameDataError(gameData, state, TextFormat( 138 "Invalid value range for %s, range is [%d, %d], value is %d",
139 key, minRange, maxRange, *value)); 140 } 141 142 return TryReadResult_Success; 143 } 144 145 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state) 146 { 147 int levelId; 148 TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31); 149 if (result != TryReadResult_Success) 150 { 151 return result; 152 } 153 154 gameData->lastLevelIndex = levelId; 155 Level *level = &gameData->levels[levelId]; 156 157 // since we require the initialGold to be initialized with at least 1, we can use it as a flag 158 // to detect if the level was already initialized
159 if (level->initialGold != 0) 160 {
161 return ParseGameDataError(gameData, state, TextFormat("level %d already initialized", levelId)); 162 } 163 164 int initialGoldInitialized = 0; 165 166 while (1) 167 { 168 // try to read the next token and if we don't know how to handle it, 169 // we rewind and return 170 ParserState prevState = *state; 171 172 if (!ParserStateReadNextToken(state)) 173 { 174 // end of file 175 break; 176 } 177 178 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000)) 179 {
180 case TryReadResult_Success: 181 if (initialGoldInitialized)
182 { 183 return ParseGameDataError(gameData, state, "initialGold already initialized"); 184 } 185 initialGoldInitialized = 1; 186 continue; 187 case TryReadResult_Error: return TryReadResult_Error; 188 } 189 190 // no match, return to previous state and break 191 *state = prevState; 192 break;
193 } 194
195 if (!initialGoldInitialized) 196 { 197 return ParseGameDataError(gameData, state, "initialGold not initialized"); 198 } 199 200 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold); 201 return TryReadResult_Success; 202 } 203 204 int ParseGameData(ParsedGameData *gameData, ParserState *state) 205 { 206 *gameData = (ParsedGameData){0}; 207 gameData->lastLevelIndex = -1; 208 209 while (ParserStateReadNextToken(state)) 210 { 211 switch (ParseGameDataTryReadLevelSection(gameData, state)) 212 { 213 case TryReadResult_Success: continue; 214 case TryReadResult_Error: return 0; 215 } 216 217 // read other sections later 218 } 219 220 return 1; 221 } 222 223 #define EXPECT(cond, msg) if (!(cond)) { printf("Error %s:%d: %s\n", __FILE__, __LINE__, msg); failedCount++; } else { passedCount++; } 224 225 int RunParseTests() 226 { 227 int passedCount = 0, failedCount = 0; 228 ParserState state; 229 ParsedGameData gameData = {0}; 230 231 state = (ParserState) {"Level 7\n initialGold 100\nLevel 2 initialGold 200", 0, {0}}; 232 gameData = (ParsedGameData) {0}; 233 EXPECT(ParseGameData(&gameData, &state) == 1, "Failed to parse level section"); 234 EXPECT(gameData.lastLevelIndex == 2, "lastLevelIndex should be 2"); 235 EXPECT(gameData.levels[7].initialGold == 100, "initialGold should be 100"); 236 EXPECT(gameData.levels[2].initialGold == 200, "initialGold should be 200");
237 238 state = (ParserState) {"Level 392\n", 0, {0}};
239 gameData = (ParsedGameData) {0};
240 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
241 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for Level") >= 0, "Expected invalid value range error"); 242 EXPECT(TextFindIndex(gameData.parseError, "line 1") >= 0, "Expected to find line number 1"); 243
244 state = (ParserState) {"Level 3\n initialGold -34", 0, {0}}; 245 gameData = (ParsedGameData) {0}; 246 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
247 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for initialGold") >= 0, "Expected invalid value range error");
248 EXPECT(TextFindIndex(gameData.parseError, "line 2") >= 0, "Expected to find line number 1"); 249
250 state = (ParserState) {"Level 3\n initialGold 2\n initialGold 3", 0, {0}}; 251 gameData = (ParsedGameData) {0}; 252 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 253 EXPECT(TextFindIndex(gameData.parseError, "line 3") >= 0, "Expected to find line number 1"); 254 255 state = (ParserState) {"Level 3", 0, {0}}; 256 gameData = (ParsedGameData) {0};
257 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 258 259 state = (ParserState) {"Level 7\n initialGold 100\nLevel 7 initialGold 200", 0, {0}};
260 gameData = (ParsedGameData) {0}; 261 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section"); 262 EXPECT(TextFindIndex(gameData.parseError, "line 3") >= 0, "Expected to find line number 1"); 263 264 265 printf("Passed %d test(s), Failed %d\n", passedCount, failedCount); 266 267 return failedCount; 268 } 269 270 int main() 271 { 272 printf("Running parse tests\n"); 273 if (RunParseTests()) 274 { 275 return 1; 276 } 277 printf("\n"); 278 279 char *fileContent = LoadFileText("data/level.txt"); 280 if (fileContent == NULL) 281 { 282 printf("Failed to load file\n"); 283 return 1; 284 } 285 286 ParserState state = {fileContent, 0, {0}}; 287 ParsedGameData gameData = {0}; 288 289 if (!ParseGameData(&gameData, &state)) 290 { 291 printf("Failed to parse game data: %s\n", gameData.parseError); 292 UnloadFileText(fileContent); 293 return 1; 294 } 295 296 UnloadFileText(fileContent); 297 return 0; 298 }
Running parse tests
Parsed level 7, initialGold=100
Parsed level 2, initialGold=200
Parsed level 7, initialGold=100
Passed 15 test(s), Failed 0

INFO: FILEIO: [data/level.txt] Text file loaded successfully
Parsed level 0, initialGold=500

The error we would see would look like this:

Error at line 2: Invalid value range for initialGold, range is [1, 100000], value is -34

The tests are now also checking (occasionally) if the correct line number is provided in the error message. We determine the line number in the ParserStateGetLineNumber function - which simply counts the number of newline characters between the start of the input string and the current position.

Conclusion

This is a good point to finish this post. We have the foundation for a parser that can read a simple text file and some simple tests that ensure that the parser is working as expected for the few things we have implemented so far.

In the next part we will add support to initialize all the game data structures from the data provided in the text file. After that is done, we can integrate the parser into the game.

While this certainly is not the most exciting part of the game development, it is a very important one that will later empower us to iterate more efficiently on the game. So bear with me for a little longer.

🍪