The Game Developer's Guidepost

The Game Developer's Guidepost

Your guide to learning game development

Programming Simple 2D Games in D

Chapter 7: Adding the Treasure

If you think about it, we've done some pretty interesting things in such a short amount of time. But what we have so far doesn't really amount to much of a video game. We have a character that can be moved around but there really isn't anything for the player to do just yet. This chapter we'll change all that by adding in treasure for the player to collect.

Let's discuss the design of the game a little before we set to work. We want treasure to spawn every so often at random locations in the world. Each time new treasure spawns the old treasure disappears in order to incentivize the player to collect the treasure quickly before it's too late. Each time the treasure spawns there's a small chance the treasure is rare. As the player collects treasure, points are added to the player's score. Rare treasure is worth more points than ordinary treasure.

In order to bring this design to life, we'll begin by adding a new Entity called "treasure" to the GameState. We'll also want to add a texture for the treasure and a timer to track when the next treasure should spawn. Add the following to the GameState struct:

    Entity treasure;
    // ...
    SDL_Texture* texture_treasure;
    // ...
    float treasureTimer;

Let's initialize these new members. Remember the texture will be loaded automatically thanks to our spiffy metaprogramming from the last chapter. The following should be added to the bottom of the initGame function:

    s.treasure.width  = 36;
    s.treasure.height = 48;

    s.treasureTimer = 0.0f;

To simplify things a bit let's extract what little game logic is currently in the game loop and place it inside its own function. Cut the player character movement code from inside the game loop and paste it into a new function called "updateGame". This function should take the delta time and a pointer to the GameState as parameters. Call this new function where the player movement code used to be.

With that done we're ready to handle the timer for spawning treasure. Each frame we'll subtract the delta time from the treasure timer. If the timer hits zero or below a treasure should be spawned. Add the following to the bottom of the new updateGame function:

    s.treasureTimer -= dt;
    if (s.treasureTimer <= 0.0f)
    {
        spawnTreasure(s);
    }

What we need now is to actually make a function called "spawnTreasure". Recall we had planned to spawn the treasure at a random location in the world. But how do we achieve randomness in video games? It turns out computers are well suited for rapidly running calculations and performing logic but are unable to create truly random numbers on their own. Instead, developers rely on mathematical algorithms to generate sequences of numbers that appear random but are actually deterministic. This sort of algorithm is known as a Pseudo-Random Number Generator (PRNG). There are very many PRNGs out there. The biggest differences between these algorithms is in the quality of the numbers generated and the processing time it takes to compute the next number in the sequence. Typically, the better the quality of numbers generated the more processing time required.

PRNGs generate their numbers based on some internal state. Each time the application asks for a random number the state is modified and the next number in the sequence is returned. Usually the state must be set to some initial value before numbers are generated. This is called "seeding" the random number generator. It's important to note that if a given PRNG is seeded with the same value (say the number 1236) the sequence of numbers generated after seeding will be the same every time. This property of PRNGs is how games such as Minecraft and The Binding of Isaac allow players to share a single number that will generate the same world for every player.

If we use a hardcoded seed value when we initialize the game, each session would play out the same way; the player could easily come to predict where the treasure will spawn. This isn't what we want, but what can we do? The obvious solution is to use a different value each time we seed the PRNG. There are a lot of ways we could do this. Because we'll only seed the PRNG once as the game boots up, we can use as computationally expensive of an operation to generate a quality seed value as we deem fit. Interestingly, early Pokemon games would use various techniques to generate their initial seed value, such as recording the time elapsed before the player reached the main menu and how many buttons were pressed before loading. Depending on the operating system, we could even ask the computer for an unpredictable number based on various hardware conditions (see /dev/urandom on Linux) which would be too expensive to do each time we needed a random number during gameplay. For this game we'll keep things simple and use the current time for our seed value.

But enough of this rambly introduction, let's get ready to generate some random numbers! Modify the import statements to match the following:

import core.stdc.stdio : printf, fprintf, stderr;
import sdl2;
import std.random;
import core.time;

In this game we'll use a PRNG called the Mersenne Twister. D has an implementation of this algorithm in std.random (which we just imported). It has good quality random numbers without being very expensive to compute. After all, this was the algorithm used in Final Fantasy XII on the PS2 and practically any personal computer on the market today runs rings around that system in terms of processing power. Anyway, let's add this PRNG to the GameState:

    Mt19937 rng;

In the initGame function, let's seed the PRNG with the current time:

    s.rng.seed(cast(uint)MonoTime.currTime.ticks());

We're finally read to write that spawnTreasure function from earlier:

void spawnTreasure(GameState* s)
{
    int margin = 30;
    int minX = margin;
    int minY = margin;
    int maxX = windowWidth - margin;
    int maxY = windowHeight - margin;

    s.treasure.pos.x = uniform(minX, maxX, s.rng);
    s.treasure.pos.y = uniform(minY, maxY, s.rng);

    s.treasureTimer = 3.0f;
}

Here we calculate the area of the window in which the treasure can spawn, which is inset from the edges by thirty pixels (the "margin" variable). The "uniform" function gives us a random number between a minimum and maximum value. We use this function to generate a new position for the treasure within the area of the window we calculated. We then set the timer for spawning the treasure to three seconds.

