The Game Developer's Guidepost

The Game Developer's Guidepost

Your guide to learning game development

Programming Simple 2D Games in D

Chapter 8: Turrets and Bullets

In the last chapter we finally began to introduce some gameplay elements to our game project. In this chapter we'll follow up be adding even more. We're going to place turrets at each corner of the screen and have them fire bullets at the player character in order to make grabbing treasure more of a challenge. We'll give the player character a limited amount of health that will decrease each time they collide with a bullet. If the player character runs out of health points the Game Over screen will be displayed. As the player earns points by collecting treasure, the rate at which the turrets fire will increase. Once the player accumulates enough points they are given a "bonus" which returns the rate at which bullets are fired to normal and restores one point of lost health.

To support all this new functionality let's add the following members to the GameState:

    Entity[4] turrets;
    Entity[16] bullets;
    // ...
    SDL_Texture* texture_turret;
    SDL_Texture* texture_bullet;
    // ...
    uint health;
    bool gameOver;
    float bulletTimer;

Let's define some global constants to make the gameplay easier to adjust:

enum maxHealth = 3;
enum bulletSpeed = 300.0f;
enum bulletFireRateFast = 0.35f;
enum bulletFireRateSlow = 1.0f;
enum bonusRate = 400;

Now we're ready to initialize those members we added to the GameState. Append this code to the bottom of initGame:

    s.health = maxHealth;

    foreach (ref turret; s.turrets)
    {
        turret.width  = 32;
        turret.height = 96;
    }

    float turretMarginX = 24 + s.turrets[0].width / 2;
    float turretMarginY = 24 + s.turrets[0].height / 2;

    s.turrets[0].pos = Vect2(turretMarginX, turretMarginY);
    s.turrets[1].pos = Vect2(windowWidth - turretMarginX, turretMarginY);
    s.turrets[2].pos = Vect2(turretMarginX, windowHeight - turretMarginY);
    s.turrets[3].pos = Vect2(windowWidth - turretMarginX, windowHeight - turretMarginY);

    foreach (ref bullet; s.bullets)
    {
        bullet.width  = 24;
        bullet.height = 24;
    }

    s.bulletTimer = 0.0f;

In the code above, we initialize the player character's health to its maximum value. We loop over each turret and bullet, setting all of their dimensions based on the their texture sizes. At the end we set the bullet timer to zero. The most complex part of the function is how we position the turrets in the game world, and even that's quite simple. We place each turret at each corner of the screen, inset slightly from the edges by using a small margin value (in pixels).

Now we need to decrease the bullet timer and fire off a bullet if it reaches zero. Add the following to updateGame, just below the the code for spawning treasure:

    s.bulletTimer -= dt;
    if (s.bulletTimer <= 0.0f)
    {
        fireBullet(s);
    }

This is rather similar to the code we wrote to spawn the treasure. All we need to do is write that fireBullet function. Before we get to that, though, we need to take a moment to consider how firing a bullet should work. Each time we call fireBullet, we should loop over the bullets array in the GameState to see if there's a bullet not currently on the screen we can use. If we find one, we randomly select a turret from which the bullet will be fired and set the velocity of the bullet to fly in the direction of the player character. The simplest way to do this would be to flag if an Entity is currently in use or not. We can do this by adding a simple boolean to the Entity struct:

    bool active;

Now that we have the theory down let's go ahead and add a function for firing bullets:

void fireBullet(GameState* s)
{
    foreach(ref bullet; s.bullets)
    {
        if (!bullet.active)
        {
            bullet.active = true;

            size_t turretIndex = uniform(0, s.turrets.length, s.rng);
            Entity* turret = &s.turrets[turretIndex];

            float turretBarrelY = -(turret.height / 2) + 22;
            bullet.pos = Vect2(turret.pos.x, turret.pos.y + turretBarrelY);

            Vect2 dir = Vect2(s.player.pos.x - bullet.pos.x, s.player.pos.y - bullet.pos.y);
            dir = normalize(dir);

            bullet.vel = Vect2(dir.x * bulletSpeed, dir.y * bulletSpeed);

            break;
        }
    }

    float percentOfBonus  = cast(float)(s.score % bonusRate) / cast(float)bonusRate;
    s.bulletTimer = bulletFireRateSlow - (percentOfBonus * (bulletFireRateSlow - bulletFireRateFast));
}

This code is a bit dense, so let's break it down. We loop over each member of the bullet array, grabbing a reference to each element so it can be modified. If the bullet hasn't been activated, we know we have a bullet we can fire. We then randomly select a turret from which to fire the bullet. The bullet should be fired from the center of the turret's barrel, which is twenty-two pixels from the top of the turret texture. We subtract half the turret height to get the top of the turret, then add 22 pixels to get the center of the barrel. We use this value to place the bullet.

What's more interesting is how we set the velocity of the bullet. We want the bullet to move towards the current position of the player character. To do that, we get the difference between the player's position and the position of the bullet. But this gives us the distance between the player character and the bullet; we only want the direction. We can find that simply by normalizing the vector, effectively stripping off its length and leaving us with the direction. We then scale this vector by the bullet speed we defined earlier and set the bullet velocity to the result. This way the bullet is fired in the direction of the player character and will move towards that point at the appropriate speed.

