⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.253+16d31ceedWhat's newOn an older Zig?Blog

Vendoring raylib

Updated · Code verified against Zig 0.18.0-dev.253+16d31ceed

raylib is a C library for windows, drawing and sound. This chapter wires it into a Zig project.

The one line version, and why it fails

raylib ships a build.zig. Using it should be one line:

const raylib = b.dependency("raylib", .{ .target = target, .optimize = optimize });

On Zig master today that line itself does not compile, before any of raylib’s C is built.

raylib/build.zig:95:10: error: member function expected 1 argument(s), found 2
raylib/build.zig:762:23: error: no field named 'build_root' in struct 'Build'

Naming a package in build.zig.zon makes the build runner import that package’s build.zig. Every dependency’s build script is compiled before any of your code is. So a dependency whose build script has not caught up with Zig master fails your build before it reaches you.

Both errors are in raylib’s Wayland detection, which this project does not use. That makes no difference. The whole build script has to type-check before your code is reached.

raylib-zig, the usual binding, is further behind. It pins raylib 5.5 and calls three standard library functions that no longer exist on master.

Neither project is doing something wrong. They track releases. This guide tracks master. The breakage comes from that difference, so it is better to know about it before you add the dependency.

Depend on the C instead

raylib’s C changes far more slowly than Zig’s build API. So this project depends on the C.

1. Fetch the source

Clone raylib at a pinned commit into a directory the build can read. Pin it: an unpinned dependency changes under you.

git init vendor/raylib
git -C vendor/raylib remote add origin https://github.com/raysan5/raylib.git
git -C vendor/raylib fetch --depth 1 origin <commit>
git -C vendor/raylib checkout FETCH_HEAD

Keep src/ and the licence. Delete the rest. raylib’s examples/ directory is 72 MB by itself, and none of it gets compiled. fetch-raylib.sh in this project does those steps and records the commit it fetched.

Add vendor/ to .gitignore. The script is the record. The vendored copy is not.

2. Do not declare it in build.zig.zon

vendor/raylib stays a plain directory of C files. The build runner imports the build script of every declared package and never looks at a plain directory, so raylib’s build.zig sits there unread.

3. Compile the C

const mod = b.createModule(.{
    .target = target,
    .optimize = optimize,
    .link_libc = true,
});
const lib = b.addLibrary(.{ .name = "raylib", .linkage = .static, .root_module = mod });

mod.addCMacro("PLATFORM_DESKTOP_GLFW", "");
mod.addCMacro("GRAPHICS_API_OPENGL_33", "");
mod.addCMacro("_GLFW_X11", "");

mod.addIncludePath(b.path("vendor/raylib/src/platforms"));
mod.addIncludePath(b.path("vendor/raylib/src/external/glfw/include"));
mod.addCSourceFiles(.{
    .root = b.path("vendor/raylib/src"),
    .files = &.{ "rcore.c", "rshapes.c", "rtextures.c", "rtext.c", "raudio.c", "rglfw.c" },
    .flags = &.{"-std=gnu99"},
});

for ([_][]const u8{ "GL", "X11", "Xrandr", "Xinerama", "Xi", "Xcursor" }) |name| {
    mod.linkSystemLibrary(name, .{});
}

That is six C files. rglfw.c is raylib’s bundled GLFW, the windowing layer. The rest are raylib’s modules, switched on and off by the SUPPORT_MODULE_* macros.

On Debian or Ubuntu the system libraries come from:

sudo apt-get install libx11-dev libxrandr-dev libxinerama-dev \
                     libxi-dev libxcursor-dev libgl1-mesa-dev

No ALSA package is needed. raylib’s audio backend loads libasound at runtime rather than including its header, so the game compiles without it and stays silent where there is no device.

4. Turn raylib.h into a Zig module

@cImport was removed from the language. C headers now arrive through a build step.

const translated = b.addTranslateC(.{
    .root_source_file = b.path("vendor/raylib/src/raylib.h"),
    .target = target,
    .optimize = optimize,
    .link_libc = true,
});
const rl = translated.createModule();
rl.linkLibrary(lib);

exe_mod.addImport("rl", rl);

5. Use it

const rl = @import("rl");

pub fn main() void {
    rl.InitWindow(540, 960, "Lane Dodger");
    defer rl.CloseWindow();

    while (!rl.WindowShouldClose()) {
        rl.BeginDrawing();
        rl.ClearBackground(rl.RAYWHITE);
        rl.DrawText("hello", 20, 20, 24, rl.BLACK);
        rl.EndDrawing();
    }
}

Names come through translate-c unchanged, so raylib’s own documentation applies directly.

The whole thing

