This post is my journey of rewriting web-based moto trials simulation game prototype to a native application using Zig and WebGPU.

I don’t use LLMs/agents to produce content (text or code), only to gather information and troubleshoot external dependency/build/tooling issues.

I make games and write posts because I enjoy the process and want to share my progress, not to increase shareholders’ value

Context

For the past 6 months or so I’ve been working on a moto trials simulator. For the early prototype I picked Three.js (WebGL) + JoltPhysics.js for quick iteration and out-of-the box graphics. As a bonus I already had experience making web based games so it was a breeze.

Prototype turned out great, but issues were obvious:

Stack

Becoming fed up with browser overhead the solution was simple: WASM+WebGPU1 native OS game.

By that time I had a good understanding of what I need from the game stack:

Language

Considered options:

Graphics API

Picking graphics API these days might be even harder than picking a language, but it’s more forced by target OSes.

Linux and Windows native support narrows the list down to OpenGL, Vulkan, and WebGPU. As OpenGL is quite a mess for beyond-tutorial graphics programming and Vulkan is more verbose than enterprise Java (which I’ve been writing professionaly for my whole career), I landed on WebGPU. In fact, native WebGPU support multiple backends making it a nice abstraction over Vulkan. And similar to Zig, I also had great expirience working with browser implementation of WebGPU (even though it barely worked on my standard Firefox+Linux setup).

There are two major WebGPU native implementations, wgpu and dawn. I picked wgpu but we will discuss later what this duopoly means for the ecosystem.

Physics engine

As my prototype was made using Jolt I initially considered using it here too, but legend himself dropped Box3D out of nowhere. Not just that, but its features and performance seemed not worse than Jolt’s. So I decided to give Box3D a try.

Other

For handling windows and input I just picked GLFW via zglfw wrapper and barely had any issues with it.

I rely on GLTF models exported from Blender for everything scene related (rider, bike, scenery, obstacles), cgltf wrapped via zmesh worked like a charm. Images are loaded via stb_image/zstbi.

Tracy is a must have for good instrumentation and sampling, wrapped via ztracy.

zalgebra is a nice little library for math primitives absolutely necessary for game development.

Making GUI from scratch is a fun challenge, but for debug menus imgui is well established. Wrapped via zgui.

I will also need audio API which will likely be FMOD, but I haven’t tackled sfx yet.

Difficulties

Outdated

Because Zig is not 1.0 yet, breaking changes are quite common and quite often. Most Zig libraries are not fast enough to catch up with the latest 0.x release, but luckily they usually have an already open PR to bump the project’s Zig version.

Missing Zig bindings

While Zig has excellent C interop, Zig has its own “idiomatic” way to writing libraries which is quite different from C. Which is why it’s common to have a wrapper libraries to convert between the two.

I managed to find two Box3D wrappers for Zig, just-deleted box3d-zig (classic GitHub) and zigbox3d. While one of them had full box3d header, another had nicer Zig bindings. I shamelessly combined the two into zbox3d library.

There are probably countless of great C libraries that I haven’t even considered for my game just because it has zero exposure in Zig ecosystem. Which is a shame but also an opportunity.

zalgebra OpenGL assumptions

Camera perspective functions in zalgebra assume that NDC depth is in range [-1, 1], while WebGPU and all other modern graphics APIs have it in range [0, 1].

Zig Vector != Zig array

What will the following expression produce?

@sizeOf(@Vector(2, u32))
@alignOf(@Vector(2, u32))

Any definite answer would be wrong, because it depends on the backend and OptimizeMode. On x86_64-linux-gnu it gives 8 8 in Debug and 4 4 in Release.

I naively used za.Vec2Us in my uniforms struct and was quite surprized to see my shaders work in debug but break in release mode.

Worst part is that there is no concise fix for it. I had to rewrite all vectors to use arrays instead (no significant performance difference).

It was extremely easy to debug though, big thanks to RenderDoc. All I had to do is to sample two frames into two RenderDoc windows and compare uniform buffers bound to vertex shaders2.

Prebuilt binaries in build.zig.zon

