Making a game for Playdate with Pure C. Chapter 3
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 firsthand how a game is created from scratch from idea to publication.
In the last chapter, I described how I initialize the scene, how I clear resources, showed how I fill the scene with props, and even experimented with generating those props. In this chapter, I will explain how the most important function of GameUpdate works, in particular, input processing and data processing.
GameUpdate is a callback function that is called every tick. So its task is to realize the holy trinity of any game:
- read input from the player
- update the game state
- draw the updated game state.
If you've ever been to a job interview at a game dev office, there's a 90% chance you've been asked about these three steps. And if you've ever written code for Arduino, you must remember two functions that must always be there: setup and loop. GameUpdate is just the analog of loop.
There is a car on stage that moves by pressing a D-pad, and there are props: cacti, sand mounds and tumbleweeds. The tumbleweeds move just like in real life. That is, they change position horizontally (on the X-axis) and also jump up and down as if bouncing off the ground. In order to realize the movement we need to manage time. For this we need to know in each tick exactly how much time has passed since the previous tick. Out of the box this parameter is not present in the Update event, but we can calculate this parameter using the API function playdateApi->system->getElapsedTime();. This function returns the number of seconds that have passed since the game was started. It is not a difference in ticks, but it is a good start. For the time difference in ticks we need to know the value obtained from the same function in the last tick. That's why there is a field float previousElapsedTime; in Game structure. At the end of GameUpdate function we save the result of getElapsedTime call into this field, and at the beginning of GameUpdate we subtract the difference between the current value of getElapsedTime and previousElapsedTime. This value is the dt, which equals the number of seconds that have passed since the previous tick. Since at the start of the game in main.c file in the first chapter I set the FPS to 30, I have an average dt equals to 0.033 seconds.
Then we process the input - we collect the values of pressed buttons and update the data depending on them.
PDButtons is a bitmask declared in Playdate SDK. In pure C bitmasks are implemented either as enum or simply as int, unlike Swift, where a bitmask is a completely different special class of data.
PDButtons bitmask contains a list of pressed and unpressed console buttons.
Also, you might have a question about what the PlayerVehicleAngleCreateFromButtons function is on line 72. This is a way to determine one of the eight directions of the vehicle by pressing the buttons on the device:
Why do we need the oldValue parameter in it? The point is that we need to return something even if no button is pressed. What should we return if no button is pressed? Which direction? In Swift/C++/C# I would return a nullable value (Optional in Swift, std::optional in C++ and Nullable in C#), but in C this is not so convenient because there are no generics/templates, so I decided to pass the old direction value because in case when no button is pressed the direction of the vehicle just doesn't change. This is right because in real life if you don't touch the steering wheel, the direction of the car doesn't change either. That's why we pass the old value and return it when Swift/C++/C# would return null. If I worked in a corporation with agile meetings, retrospectives, effective managers, team building and code-reviews, there would definitely be a reviewer who told me that oldValue argument, if you look at the situation from a certain angle, brings the logic of how the vehicle moves inside PlayerVehicleAngleCreateFromButtons function, and that's wrong because if you follow SOLID, strive to write perfect code, brush your teeth morning and night, go to yoga, run city marathons, give up meat, gluten, dairy, sugar, salt, this function should be responsible only for creating a PlayerVehicleAngle enumeration instance and nothing else, and the logic of passing the old value to PlayerVehicleAngle enumeration instance should be done without any discussion, talks or negotiations, must be outside PlayerVehicleAngleCreateFromButtons function, because purely theoretically we can use this function not only for a vehicle, but for something else that also has 8 directions, but if nothing is pressed, the direction will be, for example, reset upwards. And don't give a damn to the reviewer that this will happen exactly after the second coming or approximately never.
If we put aside irony and formulate an answer for the nerdy imaginary reviewer, the answer is as follows: oldValue is a great approach to code implementation very similar to building electric circuits. The value is like a current flowing through a function, and under certain conditions it may change at the output or remain the same. In general, code in the style of electric circuits is popular in C and not so popular in object-oriented languages. Of course, I don't urge everyone to write in this paradigm in C, but I answer for myself in this way.
Whew. Next. There is also a function GameAnyArrowIsPressed. It returns 1 if at least one button on the D-pad is pressed and 0 otherwise:
Well, we've arrived at the next incredibly important step of our life-saving tick - processing tumbleweeds.
screenSize constant is not needed yet - it will come in handy later. Then we go through the array using the old tried and tested method: get the number of objects and make a for loop. When I get the next tumbleweed object on line 118, I'm ready to change it (that's why the pointer to Tumbleweed is non-constant). I warm up and say to myself "do it well!". The first thing we do is process the position because the tumbleweed is rolling horizontally across the field. Every tick the moving object shifts, which means we have to do some simple manipulations with the position. This requires a basic knowledge of the mechanics section of physics (the one about "speed equals distance passed divided by time"). To better understand the process of movement in the code, first of all we need to understand what we need to do per tick. During a tick we need to change the position of each tumbleweed object. More precisely, we need to understand how much the tumbleweed object's position has changed relative to the old position per tick. This change is stored in dTumbleweedPosition constant, which is created on line 121. It is calculated very simply: the tumbleweed speed is multiplied by dt, that is, the speed is multiplied by the elapsed time for one tick. And then the change of dTumbleweedPosition is simply added to the position of the same tumbleweed field.
Similarly, motion works everywhere, not only in my game, but in all games and not only games - all kinds of smoothly moving buttons in the user interface, jumping download icon in Apple's browser Safari, pop-up window of Avast antivirus, falling push notifications on iOS and many other things that can be listed here until midnight.
Okay, we've got the movement. Let's move on. And then we're going to process the bounce. The point is that tumbleweeds bounce in motion. So we in our world, which we create with our thought and code need to program similar jumps tumbleweeds and preferably that the result looked plausible, and not clumsy as animation in 'Heroes of Might and Magic 4'. But how to make jumps look plausible enough? Just linear like movement? But it will be shitty, because in reality in any vertical motion involves the acceleration of free fall, and this makes the motion function quadratic, so linear motion will not work. The function must be exactly quadratic, that is, its argument must be squared in at least one place. The most trivial is a parabola. It is the most suitable here because in real life everything falls on a parabola (of course, if you ignore the wind and in general if you experiment on spherical chickens in a vacuum). But if the tumbleweed will lazily fly towards the ground on a parabola, then when it collides with the ground I will need to have a realized logic of this very collision for rebounding. Here I told myself 'check it out, dial it in, amp it up' just like Action Man (remember this superhero? I loved "Action Man" tv show when I was a kid, and I especially liked his three-dimensional computer drawing. At the time, I thought it was the best graphics ever. Recently I decided to revisit this show and was shocked at how terrible the graphics really were! RDR2 spoiled me! In general, Action Man at the climax of each episode would say 'check it out, dial it in, amp it up', calculate his movements to the smallest precision, and in the next 10 seconds would kick all enemies' asses) and decided to make it easier: I use the equation of a circle, or rather, the equation of cosine (or sine if you like, because the graph of sine is the graph of cosine shifted by 90 degrees).
But for our purposes, we will modernize the sine graph a bit - we will stick it in a module. Shoving any function into a module does an interesting trick to its visualization - it displays the bottom half upwards as if the x-axis had turned into a mirror.
To adapt this math trick into code, we need each tumbleweed object to have a jump 'angle' value, as well as the speed of that angle (how many radians the angle value will change by in 1 second). Why the angle? Because it is the angle that the sine graph takes as a variable. For the sake of clarity, you can look at it as a phase that rotates. So the Tumbleweed structure has the jumpVelocity and jumpAngle fields. On line 125 we calculate the value of dTumbleweedJumpAngle equal to the number of radians by which jumpAngle has changed, on line 126 we add this value to jumpAngle, and on line 127 we normalize jumpAngle. Normalizing directions is a thing you should do sometimes if you're working with directions - kind of like cleaning up cat poop if you live with a cat (or it lives with you, lol). Since the value of a direction is cyclic (0 radians and 2π radians are the same value, for example), you can, for the sake of clean code, conscience and credit history, after operations on a direction you can bring it to the range [0; 2π) if suddenly this angle is out of bounds (if the cat poops past the litter box you have to wipe it all up, because the cat is unlikely to do it).
In general, if we had C++ instead of C, I might have stuck normalization right inside the Angle class in the assignment operator, which can be overloaded freely. Or maybe not - implicitnesses sometimes make the code worse. Anyway, this is how we process tumbleweed jumps.
Total, we have sorted out the processing of the tumbleweed position, jumps (in fact, to process jumps is only half a job, you still need to draw them good, and this I will show further), it remains to process the frame. Yes, the tumbleweeds in my game have several frames for beauty. I did so because otherwise if the tumbleweeds would have one frame it would look shitty. And I don't want my game to look shitty. So I added frameIndex field to the Tumbleweed structure for frame processing. In general, many things in the game will have such a field and similar logic. Well, there is also frameIndex change rate: this is the frameIndexVelocity field. Yes, every Tumbleweed object has this field, although all objects have the same value. I could not add this field because it is kind of redundant, but let it be - in case I decide to make the velocity different for different instances of the tumbleweed (and I had such thoughts while writing the code), and saving memory on matches is the way to the madhouse. The tumbleweeds have a total of 4 frames. In one of the previous chapters you saw the constant TumbleweedSpritesCount = 4 - that's about it. frameIndex is a floating point number that changes in the range [0; 4) at the rate specified in frameIndexVelocity. The logic of lines 130 - 134 does exactly that.
This is how tumbleweed processing works. What do you think? I'm personally addicted. Let's move on.
Sometimes you have to create a tumbleweed, not just process it. To do this, you need to decide what logic you want to use to create it. When I was playing Minecraft hard, I used to read the Minecraft wiki. And in the wiki on Minecraft told how different entities are spun. And the logic of spawning is approximately as follows: the chance of one in ten thousand that in a particular tick spawned entity. This is the same logic I decided to put in, because it is simple and clear.
Line 139 tells us that with a 1 in 100 chance (tumbleweedSpawnChangePercentage is 1) a new tumbleweed will be created in a tick. On line 154, a tumbleweed instance is created with TumbleweedCreate function, and on the next line this instance is sent (actually copied) to the game->tumbleweeds array.
To create a tumbleweed we need 4 arguments: map position, movement speed, bouncing speed and frame rate. The position on the map is calculated in a very clever way - a new tumbleweed appears just exactly outside the border of the screen left or right. And moved towards the player's vehicle horizontally. You can, of course, spawn "honestly" in a random point of a large enough game map, but then the player will just rarely see the tumbleweeds, especially at the beginning of the game, and it worsens the user experience. The bouncing rate is the number of radians elapsed per second for the value from which we count the sine of the graph I showed earlier. And you already know about the frame rate: the tumbleweed has 4 frames, as I said earlier, and they have to be changed at a certain rate.
Then on line 158 the camera offset is assigned to the position of the car so that the car is always in the center of the screen wherever it goes. And on line 160 GameDraw function is called, which draws the whole mess I'm describing here so that the player can see what's going on, otherwise what's the point of all this?
We'll cover drawing in the next chapter, but thanks for reading. If you like my writing and want to support me with real money, here is my Patreon and Boosty.
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 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.