Thanks for sharing this feedback, it’s really interesting and valuable to me. If you’re willing I’d love to hear more specifics as to where zig falls short for game dev - as candid and brutal as possible. Public or private (email me) either way would be super helpful. Of course, just a request, it’s not something I feel entitled to.
Apologies for hopping in, but the topic is also dear to me ![]()
From my POV there are two main friction areas for gamedev:
- Too much manual casting required in math expressions (the
@-noisetopic), e.g. game code is usually quite ‘math-expression-heavy’ and often mixes all sorts of primitive types (but especially floats and signed vs unsigned integers). Of course this also comes with various footguns in C and C++, but the downsides are often acceptable in game code, but may be catastrophic in more mission critical areas. This conflict (different requirements for different usage areas) may be easier to solve for Jai since its main goal is game development, it doesn’t need to worry about nuklear reactor control software
- Better support for vector/matrix math (this often comes diguised as a ‘missing operator overloading’ feature request, but lets be honest, 99% of operator overloading in game C++ code is for vector math). IMHO Zig should have vectors and matrices up to 4 dimensions as builtin types, similar to shading languages (and don’t even try to attempt to have generalized support for wider vector/matrix types - leave this to some sort of cross-platform SIMD sub-syntax which might look more like intrinsics).
OTH the focus on the new IO system has relatively little impact for gamedev, same with async/await, at least for the ‘game client’ (e.g. the thing that runs on the user’s machine - it might be more useful for the server backend of multiplayer games with client/server architecture).
In general I have a feeling that there’s a bit of “no pain - no gain” design philosophy going on - (more so than in the early days?). E.g. correctness above all else, no matter how inconvenient the resulting code looks like. Game-dev is all about moving targets and flexibility, at least in the higher level areas of game code (that’s why there was often a split between using C++ for the lower level areas and scripting languages like Lua for the higher level parts - but ideally such a split wouldn’t be necessary).
I am also working on a game using zig ( Traction Point on Steam ) and I would love to talk about pain points with zig and game dev, but I don’t want to hijack this thread so I’ll be short here and feel free to ping me later for a deeper discussion.
I mostly agree with what has already been said but I’d like to highlight the need for good windows support. Like it or not, windows is still the most important game dev OS and probably will be for some time, even if playing games is starting to gain traction on Linux.
Working on windows is currently fine, but it could be awesome with better debugging support, fast recompilation with the new compiler etc.
Happy to talk more!
EDIT: Forgot the most important part (even though I’ve congratulated you before), congrats on the Tides release and good luck with the new version(s)!
even if playing games is starting to gain traction on Linux
Tbh, when that happens it will be through the Windows APIs and Proton anyway, Win32 is the only stable API on desktop Linux (e.g. I fully expect that Linux will have better Windows backward compatibility than future versions of Windows - at least for the Win32 subset used by PC games, and that the Win32 subset used for games will continue to play an important role on Linux - e.g. I bet it’s here to stay and not a temporary ‘migration crutch’, also some Win32 APIs are clearly better than their desktop-Linux counterparts (especially when taking the Wayland mess into account).
Hello Andrew. I assume you want feedback from multiple parties, so I’ve been developing a game for a long time in Zig. And while I’m having a great time with Zig there are some stuff that felt annoying to say the least:
First of. Distinct types are annoying to make distinct types, the non-exhaustive enums method does does not scale.
const EntityIndex = enum(usize) {
_,
const Self = @This();
fn new(index: T) Self {
return @enumFromInt(index);
}
fn get(self: Self) usize {
return @intFromEnum(self);
}
};
Making something like this for every type is a chore, So i came up with another method to make then with comptime but it’s also a hack with bad errors
fn DistinctEnum(comptime T: type, comptime t: type) type {
return enum(T) {
_,
const Self = @This();
fn new(index: T) Self {
return @enumFromInt(index);
}
fn get(self: Self) usize {
return @intFromEnum(self);
}
comptime {
_ = t;
}
};
}
// To use it:
const EntityIndex = DistinctEnum(usize, struct {});
This is also another issue that I don’t think it will be fixed. Sometime I just need to extend a type and add a few more methods without the need to a wrapper struct. Kinda like inheritance but no virtual calls Like:
const EntityManager = struct {
gpa: Allocator,
entities: ArrayList(Entity),
fn init(gpa: Allocator) EntityManager {
return .{
.gpa = gpa,
.entities = .empty,
};
}
fn deinit(self: *EntityManager) void {
self.entities.deinit(self.gpa);
}
fn push(self: *EntityManager, entity_type: EntityType) !EntityIndex {
try self.entities.append(self.gpa, .init(entity_type));
return .new(self.entities.items.len - 1);
}
fn find(self: EntityManager, entity_type: EntityType) ?EntityIndex {
for (self.entities.items, 0..) |entity, index| {
if (entity.entity_type == entity_type) return .new(index);
}
return null;
}
};
It’s a pain to dig like this manager.entities.items just to loops over them. I’d rather we having something like this.
const EntityManager = struct {
using ArrayList(Entity);
// And add your own methods.
};
Other than that builtin matrix and vector types. I have more complains like the annoying casting, but these are the main ones.
What’s wrong with DistinctEnum(T, t)? That’s a sensible way to generate strong integer types. Should probably use opaque {} for the tag type instead of struct {} though.
I have been thinking about it (of course or it would be a very unresponsible change
) but thought it might be best to do with a little bit of time in between. But since you asked I will answer in a little more detail at least.
I would like to stress again that I think it’s fine that Zig didn’t end up exactly what I’d personally hoped for, Zig has it’s own goals and arguably being a bit narrow is better for a language.
Also I would like to note that many of my objections are vibe-based, as the kids say. I’m not looking to start a debate because I know there are other Zig programmers (gamedevs, even!!) who disagree with me, taste-wise.
Also I do know that Zig is in development and maybe if we’d just kept using it these things would get addressed. But as I think will be clear, our confidence that Zig is moving in a direction that is aligned with our needs is low.
So here is an unordered list of things. Be warned, there are some potentially hot takes in here. I was hoping this would become a thread about the game and not about Zig but here we are.
Some things may be a bit spicy, I apologize if I come off rude, while writing I went well past my bed time but I wanted to finish it and post it in case Windows needs to reboot and throw away everything I’ve written!
Zig decides how you should code.
(This is a bit like having an Andrew Kelley sitting on my shoulder, telling me I should be coding according to his tastes.)
For example we only just barely got 0..n syntax in for loops because it happened to coincide with multi-list-for-loops (which is honestly a great thing in Zig). But there was absolutely reasoning that “you can just loop over a value using a while loop” which is terrible in many use cases.
At some point you made tabs in comments an error, and like, who asked for this? Compared to other things that are much more important it felt like “wow, this is what they are prioritizing? Not that it was terribly painful, but a little bit, because every such change made a Zig upgrade a bit more “dumb churn work”.
It’s as if Zig doesn’t trust that I can program.
There are definitely some things I’m very happy that it auto-checks for me (OOB, overflow, etc). I’m not sure what the best example of this is but I think every time I hit an error that could’ve been a warning, this feeling just struck me.
It requires unsigned integers for indexing into arrays even though it does range checking. Sometimes (often) signed integer math is much nicer to work with but then you have to cast to use it as an index. Well actually, my feeling goes, I know that my signed integer would do well here as an index, you don’t have to protect me by forcing a cast, thank you very much!
(and to reiterate, it does nothing to protect me anyway)
Errors
I initially thought they were great but as it turns out they are just not very useful for game development, or at least my style. There are a few cases where it’s nice, primarily when doing things like file I/O, but for lists and allocators, my feeling is that more than anything I’m littering my code with catch unreachable, and that’s even though I try to use reserve+assumeCapacity as much as possible.
Zig prioritizes code quality over product quality.
It’s Very Important that the code is correct, more-so than that the program does what it’s supposed to. In a more low-level, system-level, programming I don’t think there is much distinction here between the two, in fact I’m guessing the statement may raise a few eyebrows - how can the code be correct but the program isn’t!?
Well, in game development, especially the closer you get to things like gameplay and procedural generation, a function can compile and be “done”, but then the requirements for that function can change. For example maybe my castle should now have towers. And maybe there should now be flags along the walls. What does it look like without the towers? Oh it doesn’t compile now because my “curr_tower_count” now needs to be a const. Gotta find that place in the code and fix it and recompile. Actually it was better with the towers. Compile and wait. Oh I forgot, now it needs to be a var instead.
Being able to iterate on code is much more of a guarantee of a good product than the code, say, passing a linter.
In fact, the Tides codebase has a bunch of terrible code, code that does what it needs to do but probably wouldn’t pass code reviews, which is fine, because looking good and passing code reviews isn’t the purpose of that type of code. Had my goal been to consistently write nice code I can show off on GitHub, I probably wouldn’t have shipped this release by now. Here’s an example, feel free to judge me!
Math
Mr. Sokol (
) already went into this so I don’t think I need to rehash it, but yes, as it turns out, not having overloads for linear algebra is a big PITA. Allowing it on the Vector primitives wouldn’t be enough IMHO because (and correct me if I’m wrong) Vectors enforce SIMD requirements (alignment I guess), which isn’t necessarily something I want for a f64-xyz vector. I am also seriously considering using fixed points for gameplay math and Zig’s issue on this is still open. With a language with operator overloading, even the basics, I can relatively easy switch out a float based vector to a fixed point based one.
I will note that in addition to math casts (which I already made note of in another thread on here) loop-related casts are also a pain point. If I have a loop that is explicitly over (0..3) then it sucks that I need to cast it from a usize for when I pass it to a function requiring a u8.
Zig does not prioritize UX
Not sure how to put this, but, for example, when the loop issue got closed, it was with the message “this would be hard to implement” or something like that. Of course you are allowed to do with Zig as you wish, but as a user, I don’t really care so much if it’s hard to write the program that I’m using, or if the code of the program becomes nastier. (Also I know that the Zig team is very competent and that they don’t exactly shy away from challenges)
Similar things happened with usingnamespace, where I’m no longer allowed to pull in an entire import into my file scope. I think there was design rationale for this but I also think it was to make the Zig compiler code nicer.
Zig’s “only one way to do things” mantra is to some degree is good but in some cases it means you are writing harder-to-read code because Zig doesn’t support some basic syntax UX. One example of this is that people have asked for a way to specify a distinct type, a type that is exactly like another but is will not get mixed up by the type system. This was closed because there are “Non-exhaustive enums”. I still don’t quite understand how they work and how to use them, nor do I think if someone saw them in the code base they’d be “oh that’s a distinct int”, but hey, it solves the same problem!
There was a change recently-ish where when you have a loop over an array and you want to change it, it is no longer enough to put an asterisk on the item name, you also need to prefix the array with an &. Does it make it explicit that you are sending in something that will change? Yes. Was it already explicit because you asked for a mutable item? Also yes. Did it homogenize Zig to fix a rare edge case, or something like that? Yes. Did I like it? No. Also: I imagine it is only a matter of time before if I don’t actually mutate the item, I should remove the asterisk or get a compile error. And if I don’t have the asterisk, I need to remove the &.
The trajectory here is: The longer we keep using Zig, the worse our experience is going to be.
Zig’s lack of varargs is also a case of where the Zig team prioritizes Zig being clean and elegant at the expense of the programmer actually writing Zig code. I do quite like use logging for debugging (in addition to using a debugger - different use cases) and having to add that .{} every time gets old very fast.
Zig does not value using a debugger
I opened this issue two years ago: (tracking issue) Most important Zig issues affecting Tides of Revival · Issue #46 · Srekel/tides-of-revival · GitHub
There were a couple of improvements to debugging but at least as of 14.1 it was still a very poor experience, especially on Windows. I had a couple people tell me “I’m also a Zig gamedev and I fully share your prios on this list”.
Zig does not value Windows
Again @floooh brought this up, but to go into more detail: It is my impression that all Zig devs are either on Mac or Linux. This in an of itself provides a huge bias towards not improving what it’s like to work on Windows. There’s a bug relating to libc or libc++, perhaps along with targeting MSVC, that made things quite painful. I believe even building Zig from source was quite painful last I tried.
This also ties into the debugger UX IMHO.
Last year you posted this “LLDB Fork for Zig” update. First, improving LLDB to get good debugging support shouldn’t be the first step, surely there are things you can do to output better debugging output (pdb/dwarf) first? Secondly, to get it to run… it seems tricky on Linux and painful on Windows.
Bypassing Kernel32.dll for Fun and Nonprofit
I honestly am still not sure what to make of this, I may be misunderstanding and making a hen out of a feather.
I have via social media seen a number of people work on mobile games who suddenly had their games not work anymore because Apple updated some random thing, so they needed to fix it and republish their builds. This can be totally untenable as a game developer (if you have a publisher you probably don’t even have the rights to do this). So if Zig takes the stance “yeah you might have to do that but at least we won’t have to fix bugs” it’s a big blocker.
Zig’s error messages are not written for the programmer
(Again, let me reiterate now that I know Zig is under development and things may get better!)
I have lost a lot of time to Zig’s error messages. They are often written IMHO to be “technically correct”, which is to say, if you know compiler/language design/Zig spec lingo, you’re good to go. If you’re just a programmer using Zig, they are terrible. I have said on a few occasions that I would love it if the Zig compiler treated me like an idiot if it meant it saved me time to understand what I did wrong and how I can fix the problem.
No macros
As I understand it, assert(heavyFunction()); may in theory always call heavyFunction even with all optimizations on, and there isn’t really a way to write a custom assert function that would do this.
No compilation of unused functions
This has hit me a few times and it’s quite annoying. You write a function and everything compiles fine, commit it, then some time later you actually try to call it and realize it actually doesn’t compile. I know the reason why but I also feel like it’s a bit of a shortcoming of comptime and the lack of macros.
Comptime code is hard to debug
I’m not sure it can be debugged? At the very least getting a printout of the resulting code would be extremely useful.
Anytype makes for bad interfaces
Probably enough has been said on this but having to not just look at a function API but dig through its code to figure out exactly what it needs is not great.
Those are the things that come to mind… hope it’s not too rough. In some sense I think it sounds worse than it is. It is very much, I think, a case of many minor things building up some level of annoyance, rather than a bunch of dealbreakers.
Also this is not bringing up any of the things I like about Zig, of which there are quite many things!
Thank you for obliging me, I really appreciate it.
I can’t tell you how refreshing to get critical feedback from someone who actually used Zig in earnest, rather than from people on social media posing as a user, meanwhile they’re actually just trying to promote their competing horse in the race.
I’m curious what do you think about Odin if you have used it?
Having used Zig for almost a year now (majority of time on chess) I share most things you write.
Except:warnings (i like yes or no, i hate warnings).
My short pain list:
- math with mixed types.
- the impossibility to write a custom function that never will be called in releasefast.
- the impossibility to index arrays with a signed int (or enum).
- allowing non-compiling functions which are never called. difficult area that is I guess.
But I must say: when I compare Zig code with C++ I very heavily prefer Zig. You can actually directly SEE what is happening! No hidden tricks.
And I never had so much fun writing chess after failed attempts with Delphi (inlining sucks), C# (slow, no memory control), Rust (traumatic).
From time to time I go thru the Zig repo, to see where the stuff is going, but possibly missed this.
On here i saw discussion on proposing this:
for (0..8) |i: u8| {
...
}
Is this possibly what the loop issue was about?
I liked this idea a lot, but i guess there might be some type checking issues?
(any links would be appreciated)
Robert ![]()
pub fn assert(...) void {
if (builtin.mode != .Debug) return;
// ...
}
is noop and optimized away in release modes
You can use @import("builtin").mode to do that:
const std = @import("std");
const builtin = @import("builtin");
pub fn main() void {
std.log.info("Runs always", .{});
if (builtin.mode == .Debug or builtin.mode == .ReleaseSafe)
std.log.info("Runs only in safe modes", .{});
if (builtin.mode != .ReleaseFast)
std.log.info("Runs always, except ReleaseFast", .{});
}
Personally, I believe using non-exhaustive enums for distinct types was a bit overhyped. FWIW, when I felt a need for distinct types in my (tiny) game, I instead landed on using a single-member struct: https://codeberg.org/spiffyk/FruitsAndTails/src/commit/fb1e46d77f421c79f66fffd111282393c49ba6f9/common/src/vecmath.zig#L396
Such a type is actually distinct as viewed by type checks, can additionally have methods defined for them when needed, and I can easily get at the inner type by just accessing the single member.
As far as I understand, that’s the issue, right? You have to guard every call site of the function.
pub fn assert(...) void { if (builtin.mode != .Debug) return; // ... }is noop and optimized away in release modes
But heavyFunction() has already been executed by that point.
As far as I understand, that’s the issue, right? You have to guard every call site of the function.
What about this?
const std = @import("std");
const builtin = @import("builtin");
fn expensiveFunc(a: bool) bool {
std.log.info("expensiveFunc {}", .{a});
return a;
}
fn lazyAssert(func: anytype, args: anytype) void {
if (builtin.mode == .ReleaseFast)
return;
std.debug.assert(@call(.auto, func, args));
}
pub fn main() void {
lazyAssert(expensiveFunc, .{true});
lazyAssert(expensiveFunc, .{false});
}
Srekel might have been referring to this comment: Proposal: Infer type on ranged for loops from the type of the range values instead of defaulting to usize · Issue #14704 · ziglang/zig · GitHub .
In another comment Andrew mentioned that #3806 might resolve this in the future.
Well, it certainly works, but pretty it ain’t. I guess those are the choices the language leaves us for now, hence why people are complaining.
Such a type is actually distinct as viewed by type checks, can additionally have methods defined for them when needed, and I can easily get at the inner type by just accessing the single member.
non-exhaustive enums types are still distinct, they can also have methods (and frequently do), and the backing type is readily available. The main purpose for non-exhaustive enums in this case is named integers, sometimes with convenience functions attached to them.
Pretty ultimately is subjective. For what it’s worth, the change in function signature is minimal, in my opinion, and you still keep all of the compiler errors when providing incorrect parameters, so it matches all of the functionality of the assert macro (afaik!). It could be called ugly, but not impossible to do.
Hmm, maybe I should have worded my point differently. Yes, non-exhaustive enums are a way to get some distinct types, and for named integers they’re perfectly valid. But they only solve that one use case of distinct types, yet seem to get way too frequently presented as the solution, hence my “overhyped” position.
What I wanted to say is that there are multiple ways to get what you want, depending on the particular situation. To name a few I have personally encountered: for named integers, enums; for categorized identifiers, packed structs; for distinct vector types, single-member structs.
Edit: Maybe the discussions about distinct types and “lazy” asserts should be split into separate topics, in hopes of getting this one back on track of talking about the game presented here. Sorry about hijacking this.
Thanks @Sze ![]()