In the last two lines of the function we calculate how long it should take before the next bullet is fired. We do this by first determining how close the player's current score is to reaching the next bonus. For example, let's say the player has earned 600 points; this means the player has already received one bonus and is halfway there to earning another. But how do we ignore the first bonus the player received? The simplest way to do this would be to discard all the times the player's score exceeded a multiple of the bonus rate. Looking at the problem from this angle, we can solve this by dividing the player's score by the bonus rate and keeping only the remainder of the division. This is what the mod operator (%) is used for; it returns the remainder of the division between two integers. We then divide this remainder by the bonus rate to learn, as a fraction, just how close the player is to earning another bonus. If the player's score is 600 points, percentOfBonus will be fifty percent (0.5f).

On the following line we calculate when the next bullet should be fired. We want the bullets to be fired at a rate between bulletFireRateSlow and bulletFireRateFast, approaching the latter as the player's score increases. We start by calculating the difference between these two rates of fire. This tells us how much of the fire rate the player's score will affect. Next we multiply the result by the percentage of the bonus we calculated earlier. This gives us a length of time that will be larger the closer the player's score gets to the next bonus. Finally, we subtract this amount of time from bulletFireRateSlow to make the rate of fire shrink as the player's score increases.

Whew! With all that out of the way, we need to simulate bullets so they'll actually move across the window. Add the following just below the bullet timer code in updateGame:

    foreach(ref bullet; s.bullets)
    {
        if (bullet.active)
        {
            bullet.pos.x += bullet.vel.x * dt;
            bullet.pos.y += bullet.vel.y * dt;

            if (rectsOverlap(
                Rect(s.player.pos, extents(&s.player)),
                Rect(bullet.pos, extents(&bullet))
            ))
            {
                bullet.active = false;
                if(s.health > 0)
                {
                    s.health -= 1;
                    printf("Health: %u\n", s.health);
                }
            }

            if (isOffscreen(&bullet))
            {
                bullet.active  = false;
            }
        }
    }

Here we loop through each bullet. If we find an active one, we move the bullet by it's velocity. We then check to see if the bullet overlaps the player. If it does, we deactivate the bullet and reduce the player character's health points by one (if any health remains) and print out the value. We also want bullets to be recycled if they leave the game window. We test for this by calling a function we haven't written yet called "isOffscreen". Let's add that function now:

bool isOffscreen(Entity* e)
{
    float eLeft   = e.pos.x - cast(float)(e.width / 2);
    float eRight  = e.pos.x + cast(float)(e.width / 2);
    float eTop    = e.pos.y - cast(float)(e.height / 2);
    float eBottom = e.pos.y + cast(float)(e.height / 2);

    return eRight < 0.0f
        || eLeft > windowWidth
        || eBottom < 0.0f
        || eTop > windowHeight;
}

All this function does is calculate the edges of the entity and tests if any of them are outside the bounds of the window. If any of them are, the result is "true"; otherwise the result is "false".

Now that the player has health, let's make it so the Game Over screen appears when the player character's health reaches zero. Add this to the bottom of the updateGame function:

    if(s.health <= 0)
    {
        s.gameOver = true;
    }

We want the game to stop playing if the Game Over screen is visible. Wrap all the updateGame code inside an if statement that checks to make sure the game hasn't ended:

    if(!s.gameOver)
    {
        // The rest of the updateGame code should go here
    }

Now we want to restore one point of health if the player just earned a score bonus. Edit the code where we check to see if the player character collided with the treasure to reflect the following:

        if (rectsOverlap(
            Rect(s.player.pos, extents(&s.player)),
            Rect(s.treasure.pos, extents(&s.treasure))
        ))
        {
			// ... Code that calculates the score increase

            int prevBonus = s.score / bonusRate;
            int nextBonus = (s.score + scoreIncrease) / bonusRate;
            
            if (prevBonus < nextBonus && s.health < maxHealth)
            {
                s.health += 1;
            }
            
			// ... Code that applies the score increase
        }

All this new code does is check to see how many bonuses the player has earned before (prevBonus) and after (nextBonus) the score is increased. If the first number is less than the second, we know the player just earned a new bonus. If they did, and the player's health isn't full, we add back one health point.

Now all we need is to edit renderGame to draw the turrets and the bullets they've fired. We also want to fill the screen with a solid red color for the Game Over screen:

    if(!s.gameOver)
    {
        // ... Code for clearing the screen and rendering the background

        foreach(ref turret; s.turrets)
        {
            renderEntity(renderer, s.texture_turret, &turret, turret.width, turret.height);
        }

        // ... Code for rendering the player and treasure

        foreach(ref bullet; s.bullets)
        {
            if (bullet.active)
            {
                renderEntity(renderer, s.texture_bullet, &bullet, bullet.width, bullet.height);
            }
        }
    }
    else
    {
        SDL_SetRenderDrawColor(renderer, 255, 0, 0, 255);
        SDL_RenderClear(renderer);
    }

    SDL_RenderPresent(renderer);

That's it for this chapter! We've reached the point where most of the gameplay has already been implemented. Even still, there's a lot of improvements that need to be made before we can call this project complete. We'll cover those improvements in the chapters ahead.

Chapter Challenges

  1. Adjust the constants we defined in this chapter and observe how this affects the game.
  2. Set the player character's health to zero if the player exits the screen.

Chapter Resources