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.

Starting out with Node

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:

Raylib window

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.

Switching to Electron

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:

Electron window

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:

Electron and Raylib windows

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:

Electron window

Building standalone executable

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.

Source code

You can find the source code for the node example here. The code for Electron is here.

Conclusion

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.

⚠️ **GitHub.com Fallback** ⚠️