Raylib - chung-leong/zigar GitHub Wiki
In this example we're going to explore the use of Raylib with Zigar. Initially, the instructions will only cover Windows, as Raylib works "out of the box" on that platform. I'll add instructions for Linux and MacOS once I've figured out how to install the necessary dependencies.
We take the usual steps of creating a Node project:
mkdir raylib
cd raylib
npm init -y
npm install node-zigar
mkdir src zigWe then proceed to add Raylib as a module. First we need an empty build file:
cd zig
touch build.zigNow we ask Zig to install the last version of Raylib on Github that's compatible with Zig 0.15.2:
zig fetch --save https://github.com/raysan5/raylib/archive/60eb3a14d7d1bc27adfd1e5586fefa540016edab.zipNow there's also a build.zig.zon in the directory. Run the following command to create a copy of
build.extra.zig:
npx zigar extraOpen the file and add Raylib as a dependency:
const std = @import("std");
pub fn getIncludePaths(b: *std.Build, args: anytype) []const []const u8 {
const raylib = b.dependency("raylib", .{
.target = args.target,
.optimize = args.optimize,
});
args.library.linkLibrary(raylib.artifact("raylib"));
args.module.addIncludePath(raylib.path("src"));
return &.{};
}The others functions can be removed.
Next, create main.zig:
const r = @cImport(@cInclude("raylib.h"));
pub fn main() void {
r.InitWindow(800, 450, "raylib [core] example - basic window");
defer r.CloseWindow();
while (!r.WindowShouldClose()) {
r.BeginDrawing();
r.ClearBackground(r.RAYWHITE);
r.DrawText("Congrats! You created your first window!", 190, 200, 20, r.LIGHTGRAY);
r.EndDrawing();
}
}The code above is simply a Zig version of Raylib's basic example.
In the src directory, create index.js:
import { main } from '../zig/main.zig';
main();Open package.json. Set the package type to module and add a script command for launching the
program:
"type": "module",
"scripts": {
"start": "node --loader=node-zigar --no-warnings src/index.js"
},Compilation will take a minute or two. When it's done, you should see the following:

While main() runs, Node.js is basically frozen. That's not acceptable in a real application.
Let us move main() into a separate thread:
const std = @import("std");
const zigar = @import("zigar");
const r = @cImport(@cInclude("raylib.h"));
pub fn launch(promise: zigar.function.Promise(void)) !void {
try zigar.thread.use();
errdefer zigar.thread.end();
const thread = try std.Thread.spawn(.{}, runMain, .{promise});
thread.detach();
}
fn runMain(promise: zigar.function.Promise(void)) void {
main();
promise.resolve({});
zigar.thread.end();
}
pub fn main() void {
// ...
}In index.js:
import { lanuch } from '../zig/main.zig';
await lanuch();That frees up Node.js's event loop.
Node.js is generally used to create server-side app. Using Raylib there doesn't make much sense. Let us switch track and build an Electron app instead. Electron lets us create an graphical user interface with minimal effect. That can be quite useful during game development, when you might need to ticker with or display your game's internal state.
We'll start by creating an Electron-Vite boilerplate app:
npm create @quick-start/electron@latestNeed to install the following packages:
@quick-start/[email protected]
Ok to proceed? (y)
✔ Project name: … raylib2
✔ Select a framework: › react
✔ Add TypeScript? … [No] / Yes
✔ Add Electron updater plugin? … [No] / Yes
✔ Enable Electron download mirror proxy? … [No] / Yes
We then add the node-zigar module:
cd raylib2
npm install
npm install node-zigarCopy the zig directory from our Node project into this one. Then open src/main/index.js and
add the following lines at the top:
require('node-zigar/cjs')
const { launch } = require('../../zig/main.zig')Electron does not have native support for ESM currently. That's why we're sticking with
CJS and require() here.
Scroll down further and change the IPC handler for ping:
// IPC test
ipcMain.on('ping', () => launch())We're good to go. Launch Electron with the following command:
npm run startAfter a while we'll be greeted by the Electron boilerplate app. Click the "Send IPC" button. The same Raylib window that you saw earlier should appear.
If you click the button a second time while the Raylib window is still open, you'd get a second window that doesn't function properly. This is due to Raylib being a single-threaded library. You can't have two threads using the same copy of Raylib at the same time.
We'll change our app so that opening a second window is not possible. The button should be disabled
until the promise returned by launch() is fulfilled.
The first thing we need to do is change the IPC handler in src/main/index.js. Instead of on()
we'll instead use the async-aware
handle():
ipcMain.handle('raylib:launch', () => launch())In src/renderer/src/App.jsx, we switch from send() to
invoke().
We'll use the occasion to get rid of garbage from the boilerplate app:
import { useCallback, useState } from "react";
function App() {
const [ open, setOpen ] = useState(false);
const onLaunchClick = useCallback(async () => {
try {
setOpen(true);
await window.electron.ipcRenderer.invoke('raylib:launch');
} finally {
setOpen(false);
}
}, []);
return (
<>
<div>
<button disabled={open} onClick={onLaunchClick}>Launch</button>
</div>
</>
)
}
export default AppThe Launch button will now be grayed out while the Raylib window is open:

The next thing to do is to replace hardcoded values in main.zig with references to variables.
We'll also add functions for changing them:
const std = @import("std");
const zigar = @import("zigar");
const r = @cImport(@cInclude("raylib.h"));
pub fn launch(promise: zigar.function.Promise(void)) !void {
try zigar.thread.use();
errdefer zigar.thread.end();
const thread = try std.Thread.spawn(.{}, runMain, .{promise});
thread.detach();
}
fn runMain(promise: zigar.function.Promise(void)) void {
main();
promise.resolve({});
zigar.thread.end();
}
const Settings = struct {
x: c_int = 190,
y: c_int = 200,
font_size: c_int = 20,
text_color: r.Color = r.LIGHTGRAY,
background_color: r.Color = r.RAYWHITE,
};
const allocator = std.heap.c_allocator;
const default_text: [:0]const u8 = "Congrats! You created your first window!";
var text: [:0]const u8 = default_text;
var settings: Settings = .{};
pub fn getText() []const u8 {
return text;
}
pub fn getSettings() Settings {
return settings;
}
pub fn setText(arg: []const u8) !void {
if (text.ptr != default_text.ptr) allocator.free(text);
text = try allocator.dupeZ(u8, arg);
}
pub fn setSettings(arg: Settings) void {
settings = arg;
}
pub fn main() void {
r.InitWindow(800, 450, "raylib [core] example - basic window");
defer r.CloseWindow();
while (!r.WindowShouldClose()) {
r.BeginDrawing();
r.ClearBackground(settings.background_color);
r.DrawText(text, settings.x, settings.y, settings.font_size, settings.text_color);
r.EndDrawing();
}
}We import these functions in src/main/index.js:
const { launch, getText, getSettings, setText, setSettings } = require('../../zig/main.zig')And add IPC handlers for them:
ipcMain.handle('raylib:launch', () => launch())
ipcMain.handle('raylib:get-text', () => getText().string)
ipcMain.handle('raylib:get-settings', () => getSettings().valueOf())
ipcMain.handle('raylib:set-text', (evt, text) => setText(text));
ipcMain.handle('raylib:set-settings', (evt, settings) => setSettings(settings))getText() and getSettings() return Zig objects, which cannot be serialized. We need to
therefore convert them to string and object first.
With the plumbing in place, let us add some HTML input controls to out app:
import { useCallback, useEffect, useRef, useState } from "react";
function App() {
const [ open, setOpen ] = useState(false);
const [ text, setText ] = useState('');
const [ x, setX ] = useState(0);
const [ y, setY ] = useState(0);
const [ textSize, setTextSize ] = useState(0);
const [ textColor, setTextColor ] = useState('#000000');
const [ bkColor, setBkColor ] = useState('#ffffff');
const settings = useRef();
const updateRaylibText = (s) => window.electron.ipcRenderer.invoke('raylib:set-text', s);
const updateRaylibSettings = (s) => {
Object.assign(settings.current, s);
window.electron.ipcRenderer.invoke('raylib:set-settings', settings.current);
};
const onLaunchClick = useCallback(async () => {
try {
setOpen(true);
await window.electron.ipcRenderer.invoke('raylib:launch');
} finally {
setOpen(false);
}
}, []);
const onTextChange = useCallback((evt) => {
const s = evt.target.value;
setText(s);
updateRaylibText(s);
}, []);
const onXChange = useCallback((evt) => {
const x = parseInt(evt.target.value);
setX(x);
updateRaylibSettings({ x });
}, []);
const onYChange = useCallback((evt) => {
const y = parseInt(evt.target.value);
setY(y);
updateRaylibSettings({ y });
}, []);
const onTextSizeChange = useCallback((evt) => {
const size = parseInt(evt.target.value);
setTextSize(size);
updateRaylibSettings({ font_size: size });
}, []);
const onTextColorChange = useCallback((evt) => {
const color = evt.target.value;
setTextColor(color);
updateRaylibSettings({ text_color: parseColor(color) });
}, []);
const onBkColorChange = useCallback((evt) => {
const color = evt.target.value;
setBkColor(color);
updateRaylibSettings({ background_color: parseColor(color) });
}, []);
useEffect(() => {
window.electron.ipcRenderer.invoke('raylib:get-settings').then((obj) => {
settings.current = obj;
setX(obj.x);
setY(obj.y);
setTextSize(obj.font_size);
setTextColor(stringifyColor(obj.text_color));
setBkColor(stringifyColor(obj.background_color));
});
window.electron.ipcRenderer.invoke('raylib:get-text').then((str) => {
setText(str);
});
}, []);
return (
<>
<div>
<button disabled={open} onClick={onLaunchClick}>Launch</button>
</div>
<div>
Text: <input type="text" value={text} onChange={onTextChange} />
</div>
<div>
X: <input type="range" value={x} min="0" max="1000" onChange={onXChange} />
</div>
<div>
Y: <input type="range" value={y} min="0" max="1000" onChange={onYChange} />
</div>
<div>
Text size: <input type="range" value={textSize} min="4" max="128" onChange={onTextSizeChange} />
</div>
<div>
Text color: <input type="color" value={textColor} onChange={onTextColorChange} />
</div>
<div>
Background color: <input type="color" value={bkColor} onChange={onBkColorChange} />
</div>
</>
)
}
function hex2(n) {
return n.toString(16).padStart(2, '0')
}
function stringifyColor(c) {
return `#${hex2(c.r)}${hex2(c.r)}${hex2(c.r)}`;
}
function parseColor(s) {
return {
a: 255,
r: parseInt(s.slice(1, 3), 16),
g: parseInt(s.slice(3, 5), 16),
b: parseInt(s.slice(5, 7), 16),
};
}
export default AppAs you intereact with the HTML form, you'll see changes immediately reflected in the Raylib window:

