See that resplendent, awe-inspiring work of art above?
That’s what we’re going to make today.
First we’ll get our environment running, then we’ll add SDL3 to the build, and finally write our first lines of code.
Installing Zig
Zig is a low-level programming language designed as an alternative to C. Its design principles “no hidden control flow” and “no hidden memory allocations” combined with its strong type system are what convinced me to use it for my game engine.
If you’re using Visual Studio Code like me, the best way to install Zig is with the official extension (which includes ZLS). You can then install the binary via the Command Palette: cmd/ctrl + shift + P then typing "Zig Setup" and clicking the option you’d like to install. We’re using 0.16.0 at the time of writing.
Alternatively you can install Zig directly or via package managers. You’ll also want to install ZLS (Zig Language Server).
Learn about configuring ZLS here.
Once Zig and ZLS are installed, let’s create a directory and initialize a new project with the following commands:
If you see "All your codebase are belong to us" in the terminal, then Zig is installed correctly and you’re ready to start writing code.
These are the configuration settings I’m using. You can add them to your vscode settings.json file:
Installing SDL3
SDL3 is a cross-platform C library that handles windows, input, and audio. It wraps the platform’s native graphics API, so most of our code will work regardless of the environment it runs on. Today we’ll be using SDL_Renderer, a hardware-accelerated 2D renderer. This is a good starting point and will help us open a window without much effort. In a few articles we’ll switch to the lower-level SDL_GPU for 3D graphics pipelines and shaders.
Since Zig can use C directly, it’s perfectly valid to use SDL3 without any bindings at all. However, I didn’t want to deal with writing my own wrappers around the C ABI, so I decided to use zig-sdl3, which is currently the most popular Zig SDL3 library, and is supported by a bunch of regular contributors.
zig-sdl3 gives us SDL3 plus the bindings. Be aware that function and variable names will follow the Zig naming conventions instead of SDL’s. The friction is worth it for idiomatic Zig code.
At the time of writing this article, the latest stable release of zig-sdl3 is v0.2.2. You can install it with the following command:
Adding SDL3 to our project
Now we need to link the zig-sdl3 library to our build process so we can use it in our code. We’ll do this in build.zig since this is where we configure our dependencies and build steps.
First, grab the dependency we just installed. You can add this underneath the const mod declaration:
const sdl3 = b.dependency("sdl3", .{
.target = target,
.optimize = optimize,
});
Then, let’s add this struct to the exe declaration within the root_module.imports array:
.{ .name = "sdl3", .module = sdl3.module("sdl3") },
nameis the string we’ll use when we@import("sdl3")in our code.modulepulls a named module out of thesdl3variable, using the string defined in the package’s ownbuild.zig. This is how Zig knows where to find the code for the library.
Next, let’s completely replace the existing code in main.zig. The zig init command generates a useful structure for integrating a library (root.zig) with your application. However, it can also get in the way depending on what you’re trying to do. We’ll come back to root.zig in a future article.
// main.zig
const std = @import("std");
const sdl3 = @import("sdl3");
pub fn main() void {
const version = sdl3.Version.get();
std.debug.print("SDL3 version: {d}.{d}.{d}\n", .{
version.getMajor(),
version.getMinor(),
version.getMicro(),
});
}
Now for the moment of truth! Let’s try and run our software using the command zig build run. You should see the version printed to the console:
SDL3 version: 3.4.0
Making the window
Okay, now that we have SDL3 working successfully, let’s make a window. Replace the contents of our main function with the following:
sdl3.init(.{ .video = true });
defer sdl3.shutdown();
sdl3.render.Renderer.initWithWindow(
"What's up? SDL3",
1280,
720,
.{},
);
The init function must be called at the start of your application. We pass in a struct with the video field set to true, since we want to use the video subsystem which SDL_Renderer depends on.
You may notice that we’re using a new concept defer; this keyword allows you to schedule code to run when the current scope exits.
defer sdl3.shutdown(); ensures that the shutdown function is called when the program ends, and will cleanup any resources used.
Let’s run zig build run in the terminal.
Oh no! We have an error, let’s understand what it means:
src/main.zig:5:14: error: error union is ignored
sdl3.init(.{ .video = true });
~~~~~~~~~^~~~~~~~~~~~~~~~~~~~
src/main.zig:5:14: note: consider using 'try', 'catch', or 'if'
error union is ignored → The function that we’re calling returns an error union: a type that holds either the successful value or an error, and the caller has to handle both cases. This is a common pattern in Zig and an alternative to the typical exceptions you find in other languages.
consider using 'try', 'catch', or 'if' → Let’s add try in front of sdl3.init and initWithWindow since both functions return an error union. Error unions are written with an exclamation mark between the error set and the return type, for example error{OutOfMemory}!void. You’ll often see the error set itself left out, as in !void; this tells the compiler to infer the error set from the function body.
try sdl3.init(.{ .video = true });
defer sdl3.shutdown();
try sdl3.render.Renderer.initWithWindow(
// existing code
);
Rerunning the build, we get an error:
src/main.zig:5:5: error: expected type 'void', found '@typeInfo(@typeInfo(@TypeOf(sdl3.init)).@"fn".return_type.?).error_union.error_set'
try sdl3.init(.{ .video = true });
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
src/main.zig:4:15: note: function cannot return an error
pub fn main() void {
^~~~
This time Zig is telling us that our main function is typed to return void. Since try passes any error up to the caller, main needs a return type that can hold one. For us to fix this, we simply need to change the return type to !void:
pub fn main() !void {
Run the build again… 🥁
Oh my, another compilation error. Let’s read it together:
src/main.zig:8:5: error: value of type 'struct { video.Window, render.Renderer }' ignored
try sdl3.render.Renderer.initWithWindow(
^~~
src/main.zig:8:5: note: all non-void values must be used
src/main.zig:8:5: note: to discard the value, assign it to '_'
Again we’re told exactly how to fix the error. We need to discard the return value from initWithWindow.
In this case, our return value is a tuple with two elements, a Window and a Renderer. Since we aren’t using these yet, discard them with an underscore _. This is Zig’s way of saying “I don’t care about this value, discard it.”
_, _ = try sdl3.render.Renderer.initWithWindow(
"What's up? SDL3",
1280,
720,
.{},
);
Awesome, no errors this time!
Though I can hear you saying: “But Jasper, where’s the window??”
Not to worry, we simply need to add a loop to keep the window open, otherwise the program will exit before we can see it. This feels counter-intuitive, but it’s how nearly all interactive software works. We want to create an infinite loop! 🤪
We’ll create two loops: an outer loop that runs our code until we stop it, and an inner loop to go over any events that SDL3 has observed since the last frame. We’ll use a switch statement inside this event loop to set running to false when we want to stop the process.
Add the following code below the initWithWindow function call:
var running = true;
while (running) {
while (sdl3.events.poll()) |event| {
switch (event) {
.quit => running = false,
.key_down => |keyboard| {
if (keyboard.key == .escape) {
running = false;
}
},
else => {},
}
}
// render code goes here
}
You may be wondering what the pipe | symbol is for. Pipes are Zig’s capture syntax, used to pull a value out of something that wraps it; for example, optionals, error unions and loop elements. In this case, while (poll()) |event| runs the loop as long as poll() returns an event, and then gives us the current value which we’ve named event. Inside the event loop, .key_down => |keyboard| gives us the data associated with that event. We can then check to see if the Escape key was pressed.
It’s time to run the build… 🥁
Fantastic! A blank window opens and we can close it via the typical “quit application” shortcuts on your platform, pressing the Escape key, or by clicking the close button of the window.
Okay, now let’s add some color!
First bring back both the Window and Renderer from the tuple we discarded earlier. Change the code to:
const window, const renderer = try sdl3.render.Renderer.initWithWindow(
// existing code
);
defer window.deinit();
defer renderer.deinit();
Again we use defer to clean up the resources used by the Window and Renderer when the program exits. The order is important here, since the Renderer depends on the Window, we need to deinitialize the Renderer before the Window.
Zig runs defer statements in reverse order, so renderer.deinit() will run before window.deinit(), which is exactly what we want.
Now for the color, let’s add this line above the var running = true; line:
renderer.setDrawColor(.{ .r = 227, .g = 115, .b = 94, .a = 255 });
Run the build, I’m sure it won’t take you too long to try solving the errors that appear. 😉
Still seeing a blank window??
Well that’s because setDrawColor only updates the state, it doesn’t paint onto the screen. We need to clear the render target first, and then update the screen with any draw code executed since the previous draw with renderer.present().
This swapping behavior is known as double buffering, and is very common in graphics programming. It helps prevent flickering by drawing to a back buffer, then swapping it to the front buffer when we’re ready to display it. We’ll explore this in more detail in a future article, but for now, just know that we need to call renderer.clear() and renderer.present() every frame.
Replace the comment // render code goes here with the following:
try renderer.clear(); // fill the render target with the draw color we set earlier
try renderer.present(); // swap the back buffer to the front, displaying what we've drawn
Hell yeah! We have a nice terracotta colored window!
Up next, we’ll animate some squares and learn about delta time.
All code for this series can be found in the repository.