wgpu_native_zig relies on prebuilt binaries of a fixed (1 year outdated) wgpu-native version. While I understand the convenience, I would like to have full control over dependency sources and have a way to update it. Of course build.zig needed an overhaul but was not that bad. Compiling it from scratch unfortunately can’t be handled by Zig’s toolchain as wgpu is written in Rust, but there is nothing addSystemCommand can’t do:

const make_target = switch (optimize) {
    .Debug => "lib-native",
    else => "lib-native-release",
};
var cmd = b.addSystemCommand(&.{ "make", make_target });
cmd.cwd = b.path("lib/wgpu_native_zig/lib/wgpu-native");
b.getInstallStep().dependOn(&cmd.step);

Would be nice to have this code in wgpu_native_zig’s instead of my game’s build.zig, but I don’t think it’s possible to add a dependency to Module directly.

wgpu-native update pain

Since wgpu_native_zig was using old prebuilt binaries of wgpu-native I wanted to update it to the latest version built from source. Of course it broke Zig bindings because of breaking changes in webgpu.h, without an obvous way around it. I had to go through all [breaking] commits in webgpu-headers project and fix them one by one.

Updating webgpu.h also made it necessary to update Box3D’s WebGPU backend, which turned out to be quite straightforward, except I somehow managed to include two different versions of WebGPU headers used by wgpu-native and imgui. Troubleshooting it was especially painful because ABI mismatch only surfaces at runtime as segfaults or failed wgpu pipeline/bind group/pass validations.

Not using submodules

Many projects just raw dog external dependencies into their project without any reference to its version or origin. On a bright side, it was a great opportunity to learn how to properly use git submodules to vendor external deps.

zig + imgui + wgpu = big misery

Using imgui alone is not as straightforward thing as I thought. There are custom options to use docking version, include plot, knob, freetype support. And on top of that you have window system + graphics API combo (glfw + wgpu in my case). It wouldn’t be that bad if zgui didn’t assume I’m using zdawn backend.

I had to rewrite its build.zig to add -DIMGUI_IMPL_WEBGPU_BACKEND_WGPU flag and include webgpu.h together with wgpu-specific wgpu.h.

glfw + wgpu = minor inconvenience

I said that I barely had any issues with GLFW, but barely is not none. GLFW does not know by itself how to create surface when used with WebGPU. I think it’s reasonable, except wgpu_native_zig exposes 6 functions depending on what window system is without exposing one function to use in all cases.

pub inline fn surfaceDescriptorFromAndroidNativeWindow(descriptor: MergedSurfaceDescriptorFromAndroidWindow) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromMetalLayer(descriptor: MergedSurfaceDescriptorFromMetalLayer) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromWaylandSurface(descriptor: MergedSurfaceDescriptorFromWaylandSurface) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromWindowsHWND(descriptor: MergedSurfaceDescriptorFromWindowsHWND) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromXcbWindow(descriptor: MergedSurfaceDescriptorFromXcbWindow) SurfaceDescriptor;
pub inline fn surfaceDescriptorFromXlibWindow(descriptor: MergedSurfaceDescriptorFromXlibWindow) SurfaceDescriptor;

Fine… I’ll write it myself steal it from here:

pub fn createSurface(instance: *wgpu.Instance, window: *zglfw.Window) !*wgpu.Surface {
    switch (builtin.target.os.tag) {
        .macos => return createMetalSurface(instance, window),
        .windows => return createWindowsSurface(instance, window),
        .linux => return switch (zglfw.getPlatform()) {
            .x11 => createX11Surface(instance, window),
            .wayland => createWaylandSurface(instance, window),
            else => return error.PlatformUnsupported,
        },
        else => return error.PlatformUnsupported,
    }
}
// ...
// skipped for brevity

Box3D world snapshots

While I was very enthusiastic about using Box3D, the time has come to implement physics world save/restore to have the bike reset to the latest checkpoint on failure. To make it work I need:

  1. Create bodies, shapes, constraints, sensors for the scene
  2. Save the world snapshot
  3. Once it’s time for reset, restore existing world to the saved snapshot

I read this line in documentation and immediately became 75% less enthusiastic:

This machinery is currently internal, used only to seed and replay recordings. Box3D does not yet expose a standalone save-state / restore API.

