The square moves faster on my machine

An interactive representation of delta time.


Two black squares racing across a terracotta window.

We created a window in the previous article, and today we’ll build on that code to explore a familiar problem in software: inconsistent behavior across environments.

You’ve probably seen this in older games: the same animation crawls along on one machine and sprints at super-speed on another. It happens because motion is tied directly to the frame rate. The solution is delta time.

Before we get there, we need something to look at, so let’s draw a square. Once it’s on screen, we’ll change the frame rate with our keyboard and watch the behavior change in real time.


Creating the square

Before drawing the square, we’ll need a struct to hold its position and size, plus a toRect function that converts our Square into the rectangle type that SDL can render.

Create a new file square.zig next to our main.zig file with the following code:

const sdl3 = @import("sdl3");

const Square = struct {
    x: f32,
    y: f32,
    size: f32,

    fn toRect(self: Square) sdl3.rect.FRect {
        return .{
            .x = self.x,
            .y = self.y,
            .w = self.size,
            .h = self.size,
        };
    }
};

Zig structs are both data and modules, so a function defined inside one lives in the struct’s namespace.

NOTE: This goes further than you might expect. A .zig file is actually a struct, which is why @import gives you back that struct’s type. We’ll look at this in more detail in a future article.

A function whose first parameter is a Square can be called through the namespace: Square.toRect(square), or on the value itself: square.toRect().

The compiler rewrites square.toRect() into Square.toRect(square), so pick whichever reads better.

Drawing the square

Just like absolute-positioned HTML elements in the browser, XY coordinates in SDL start at the top-left corner (0, 0) and increase to the right and down. Our window is 1280px wide and 720px high, so the bottom-right corner is (1280, 720).

We’ll draw our square at (0, 0), with a size of 200px; small enough to fit comfortably inside the window.

First, import std and the Square at the top of our main.zig file:

const std = @import("std");
const Square = @import("square.zig").Square;

const square_size = 200;

Then create our square under the window and renderer deinitialization code:

defer window.deinit();
defer renderer.deinit();

var square_a = Square{ .x = 0, .y = 0, .size = square_size };

We’ve made it mutable using the var keyword so that we can update its position later.

Remember from our last article that we used try renderer.clear(); to fill the window with our current draw color? Well, draw calls happen one after another, so if we want what we draw to appear on top of the background, we’ll need to draw it after clearing the window.

SDL_Renderer has a neat little function called SDL_RenderFillRect that takes a rectangle and fills it with the current draw color. Let’s use that to draw a square in the corner of our window.

try renderer.clear();

// Draw
try renderer.setDrawColor(.{ .r = 0, .g = 0, .b = 0, .a = 255 });
try renderer.renderFillRect(square_a.toRect());

try renderer.present();

Run the command zig run build in your terminal, and see if you can fix the error that appears.

pub const Square = struct {

Zig needs to know that Square and toRect are accessible in other files, so we just add the pub keyword to make them public. Now run the command again, and you should see a black square; once you’ve made the toRect function public that is… ;)

…Oh, the whole window is black?

That’s because we set the draw color to black within the loop, and never set it back to the terracotta color that we’ve become so fond of. Let’s push that existing setDrawColor line down into the loop above the clear call.

try renderer.setDrawColor(.{ .r = 227, .g = 115, .b = 94, .a = 255 });
try renderer.clear();

Run the app again, and you should see our terracotta background with a black square in the top-left corner.

Let’s offset the square a little so it’s not flush against the edge of the window. While we’re here, let’s also replace the literals in the initWithWindow call with some constants:

// At the top of the `main.zig` file, above our existing `square_size` definition.
const window_width = 1280;
const window_height = 720;
const padding = 32;

const pixels_per_frame = 4; // We'll use this in the next section.

// Update our existing square code to use `padding`.
var square_a = Square{ .x = padding, .y = padding, .size = square_size };

Making it move

We want our square to move from its initial position to the other side of the screen. Since we know that the x axis is horizontal, we can simply increase our square’s x value within the loop until it reaches the right side.

Let’s increase the square’s x value when square_a.x is less than the window’s width minus the square’s width and padding:

// Add this code underneath the event loop

// Update
if (square_a.x < window_width - square_a.size - padding) {
    square_a.x += pixels_per_frame;
}

Let’s also add a shortcut to reset the square’s position whenever we press the R key. Add the following code below the existing .escape key handling code:

