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 zig
We then proceed to add Raylib as a module. First we need a custom build file:
cd zig
npx node-zigar custom
That creates a copy of build.zig
in the CWD. Now we ask Zig to install Raylib from Github:
zig fetch --save https://github.com/raysan5/raylib/archive/refs/heads/master.zip
Now there's also a build.zig.zon
in the directory. Open build.zig
and link in Raylib:
const raylib = b.dependency("raylib", .{
.target = target,
.optimize = optimize,
});
lib.linkLibrary(raylib.artifact("raylib"));
And add it as an include path:
mod.addIncludePath(raylib.path("src"));
Then 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@latest
Need 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-zigar
Copy 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 start
After 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 App
The 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(a: std.mem.Allocator) ![]const u8 {
return try a.dupe(u8, 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();
}
}
Note: Electron does not allow creation of ArrayBuffer
referencing external memory. A workaround
was implemented in Zigar 0.14.0 but it does not seems to be working. That's the reason why
getText()
accepts an allocator as an argument instead of simply returning text
. Allocating
memory from JavaScript and copying the text into it means not running afoul of the restriction.
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 App
As 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 init
In 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 run
In 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.