Good news I own my dependency sources and can implement it myself, how hard can it be?

Existing record/replay system in Box3D is focused on providing devs with a debug replay of a simulation. Naively deserializing it into the same world is not possible with current API, you read replay world ID from deserialized replay object. But updating Box3D to accept existing world breaks simulation and existing body/joint/shape references.

Save/restore is not a thing yet because serialized state should be self-contained, meaning it should reproduce the whole world exactly from bytes alone. There is an open issue for it:

There needs to be some optimizations to make this reasonable. The current snapshot embeds all collision meshes. There should be some sort of context to keep the data size more reasonable.

But wait! My use case is different. I don’t create/remove shapes after snapshot save, so it’s not a big deal?

Box3D privately uses this snapshot functions to drive its replay system

int b3SerializeWorld( b3World* world, b3RecBuffer* buf, b3Recording* rec );
bool b3DeserializeIntoShell( const uint8_t* data, int size, b3World* world, b3RecReader* rdr );

All I had to do is to make two similar functions that basically do the same thing just don’t include shapes in the snapshot:

int b3SaveSnapshot( b3WorldId worldId, b3RecBuffer* buf );
bool b3RestoreSnapshot( const uint8_t* data, int size, b3WorldId worldId );

…and expose them in box3d.h and zbox3d.

Jolt side track

Talking about patching Box3D like it was easy is not really true and I procrastinated for two days not wanting to understand Box3D’s snapshot serialization logic. Another possible approach was to switch back from Box3D to familiar Jolt as I’m sure it has all functionality I need.

Did you know C++ library can’t be used directly as C-compartible ABI? It needs a shim like any other language. Thinking more about it it makes perfect sense since C++ has a bazillion features foreign to ABI.

Jolt allegedly has Zig bindings and 3 (three!) more providing C bindings. Such diversity!

Let’s use zphysics then. Its C bindings seem to be hand-written. Oops, it’s missing basic building blocks like constraints other than fixed.

What about JoltC, it has more stars on GitHub® meaning that it must be objectively higher quality. Good, it has all constraint types, even PhysicsSettings, but no PhysicsSystem.restoreState…

Same for other two C bindings repos, they lack some of the Jolt’s interface that I use.

Even if they had it I would still need to write Zig bindings for it by hand (great use case for LLMs by the way!) or spend another week writing bindings generator.

Current state of the game

Now that I can breathe out and write my down journey, game is in a good state.

Performance-wise, I get 120fps in the same scene setup where I had a GPU bottleneck in a browser version, but this time with 4xMSAA instead of FXAA. Without AA I get 210fps. Surprisingly CPU-bound this time, spending a hefty amount of time submitting command buffers to the GPU. Might experiment with a render thread architecture and triple buffering once I get bored implementing gameplay.

Haven’t tried Windows3 build yet, but expecting a bunch more problems building dependencies from source.

Things I learned

Conclusion

Using Zig for gamedev and anything else is a great choice if you know what you’re doing. Or at least know how to navigate encountered issues.

Native WebGPU has great performance and nice yet flexible API. I found it quite annoying to have to declare BindGroupLayout for each BindGroup4, but luckily I only need to do this once when initing graphics. Validators in wgpu are capable of detecting many misconfigurations early (on first frame).

High quality game physics is finally available for indie devs with the release of Jolt and now Box3D.

While web is an amazing platform for delivering content and providing service, it still lacks performance and tooling needed for joyfull videogame development.

Be useful

You did all this hard work and didn’t contribute to the projects you complained about so much?

Credit

Maddest props to zig-gamedev project and all its contributors. While this post might be a bit harsh towards their work, without it my journey might’ve never started or took N times more time and M times more energy.

Self promotion

If you would be interested in playing trials game and other hand-crafted games I’ll make in the future, I invite you to the Substep Game’s discord.

Further reading



  1. while it might be good for performance, tooling is non-existent, and writing/generating JS shims is not fun.↩︎

  2. would be not too hard to troubleshoot this CPU-side with std.mem.asBytes(&uniforms).↩︎

  3. imagine having a Windows machine to play games in 2026.↩︎

  4. now I understand why everyone is prasing “bindless” in Vulkan.↩︎