It's nice that we can spawn treasure now, but it'd be even better if we could see it in the game. Add the following to the renderGame function, just before the code that renders the player character:

    renderEntity(renderer, s.texture_treasure, &s.treasure, s.treasure.width, s.treasure.height);

If you run the game now, the treasure will be placed at a random location every three seconds. That's really nice, but wouldn't it be cool if we could pick up the treasure? Let's get started on doing that. First let's add a score counter to the GameState:

    uint score;

Let's also add a constant below the import statements that tells how many points are earned each time the player character grabs the titular treasure:

enum treasurePoints = 20;

Now we just need a way to tell if the player has touched the treasure. Remember how entities in this game have rectangular bounds? If the bounding boxes of two entities overlap then we know the entities have collided. If the player character collides with the treasure then the treasure should be collected. So what we need is a new data type that represents a rectangle and a formula for detecting collisions between two rectangles:

struct Rect
{
    Vect2 center;
    Vect2 extents;
    
    float left() const
    {
        return center.x - extents.x;
    }
    
    float right() const
    {
        return center.x + extents.x;
    }
    
    float top() const
    {
        return center.y - extents.y;
    }
    
    float bottom() const
    {
        return center.y + extents.y;
    }
}

// ...

bool rectsOverlap(in Rect a, in Rect b)
{
    return a.left() < b.right()
        && a.right() > b.left()
        && a.top() < b.bottom()
        && a.bottom() > b.top();
}

// ...

Vect2 extents(Entity* e)
{
    Vect2 result = void;
    result.x = e.width / 2;
    result.y = e.height / 2;
    return result;
}

Note our Rect type differs from the SDL_Rect type. For starters, the members are floating-point numbers. Beyond that, we've chosen to represent the rectangle as a central point and its extents. The extents of a rectangle are similar to the radius of a circle; they're half the width/height of the rectangle and "extend" outwards from the center of the shape. There are useful properties of representing a rectangle this way, but that discussion will have to wait for a later project. We can calculate each edge of the rectangle by adding or subtracting the extents from its center. The left(), right(), top(), and bottom() methods will be used to find those edges. We've made those methods const since they don't modify state, allowing us to get each edge even when given an immutable Rect instance.

We now have a way of representing a rectangle, but how do we test if two Rects overlap? Fortunately there's a rather simple formula for just such a purpose:

The rectsOverlap function uses a rather simple formula that tests if two rectangles intersect. Each edge of the first rectangle is tested against the opposite edge of the second rectangle. How the edges are tested may seem a little strange at first. Look at it this way; the formula checks to see if the first rectangle is not so far away that it's unable to overlap the second. If this is true, then the logical conclusion is the two boxes must overlap. If you'd like to see an interactive demonstration of how this formula works, you can find one here.

We also added a small convenience function to calculate the extents of an entity. With that done we're finally ready to test if the player character has touched the treasure. If it does, we add the points to the player's score, print the value, and respawn the treasure. Add the following to the bottom of updateGame:

    if (rectsOverlap(
        Rect(s.player.pos, extents(&s.player)),
        Rect(s.treasure.pos, extents(&s.treasure))
    ))
    {
        s.score += treasurePoints;
        printf("Score: %u\n", s.score);
        spawnTreasure(s);
    }

Compile and run the game. Grab a little treasure and notice where the treasure spawns. Close the game and do the same a second time. Notice how the treasure spawns in different places each time the game is run. This is part of what we set out to do. We still have yet to randomly spawn rare treasure, though. The window of opportunity for collecting rare treasure should be shorter than it is for normal treasure. Add the following constants below the one you added earlier to implement this:

enum rareTreasurePoints = 50;
enum treasureLifetime = 5.0f;
enum rareTreasureLifetime = 2.5f;

We also need to add a flag in the GameState that tells if the treasure is rare or not:

    bool isTreasureRare;

Edit the spawnTreasure function to have a one-in-eight chance to spawn rare treasure:

    int rareChance = uniform(0, 8, s.rng);
    if (rareChance == 0)
    {
        s.isTreasureRare = true;
        s.treasureTimer = rareTreasureLifetime;
    }
    else
    {
        s.isTreasureRare = false;
        s.treasureTimer = treasureLifetime;
    }

Finally, edit the updateGame function to take the rarity into account when the player character overlaps the treasure:

    if (rectsOverlap(
        Rect(s.player.pos, extents(&s.player)),
        Rect(s.treasure.pos, extents(&s.treasure))
    ))
    {
        uint scoreIncrease;

        if (s.isTreasureRare) scoreIncrease = rareTreasurePoints;
        else scoreIncrease = treasurePoints;

        s.score += scoreIncrease;
        printf("Score: %u\n", s.score);
        spawnTreasure(s);
    }

And that's all there is to it! Compile the game and give it a go. Notice how the score will increase more if you manage to grab the rare treasure. It's impossible to tell if the treasure is rare until the points are added to the player's score. We'll fix that in a later chapter. But for now, this is everything we set out to do.

Chapter Challenges

  1. Place the player character at a random location on the screen when the game is first started.

Chapter Resources