Flint - building blocks for making bespoke game engines

Hey everyone! I’ve been working on a project called Flint and finally feel ready to share it. I wrote a longer post about it here but the short version is: it’s a set of building blocks for creating your own game engine in Zig, built on SDL 3 and Dear ImGui. Instead of giving you a full engine, it gives you the pieces to put together one that fits your specific game: hot reloading for code and assets, Aseprite support, comptime-generated inspector UIs, that kind of thing. The aim is to provide all the things I would otherwise setup each time I start a new project so I can get started quickly, but still have the freedom to customize it as the game matures.

It’s still early days and I can only work on it in my spare time, but it’s already usable for 2D stuff and I’m having a lot of fun with it. Would love to hear what you think!

9 Likes

Here are some of my notes from attempting to get this running at all:

  • The arguments to the flint.buildExecutable() function are in what feels like a completely random order. It would be more sensible in my eyes to have the std.Build.ExecutableOptions struct as an argument, and have any non-standard arguments precede it.
  • The build example in the documentation leaves out that you should import flint’s build.zig in your own build.zig with const flint = @import("flint");.
  • I’m not super fond of having to create an options module to plug-in to the flint module. Since dependencies’ b.option() arguments can be specified when you get them as a dependency in a similar fashion to target and optimize, it’s probably better to define a few of these, and error out the build if they’re not explicitly supplied by the user.
  • I think the documentation should urge you to look at GameLib first, since it shows you all the functions you need to export to actually get your executable up and running.
    • It should probably also tell you that your init function of choice should be named init() instead of the name it’s given in GameLib.
  • It would be helpful as a developer to know if my processInput() function will be called before or after my tick() function in a frame.
  • It’s annoying that if the rebuild fails, the flint executable locks up instead of actually crashing.
    You could fix this with the following block:
if (codeChanged) {
    std.log.info("Code changed, rebuilding game library...", .{});
    var zig_build_process = std.process.Child.init(&.{ "zig", "build", "-Dlib_only" }, allocator);
	// Block until the process finishes.
    const term = try zig_build_process.spawnAndWait();
	// Additionally crash if it failed.
	switch(term){
		.Exited => |code| if(code != 0) @panic("Rebuild failed, check stderr"),
		.Signal => |code| if(code != 0) @panic("Rebuild failed, check stderr"),
		.Stopped => |code| if(code != 0) @panic("Rebuild failed, check stderr"),
		.Unknown => |code| if(code != 0) @panic("Rebuild failed, check stderr"),
	}
    std.log.info("Done!", .{});
}
  • I found that when trying to rebuild the shared library, in spite of the lib_only flag, the line b.getInstallStep().dependOn(&b.addInstallArtifact(exe, .{}).step); was still trying to install the executable, causing error.AccessDenied. In practice, adding if(!lib_only) before this line was enough to fix this.
  • While this isn’t really your “fault” so to speak, I found that even when using initMinimal(), the application was unresponsive unless I implemented SDL’s event loop at some point during the frame.

Now some notes on the application design:

  • I don’t understand the purpose of the game state pointer, at all. It’s a pointer we receive from the implementer, to… do nothing with and pass directly back to the implementer? If so, why bother? Won’t the implementer already have access to all of their own state?

  • getSettings() is a pretty pointless part of GameLib. It’s called once and only once, after the game DLL has been loaded for the first time. If the game DLL is reloaded, getSettings() is not called again. Either make these settings explicltly comptime-known, or always call getSettings() on DLL reload. Which brings me to my second point…

  • processInput(), tick() and draw() are three functions which are all called every frame, with seemingly nothing between them that would require them to be called separately or in any specific order. It feels like there’s no technical reason for these to be separate functions, and they’re only used as a style guide. In Zig’s Zen, it says “focus on code rather than style”.

    • Also, since these three functions are only ever called through pointers, you’re incurring the overhead of three pointer dereferences every frame, and more importantly, making all three functions opaque to the optimiser, particularly in relation with one another.
  • This brings me back to my first point - if re-fetching settings is something we want to do after reloading, why not make it intrinsic to the act of loading the user’s DLL to also re-fetch settings? Why not even just require the user to export a settings variable, and make GameLib store a pointer to those settings, which it looks up at load time exactly like it does with its many function pointers? That way, we can obtain a value at load time which can be freely updated at runtime, and we can completely remove getSettings()!

  • Since the executable’s root is always flint/src/lib/main.zig, it may be wise to have this file create a custom panic handler (e.g. using pub const panic = std.debug.fullPanic(panic_handler);, where this function calls game.deinit(). This would help the user properly deinit in the case of a crash. This would matter in the rare edge-case where they have non-memory resources they need to clean up (such as temporary files)

  • On that topic - why not expose a root directory for the application? Or a canonical, OS-dependent save file directory? These things are very helpful for a game developer to have, and to not have to set up yourself.

And that brings me to the topic of two unfortunate issues with trying to run the application after first building it:

  • Like most Zig projects that use SDL3, it links against SDL3 dynamically. This isn’t noticed when using zig build run, since the executable is running from the cache, and is well instructed on how to find SDL3.dll (also in the cache). But when the executable is running outside the cache, it can no longer find SDL3.dll.
    • It seems as though you were aware of this and added code that should have installed SDL3.dll, but doesn’t. I tried quite hard to fix this on flint’s side, but it seems impossible. The only way I could make it work was by adding these lines to my build.zig file:
const SDL_dll = flint.getSDL(
	flint_dep.builder,
	target,
	optimize,
) orelse @panic("Could not fetch SDL dependency");
b.installArtifact(SDL_dll);
  • After this, the next problem is Windows-specific; when searching for the temp DLL, it doesn’t have the fallback condition that it does for the real DLL, where if it doesn’t find it in “zig-out/bin” it searches in the CWD directly instead. Meaning it just crashes the entire program with error.FileNotFound. This is an easy fix luckily - just catch the error, switch on it and if it’s error.FileNotFound, fall back to the CWD itself.

After both of these issues are fixed, the executable works flawlessly no matter where you move it.

I don’t really have much left in terms of feedback.
I’ll probably try to build a small game with this so I can form a stronger opinion.
I think the idea of an engine that provides the unopinionated “bare minimum” for you to build a specialised engine on top of is a really solid one, and providing “Unity-like” functionality of live recompilation and easy asset-management/debugging utilities is something that could easily have broad appeal.
One thing I wanna mention is that I’ve really been liking the design of raylib lately. Keyboard events? Nah fam, you get the state of a key by calling a function. That’s the ideal amount of unopinionated in my eyes.

Final random suggestion: Expose the Steam Audio C API.

2 Likes