The Game Developer's Guidepost

The Game Developer's Guidepost

Your guide to learning game development

Programming Simple 2D Games in D

Chapter 4: Improved Player Character Movement

In the last chapter we added the player character which could be controlled using the keyboard. But, as we mentioned then, there are issues with the movement code as it stands. This chapter will explore how to address those issues.

Let's consider the problem a bit more before we talk about solutions. Since there's only so many calculations a computer can make per frame, motion in video games is an approximation rather than a realistic simulation. Each frame we calculate an entity's velocity and then use this value to guess where the entity should be placed when the frame ends. At no point during a frame does the entity occupy the space between its starting position and ending position; it's as though the entity is magically teleported each frame. Since each frame is processed many times a second, this creates the illusion of smooth motion. To guess where an entity should be placed at the end of each frame we use a mathematical concept called "numerical integration." There are many different integration formulas out there, each with their own strengths and weaknesses. For this game we'll stick to using Euler (pronounced "Oiler") integration to approximate entity motion as it's quite simple to implement.

Returning to the problem at hand, we're currently moving the player character a fixed amount per frame. This means its motion is dependent on how quickly the computer is able to process each frame. This will vary wildly between different hardware and is even unpredictable when run on a single computer due to how the OS handles process scheduling and other factors. Therefore, tying entity motion to the framerate is problematic because we don't know how much time it will take the computer to process any given frame. By stating the issue in this way, the solution may already be obvious. If the framerate is too unpredictable to base motion on, we instead would like to base motion on the passage of time itself. This makes intuitive sense anyway if you considers how the speed of an object is always measured in distance over time.

Before we go on we need to establish a consistent unit of time to use throughout the source code of our project. In video game programming it's common to internally use seconds as the unit of time and so, as there's little reason to buck convention, we'll do the same. Now that we're basing motion on time, the velocity of our player character is in pixels per second rather than pixels per frame. To move the player character at the correct rate, we need to figure out how many seconds have elapsed since the last frame. This is called "delta time" (frequently abbreviated as "dt"). Since frames will be processed many times per second, the delta time will be a fractional value. With this information we now have all we need to move entities at a consistent rate. We can do this simply by multiplying an entity's velocity by the delta time before adding the result to its position. This is Euler integration (which we mentioned earlier) in a nutshell. Simple enough isn't it?

To implement this we need a way to ask the computer how much time has passed. Fortunately this is quite easy as the SDL_GetTicks function will do just that; it returns the amount of time in milliseconds since we called SDL_Init. If we call this each frame we can calculate how many seconds have passed since the last frame. Add the following just before the main loop:

    uint lastTick = SDL_GetTicks();

This next bit should go after the event loop but before the player input handling:

        uint currentTick = SDL_GetTicks();
        float dt = cast(float)(currentTick - lastTick) / 1000.0f;
        lastTick = currentTick;

In the snippets above we begin by calling SDL_GetTicks and storing the timestamp returned in a variable named "lastTick". We do this right before entering the main loop to prevent the delta time from being larger than expected the first time through. In the next snippet, each frame we call SDL_GetTicks to get the current timestamp. We then calculate the delta time by subtracting last frame's timestamp from the timestamp of the current frame and then dividing the difference by one thousand to convert from milliseconds to seconds. We're now finally ready to decouple the player character's movement from the framerate! Edit the player character movement code to reflect the following changes:

        float playerSpeed = 200.0f;
        // ...
        s.player.pos.x += s.player.vel.x * dt;
        s.player.pos.y += s.player.vel.y * dt;

And that's all we need to do to keep the movement of the player character consistent across different hardware! We did need to cover a bit of theory first, but we've finally decoupled entity motion from the framerate. Go ahead and compile the game, run it, and move the player character for a while to see how it feels. The player character should move smoothly across the game window. However, you may notice something doesn't feel quite right. It's subtle, but the player character currently covers more ground when moving diagonally than when moving up or down. There's a good reason for this. The speed of the player character is 200 pixels per second in either direction. But when moving diagonally, the player character moves more than 200 pixels in a second. We can prove this by using the Pythagorean theorem to calculate the length of the player character's diagonal line of motion:

    // Pythagorean theorem: a^2 + b^2 = c^2;
    sqrt(200.0f*200.0f + 200.0f*200.0f); // Result: 282.842712475f

Instead of 200 pixels per second the player character's speed is approximately 282 pixels per second when moving diagonally. To fix that we can normalize the acceleration vector before adding it to the player character's velocity. If you're not familiar with what this means, consider how our acceleration vector gives us two pieces of information: it expresses both a direction and a distance. By normalizing a vector, we effectively strip off the distance leaving only the direction. Normalizing a vector shrinks each component within the range of -1.0 and 1.0. A vector in this range is said to be unit length. After we normalize the acceleration we can scale it to any arbitrary length we want simply by multiplying each component by the desired amount. Doing this to a normalized vector will essentially change the vector to one which represents both a direction and distance. If we multiply each component of the normalized acceleration vector by the player character's speed the acceleration vector will be 200 pixels long regardless of which direction the player character is moving.

We'll use the follow utility function to normalize vectors:

Vect2 normalize(Vect2 v)
{
    import std.math : sqrt;

    Vect2 result = Vect2(0, 0);
    float magnitude = sqrt(v.x * v.x + v.y * v.y);
    if (v.x != 0.0f) result.x = v.x / magnitude;
    if (v.y != 0.0f) result.y = v.y / magnitude;
    return result;
}

First we import the square root function from the D standard library. Then we calculate the magnitude of the vector and divide each component by this value so long as the component is not zero. We store the result in a new vector and return it. Now we're ready to normalize the player character's acceleration. Edit the player character movement code again, like so:

        if (s.isKeyDown[INPUT_KEY_LEFT])
        {
            playerAccel.x = -1.0f;
        }
        if (s.isKeyDown[INPUT_KEY_RIGHT])
        {
            playerAccel.x = 1.0f;
        }

        if (s.isKeyDown[INPUT_KEY_UP])
        {
            playerAccel.y = -1.0f;
        }
        if (s.isKeyDown[INPUT_KEY_DOWN])
        {
            playerAccel.y = 1.0f;
        }

        playerAccel = normalize(playerAccel);
        playerAccel.x *= playerSpeed;
        playerAccel.y *= playerSpeed;
        s.player.vel.x += playerAccel.x;
        s.player.vel.y += playerAccel.y;

Try to compile and run the game now. Move the player character around paying special attention to diagonal movement. To see how much of a difference this all made, comment out the line where we normalized the acceleration then recompile the game and test it again. You should be able to see a marked difference in how diagonal movement feels. Reinstate the normalization code by removing the comment you just made. You don't want all that hard work to go to waste! We covered a lot of ground this chapter but there's even more interesting things on the horizon.

Chapter Resources