examples/lane-dodger/build.zigraylibModule
fn raylibModule(
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    optimize: std.lang.Optimize,
    platform: Platform,
    emsdk: ?[]const u8,
) Raylib {
    const mod = b.createModule(.{
        .target = target,
        .optimize = optimize,
        .link_libc = true,
        // Zig instruments C with UBSan by default in the debug modes and links
        // its own runtime to catch the reports. That runtime comes with a Zig
        // link, and the web build is linked by emcc, so the instrumentation
        // would leave a few dozen undefined `__ubsan_*` symbols behind. None of
        // this C is ours: it is a pinned upstream release.
        .sanitize_c = .off,
    });
    const lib = b.addLibrary(.{
        .name = "raylib",
        .linkage = .static,
        .root_module = mod,
    });

    inline for (.{
        .{ "_GNU_SOURCE", "" },
        .{ "GL_SILENCE_DEPRECATION", "199309L" },
        .{ "SUPPORT_MODULE_RSHAPES", "1" },
        .{ "SUPPORT_MODULE_RTEXTURES", "1" },
        .{ "SUPPORT_MODULE_RTEXT", "1" },
        .{ "SUPPORT_MODULE_RAUDIO", "1" },
        // 3D model loading pulls in cgltf, m3d and par_shapes, and this game
        // only draws rectangles, so it is turned off.
        .{ "SUPPORT_MODULE_RMODELS", "0" },
    }) |macro| mod.addCMacro(macro[0], macro[1]);

    switch (platform) {
        .desktop => {
            mod.addCMacro("PLATFORM_DESKTOP_GLFW", "");
            mod.addCMacro("GRAPHICS_API_OPENGL_33", "");
            mod.addCMacro("_GLFW_X11", "");
        },
        .web => {
            mod.addCMacro("PLATFORM_WEB", "");
            // WebGL 2. ES2 also works and reaches older phones; ES3 is what
            // every browser this game would be played in supports today.
            mod.addCMacro("GRAPHICS_API_OPENGL_ES3", "");
        },
    }

    mod.addIncludePath(b.path(raylib_root ++ "/platforms"));
    mod.addIncludePath(b.path(raylib_root ++ "/external/glfw/include"));
    if (emsdk) |sdk| {
        mod.addIncludePath(.{ .cwd_relative = b.pathJoin(
            &.{ sdk, "upstream", "emscripten", "cache", "sysroot", "include" },
        ) });
    }

    // rglfw.c is raylib's bundled desktop GLFW. On the web Emscripten provides
    // GLFW against the canvas instead, so it is not compiled.
    const sources: []const []const u8 = switch (platform) {
        .desktop => &.{
            "rcore.c", "rshapes.c", "rtextures.c", "rtext.c", "raudio.c", "rglfw.c",
        },
        .web => &.{
            "rcore.c", "rshapes.c", "rtextures.c", "rtext.c", "raudio.c",
        },
    };
    mod.addCSourceFiles(.{
        .root = b.path(raylib_root),
        .files = sources,
        .flags = &.{"-std=gnu99"},
    });

    if (platform == .desktop) {
        for ([_][]const u8{ "GL", "X11", "Xrandr", "Xinerama", "Xi", "Xcursor" }) |name| {
            mod.linkSystemLibrary(name, .{});
        }
    }

    const translated = b.addTranslateC(.{
        .root_source_file = b.path(raylib_root ++ "/raylib.h"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    if (emsdk) |sdk| {
        translated.addIncludePath(.{ .cwd_relative = b.pathJoin(
            &.{ sdk, "upstream", "emscripten", "cache", "sysroot", "include" },
        ) });
    }
    const rl = translated.createModule();
    rl.linkLibrary(lib);
    return .{ .module = rl, .lib = lib };
}

The macros are copied from raylib’s own build script. That is the cost of this approach. When raylib changes its configuration, this function has to follow. The macro list is a dozen lines, and a mismatch fails at compile time rather than quietly producing a different library.

The same source, on a canvas

The platform choice reaches two places: the raylib module below, and a separate addWeb step that runs emcc.

Desktop is PLATFORM_DESKTOP_GLFW with OpenGL 3.3 and raylib’s bundled GLFW. Web is PLATFORM_WEB with GLES 3, and rglfw.c is not compiled at all, because Emscripten supplies its own GLFW bound to the canvas.

Zig compiles the game and raylib to a static library for wasm32-emscripten. emcc does the link. The JavaScript glue, the GL context and the canvas setup come from Emscripten.

examples/lane-dodger/build.zigaddWeb
/// Build the game for a browser canvas.
///
/// Zig compiles our code and raylib's to a static library for
/// `wasm32-emscripten`, and `emcc` does the link, because the JavaScript glue,
/// the GL context and the canvas setup are all things only Emscripten knows
/// how to emit. The same split is why `main.zig` has a `Game.frame`: emcc
/// hands the loop to the browser and never returns.
fn addWeb(
    b: *std.Build,
    optimize: std.lang.Optimize,
    options: *std.Build.Step.Options,
) void {
    const step = b.step("web", "Build the browser version with Emscripten");

    const emsdk = b.option([]const u8, "emsdk", "Path to an activated emsdk") orelse
        b.graph.environ_map.get("EMSDK") orelse
    {
        step.dependOn(&b.addFail(
            "no Emscripten SDK found: pass -Demsdk=/path/to/emsdk, or source emsdk_env.sh",
        ).step);
        return;
    };

    const target = b.resolveTargetQuery(.{
        .cpu_arch = .wasm32,
        .os_tag = .emscripten,
    });

    const game = gameModule(b, target, optimize, options, .web, emsdk);
    const lib = b.addLibrary(.{
        .name = "lane-dodger",
        .linkage = .static,
        .root_module = game.module,
    });

    const emcc = b.addSystemCommand(&.{b.pathJoin(&.{ emsdk, "upstream", "emscripten", "emcc" })});
    emcc.addArtifactArg(lib);
    emcc.addArtifactArg(game.raylib);
    emcc.addArgs(&.{
        // Emscripten supplies GLFW 3 against the canvas; raylib's own bundled
        // GLFW is a desktop windowing library and is not built here.
        "-sUSE_GLFW=3",
        // The canvas can be any size, and the page decides. Growth costs a
        // little speed and saves reserving worst-case memory up front.
        "-sALLOW_MEMORY_GROWTH=1",
        // A factory function instead of a global `Module`, so the page can
        // start the game when it wants and hand it its own canvas.
        //
        // This is deliberately not an ES6 module. The guide embeds this in a
        // page built by Vite, and Vite rewrites `import()` even for a file it
        // is only serving from `public/`, appending `?import` and then failing
        // to transform a 190 KB Emscripten bundle. A classic script that defines
        // one global is loaded by a plain `<script>` tag, which no bundler
        // touches.
        "-sMODULARIZE=1",
        "-sEXPORT_NAME=createLaneDodger",
        // The loop is driven by emscripten_set_main_loop, so none of the
        // stack-unwinding machinery is needed.
        "-sASYNCIFY=0",
        "-sSTACK_SIZE=1048576",
        // raylib is built here against GLES 3, which emits `#version 300 es`
        // shaders. Those need a WebGL 2 context, and Emscripten creates a
        // WebGL 1 one unless told otherwise: the symptom is a game that loads,
        // runs, and fails to compile a single shader.
        "-sMIN_WEBGL_VERSION=2",
        "-sMAX_WEBGL_VERSION=2",
    });
    if (optimize != .debug) emcc.addArgs(&.{ "-O3", "--closure", "0" });
    emcc.addArg("-o");
    const out = emcc.addOutputFileArg("lane-dodger.js");

    // emcc writes the wasm next to the js, under the same basename.
    const install_js = b.addInstallFileWithDir(out, .{ .custom = "web" }, "lane-dodger.js");
    const install_wasm = b.addInstallFileWithDir(
        out.dirname().path(b, "lane-dodger.wasm"),
        .{ .custom = "web" },
        "lane-dodger.wasm",
    );
    // The page that hosts the canvas. It ships with the two artefacts, so
    // `zig build web` produces a directory that can be played with no other
    // setup.
    const install_page = b.addInstallFileWithDir(
        b.path("web-shell/index.html"),
        .{ .custom = "web" },
        "index.html",
    );
    step.dependOn(&install_js.step);
    step.dependOn(&install_wasm.step);
    step.dependOn(&install_page.step);
}

Two flags there prevent silent failures.

-sMIN_WEBGL_VERSION=2. GLES 3 emits #version 300 es shaders, which need a WebGL 2 context. Emscripten creates a WebGL 1 one unless told otherwise. The symptom is a game that loads, runs, reports no error, and compiles not one shader.

.sanitize_c = .off on the raylib module. Zig instruments C with UBSan in the debug modes and links its own runtime to catch the reports. That runtime comes with a Zig link, and this link is done by emcc, so the instrumentation leaves dozens of undefined __ubsan_* symbols behind.

Running it

cd examples/lane-dodger
./fetch-raylib.sh
zig build run -Doptimize=ReleaseFast

The browser build is a 200 KB .wasm plus about the same amount of JavaScript. It needs the Emscripten SDK:

zig build web -Doptimize=ReleaseFast
python3 -m http.server -d zig-out/web 8080