else if (keyboard.key == .r) {
    square_a.x = padding;
}

Now go and run the engine to see the square move across your screen!

Frame rate

Capping the frame rate

As I explained earlier, the speed that the square travels across the screen is currently dependent on the frame rate, so faster machines will push the square faster than slower ones.

If you’ve heard of VSync, you’re probably thinking that this is the solution, but VSync simply caps your frame rate at your monitor’s refresh rate. This does stop screen tearing, though a 120Hz monitor would still move the square twice as fast as a 60Hz monitor.

To see the problem, let’s cap the frame rate ourselves to a specific value and add some controls so that we can change it in real time.

Add this above our running definition. We’ll be changing target_fps dynamically, so we need to provide a type and use the var keyword:

var target_fps: u32 = 120;

Capture how many nanoseconds have passed in the first line of our render loop:

const current_frame_ns = sdl3.timer.getNanosecondsSinceInit();

Then create a function outside of main to throttle the FPS. We’ll call it at the very bottom of the render loop, under the present() call:

        // existing code ^^^
        try renderer.present();

        capFrameRate(current_frame_ns, target_fps);
    }
}

/// Call at the end of the render loop to throttle the frame rate to `target_fps`.
fn capFrameRate(frame_start_ns: u64, target_fps: u32) void {
    const elapsed_ns = sdl3.timer.getNanosecondsSinceInit() - frame_start_ns;
    const frame_budget_ns = std.time.ns_per_s / target_fps;

    if (elapsed_ns < frame_budget_ns) {
        const delay_ns = frame_budget_ns - elapsed_ns;
        sdl3.timer.delayNanoseconds(delay_ns);
    }
}

Changing the frame rate

Let’s build some controls to change the FPS. Replace the .key_down case in the event loop with the following inline switch:

.key_down => |keyboard| switch (keyboard.key orelse continue) {
    .escape => running = false,
    .r => {
        square_a.x = padding;
    },
    .one => target_fps = 15,
    .two => target_fps = 30,
    .three => target_fps = 60,
    .four => target_fps = 120,
    else => {},
},

This time, when running the engine, press keys 1, 2, 3, and 4 to watch square_a change speed. This happens because our movement is tied to the frame rate, which we’ll soon fix with delta time.

Racing two squares

The setup

Let’s race our square against another one, so we have a reference point. Duplicate our existing square and move it down a little bit so that both squares are visible on screen.

Add the following under our square_a definition:

var square_b = square_a; // Structs copy by value in Zig
square_b.y = window_height - square_b.size - padding;

This to our “reset” code:

square_b.x = padding;

And then add this code below our if statement which is updating square_a:

if (square_b.x < window_width - square_b.size - padding) {
    square_b.x += pixels_per_frame;
}

Also add the render call:

try renderer.renderFillRect(square_b.toRect());

Running the code now, we see both squares travel across the screen at the same speed as each other, relative to the FPS.

Delta time

We want our game to run at the same speed regardless of the frame rate that a system is capable of. This is where delta time comes in. Delta time (often written as dt or Δt) is simply the duration that has passed since the last frame was drawn.

If we know how much time has passed, we can use delta time to calculate how far the square should move in the current frame, keeping its speed consistent at any FPS.

distance = speed \times \Delta t

First add some more constants underneath pixels_per_frame:

const travel_distance = window_width - (padding * 2) - square_size;
const seconds_to_cross = 4;
const pixels_per_second = travel_distance / seconds_to_cross;

Now let’s add a variable above the two squares to store the duration since the last loop.

var previous_frame_ns = sdl3.timer.getNanosecondsSinceInit();

Then underneath our current_frame_ns line:

defer previous_frame_ns = current_frame_ns;
const delta_ns = current_frame_ns - previous_frame_ns;
const delta_seconds = @as(f32, @floatFromInt(delta_ns)) / std.time.ns_per_s;

And replace the code to update the position of square_b with:

square_b.x += delta_seconds * pixels_per_second;

You should now see square_b travel across the window at a uniform speed regardless of the frame rate, while square_a still varies in speed.

As you can see, delta time keeps movement at the same speed no matter the FPS. It also lets us describe motion in seconds, which is far more useful than using frames directly.

In our next lesson, we’ll learn how to project three-dimensional data onto the window and end up with a spinning cube.


All code for this series can be found in the repository.


Further reading… or watching