Neat!
As a final enhancement to our app, let us add a box showing us the last key-press received by Raylib. This demonstrates Zigar's ability to marshal calls from Zig to JavaScript.
The Raylib function GetKeyPressed() returns a key code--or zero if no key press sits in the
queue. We add a call to our loop in main():
while (!r.WindowShouldClose()) {
const key_code = r.GetKeyPressed();
if (key_code != 0) if (key_reporter) |f| f(key_code);And define key_reporter:
const KeyReporter = *const fn (c_int) void;
var key_reporter: ?KeyReporter = null;And add a function for setting it:
pub fn setKeyReporter(fn_ptr: ?KeyReporter) void {
key_reporter = fn_ptr;
}We import this function on the JavaScript side in src/main/index.js:
const { launch, getText, getSettings, setText, setSettings, setKeyReporter } = require('../../zig/main.zig')And call it inside createWindow(), where we have a reference to the main window:
setKeyReporter(keyCode => mainWindow.webContents.send('raylib:key-pressed', keyCode))When the callback is called an event gets send to the renderer. In src/renderer/src/App.jsx,
we add a listener to this event in our useEffect hook:
window.electron.ipcRenderer.on('raylib:key-pressed', (evt, keyCode) => setLastKey(keyCode));
}, []);Where setLastKey() comes from a useState hook:
const [ lastKey, setLastKey ] = useState();Whose output is used to populate a read-only text input:
<div>
Last key: <input type="text" value={lastKey} readOnly />
</div>Those are the changes required. Now when you hit a key in the Raylib window, the key code shows up in Electron:

Let us create the build file for standalone executable. Go to the root directory of the project and run the following command:
zig initIn addition to build.zig and build.zig.zon, Zig will create a main.zig and root.zig in
our src directory. Delete the former and move the latter into the zig directory. We won't
actually be using it. Keeping it saves us from having to delete its entries in the build file.
Open build.zig and update root_source_file for lib_mod and exe_mod to use zig instead
of src. Copy the config for Raylib from zig/build.zig:
const raylib = b.dependency("raylib", .{
.target = target,
.optimize = optimize,
});
lib.linkLibrary(raylib.artifact("raylib"));
exe_mod.addIncludePath(raylib.path("src"));And in build.zig.zon, the dependencies:
.dependencies = .{
.raylib = .{
.url = "https://github.com/raysan5/raylib/archive/refs/heads/master.zip",
.hash = "raylib-5.5.0-whq8uDynMwRSRR9e6C0Kexq7thk62Iu6iLzwEQSg36XC",
},
},Everything is set. We can now build and run it:
zig build runIn this build configuration, all our Zigar related functions will be ignored by the compiler
since only main() is being exported.
You can find the source code for the node example here. The code for Electron is here.
This example is really just scratching the surface. The browser brings a lot of capabilities to the table. You can open a file selection dialog box and choose a new file. You can create a bitmap editor with relatively small effort. Electron gives your the full power of Node.js to boot. You can potentially create a very nice dev tool that speeds game development with Raylib.
The need to perform IPC operations is one of the major pain point with Electron. NW.js is potentially a much better alternative. Since the browser and Node.js side run in the same process, your UI code would be able to call into Zig directly. No need for stupid IPC plumbing. Building for final distribution is easier with Electron, but that's not relevant in this usage scenario.
If you're an experienced Raylib developer with knowledge on using the library on Linux and MacOS, I would love to hear from you. I was simply unable to get it to build on those platforms due to missing dependencies.