Making a game for Playdate with Pure C. Chapter 2
I'm writing a game for the Playdate game console in pure C. The game is a survivalist game like Vampire Survivors. Since pure C lacks many modern object-oriented conveniences, I have to twist and turn to adapt my ideas into code. In these notes you will learn first hand how a game is created from scratch from idea to publication.
In the last chapter I described the setting, showed a video of the first iteration (duplicated below), and detailed how I implemented my dynamic array from scratch, because neither pure C nor Playdate SDK provide me with anything like that out of the box. If you haven't read the last chapter, it's a good place to start.
You know, in detectives there is such a peculiarity: the murderer is necessarily shown in the first minutes. There's no such thing as the killer appearing for the first time at the end of a film or an episode if it's a TV series, because that would be uninteresting to the viewer. And I would never believe it if someone told me that there was a bug in my already published code that I had overlooked, and that this bug would definitely backfire on me in the future.
As you remember the logic of the game, I planned to put it in the Game class. Yes, it is a class, at least in my coordinate system. Of course, the pure C compiler doesn't know what a class is at all, but it doesn't upset us. The Game class has functions
- GameCreate
- GameSetup
- GameUpdate
- GameDestroy
The interface of the Game class looks like this:
Pay attention to the word typedef on line 12. You will encounter this word in my code often. Why do I need it here? So that I can use the Game type as a separate-living fully independent type without constantly specifying the word struct before it. That is, in C, if you write it like this by default:
struct MyStruct { ... };
and then try declaring an instance of MyStruct structure like this
MyStruct myStruct;
or try to pass MyStruct to a function
void myFunction(const MyStruct *myStruct)
we will get a compilation error. This is because we need to add the word struct before the type name:
struct MyStruct myStruct;
and
void myFunction(const struct MyStruct *myStruct)
You're gonna say "why?". And I'll answer "because that's the way it is, because it's C, baby". If you declare a structure in C, then every instance with the type of this structure must have a note that it is a structure so that the compiler will not suspect that you mean something else.
I guess if you are a sharper or a swifter you are sitting in awe of this, because in both C# and Swift simply declared structure is an independent type without any attributes. Yes, everything is true, but C# and Swift are modern languages, and pure C was created about a million years before the October Revolution, and then trends and habits in development were quite different, and, in particular, data types struct and union were something outlandish, so when instantiating them you need to write an additional word struct and union respectively. As time passed, struct and union changed from equal entities to status: struct became megapopular (objects in OOP appeared from it), and union remained a local fancy. It's like USB sticks and compact discs: once both were plus or minus equal ways of transferring information, but over time we've come to a point in the story where the modern generation doesn't know what a compact disc is. Oh, and I myself have forgotten when I used compact discs. Oh no, I remembered: when I had my teeth scanned at the dentist. I don't know why dentists prefer to save dental scans on compact discs rather than USB sticks. But perhaps you need a medical degree along with the ability to handwrite in incomprehensible handwriting to understand this
So, I understand the strangeness of describing structures in C, but what does the word typedef have to do with it? And this is a completely different feature that exists in some form in all modern programming languages: the typedef existing new; construct is a declaration of an alias just like
using new = existing; // in С++ typealias new = existing // in Swift // I forgot how to do it in C#. Write in the comments if you know.
Except that, as you can see, the new and old values are transposed in the typedef construct. Why? Because it is pure C, don't ask questions!
The bottom line is that a statement like
typedef struct {...} MyStruct;
is an indication that I will consider the MyStruct expression as an alias to struct {...} specified in the same line. Sometimes you may see this:
typedef struct MyStruct {...} MyStruct;
It is the same, but with redundant name specification in the original structure. The first variant has no name, and in fact declares an anonymous structure (yes, C has anonymous structures!) and makes an alias for it. The second variant creates a named structure that can be used out of the box with struct keyword, but since we immediately declare an alias to it with the same name, struct can be omitted.
Do you think "what a nonsense!"? What if I tell you that this feature is preserved in modern C++, but only as an optional feature? Look: you can open your favorite C++ IDE right today and write
class std::vector<int> vec;
instead of
std::vector<int> vec;
and it will compile! When I first learned about this a few years ago, my reaction was something like this
Well, typedef has been dealt with, phew! Let's get to the point: functions. Let's first go straight inside GameCreate.
The task of GameCreate is to create a game instance - it is a builder-function, analogous to the constructor in C++. We create the instance in the heap - this allows us not to create the instance at application startup, but to postpone its creation until we receive an init-event from the Playdate operating system. Of course, there is almost zero time difference between starting the game and receiving the init-event, but technically this difference exists, that's why we do it this way.
The only argument needed to create a game instance is a pointer to the PlaydateAPI. Without it we cannot call Playdate operating system's API. A pointer to PlaydateAPI is like a context in Android - you can write code without it, of course, but only spherical code in a vacuum, not real code that interacts with the system's API widely.
Since I'm mimicking OOP, I should implement some OOP stuff on the manual pull. In particular, initialization of all structure fields. That is, I made a promise to myself that create-functions should initialize all fields of the created structure as it is done in Swift out of the box, for example. Why is this so important? Because if you create an instance of something in the heap, no one will give you a guarantee that there will be no garbage in the train of allocated bytes. Allocating memory in the heap is like sitting down to eat at a table in the food court at the mall. The table may be crystal clear, or it may be incredibly dirty, as if a family of hereditary pigs had just eaten on it.
On line 16 we allocate memory for the Game instance, which the function will return at the end. Then we fill in all fields without skipping anything.
The pointer to PlaydateAPI is stored in the game on line 17 - this is important. Next, we create an instance of the car - the very car on which the player rides and shoots animals (see the video). By the way, about the car - it is a separate class, the instance of which is stored in the game in a single copy, because the car is only one. The car instance is also created in the heap because at the beginning I thought that I would create instances of everything in the heap. Spoiler: literally after the car I changed my mind, because it's enough to store everything you need in the form of values as it is inside the game as part of the game. But the car exists in the heap.
The machine has very few fields: it doesn't need many. These are:
- position in Vec2f format (vector with x and y fields in float format) - line 9
- angle - direction, one of the eight choices of car directions. This is an enumeration (enum), the declaration of which I will show very soon - line 10
- isMoving boolean field, which is 0 if the car is standing and 1 if the car is moving. Since there is no bool type in pure C, we brazenly use int - line 11
- acceleration value, which is necessary for realization of motion physics in float format - line 12
And two more functions: constructor and destructor (lines 15 and 16 respectively). Like the array API, these functions take a pointer to the realloc function, as this function is provided by PlaydateAPI.
Now let me show you what PlayerVehicleAngle looks like:
As you remember from the first chapter, in enumeration values I insert the names of these enumerations as a prefix so that there are no name overlaps, since in C all enumeration values are available in the global namespace. In Swift, I'd make things much simpler:
Or C#:
And how could it be without C++:
The direction is clear, I guess, as is the way I describe enumerations. Now let's return to the vehicle, i.e. to the PlayerVehicle class. I showed you its declaration, now let me show you its implementation.
As you can see, everything is very simple - we create an instance in the heap, assign all fields without exception and return what we get. I'm talking about the constructor, PlayerVehicleCreate function. And the destructor, PlayerVehicleDestroy, simply clears the memory allocated for the car.
Back in the game to where we left off: GameCreate, the game constructor. On line 19, I assign cameraOffset, which is the camera offset needed to render the position of everything. More on this later. Next, I assign NULL to cactusImage and sandImage fields. This is exactly what I was talking about: it's important to do this because otherwise these values will have garbage, and since their type is a pointer (LCDBitmap *cactusImage), without initialization with NULL at the start, these pointers will point to who knows where, and if I suddenly decide to dereference this address, my program will blow up, i.e. crash or segfault. We don't want that, because that's not what Americans created this little yellow console for, but for endless fun.
Next, please note three lines: 22, 23 и 24. In these lines we initialize our dynamic arrays. The first one is cactuses, the second one is sands, and the third one is tumbleweeds. If after reading the first chapter you still have a question about how to initialize the array that I so meticulously created byte by byte, this is just the right example.
Let's skip line 25 for now - I'll explain everything later. And after that we have the filling of cactus and sands. The most important lines are 40 and 43. In them we directly add a freshly created cactus or sand to the corresponding array by calling the ArrayPushBack function already known from the first chapter.
How exactly is the game map filled with sand and cactuses and what are RangeCreate (lines 31 and 33) and RandomIntInRange (line 37)? Range is an incredibly handy helper class that I created after peeking at an idea in Swift's standard library. Range(x, y) is a range of values analogous to the way [x, y) is specified in math, meaning a range from a number x including x (indicated by a square bracket) to a number y NOT including y (indicated by a round bracket).
I also decided to put the random number generator directly to the range: if you have a range, say, from zero inclusive to fifty non-inclusively, you can call the RandomIntInRange function and pass Range, and it will return a random number in the specified range. I found it much more convenient than all those millions of functions for generating random numbers that the StackOverflow website is full of.
Now let me tell you how I fill the game field with cacti and sand, and why there is no tumbleweed in this code. The dumbest option is to fill N objects (cacti and sand) with random x and y coordinates within the size of the field (the size of the field, by the way, is from -1000 to 1000, that is, 2000). But you shouldn't do it this way because with this approach it can theoretically happen that on a large enough piece of the field (for example, the size of the screen, i.e. 400 by 240) not a single object will fall out. And in this case the field will look empty and it will seem that the car is driving on a white sheet in MS Paint. We don't need that. Instead, we need the player to regularly see props that remind him that we are driving in the desert. It is possible, of course, to stick through the same distance sequentially cactus and sand as if they were dug by soldiers square-nest method, but it will look weird and without soul. It's important to find the golden mean - use random, but tame it. That's why I chose the following algorithm: I divide the map into rectangular blocks 150 by 100 (line 27)
const Vec2i blockSize = Vec2iCreate(75 * 2, 50 * 2);
and in each block I place one object at a random point in that block: either a cactus or sand. I determine whether to put cactus or sand by randomizing on line 37:
const int isCactus = RandomIntInRange(RangeCreate(0, 2));
And if isCactus is 1, then we put cactus, otherwise we put sand.
In general, of course, you can try for the sake of a simple test to generate the same number of cacti and sands with absolutely random position without any restrictions in the form of blocks. You know, let's try it right now. Look: the total number of cacti/sands on the map created equals to
ceil(2000 / 150) * (2000 / 100)
ceil is an upward rounding function. In math, it is denoted as square brackets without lower pips (square brackets have upper and lower horizontal pips, so if you remove the lower ones, you get upward rounding). Why I added this function here - because dividing 2000 by 150 will not give us an integer, and if we need an exact value, we need to decide what we do with the remainder. Based on the condition of our loop on line 28
for (float xMin = -1000; xMin <= 1000; xMin += blockSize.x + 20) {
the remainder should be accounted for. Oops, I just noticed that there's another +20 on the same line 28 - that's me making a space between the blocks for plausibility so that there aren't two cacti glued together. Hmm, so we're recalculating everything. On the x-axis I'm indenting 20, on the y-axis I'm indenting 15. That means that the total number of cacti/sands created on the map is
ceil(2000 / (150 + 20)) * ceil(2000 / (100 + 15)
is simplified into
12 * 18 = 216
So that's a total of 216 objects. Now let's rewrite the cactus/sand generation to a random position between -1000 and 1000 and see what happens.
In general, this option also looks good, but there are large areas with nothing at all. And one of a thousand variants of generation will definitely create such a thing that will be a huge hole, and I don't want that. So we've played around and that's enough - let's return the old algorithm and move on.
By the way, perhaps it would be more logical to put the creation of cacti in GameSetup, not GameCreate. But never mind - as it was done, so it was done.
This is the end of the GameCreate function. Next is GameSetup - a small function that starts the game. As I said, it is logically similar to GameCreate because it is also called once and also before all updates (ticks) of the game, but strictly after GameCreate.
There is not much code here: we first call srand function, which initializes the random number generator. You may not call it, but then by the fiftieth game you'll start to notice that all the randomness in the game (for example, the positions of cacti) is not random at all. We don't need that. Next, we call GamePreloadImages to load pictures (remember we initialized all pictures as NULL in GameCreate?). After that, we set the value in cameraOffset to half the screen - that's the way to do it. Let me tell you more about loading pictures - there are some interesting things there.
I have all the pictures in images folder in the project. The images of the car, of which there are 8 pieces, one for each direction, have ingenious names: 1.png, 2.png and so on up to eight. To load one picture I need to call loadImageAtPath function, which takes the path to the picture within the project (images/1.png, images/2.png and so on) and the necessary pointer to PlaydateAPI.
(LCDBitmap is a type from Playdate SDK, which means picture) I honestly borrowed this function in the C game example from Playdate itself, so there is nothing special here. It's how I load it that should be understood though. How do you even load 8 pictures with consecutive names? Of course, you could just do 8 lines like this:
This approach is called Chinese code in common parlance. Of course, I do not do it this way, but implement a loop. The name of the picture in the loop can be generated by knowing the iteration index. And a string formatting function would be used in high-level languages. Here's how I would do it in Swift:
In the Swift code, it is important to pay attention to the generation of the full path to the image file. The red-painted expression creates a string by inserting into it the value of the i iterator variable, which varies from 1 to 8 inclusive. In C, unfortunately, there is no simple API for such generation of dynamic strings. Yes, there is a function sprintf, which allows you to do something similar, but it doesn't care about the size of the string because that is our concern, and we don't need it. That's why I don't use this function either. Instead, I created a template path to the file "images/0.png" (line 205) in my C code and in each iteration I just substitute one letter, or rather a digit before the dot (line 207):
imagePath[7] = '0' + i;
What is so special about it? And that in the Swift version (or in any other high-level language, including C++ with fmt library) one dynamic line will be allocated and cleaned every iteration, while in pure C we allocate 0 dynamic lines, there is only one static line (because we know its size in advance), and we carefully change one byte in it with a scalpel and voila - no lines need to be generated. No need to use a tricky formatter, I am my own formatter!
Why am I doing this? That's an excellent question, thank you. I don't do it to reduce the load on the console processor because it is not significant. I do it only because it's very easy to do with C and also because C doesn't have a normal lib for formatting dynamic strings.
By the way, more details about the string in the code imagePath[7] = '0' + i;. This is where the magic of ascii characters happens. A zero in single quotes is a single character literal equal to zero, like a string, but only one element of it. If a string is a train, then a zero in single quotes is a single carriage. If you add an integer to it, the symbol becomes a different symbol. If you add one, the character changes to one forward in the ascii table. And in the ascii table, numbers are sequential, fortunately. That is, if I add 1 to zero, I get a one character. If the digits in the ascii table were in disorder, this trick would not work, and the problem would have to be solved either by a formatter or by Chinese code.
Whew. How are you feeling? Not tired yet? We've come a good way: we've covered GameCreate, GameSetup, and now I want to finish this chapter with the function that ends a game - GameDestroy.
It's super simple: we destroy the car object (line 186) because it was created in a heap like the arrays, then we destroy all three arrays that were similarly sequentially created inside GameCreate (cacti, sand and tumbleweeds), and then we destroy the game itself.
By the way, at the very beginning of the function I check game for NULL (line 182), and if it is, I just exit the function. At first I thought it was logical and allows you to write safe code. In C++ in particular, I always do this. But in C, unlike C++, almost everything is passed through pointers, and you will simply get lost checking everything for NULL (for example, imagine if you check the PlaydateAPI pointer for NULL everywhere). That's why it was the first and the last time when I made such a check - further I just decided for myself that everywhere where non-zero data is expected I'll just take my word for it that it's not NULL.
Total I created a car and a field on the first day, and cacti and sand mounds on the second day.
What about GameUpdate function? GameUpdate is the most important function in my code at the moment, because unlike all other functions of the Game class it is called steadily every tick, just like the heart pumps blood back and forth through the body every second. Without this function there will be no game. But we will analyze this function in the next chapter.
=========
My name is Eugene. I am a software developer from Almaty, Kazakhstan. I eat horses🐴. Thanks for reading. The next chapter is here.
Get Australian Safari
Australian Safari
Survival for Playdate
More posts
- Making a game for Playdate with Pure C. Chapter 4Jun 15, 2024
- Making a game for Playdate with Pure C. Chapter 3Apr 18, 2024
- Making a game for Playdate with Pure C. Chapter 1Jan 28, 2024
Leave a comment
Log in with itch.io to leave a comment.