I've been working on network services since I was 15 years old. I started from doing HTML pages, then simple web sites with simple forms on PHP4 to order something or to leave a feedback, then more complicated web sites backed by some CMS like Joomla, after I switched to web frameworks and started making fully customized HTTP services, then some network services which communicate over different protocols (sometimes even custom internal ones). However, initially I wanted to do games as probably most of the programmers of that epoch. I liked playing video games, and programming seemed like an ultimate game. Unfortunately, I couldn't get a chance go get a job in that field, so took the way I took. However, the desire to work with graphics hasn't left me. This post is about me creating a tribute to Space Invaders and the differences between game development and web development I found.
For those who wants to see the game and the code:
I tried to approach the field a few times. I tried to go from bottom to top, trying to create my own game engine. I tried to go from top to bottom, starting with Unreal Engine 4. I tried to start from somewhere in the middle, taking OGRE. However, I couldn't last for long time with any of them for various reasons. Feel free skipping the details until here, because those details have more emotions rather than technical value.
Creating own 3D game engine is a tough task for a regular student programmer. However, I had a lot of enthusiasm and a great (in my opinion) idea. I knew that I needed OpenGL for graphics, OpenAL for sound, GLUT to create the window and handle the user input. I was locked and loaded and ready to engage. However, I mostly worked on how to render the scene rather than on the game itself. It was a pretty dark age, I had to do something like glEnable(GL_LIGHT1);
. Eventually, I found that I spent a lot of time just to render some simple things. That compelled me to try another way.
For some reason, I remember that OGRE was defined as a graphics framework. Now, I see that its proper definition is Graphics Rendering Engine. In fact, the abbreviation OGRE stands for object-oriented Graphics Rendering Engine. Anyway, starting with OGRE was a bit better experience. The examples from OGRE inspired me enough to make another approach. However, I just couldn't understand why something doesn't work within my code, but does work within an example. By that time I already had some experience in web development. Saying web development, I mean programming applications which communicates not only HTTP, but a variety of network protocols. In fact, I had a little success at that field. That success also made me thinking I'm mature enough to approach a game project. I was fixing websites a lot. When a web site doesn't work, it's pretty straightforward to debug it. However, bugs in a game are different. Some of my meshes had black spikes sticking out. I had no idea what was wrong. Also, I didn't want to admit some things like the necessity to compile a dependency with debug symbols to be able what's under the hood of the dependency. We don't do in Python, why would we need it in C++? Nowadays, the answer clear as a day. Anyway, the outcome wasn't good enough, so I tried to find something else.
Eventually, I took Unreal Engine 4 when it became open source and available under GNU/Linux. However, there were three things which demotivated me:
UCLASS
or USTRUCT
was incomprehensible. In addition, the debugger can't really help you to walk through the code like them because they are macros..git
directory grows too fast. The moment with Git disappointed a lot because I rely on Git diffs. Even, when I learn something I want to have a tool to spot the thing which breaks everything.There was some positive outcome, though. I created is the plugin for inverse leg kinematics. I even supported it for the newer versions of Unreal Engine 4. At certain moment I couldn't understand the changes in the API and gave up. Eventually, the functionality became the part of the engine.
After the attempt to make a game with a game engine, I realized that I miss something. I had no problems in web development. There was no unknown unknowns. Most of the times a problem was a matter of how fast I can type. However, I felt like I'm far from being a competitive programmer in general.
There is a saying: "Every programmer has to create a game, an operating system, a music player". By that moment, I had created a music player and failed to create a game. Therefore, I decided to approach an operating system. I knew that it's a difficult problem to solve. However, I hoped that the attempt would teach me something to make games. I found the website OSDev.org which has the list of books required to create an OS. By chance I showed the list to my CTO at that moment, who told me to take something else. He advised to start from:
It's been years since those days. I read those book and a couple more from their references. In particular, it's worth it to mention the series of articles What every programmer should know about memory. Also, I read a brilliant book introducing graphics concepts, the book is Learn OpenGL - Graphics Programming by Joey de Vries. However, I did the exercises from the book in Rust. A good addition to the book was the website about WebGL https://webglfundamentals.org/. Despite the fact that OpenGL and WebGL have some difference, it was nice to experiment with a shader and see the result of the examples straight away. Eventually, all these materials helped me to understand what the capabilities of Linux are, how to program considering CPU caches, purposes of some data structures.
I came across the article Challenging projects every programmer should try. One of the challenges from the list is making a clone of Space invaders. After all these attempts, I decided that it makes so much sense to create the first complete game as simple as possible. My first website had just 3 HTML pages with no interaction. Why did I expect to make an amazing game from the beginning? Let's make a simple game!
It is a magic moment when you are choose the available solutions on the internet to start your new project, isn't it? What do I need?
As the picture is the most important when you do graphics, I started looking for some software for rendering. Based on the previous experience, I decided that I'm not ready to create my own engine. Therefore, I'm not taking bare graphics API again. I don't want to take a monster like Unreal Engine either. What do I take?
In web development things are clear to me. There are the following sets of software to start from:
Therefore, the relations between the software you take and possible application look this way
Software | Possible application |
---|---|
TCP socket library | Network service, HTTP service, Website, Blog |
HTTP server implementation | HTTP service, Website, Blog |
Web framework | Website, Blog |
Blog engine | Blog |
The relations in game development were totally unclear to me. I found some lists on the internet, but the levels from the lists combined software with a huge difference in their features. Intuitively I determined the following kinds of applications:
However, the gap between 3D graphical application and a game seemed huge. Because, we might have a 3D graphical application, but with sound and other facilities. Also, despite my wish to create a 3D game, I considered to create it in 2D. Given that, I expected to have something which would allow me to make 2D graphics, but also would allow me a fairly simple transition to 3D if I feel like that. In addition, I remember an old forum thread with a message. It said that making games with DirectX is much easier than with OpenGL. Given all that, I had a hunch that there is some kind of libraries which closes the gap.
Apparently, I've never considered DirectX, because I wanted to make a game for GNU/Linux. All in all, it's nice to have a tool to make a cross-platform application rather than for a single-platform. Seeking it, I did a little research about Vulkan because I had heard that it's cross-platform. However, a "hello world" in Vulkan is verbose enough to show that it's not an option. And still, Vulkan is only an API.
I don't remember how, but eventually I found SDL. I mean that I knew about SDL, but I had an impression that it's a 2D graphical library for mini games. However, I found this the talk from Steam Dev Days 2014. In that talk, Ryan Gordon (@icculus) actually compared a "hello world" in SDL2 and DirectX. The example with DirectX had much more code. The video showed that it has support of everything I need: sound, user input, window creation etc. It seemed exactly what I need. However, I still wanted to have an ability to make 3D graphics. I found Raylib, but I read that font rendering is worse than in SDL. While I was researching how to use 3D with SDL, a miracle happened! SDL3 (actually 3.2.0) had been released. One of the features was a thin layer over 3D graphical API.
I'm still not sure about the classification, though. However, I've got an impression that the groups should be this way from the lowest level to the highest. I see the highest a level as a real-time strategy game engine as an example of a specific game genre. I brought a blog-engine as the highest level of software, but there are other kinds of websites and they have their engines. Anyway, here is the list:
Level | Hardware setup | Windows and user input | OS interaction | Graphics primitives | Model loading | Graphical user interface | Sound playing |
---|---|---|---|---|---|---|---|
Graphics API | NO | NO | NO | NO | NO | NO | NO |
Graphics API wrapper | YES | NO | NO | NO | NO | NO | NO |
Graphics toolkit | YES | YES | YES | NO | NO | NO | YES* |
3D Graphics engine | YES | YES | YES | YES | YES | NO | NO |
Game library | YES | YES | YES | YES | YES | YES | YES |
Here is a little elaboration on the table.
Two extra levels which are definitely higher. They provide so much additional functionality that it's difficult to compare with previous levels:
SDL3 solves a few things out of what I need. The picture of what I have left to setup is this:
I remember only one source of free assets is a beautiful website for gamedev newbies OpenGameArt.Org. I took the textures from there and the sounds. Also, I had to render some text. Therefore, I need a font. I decided to not to find a fancy font and take my favorite DejaVu. SDL3 has a library SDL_ttf 3.0 which renders TTF fonts, so there is no issue with rendering DejaVu. As an aside note, there is a website with a lot of free fonts - Font2U which you might like more than DejaVu. Nevertheless, I'm all set in this matter.
The only thing which is left is the language. SDL3 is a C library. If you don't want to use C, the only option for you is C++, because it supports C libraries without bindings. I didn't want to use either of them. However, it's definitely worth it to learn both of the languages:
Apart from writing code you deal with: building, debugging, profiling, testing and distributing. Experience with C and C++ will reveal something in those domains as well.
Building C/C++ projects is a bit complicated. You have 2 kinds of build systems. The first kind is build systems which execute commands to generate files, for example Make and Ninja. The second kind is meta build systems to generate the scripts for the build systems of the first kind, for example CMake and Autotools. De facto, CMake is the standard build system for C/C++ projects. Nevertheless, none of those build systems download dependencies. To get them you might need something else, for example Nix.
The state of the debugging process in C and C++ is quite satisfying. Even among free solutions you have quite a few options. You have tools to automate the debugging, for like GDB or LLDB. Both of them have text user interface (TUI) and both of them have customization like GEF. In addition to it, there is a huge variety visual debuggers based on them. Some of them are standalone (DDD), some of them are part of IDEs like QtCreator. Also, there is some support of the DAP protocol implemented in CodeLLDB and in GDB (since 14.1) itself. Also, there are some more sophisticated (and some of them commercial) debuggers which travel in time or watch every value (omniscient debugging). There is a good overview of them in the video Back to Basics: Debugging in Cpp - Greg Law - CppCon 2023.
Profiling process is quite descent as well. You use Linux Perf to record the execution of the program. To visualize you need to generate a FlameGraph, which you can visualize in a browser. Another way, you can use Hotspot.
Testing heavily depends on the foundation of a project. Qt framework provides its tools for testing, Unreal Engine provides its testing framework etc.
Distributing does not have a huge difference from other languages which compiles into a binary. You need to package you application according the target environment.
After working with all of that it will be easier to work with those language where those things were improved. Recently my hands have been on Rust and Go. At that moment SDL3 had only Rust bindings. Therefore, I had no problem with starting an SDL3 project. However, C bindings in Rust involve the conversion regular strings to null-terminated strings to pass them to C functions. By the date of the SDL3 release I had heard about the language Zig and I wanted to explore it a bit more. I read the documentation (v. 0.13.0) which made an impression of Zig as if it's a language which is as simple as Go. Out of the documentation I found that Zig handles both kinds of strings: null-terminated and length-based. Also, one of it's features is possibility to use C libraries without bindings. Just like C++! Another selling point to me was that Zig compiler can build C code. Given all these facts, it promised to have a seamless usage of SDL3 as if I used C or C++.
The main difference of an SDL3 application from a typical HTTP service based on a library is the necessity to have loop your application. A network service is based on a loop as well, however in the majority of the cases, if not always, it's hidden from the eyes. An SDL3 application framework looked to me in the following way: the application initializes, the event loop starts, the application gracefully terminates. Sounds simple, but too general. I had a few questions. What should be in the loop? How do I switch between the different screens? For example, between the menu and the actual battle. How do I store the data required for the picture drawing? Creating a network service you have a some sort of a data store, or even a few ones. For example, a web store can have two databases: PostgreSQL for the persistent data like products, Redis to store customer login sessions. I didn't expect to have an external application to store my data, but I couldn't figure what kind of objects I need and how to organize them. What do I start from?
I started off from getting something simple on the screen. A solid color with a picture would satisfy me. I browsed the examples in the repository of SDL3. They gave me the idea of what I have to start from. Also, I briefly explored the quick reference of the API. The names of the functions seemed intuitively clear. As a result, the start was pretty smooth, even though I used a fairly new programming language to me.
The code of main function of the Hello World had two parts. The initialization and the loop. Despite the fact, that it's written in an unpopular language, I'm pretty sure you get it.
The part consists of the following items:
// some setup of the basic facilities // ... if (!sdl.SDL_Init(sdl.SDL_INIT_VIDEO)) { return SDLError.InitFailed; } defer sdl.SDL_Quit(); const window_flags = 0; const window_title = "some body once told me"; const root_window: *sdl.SDL_Window = sdl.SDL_CreateWindow(window_title, 800, 600, window_flags) orelse { // ... handle the error }; defer sdl.SDL_DestroyWindow(root_window); const screen_surface: *sdl.SDL_Surface = sdl.SDL_GetWindowSurface(root_window) orelse { // ... handle the error }; const image_path = "hello-sdl3.bmp"; const hello_world_surface = sdl.SDL_LoadBMP(image_path) orelse { // ... handle the error }; defer sdl.SDL_DestroySurface(hello_world_surface);
I admit that the words defer and orelse might be cryptic for some people. Let me demystify them:
The keyword defer
sets the expression to be executed at the end of the scope (documentation). The closest alternative I've seen is implemented in D, C++ has an experimental implementation, and I remember that Unreal Engine had the macro ON_SCOPE_EXIT
, but I can't find the documentation for it. The same word appears in Go, but works differently - the statement executes at the end of a function. For example:
const std = @import("std"); pub fn main() !void { { defer std.debug.print("hello\n", .{}); } // <-- the defer executes here std.debug.print("world\n", .{}); }
This code prints
hello world
While the Go version:
package main func main() { { defer print("hello\n") } print("world\n") } // <-- the defer executes here
would print this
world hello
The keyword actually helped to put the terminating code next to the initializing code. That's why my Hello World only 2 phases instead of 3.
Another word orelse
is tightly connected to the concept of optional types. In short, the type has either nothing or something. The compiler enforces to check an instance of the type whether it has something or not. This concept came from the functional languages like OCaml where it's implemented as a variant type. The alternatives are the Option type from Rust and the type std::optional from C++1. The keyword orelse
executes the expression on the right side if the optional value on the left side is nothing.
The second phase is a loop which works as long as the variable quit
is false. The loop is split into 2 parts:
hello_world_surface
, is drawn, and the screen updates. Also, there is a constant pause between frames, because I wanted to have some VSync straight away. SDL_SetRenderVSync hadn't occurred to me by that moment.var quit = false; const frame_pause = std.time.ns_per_ms * 16; var sdl_event: sdl.SDL_Event = undefined; while (!quit) { while (sdl.SDL_PollEvent(&sdl_event)) { if (sdl_event.type == sdl.SDL_EVENT_QUIT) { quit = true; } } const white_pixel = sdl.SDL_MapSurfaceRGB(screen_surface, 0xFF,0xFF,0x00); _ = sdl.SDL_FillSurfaceRect(screen_surface, null, white_pixel); if (!sdl.SDL_BlitSurface(hello_world_surface, null, screen_surface, null)) { // ... handle the error } if (!sdl.SDL_UpdateWindowSurface(root_window)){ // ... handle the error } std.time.sleep(frame_pause); }
The two phases from the game loop of my Hello World missed something between them. This is the update of the game data. In my opinion, the description are quite general. As a result, I had a feeling that this part will be much more bigger then those two. However, eventually all three phases grew big. Eventually the Bird's Eye View of the process look this way.
Another thing occurred to me when I added more screens. This game loop is actually not for the entire game, but for one screen.
The "Battle" state is the actual where you play the game. The most interesting game loop, where the action happens, resides there. Space Invaders is the game with space, star ships, plasma or laser projectiles and explosions. I showed the structure of the game loop, let me highlight some details on it:
Time to write some code.
The beginning of a project is a beautiful moment. You can think about the architecture, that you make it perfect this time. However, after every time you see what could be improved, but you don't do it because it involves a lot tedious work. Either you do it for yourself and you never find time because you're lazy and you want to go only further, or you do it for someone, who wants to go further. Either way, architecture get worse bit by bit and becomes pain.
In the beginning of my career I remember the huge popularity of the object-oriented paradigm. I remember that most of my interviews consisted of the questions about it. It promised to make a program reflect the real process. You create entities (objects), those entities have properties (fields) and certain abilities (methods). The entities interact with each other via the abilities. This brings the wheels in motion. The paradigm stays on 4 pillars: encapsulation, inheritance, polymorphism and abstraction. The entire languages are built around this paradigm. The most bright examples are Java, Python and C#. The new PHP5 got classes! However, each of them has certain margins from the paradigm. Java doesn't allow multiple inheritance. Python allows any field of an object to be accessed. Those days, it seemed to me that only C++ implements the paradigm fully. In addition to the pillars, the paradigm has SOLID principles and the Design Patters by Gang of Four.
I learned all of them hoping to be able to create a good architecture. I revised the principles and patterns, read various articles and books about the architecture. However, I was unhappy with what I get every time. A poor architecture causes unnecessary work which would make anyone unhappy. With a hindsight I remember the thoughts in the beginning of a project. I wanted to prepare all the facilities before making the actual program. Factories of users, adapters of database connections, I even wrote singletons. I had a strong believe that those facilities would make a robust application and alleviate the maintenance.
By chance, I started thinking that C++ might be not the best language, but I wanted to have binary code instead byte code for JVM. I started looking for other object-oriented programming languages and found D language. I made a few programs in D and even translated a chapter or two of the book Programming in D by Ali Çehreli into Russian. However, I still made programs with the architecture which I constantly wanted to improve somehow. Things slightly changed with Go and Rust becoming popular. None of them has classes, so there is no inheritance! How do I make my hierarchies of exceptions? There are no exceptions? What is going to be afterward? After a few programs I realized that the absence of classes makes the purpose of the code less obscured. The explicit error handling became something natural to me. And turns out not only to me. The languages don't have classes, but have some things from object-oriented programming. This flavor of it helped me to write the programs with the architecture which made me happier to maintain.
Nevertheless, I still had a hunch that the situation could be improved. After a couple of years, I came across the presentation Data-Oriented Design and C++ by Mike Acton. I understood the points of the design, but I didn't understand which techniques to use. I had the answers for the questions: What, Why, How with the object-oriented programming. From the talk about the data-oriented design I got the answer only for the question "Why?". After a while I was browsing the Andrew Kelly's blog (the author of Zig) and I found his talk Practical DOD. The talk answered the remaining questions. I watched again the talk by Mike Acton and data-oriented Design made much more sense to me. That was a brave new world to me. With those materials which I read about the CPU caches it became somewhat of a complete picture. For those who are curious about the caches, I remember the talk Cpu Caches and Why You Care by Scott Meyers. This talk is a good addition to the article "What every programmer should know about memory".
An obvious thing is coming up: the purpose of a program is to transform the input data to the output data. There are no objects with their responsibilities. With the data-oriented design you define the data and the logic separately. Instead of encapsulating at the object level, you encapsulate at the system level. Some of the points are really interesting from the materials from the previous section:
Initially, I wanted to define the structure of a star ship with the following fields: position, size, velocity, name, type, pointer to the texture, pointer to the SDL renderer.
All the data in the fields is needed to render the star ships, but when I update the positions of the objects I need only the field with position. As a result, I create a function which takes in an object as a mutable argument. That says to the compiler and to another programmer, or to myself in the future, that any of the fields of the object can be changed by design. Even the pointer to the SDL renderer! It does not seem right because I wanted to say: "I update only the position".
The object-oriented approach suggests creating an interface with two methods - getting and setting the position. Here comes a seasoned game programmer saying that we need to know the velocity. That's right! The interface had the 3rd method to access the velocity. However, as everything was in one object, the interface simplified down to one method "move
" - this method performed the actual moving. As a result, the code got bound to the data in the interface, but it wasn't my intention! Now, an interface must have a name. It's always difficult to name an abstract thing. However, this is a textbook interface. I called it "Movable
". At that moment, I realized that I did not what I'd wanted. I spent time for solving a problem which wasn't even on the table. In addition, the complexity of the program grew! Not mentioning the unnecessary additional memory consumption which appeared only because of the design.
I fully stopped at that moment. I couldn't let this happen again. I came back only after I discovered the techniques of data-oriented design. The first fact is that there are many star ships, not a single one. Other words, there is a system of star ships. Therefore, I can maintain the consistency of the system and put the data the way need. As a result, I moved away the fields with the positions and velocities from a star ship. The data was set into their respective arrays - registries.
The code looked like this:
const StarShip = struct { size: sdl.SDL_FPoint, name: []const u8, type_: StarShipType, texture: *sdl.SDL_Texture, renderer: *sdl.SDL_Renderer, }; const PositionRegistry = struct { positions: std.ArrayList(sdl.SDL_FPoint), }; const VelocityRegistry = struct { velocities: std.ArrayList(f32), };
With this the function to update the positions looked like this:
fn updatePositions(positions: []sdl.SDL_FPoint, velocities: []const f32) void { for (0 .. positions.len) |index| { positions[index].x += velocities[index].x; positions[index].y += velocities[index].y; } }
That made it obvious that a set of velocities is not required. The velocity depends on the type of a star ship. It's either the player, or an enemy, or an enemy nomad. Given that, only 3 velocities were needed. In fact, the type of a star ship wasn't required. I replaced the argument velocities
with the argument velocity
and called the function separately for each kind of the star ships. However, I split the enemies and enemy nomad's because it allows the debugger breakpoints to be set without conditions.
In addition to the position updates I had to calculate the collisions. That says, that not only the positions, but also the sizes were needed. Therefore, one more field got removed from the structure StarShip
. At that moment, I had two options.
SDL_FRect
to be used.The second option was chosen because it felt more maintainable. As a result, a registry with rectangles was created.
pub const Rectangle = sdl.SDL_FRect; pub const RectList = std.ArrayList(Rectangle); const enemy_start_index: types.RectangleIndex = 1; const PawnRegistry = struct { starship_rectangles: RectList, enemy_nomad_rectangles: RectList, fn updatePositions(rectangles: []types.Rectangle, velocity: types.Velocity) void { for (rectangles) |*rectangle| { rectangle.y += velocity.y; rectangle.x += velocity.x; } } pub fn shiftPlayerPosition(self: *PawnRegistry, velocity: types.Velocity) void { const index = PawnRegistry.player_position; var rect = self.starship_rectangles.items[index]; const max_x: f32 = @as(f32, @floatFromInt(self.field_size.width)) - rect.w; const max_y: f32 = @as(f32, @floatFromInt(self.field_size.height)) - rect.h; rect.x += velocity.x; rect.x = clipValue(f32, rect.x, 0, max_x); rect.y += velocity.y; rect.y = clipValue(f32, rect.y, 0, max_y); self.starship_rectangles.items[index] = rect; } pub fn shiftEnemyNomadsPosition(self: *PawnRegistry, velocity: types.Velocity) void { updatePositions(self.enemy_nomad_rectangles.items, velocity); } pub fn shiftEnemiesPosition(self: *PawnRegistry, velocity: types.Velocity) void { updatePositions(self.starship_rectangles.items[enemy_start_index..], velocity); } };
One detail had been hidden until now - the rectangle of the player is stored together with the enemy rectangles. This rudiment appeared at the time when every ship had it's own velocity.
The owning of the textures by the star ships is not that simple, because some of the star ships share the same texture. There two ways:
The first way is simple, but inefficient. The second is complex, but less inefficient. However, dear reader, let's focus on the fact that there are multiple textures. The rule "Where one, there are many" was applied again. For this reason, a registry was created for the textures. There are 5 textures for the star ships.
One texture is for the player, three textures are for the enemies depending on their levels, the last texture is for the enemy nomads. Historically, I had the set of the textures for the enemies (including the nomads) and for the player. For that reason, the nomad texture is within the array of the enemies' textures. In addition to the textures, the game has the background, projectiles and the explosions. That resulted the structure:
const TextureRegistry = struct { const enemy_texture_count = types.EnemyLevel.max + 1; battle_background: Texture, explosion_atlas: Texture, player_starship: Texture, projectile: Texture, enemy_starships: [enemy_texture_count]Texture, }
The registry lasts as long as the battle. Once the battle is over, all of the textures are released. All of these registries evolved into a system which represents the battle. As a result, the system is represented as the structure Battle
. The building of the structure is complex. That's the moment where the object-oriented design comes in. The building algorithm is delegated to a few factories. The products of the factories are deleted by Battle
when it's over.
At this moment, the reader might suspect that the structure StarShip
does not exist anymore. Which is right! There two fields remained from the original idea: the name and the renderer. Names didn't find their purpose in the new design. The renderer became a field of the structure Battle
. Battle
borrows the renderer only because the renderer is required for other states of games. When it's time to render the frame, the renderer is accessed from the field and passed to the function which performs the rendering logic.
The encapsulation of the building of the structure Battle
shows that data-oriented design doesn't exclude object-oriented. The instance of Battle
is built with the Dependency-injection container in mind. The interfaces aren't involved, but it's possible to do it. The system has a few components, each one has a responsibility. The structure Reaper
has lists to harvest the expired projectiles, destroyed star ships, finished explosions, nomads which flew away. Battle
itself is a mediator for its components providing only 3 methods to handle the input, update the game data, and render.
Apart from the design there were a few challenges related to game logic itself. That's the actual reason I approached the project for. I'm not that familiar with game development. The only way I know to acquire some knowledge in programming is to create a toy project. The project does not have to explore every single aspect, but creating something always give me some clarity on the subject.
An animation is essentially a process of changing a value spanned across time according a function. The star ships in the game had to fly smoothly from one position to another instead warping.
Therefore, the position delta per frame is 16 / 250 * 25
which is about 1.6
pixels. I kept it hidden from you, dear reader, but before I came across the animations, I had wanted to store coordinates of the star ships in u16
. The thought in my mind was about the resolution which is unlikely exceeds 65536x65536 pixels. Therefore, there is an opportunity to save some memory. Let's have a look at the following presentations to understand what's happened when animations came up.
Both of the presentations show 5 frames long animation. When the coordinates are stored in u16 (the first presentation), the calculation sets the pixel to the wrong position. Imagine if the position delta is less that 0.5, the position of the pixel doesn't move anywhere, because round(0 + 0.4) = 0.
Nevertheless, the calculation is simple, nuances occur when it's time to do the last move according the animation. This has to be considered for two reasons:
The structure Timer
was introduced to address those problems:
pub const Timer = struct { duration: types.MilliSeconds, progress: types.MilliSeconds, last_tick: types.MilliSeconds, pub fn init(duration: types.MilliSeconds) Timer { return Timer{ .duration = duration, .progress = types.MilliSeconds.zero, .last_tick = types.MilliSeconds.zero, }; } pub inline fn add(self: *Timer, value: types.MilliSeconds) void { const raw_progress = self.progress.add(value); const clamped_progress = raw_progress.min(self.duration); self.last_tick = clamped_progress.subtract(self.progress); self.progress = self.progress.add(self.last_tick); } pub fn reset(self: *Timer) void { self.progress = types.MilliSeconds.zero; self.last_tick = types.MilliSeconds.zero; } pub fn lastTickRatio(self: *const Timer) f32 { return self.last_tick.toF32() / self.duration.toF32(); } pub inline fn isOn(self: *const Timer) bool { return self.progress.less(self.duration); } };
The method which addresses the issue is add
. A timer knows in advance the number of milliseconds before it goes off. Given that, it's possible to calculate the last frame step to that moment rather than to the current moment. As a result, when the velocity is calculated for the last frame, that value is used.
The calculation of the velocities through out the animation is implemented in the the structure Transition
:
pub const Transition = struct { value: types.Velocity, timer: Timer, pub fn init( value: types.Velocity, timer: Timer, ) Transition { return Transition{ .value = value, .timer = timer, }; } pub fn reset(self: *Transition, value: types.Velocity) void { self.timer.reset(); self.value = value; } pub inline fn isOff(self: *const Transition) bool { return !self.isOn(); } pub inline fn isOn(self: *const Transition) bool { return self.timer.isOn(); } pub fn step(self: *Transition, frame_duration: types.MilliSeconds) types.Velocity { self.timer.add(frame_duration); const frame_duration_ratio = self.timer.lastTickRatio(); return types.Velocity{ .x = self.value.x * frame_duration_ratio, .y = self.value.y * frame_duration_ratio, }; } };
The method step
calculates the velocity every frame. The velocity is used later in the functions which update the data of the game every frame:
if (enemy_move_transition.isOn()) { const velocity = enemy_move_transition.step(frame_duration); pawn_registry.shiftEnemiesPosition(velocity); if (enemy_move_transition.isOff()) { // Start the timer of standing } }
The story is slightly different with the timers for explosions. The idea of drawing an animation like this is rendering a certain part of an atlas. An atlas can be considered as a set of slides. Every slide shows the respective stage of an explosion. The idea is having a cursor which points to the slide. In this case, the animation is a process of changing the cursor regarding FPS. However, I update the cursor every frame. This is definitely a thing to improve. The game has VSync enabled. Given that, the frame duration depends on the screen refresh rate. As a result, the duration of the animation of the explosion depends on the hardware the player has. If you, dear reader, liked the explosion, it was taken from here.
I can't help but share two links about atlas animations. The first is the talk Shaders 101: Foundational Shader Concepts for Tech Artists by Ben Cloward. The link starts the video straight from the moment about them. However, I do recommend to watch the talk from the top if you struggle understanding shaders. I tried to approach them multiple times in my life, but the their code hadn't seem intuitive until I watched this. Also, the speaker has this nice series about creating a particle system. The second link is the article Animated Sprites and VSync by Lazy Foo. The article is a part of the SDL tutorial.
I encapsulated the atlas playing into the structure:
pub const AtlasPlay = struct { // private current_clip_count: u16, clip_count: u16, clip_size: u16, row_count: u8, pub fn init(clip_size: u16, row_count: u8) AtlasPlay { return AtlasPlay{ .current_clip_count = 0, .clip_count = row_count * row_count, .clip_size = clip_size, .row_count = row_count, }; } pub inline fn isOn(self: *const AtlasPlay) bool { return self.current_clip_count < self.clip_count; } pub inline fn next(self: *AtlasPlay) ?types.Rectangle { if (!self.isOn()) { return null; } const x = self.clip_size * (self.current_clip_count % self.row_count); const y = self.clip_size * (self.current_clip_count / self.row_count); const ret = types.Rectangle{ .x = @floatFromInt(x), .y = @floatFromInt(y), .w = @floatFromInt(self.clip_size), .h = @floatFromInt(self.clip_size), }; self.current_clip_count += 1; return ret; } };
The point of it to return a rectangle which represents the part of the atlas to render and voila:
There is nothing special in the algorithm which detects collisions in the game. The game has few objects. Every object takes only 16 bytes, that says that they fit the cache line nicely. The objects are stored next to each other, therefore they are good candidates for prefetching. Given all of that, using a dead simple brute-force doesn't hurt the performance. From the design perspective I found that two kinds of projectiles are required. One for the player, one for the enemies. Other words, the owner of the projectile had to be known. That allowed me to determine what the projectile can destroy. Given that, the collisions calculated against either the enemies or the player's star ship. Down to the code:
The code which determines if the player is hit:
pub fn calculateEnemyProjectileHits( projectiles_to_reap: *ProjectileRegistry.IndexList, projectile_registry: *const ProjectileRegistry, player_rectangle: *const types.Rectangle, projectile_length: f32, ) !bool { // brute-force to find collisions of enemies' projectiles with the player's star ship O(N). }
The function fills projectiles_to_reap
with projectiles to be reaped. Also, it returns the flag which indicates whether the player's star ship is hit. The flag is used to decrement the hit points of the player.
pub fn calculatePlayerProjectileHits( enemies_to_reap: *PawnRegistry.IndexList, projectiles_to_reap: *ProjectileRegistry.IndexList, projectile_registry: *const ProjectileRegistry, rectangle_stream_prototype: *const PawnRegistry.RectangleStream, projectile_length: f32, hit_registry: *HitRegistry, ) !void { // brute-force of projectiles and enemies which causes O(N^2). }
The enemies and the projectiles are represented as indices. The function detects the collisions and saves them into enemies_to_reap
and projectiles_to_reap
. Saving them allowed me to get the positions and run the explosion animations.
Despite the fact that the solution of the problem is quite simple, I had to debug it. Which is another interesting part about the collisions.
In web development "printf" is the most popular way to debug. Despite the facilities which debuggers provide, they aren't that popular on both backend and frontend. Using debug print works up to a certain point. This point is the moment when a programmer has a screen full of messages to read to find the desired debugging message. I decided not wait for the point. Debuggers are in my toolbox in addition to "printf" debugging. However, I felt that my efficiency with them can be improved.
I was really curious about GDB. VSCode and QtCreator provide nice and comfortable wrappers around GDB. However, I knew that people still use it as it is. I found that it has it's own text user interface (TUI). I found that Vim and Neovim have builtin plugin for GDB. Also, I found that GDB some huge extensions like GEF, GDB dashboard, peda and PWNDBG. Each of the finding improves the GDB experience, but it still does not answer the question: "Why would people use it without a GUI wrapper?".
Eventually, I found the article Scripting GDB by Serapheim Dimitropoulos. The article shows that you can automate the debugging process!
It appeared a game changer to me! A script is created with some program to dodge every obstacle on the way and get to the right spot in the runtime of you program. Having the ability to automate allows the debug print be filtered by a program. A GDB script can contain Convenience Variables with conditions and loops.
Let's imagine the case.
a
and b
.a
is called every frame. That says that there are many calls.a
calls b
twice. Two if
consequential statements are between the calls.while (true) { a(); } fn a() { b(); if (boolean_expression_1) { // line 7: body } if (boolean_expression_2) { // line 10: body } b(); }; fn b() { // breakpoint is here }
The problem: a bug is suspected in the function b
when it's called after the if
statements and both of the have true conditions. If you set a breakpoint in the function B, then you have to skip every time when the breakpoint hit in the first function call. If you set the condition for the breakpoint to skip the first hit, you still might end up in the first function, but on the next frame.
Here is what we can do. We define a convenience variable. We set a breakpoint inside function a
. The breakpoint sets the value of the variable to 0 and continues the execution. We set a breakpoint in the body of both of the if
statements. Both of the breakpoints increment the value of the convenience variable and continue the execution. We set a breakpoint in the function b
with the condition which checks that the convenience variables is 2
. Here is the code of the GDB script.
break a commands set $count = 0 continue end break 7 commands set $count = $count + 1 continue end break 10 commands set $count = $count + 1 continue end break b if $count == 2 commands # do what you need end
That means that execution stops only at the suspected. No redundant interaction is required. That was a real breakthrough! The power of GDB is being used as an interpreter of GDB scripts instead of being used interactively. I dodged so many conditions with it! A little program instead of staring at the loads of the debug print. There was a variety of conditions: the expiration of the projectiles, the changes of the location of the star ships and their velocities. Once it was necessary to count the frames after a certain moment in the game. Even more, a sleep statement in the beginning of a game loop showed if the gameplay is not affected by the performance.
There was one bug which I couldn't catch with GDB. It happened when a projectile hit a star ship, but visually it was far away from it. Spoiler: the root cause was the rotation vector being not normalized.
As it was mentioned GDB supports automation of the debugging process. With Python API the opportunities are quite broad. However, there were cases when it was easier to add some debug printing drawing. As I mentioned, I worked on a plugin for Unreal Engine to support inverse kinematics. The idea of that plugin was to take some parts of the animated skeleton and adjust them according to the normal of the model and the normal of the surface.
Unreal Engine 4 supports debug drawing. On the screenshot from above you see a few outstanding things which are drawn. Those are the normal vectors of the puppet which start at the tarsi, the normal vector of the surface, the spheres around the joints of the skeleton which are rotated. That time it helped, so I added the debug drawing to the game here. That allowed me to troubleshoot the bug with the collisions of the projectiles. The following video represents the bug:
To root cause the bug I added the debug drawing and saw this:
Every hit was stored in the HitRegistry
which was designated in the arguments of calculatePlayerProjectileHits
, but I deliberately kept it undisclosed. The structure holds the data which represents the collisions of projectiles and star ships. The registry stores the data all the time (I had an idea about the data). However, it wasn't required to render the data all the time. I needed it only for debugging. Given that, it was worth it to add a flag to the build script for the purpose. When a bug appeared, the project was compiled with the following command:
zig build -Ddebug_hits
The flag is defined this way in the build script of the project:
const exe = b.addExecutable(.{ // ... }); // ... const debug_hit_option = b.option( bool, "debug_hits", "When the flag is true, all the hits will be drawn for debugging purposes", ) orelse false; const options = b.addOptions(); options.addOption(bool, "debug_hit_option", debug_hit_option); exe.root_module.addOptions("config", options);
It is used in the code this way:
const config = @import("config"); // ... if (config.debug_hit_option) { try debug.FigureRenderQueue.drawHits(renderer, hit_registry.list.items); }
That's a very nice bonus from Zig. It allows compilation flags to be used without macros. A system of macros in C/C++/Rust is like a language inside a language. Static analysis frequently fails to understand them, they breaks in indentation, it's easy to forget their rules because they are not used frequently, it's difficult to reason about them. In fact, the internal style guide of some companies prohibit them. Zig allows the flag to be used with a regular if
expression (not statement). However, the expression is evaluated in the compile time still just like a macro. Debug drawing might be annoying because it requires to recompile the project. Zig builds project in debug mode after 0.14.0.
Programming a game is difficult but a lot of fun. Also, it's fun to share the game with your mates. In fact, people more curious about a game rather than a website. Everyone likes pixels - they are beautiful! Nevertheless, the game has to be shipped to other computers. The goal is to run only one command which produces the archive which can be sent over.
Initially, I added the dependencies of the project as Git submodules. That doesn't align the goal to build everything in one command because pulling Git submodules is an additional step. Luckily, Zig Build System allows the dependencies of a project to be downloaded. To achieve it, a Zon file has to be created. The content of the file is the list of the packages to download. A package can be a Git repository or even an archive with the code. Therefore, it's possible to download SDL3 and its extensions: SDL_ttf, SDL_image and SDL_mixer. Let's call them SDL3 suite. The instructions to build them have to be set in the building script build.zig
of the project. This would allow me to jump into the project next time without the necessity to remember how to build the project with the dependencies.
It's worth it to say that there are Zig packages which allow SDL3 to be used in a Zig project as it is. However, there's only one option for the version of Zig I used is https://github.com/castholm/SDL. Also, there are the extensions. SDL_ttf and SDL_image are packaged with Zig Build System. However, both of them require this package which uses the newer version of Zig.
All in all, I had to create the script to build SDL3 with its extension by myself. However, the SDL3 suite have their own dependencies. Fortunately, they have scripts to build the dependencies. As a result, the commands, which I used to compile the SDL3 suite, have to be put into the build.zig
. The commands have to be called only when the libraries are not compiled. The drawback of the approach is the necessity to have the build systems of SDL3 installed. With the Zig packages, which mentioned above, I would've need only Zig itself.
As the first friend, who I wanted to share my game with, uses Windows - I got to figure out how to build the project for windows. Cross-compilation is the first class citizen in Zig. However, the game crashes when Zig is used as a C compiler for the SDL3 suite. Zig sets the flags -fsanitize=undefined -fsanitize-trap=undefined
for the all of the build types except ReleaseFast. That exposed the undefined behavior from SDL_ttf. Fixing the problem would be a whole new side quest. The alternative approach is using the C compiler from MinGW. It allows a project to be compiled for Windows from GNU/Linux. The SDL3 suite provide CMake toolchain for MinGW. As a result, the commands to compile SDL3 from GNU/Linux for Windows are:
cmake -S . -B build-windows -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=build-scripts/cmake-toolchain-mingw64-x86_64.cmake
cmake -S . -B build-windows -G Ninja \ -DSDLIMAGE_VENDORED=ON \ -DSDL3_DIR=/path/to/SDL3/build-windows/ \ -DCMAKE_TOOLCHAIN_FILE=build-scripts/cmake-toolchain-mingw64-x86_64.cmake
cmake -S . -B build-windows -G Ninja \ -DSDLTTF_VENDORED=ON \ -DSDL3_DIR=/path/to/SDL3/build-windows/ \ -DCMAKE_TOOLCHAIN_FILE=build-scripts/cmake-toolchain-mingw64-x86_64.cmake
cmake -S . -B build-windows -G Ninja \ -DSDLMIXER_VENDORED=ON \ -DSDL3_DIR=/path/to/SDL3/build-windows/ \ -DCMAKE_TOOLCHAIN_FILE=build-scripts/cmake-toolchain-mingw64-x86_64.cmake
The DLL files of the SDL3 suite are ready. The game compiled into an exe file. To me it felt like a miracle! The file with the assets and the DLL files was sent to my friend. Unfortunately, the game didn't start because the library libwinpthread-1.dll was missed. The library is located within MinGW. The library was sent as well and the game crashed. Why? Well, I have no idea, but writing descriptive errors helped me to root cause hundreds times. The errors in Zig are pretty plain. A payload can't be attached to them. That's the reason for the structure Report
to exist. The structure is used in a simple way:
var report = Report.init(allocator); defer report.deinit(); errdefer report.print(&bw) catch { @panic("couldn't print the error report"); };
The keyword errdefer
works similar to defer
, but the attached code runs only if an error occurs. A report is an array of strings. If an error occurs, the report prints the strings to STDERR. Apparently, the strings have to be added. Therefore, the report is passed to every function which can fail. If some function call returns an error, the report gets a string describing the error.
pub fn createFromImage( report: *Report, renderer: *sdl.SDL_Renderer, path: [:0]const u8, ) !Texture { // ... const loaded_surface: *sdl.SDL_Surface = blk: { const ret = sdl_image.IMG_Load(@ptrCast(path)) orelse { try report.appendFmt("fail loading the image {s}: {s}", .{ path, sdl.SDL_GetError() }); return errors.SDLImageError.ImageLoadingFailed; }; break :blk @ptrCast(ret); }; // ... }
That allowed the bugs to be spotted in one hour. After that hour the game was running on Windows.
Distribution of applications for GNU/Linux has implications. In particular, the shared library GNU C Library (glibc) the programmer uses might have higher version than the user's glibc. The library is important, because it allows an application to communicate with the system. For example, it implements the file operations. The first solution which might occur is linking the library statically, but there are some difficulties with glibc. It can be replaced by musl libc for that purpose. However, SDL3 failed to initialize with musl saying that the video subsystem is not ready. I put it off after a few findings. One of them is that Valve maintains containers for different glibc versions for the games from Steam. Another wasn't related directly to my situation, but it was related to the fact that Vulkan can't be linked statically.
Another friend of mine has OSX. I couldn't manage to compile SDL3 for OSX from GNU/Linux. As Zig can be used as a C compiler as well and supports cross compilation to even to OSX, I configured the CMake project of SDL3 to be compiled with Zig in ReleaseFast mode.
$ cmake -S . -B build/build-macos \ -DCMAKE_C_COMPILER="~/.local/bin/zig-cc" \ -DCMAKE_AR="~/.local/bin/zig-ar.sh" \ -DCMAKE_RANLIB="~/.local/bin/zig-ranlib.sh" \ -DCMAKE_OSX_ARCHITECTURES=x86_64 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=10.13
Those scripts are wrappers for the commands zig cc -target x86_64-macos
, zig ar
and zig ranlib
. The last two options of the CMake command are from the README of SDL3.
/path/to/SDL3/SDL-release-3.2.12/src/hidapi/SDL_hidapi.c:48:10: fatal error: 'CoreFoundation/CoreFoundation.h' file not found 48 | #include <CoreFoundation/CoreFoundation.h> | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
I guess that the header is from the Darwin Frameworks. There is a discussion in the issues of Zig. The project macOS Cross-Toolchain for Linux and *BSD probably solves the issue, but I couldn't check it, because the it requires to download Xcode, which requires to register in Apple Developer.
The failure with OS X brought me to the idea of building the project for a browser. Browsers support WebAssembly (WASM), which is one of the targets Zig. However, compilation into WASM is not enough to run a game in a browser. A browser doesn't understand things like "open me a file". You can't access local files from a web page. Therefore, the commands of that type have to be substituted by the alternatives suitable for a browser. That's what Emscripten helps with.
Emscripten supports SDL3 and its extensions out of the box. However, I preferred to compile from the sources in order to have exactly the same version of SDL3 as I used for other targets. Compiling SDL3 with its dependencies required some configuration of their CMake scripts. Emscripten gives the tool emcmake
which allows a CMake project to be configured for Emscripten without the necessity to change the required options. The command to configure SDL3 is the same except it starts from emcmake
:
emcmake cmake -S . -B build/build-emscripten -G Ninja
Compiling SDL_image requires an additional option:
emcmake ccmake -S . -B build/build-emscripten \ -DSDLIMAGE_VENDORED=ON \ -G Ninja \ -DSDL3_DIR=/path/to/sdl/SDL-release-3.2.12/build/build-emscripten \ -DSDLIMAGE_AVIF=OFF
The option -DSDLIMAGE_AVIF=OFF
is required to prevent the error:
CMake Error at external/dav1d/CMakeLists.txt:547 (message): Unknown archivecture (configure with -DDAV1D_ASM=OFF)
SDL_ttf requires to be compiled without examples:
emcmake cmake -S . -B build/build-emscripten -G Ninja \ -DSDLTTF_VENDORED=ON \ -DSDLTTF_SAMPLES=OFF \ -DSDL3_DIR=/path/to/sdl/SDL-release-3.2.12/build/build-emscripten
Otherwise you get errors during the building phase:
wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glOrtho wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glShadeModel wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glColor3fv wasm-ld: error: CMakeFiles/glfont.dir/examples/glfont.c.o: undefined symbol: glVertex3fv
The reason for that is the sample which requires OpenGL, but browsers support WebGL.
SDL_mixer also requires one tweak as well:
emcmake ccmake -S . -B build/build-emscripten \ -DSDLMIXER_GME=OFF \ -DSDLMIXER_VENDORED=ON \ -DSDL3_DIR=/path/to/sdl/SDL-release-3.2.12/build/build-emscripten
Otherwise you get the error:
CMake Error: install(EXPORT "SDL3MixerTargets" ...) includes target "gme_static" which requires target "gme_deps" that is not in any export set. CMake Error in CMakeLists.txt: export called with target "gme_static" which requires target "gme_deps" that is not in any export set.
Also, for some reason the game can't be compiled as it is. The following errors occur:
wasm-ld: error: ~/Nest/study/sdllearn/current/.zig-cache/o/f51350f559c7c99705216d69154e3ccc/build/build-emscripten/libSDL3_image.a(IMG_webp.c.o): undefined symbol: WebPFree wasm-ld: error: ~/Nest/study/sdllearn/current/.zig-cache/o/f51350f559c7c99705216d69154e3ccc/build/build-emscripten/libSDL3_image.a(IMG_webp.c.o): undefined symbol: WebPMemoryWriterClear
The list of the symbols of libSDL_image.a says this:
$ llvm-nm -A ./libSDL3_image.a | ag WebPFree ./libSDL3_image.a:IMG_WIC.c.o: no symbols ./libSDL3_image.a:IMG_webp.c.o: U WebPFree
The letter U
next to WebPFree
means that the symbol is undefined. Therefore, let's have a look at the symbols in the library of WebP. The library is one of the dependencies of SDL_image and located in the folder external
. Here what we get:
$ llvm-nm -A ./libwebp.a | ag WebPFree ./libwebp.a:buffer_dec.c.o: 00000616 T WebPFreeDecBuffer ./libwebp.a:idec_dec.c.o: U WebPFreeDecBuffer ./libwebp.a:webp_dec.c.o: U WebPFreeDecBuffer ./libwebp.a:utils.c.o: 000000c4 T WebPFree
That says, the game has to be compiled with all of the libraries from WebP. However, the similar situation occurs with SDL_mixer:
wasm-ld: error: ~/Nest/study/sdllearn/current/.zig-cache/o/81554771ca50c2d77e0a6d3154789aad/build/build-emscripten/libSDL3_mixer.a(music_wavpack.c.o): undefined symbol: WavpackSeekSample64
At this point it's clear that the symbols of the dependencies aren't included into libSDL_mixer.a. Therefore, we link them too. Finally, all of the dependencies are build for WebAssembly, the game itself can be approached.
The game itself failed to compile with the error:
~/Apps/zig-linux-x86_64-0.14.0/lib/std/start.zig:253:21: error: unsupported arch else => @compileError("unsupported arch"), ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
As it's mentioned above, Zig supports WebAssembly, it's very likely something is used which isn't supposed to. The errors comes from the function _start
. The name gave me a hint. For those who don't know what the name means, you can run any executable with a debugger and set a breakpoint to the symbol _start
. The symbol will be there. This is the entry point of your program.
Also, you can take this "Hello World" program in assembly x86_64:
global _start section .text _start: mov rax, 1 ; write( mov rdi, 1 ; STDOUT_FILENO, mov rsi, msg ; "Hello, world!\n", mov rdx, msglen ; sizeof("Hello, world!\n") syscall ; ); mov rax, 60 ; exit( mov rdi, 0 ; EXIT_SUCCESS syscall ; ); section .rodata msg: db "Hello, world!", 10 msglen: equ $ - msg
Assuming that the text in the file helloworld.asm, the program is compiled with:
nasm -f elf64 helloworld.asm ld helloworld.o -o helloworld
However, if you change the label _start
to something else, the linker gives you a warning:
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
The program still runs, but you are on your own further. To get some picture about the low level staff you can try this Assembly tutorial.
Anyway, the name of the function made me ask: "Why would _start be needed for a WebAssembly?". WebAssembly allows the browser to load functions and run them. After a guessing quite a little bit, I went back to the documentation of Emscripten and my building script. The documentation didn't reveal to me anything, but in the building script I found that the root module for WebAssembly differs from the root module which is used for GNU/Linux and Windows. That root module does not link libc.
const exe_root_module: *std.Build.Module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, + .link_libc = true, });
That helped me to fix the compilation error, but the root cause remains hidden from me.
Another issue appeared when the invoking of emcc
was added to the build script. The game failed to find the files with the textures. The same issue happened on windows and here we go again. The file system interaction in Emscripten is built on top of a virtual file system. You do:
emcc file.cpp -o file.html --preload-file asset_dir
The folder asset_dir
will be accessible from the file file.data
residing next to file.html
.
I ran a similar command with build game as well, but the invoking of the command was slightly changed in the build script.
emcc.addArg("--preload-file"); emcc.addDirectoryArg(b.path("assets"));
The b.path("assets")
inserts the absolute path. Given that, the assets resided somewhere in /home/user/projects/thegame/assets/
in the virtual file system. The function addDirectoryArg
can't be replaced with addArg
because addDirectoryArg
makes the emcc
depending on it. Given that, if something changes in the folder, emcc
gets executed. Considering the source code of the function addDirectoryArg
I came up with the following:
const assets_folder_name = "assets"; //... b.path(assets_folder_name).addStepDependencies(&emcc.step); // ... emcc.addArg("--preload-file"); emcc.addArg(assets_folder_name);
After that another compilation error occurred:
~/Apps/zig-linux-x86_64-0.14.0/lib/std/debug.zig:870:24: error: no field named 'base_address' in struct 'debug.SelfInfo.Module__struct_13636' module.base_address, ^~~~~~~~~~~~ ~/Apps/zig-linux-x86_64-0.14.0/lib/std/debug/SelfInfo.zig:798:27: note: struct declared here .wasi, .emscripten => struct { ^~~~~~
However, the release version compiles. I couldn't give up the debug build because later I had some issues with running the game in the browser. With the help from the Zig community in Matrix I replaced the std.heap.GeneralPurposeAllocator
with std.heap.c_allocator
for WebAssembly. The game is running!
After the successful build, I found that I still see nothing on the page. This compelled me to read the flags of emcc
multiple times. However, I couldn't connect intuitively the documentation with the errors. Basically, there were two issues: no picture and no sound.
About the picture. As it's written, only the black rectangle in the middle of the page. The console with errors didn't appear even. A few minutes later the message appeared in the top of the page:
The Emscripten's FAQ list has something about it. That information reminded me that I used the function requestAnimationFrame when I wrote another program for a browser in Rust. The function takes in a callback with the logic of that application. The documentation of SDL3 says that the function emscripten_set_main_loop has to be used. Given that, bodies of the loops in the entire game have to be moved away. The other option mentioned in the SDL3 documentation is using SDL main callbacks. Either way implied reorganization of the data, which demotivated me because I'd seen the game working already. This compelled me to find another way in the documentation of Emscripten. And lucky me, here is an example with an endless loop which is compiled with the option ASYNCIFY. Compiling with the option allowed the game to run finally. Despite the fact that the game runs, the error "emscripten_set_main_loop_timing: Cannot set timing mode for main loop since a main loop does not exist! Call emscripten_set_main_loop first to set one up." occurs. I can't believe that I have to rework my game to run it with Emscripten.
About the sound. Pretty soon I realized that I can't hear the explosions. This time the console were able to appear. It showed me the message "An AudioContext was prevented from starting automatically. It must be created or resumed after a user gesture on the page." The warning is caused by this policy. I'm glad it exists. I bet we would've had some miserable audio in addition to banners like this:
Creating a complete game exposed myself to new concepts and compelled to revise some of my knowledge. Not mentioning all of the fun. I expected to try 3D API, but I spent much more time than expected for just understanding the available tools in the domain of graphics programming. However, I wanted to find the proper entry point for myself. It had to be high enough to avoid solving some of the problems I knew how to solve. It had to be low enough to expose the problems I hadn't known about. Eventually, I had two types of takeaways:
C++ does not provide a mechanism to check if the optional value has something which would the compilation.
a debugger might misinterpret something in the debugging information. The compiler might poorly connect DWARF with the source code. However, it's still worth it to make a script for the debugger, rather than re-compiling the project