Making a game for Playdate with Pure C. Chapter 4
I write a game for the Playdate game console with 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 firsthand how a game is created from scratch from idea to publication.
If you haven't read the previous chapters, it's a good place to start.
Chapter 1 - creating an analog of a dynamic array object for future needs in pure C;
Chapter 2 - programming the SUV and desert objects, initializing and clearing game resources;
Chapter 3 - describing tick processing, specifically handling user input, and updating the data model.
====================
In this chapter, you'll get fifth grade math, drunken tumbleweeds, and curbing undefined behavior.
So, most people in the world are visual learners. This means that they are most accustomed to taking in information through their eyes. I've created a whole world in the past chapters, but what's the point if you can't see it? No, of course, I can talk in a bar about what incredible code I wrote, but the other person will not be able to see it because they don't have Playdate (you remember that I live in Kazakhstan, right? We have three people in the whole country who have Playdate including me), and because the interlocutor is drunk.
Anyway, where I'm going... Our cherished GameDraw function... It draws the game (suddenly!). Let me remind you, we have a car (SUV or "jeep" (in exUSSR SUVs are called 'jeeps' usually probably cause the first popular SUV model appeared there after USSR collapse was Jeep), tumbleweeds, cacti, mounds of sand and that's it.
We start with drawing the car. We don't actually draw the car on the top screenshot, but the comment on line 233 says that we do. That's just the way it is. If you remember in the first chapter I told you that my artist drew the car for me in eight variants, because we can specify 8 directions with the D-pad as on any self-respecting console. All these 8 pictures are stored in the array game->vehicleImage (I also told about it in the previous chapters), and the index of the picture in this array we determine by the direction of the car on line 234. We call the tricky function GameVehicleImageIndexFromAngle, which has the simplest possible logic:
Next we need to do some tricks with the image. Since we want to center the image on the position of the car on the map (and eventually in the screen) we need to get the dimensions of the image. This is exactly what happens on line 238. PlaydateAPI's getBitmapData function returns all sorts of useful information about the image, with the output data being specified by the function arguments as pointers. If you pass NULL, then you won't get the data. That's why the last three arguments are NULL because they are data for raw bytes of the image, mask and picture data (I don't remember exactly how it differs from raw bytes, tbh), and I don't need that yet, I just need width and height, wrap in a bag please, shake but don't stir. I need the newVehicleImageWidth and newVehicleImageHeight values a little later. Moving on.
On line 241, we clear the screen with white color using the fillRect function. The 1 at the end is white, and if we set it to 0, it will be black. We redraw the background on the whole screen. The screen size is 400 by 240 pixels as I mentioned in the previous chapters.
Next, we draw the environment. The first one is the cacti.
Cacti are drawn relative to the position of the car, i.e. cacti are not centered on the screen, which means we have to calculate their position on the screen in a tricky way. And another important detail: let's agree in advance that the cactus picture will be "attached" to the position of the cactus on the map by its lower center. That is, if the cactus is in position x = 5; y = 5, then we should draw the cactus picture in position x = 5 - width / 2, y = 5 - height, where width and height are the width and height of the cactus picture, respectively. Why the bottom center? Because we're looking as if we're looking in 3D, and we're like sticking a cactus with its booty in a field, like a canapé in Styrofoam. For all this, we pull the dimensions of the cactus picture on line 248 with the familiar getBitmapData function.
(UPD: actually in the code the cactus is centered, it's already in future commits the cactus anchor will be changed, but for now as is).
Then we loop through the array of cacti, and on line 252 we have a pointer to the next cactus in the iteration, a constant pointer, because we don't intend to change the state of the cactus during drawing. All this is done for the sake of calling the drawBitmap function on line 259. It is this function that draws the picture on Playdate, and it is this function that will be the protagonist in this chapter. The drawBitmap function is as simple as a Kalashnikov rifle. It takes 4 arguments: a picture to be drawn, x-coordinate, y-coordinate and an enum indicating whether you want to draw the picture expanded along some axis (we won't need it, but if we do, I'll let you know, I give you my word). The x and y coordinates are specified on the device screen. PlaydateAPI is not aware of my scene and objects on it - these are all my personal abstractions, which I invented to better perceive game construction after experience with Unity, Godot and cocos2d-x.
On line 256, there is a cactusRect, which is the cactus drawing rectangle in screen coordinates. If this rectangle does not intersect with the screen rectangle, we do not call drawBitmap. Actually, this check is redundant because the operating system does the same check internally during calling drawBitmap. But at that moment I decided that it was necessary. The intersection is checked by the RectIsOutOfBounds function
Is everything clear with the cacti? What's not clear? What's not clear about the position? Okay, I'll explain. The cactus has a position, we pull it out into a convenient constant on line 253. And on the next two lines we calculate the x and y coordinates of the cactus in screen coordinates. For this transformation we use a simple linear function: we take game->cameraOffset with a minus sign, subtract half the size of the cactus picture, add half the screen size and directly the position of the cactus in the game field, add salt and pepper if you like. Why this formula? Man, can I not tell you? I'm just lazy. Thanks, man!
Now let's draw the sand piles.
It's exactly the same as the cactus drawing, except that instead of the cactus array game->cactuses we are iterating over the sand array game->sands. And we have game->sandImage instead of game->cactusImage. In general, some of you will say "why repeat the code, in OOP it can be easily done in one loop, and if ECS is used, you can even fly into space!". Yes. In OOP it takes less code, but it slightly increases the runtime overhead because of virtual calls (yes, in ECS too). Not that it's a lot of time, it is match saving, and this saving is not my goal, but we are in C, we don't have virtual functions here. You can make pointers to a function and somehow end up simulating a virtual table, but it still won't be the same because this pointer won't work out of the box.
What's next? Next we're drawing the tumbleweeds. And it's not as trivial as cacti and sand piles. The tumbleweed has a shadow, and the object itself bounces along a sine wave as described in the previous chapter. Let me show you the vid of what it looks like again.
And this is what the tumbleweed rendering code looks like:
Here we have two loops instead of one. This is because we draw the shadow first, and then the tumbleweed body itself. And notice that we draw all the shadows first, and then all the bodies. You can do everything in one loop and draw the shadows first, then the bodies of each object in turn, and I did so in the beginning, but in this case it turns out that when crossing each other different tumbleweeds can have a shadow on top of the body. That is, the shadow of the second tumbleweed in the cycle is drawn after the body of the first, and if their positions are next to each other on the map, then visually it may turn out that the shadow of the second is as if "above" the carcass of the first, which is impossible in real life because in real life the shadow is always drawn on the surface on which this shadow falls (in our case it is the plane of the earth (no, I'm not a fan of flat earth)). Anyway, if you do one loop, it's going to look shitty. How shitty? Let me show you with a gif:
That's why all the tumbleweed shadows are drawn first, and then all their bodies. Moreover, if other objects will have shadows in the future as well, their shadows should also be drawn before all the bodies are drawn. This is the logic, and we are hostages to it regardless of the platform.
The first loop is banal and familiar:
- get a constant pointer to the Tumbleweed object (line 295);
- get its position into a separate constant purely for convenience (line 296);
- calculate x and y of the shadow to be drawn on the screen (lines 297 and 298) - the position of the Tumbleweed object is actually the position of the center of its shadow;
- if the resulting rectangle has at least one pixel overlapping on the screen, it is drawn (line 302).
The interesting things start in the second loop. First, we repeat the first lines of the first loop in the second loop (lines 308-311) and I don't regret it at all. I mean, this code is wrong in the academic sense because code repetition is yikes, we should put the repetitive code into a separate function, call this function from different points, cover it with unit tests, SOLID, agile, meetings-scrum-masters, clean code, retrospective, planning poker and John Carmack. But I don't care in this case because here we don't lose anything at runtime, and repetitive code is super simple, and I don't have a goal to make academically correct code (in general, it's a bad goal to make academically correct code because practically nobody ever needs such code). I need to make code that can be written in a reasonable time, then read without difficulties, and that this code clearly fulfills its goals. As you can see, academic fidelity is not on this list. Anyway, I repeat code, don't learn beautiful code from me, kids.
Second, I check that tumbleweedFrameIndex is between [0; 4) on line 315. This constant is the ticking frame index of the tumbleweed object. If the index is casted to an integer (because it is float itself), it will be the index of the tumbleweed picture in the array of pictures used for drawing. There are 4 of them in total, as I mentioned earlier. And tumbleweed->frameIndex is ticked in the data update section (described in chapter 3), and it is also checked there to see if they are out of bounds, but I'm checking it here anyway. Why? Just to be sure. Since on line 319 I'm going into the array at this index, I need to be sure that the index is valid. Because if the index is invalid and I still access the array with that index, I won't get an exception like C#, Swift or even C++ (std::vector::at throws an exception), I'll just get some value that doesn't represent anything meaningful. This behavior is called UB or undefined behavior. Specifically in this case it is clear what will happen - I will just go a little beyond the array, get the real data casted to a pointer to the image, and then when I will try to draw it on line 326 the game will bungle: either it will draw a complete nonsense (I've seen this shit by myself!), or the operating system will crash the process because the process will try to get into someone else's piece of memory, or some other nonsense can happen.
Let's do it right here right now! Look: on line 319 we access the array by the most correct kosher checked index. But you and I are going to sabotage my code a little bit! Instead of
LCDBitmap *tumbleweedImage = game->tumbleweedImage[tumbleweedFrameIndex];
write
LCDBitmap *tumbleweedImage = game->tumbleweedImage[tumbleweedFrameIndex + 1];
that is, let's simulate the index going out of bounds by one and see what happens. So, the code has been sabotaged, compile it, run it (the sound of rocket launch, Elon Musk is looking joyfully into the sky with his hands around his head, CNN is broadcasting live, and I am being kicked out of the programmers for intentional UB in the code).
And suddenly we get a very strange picture: a frame of a tumbleweed body sometimes turns into its own shadow, displaying an unlikely situation - a double shadow. Imagine this in life: a man walks, casts a shadow, and sometimes instead of the man himself there is another shadow hanging in the air. Why does this happen? Why didn't the undefined behavior crash the game instead? The reason is that right after the array there is just an image of the shadow in memory. And the order of fields in a structure in pure C is guaranteed. Remember, in the second chapter I showed you the Game structure? There, on lines 20 and 21, there is an array of tumbleweed body frames and its shadow. The array has 4 pictures, that is, it's like if I just declared 4 fields with frames in a row - it would be the same in memory layout. And then there is the image of the shadow, which is schematically identical if instead of an array of 4 and one image there was an array of 5. That is, instead of indexes [0; 4) we use indexes [1; 5), and the last index equal to 4 falls into the shadow. Yes, this is not C#, which would throw exceptions. This is C with controlled undefined behavior, kurwa!
Since it's such a party, I'll allow myself to digress and tell a great story from my Objective-C programming experience, which happened before The Red Revolution when Swift didn't exist yet, and all native iOS development was done in Objective-C. So I'm writing code, I have a class in Objective-C, it also has a static array field of 20 objects, and objects in Objective-C are basically stored as C pointers always. The objects in this array inherit one protocol (interface in C# and Java and abstract class without fields in C++), and I have int index, by this index I go into the array and call protocol functions. Do you feel the smell? So I wrote all this code, I run it on my iPhone, and at a certain moment I get an exception saying that I am calling a protocol function on the class (!) in which all these fields are stored, i.e. as if this function were static, although I definitely don't call static functions anywhere - I checked my code several times. However, when I run it, I consistently get an error that I am calling a protocol function from the class itself, which contains the array I specified, but the class does not have an implementation of this function.
Objective-C adherents probably already have the answer. Here's what happened. I am accessing the array by an index that I store in the same class. And at the fateful moment when the exception was thrown, this index was equal to -1. This means that when accessing the array (and the array is C-style one, not Objective-C style) we went one value backwards. In the example above with the tumbleweeds, I went one value forward, because I hooked the image that is in memory next to it, or rather, the pointer to the image. Here, in Objective-C, I've gone backwards beyond the array. What does that mean? The same thing as going forward, only you have to look at what lies in memory before the array. Realizing this in the moment I went to look at the class fields. But here's the problem: the array is the very first field, the class has no fields before it. Why is the call of the protocol function recognized as if it were static? And that's when a realization came to me. In Objective-C, objects are also structures under the hood, but each object has one special pointer before its fields in memory layout. It's a pointer to its class object. A class object is analogous to a virtual table in C++, but really improved, because it stores all the information about public functions in a format that allows you to iterate through the functions and properties of the class, and even add new ones right at runtime as if we were using JavaScript. And one more important detail about Objective-C - calling functions of class members in Objective-C is not the same as in C++, where you just get the address of a function like in C, insert arguments and go. In Objective-C, you send a message to an object - it's a higher-level operation. And you can send any message to any object with any arguments. In C++, C#, Java and Swift, if you try to call a function on a class that is not in it, you will get a compilation error. But in Objective-C everything will compile, but there will be an error at runtime saying that this class cannot respond to such a message. The initial exception was about this: that the class does not have such a static function implementation. Just for fun, I implemented such a static function in a class and put a breakpoint in it, and that's when I finally understood everything. Because of the index equal to -1 we shift from the array, which is at the same time the class field, to the class-object of this object, any messages sent to class-objects are considered calls of static functions, I didn't have an implementation of a static protocol function (although at the end I added it just for the sake of experiment), and in the end we fell with an exception. That's what undefined behavior leads to, holy crap!
Okay, back to the tumbleweeds. In general, initially I told you about the peculiarity of the second loop in drawing tumbleweeds. The first thing I pointed out was code repetition. The second is checking tumbleweedFrameIndex so that it doesn't go out of bounds. I think my intentions of checking the index are much clearer to you now. I'm not saying that every programmer should do this, I'm just explaining why I think this way, and it's up to you to do the same in your code or not. Third, interesting things happen on line 325. We recalculate the value of the y variable to render the tumbleweed body. We leave x as it is because the width of the shadow and the width of the body are the same. But y must wiggle up and down the sine wave as I showed in the graph in chapter 3. To do this, every tumbleweed object has a tumbleweed->jumpAngle field, I'm sure you remember that. This is a "rotated" float value from which we take the sine (actually cosine (line 323), but sine is cosine with an offset, so it's okay). Line 323 is where we take the modulus from the cosine - something I talked about at the end of the previous chapter. And on line 325, we multiply that value by 13 and draw a little higher from the shadow (by 20 pixels, note the spaceBetweenTumbleweedAndShadow constant on line 322). To better show the importance of the value 13 let's change it a bit - by a factor of two and a factor of ten - and see what we get.
That's why there are 13. At the end of the loop, we just draw the tumbleweed body itself using the already known drawBitmap function.
And that's it. No, of course, we draw a car at the end. But it's all so super-banal that I don't even see the point of showing it: we calculate the x and y coordinates according to the known formula and draw newVehicleImage, which we calculated at the very beginning of the GameDraw function, with the drawBitmap function.
Okay okay, let me tell you how the coordinates are calculated. At the root of everything is a linear function. Let's look again at the calculation in the tumbleweed code.
int x = -game->cameraOffset.x - tumbleweedShadowImageSize.x / 2 + screenWidth / 2 + tumbleweedPosition.x;
int y = -game->cameraOffset.y - tumbleweedShadowImageSize.y / 2 + screenHeight / 2 + tumbleweedPosition.y;
and in the cacti code
const int x = -game->cameraOffset.x - cactusImageWidth / 2 + screenWidth / 2 + cactusPosition.x;
const int y = -game->cameraOffset.y - cactusImageHeight / 2 + screenHeight / 2 + cactusPosition.y;
Both formulas show a pattern that can be written like this:
screenPosition = -cameraOffset - imageSize / 2 + screenSize / 2 + objectPosition
This is our linear function. How did we arrive at it? Let's try to come up with it from scratch, it's a fun exercise.
If we assume that the formula is actually like this.
screenPosition = objectPosition
If the cactus has position {5; 5}, then it will always be drawn at position {5; 5} on the screen regardless of the car position. This is obviously a wrong formula, but that's where we start. Moreover, in position {5; 5} will be the upper right corner of the cactus picture, which is not exactly what we want - we want the cactus picture to be centered relative to that very point. To center it, we need to subtract half the size from the original position. So the formula has become
screenPosition = objectPosition - imageSize / 2
Now let's think about the camera. In theory, the camera can fly over the world as it wishes. And if, say, the camera moves away from the zero position to the left by 5 pixels, i.e. cameraOffset is equal to {-5; 0}, then the cactus located in position {5; 5} should be drawn to the opposite right in position {10; 5} minus half the size of its picture. If the camera moves away to {-10; 0}, the cactus will move to {15; 5} - even to the right on the screen. That is, we need to subtract the position of the camera to calculate the position of the object on the screen. This is how the formula became
screenPosition = -cameraOffset - imageSize / 2 + objectPosition
Everything is fine, but the camera is not centered on the screen, but left-upper centered, that is, "glued" not to the center of the screen, but to the upper left corner, because this is the zero corner, that is, the default corner from which life in the coordinate plane begins. To center the camera, you need to move it by half the screen size. We end up with our linear function
screenPosition = -cameraOffset - imageSize / 2 + screenSize / 2 + objectPosition
Easy enough actually, and this is supposedly a fifth grade math course - linear functions on the coordinate plane. That rare case when the school program came in handy in my adult life!
That's all we have to say about drawing. In the next chapters you will see the first semblance of a virtual table, the birth of the framework and the logic of car acceleration. In the meantime, you can support me on patreon and boosty to speed up the release of the next chapters and new games.
To be continued...
Get Australian Safari
Australian Safari
Survival for Playdate
More posts
- Making a game for Playdate with Pure C. Chapter 3Apr 18, 2024
- Making a game for Playdate with Pure C. Chapter 2Feb 08, 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.