Generating Raylib Bindings With and For Jai

As I mentioned a few months ago, I received access to the Jai closed beta last year. I’ve been chipping away at tooling around the language and learning as much as I can, but it was a busy year so I didn’t make as much progress as I would like. I also ran into some odd rendering issues that seem to be related to not having X11 installed on my Fedora 43 COSMIC Atomic installation.

Working with Jai is also a big part of my Year of Deshittification. Writing simpler code, with less compile-time iteration is so nice...

I started working on a VSCode plugin and while working on the LSP, I wiped and re-installed the machine I was working on without actually backing up most of my data. Not a big deal though, as I could always do it again.

The part I forgot about entirely was how to generate bindings for C libraries (e.g. libuv). I remember the first time I ever tried it last year, it only took about 10 minutes to figure it out and get it running, so I recalled it being a trivial thing to do. However, a ran into a strange mental blockwhere I randomly began to think that creating bindings was this really hard, convoluted process.

Keeping in mind that there are tons of examples on Github and in the Jai standard modules - so this is inordinately silly. And, as it turned out, I had saved the generation code - just in a different repo.

Either way, the sheer fact that I couldn't remember how I did this makes it clear that I should walk through some bindings step-by-step and write that down here as a reference. And since I want to learn how to make some small games, binding to Raylib seems obvious.

It's so easy

Briefly, you write a small compile-time program (in Jai) which runs the binding generator with a small set of options/tweaks. That program will then create the appropriate library bindings, which then get linked to based on the instructions you provide. Then it's fair game to use those bindings and the library in your application.

For this article, I'm using Jai 0.2.023.

% jai -version
  Version: beta 0.2.023, built on 27 December 2025.

A do-nothing generator

This is a bare-bones generator structure (actually, it could be inlined further, but this is a better structure for anything non-trivial).

#import "Bindings_Generator";
#import "Compiler";

#run build();

build :: () {
  set_build_options_dc({do_output = false});
  if !generate_bindings() {
    compiler_set_workspace_status(.FAILED);
    return;
  }
}

generate_bindings :: () -> bool {
  // Currently using a single bindings file for all platforms
  // Situationally this gets split out into per-platform bindings
  output_filename: string = "bindings.jai";

  // Create a default struct of options
  opts: Generate_Bindings_Options;

  // Call the actual generator
  return generate_bindings(opts, output_filename);
}

Unsurprisingly, it doesn't do much...

% jai generate.jai

  generating 0 global scope members...
      0 functions
      0 structs
      0 enums

  OK! generated 'bindings.jai'

And thus doesn't generate much...

//
// This file was auto-generated using the following command:
//
// jai generate.jai
//

#scope_file

#import "Basic"; // For assert

Setting up a project

Quick digression on how to structure a folder to make life easier. Jai will automatically look in the stdlib modules directory, or the local project's modules directory for imports. It's also looking for a module.jai file specifically.

I grabbed the latest Raylib release files from GitHub and unpacked the Linux x64 files to modules/raylib/... - while the jai files were in the top-level raylib directory. Setting up a project like this should allow for #import "raylib" to work out of the box.

% tree

  .
  ├── modules
  │   └── raylib
  │       ├── generate.jai
  │       ├── bindings.jai
  │       ├── module.jai
  │       └── raylib-5.5_linux_amd64
  │           ├── CHANGELOG
  │           ├── include
  │           │   ├── raylib.h
  │           │   ├── raymath.h
  │           │   └── rlgl.h
  │           ├── lib
  │           │   ├── libraylib.a
  │           │   ├── libraylib.so -> libraylib.so.550
  │           │   ├── libraylib.so.5.5.0
  │           │   └── libraylib.so.550 -> libraylib.so.5.5.0
  │           ├── LICENSE
  │           └── README.md
  └── helloworld.jai

Generate bindings from the header

To get something to actually happen, we'll need to point the generator at actual the library's definitions:

#import "Basic";
#import "Bindings_Generator";
#import "Compiler";

#run build();

build :: () {
  set_build_options_dc({do_output = false});
  if !generate_bindings() {
    compiler_set_workspace_status(.FAILED);
    return;
  }
}

generate_bindings :: () -> bool {
  output_filename: string = "bindings.jai";
  opts: Generate_Bindings_Options;

  // Let the generator know about the important header(s)
  array_add(*opts.source_files, "raylib-5.5_linux_amd64/include/raylib.h");

  return generate_bindings(opts, output_filename);
}

And hey, look at that, it built stuff! Wait, why did it strip declarations and skip all the functions?

% jai generate.jai

  generating 715 global scope members...
      0 functions
      36 structs
      21 enums
      2 system declarations were ignored.
      581 declarations were stripped!

  OK! generated 'bindings.jai'

Looking at the generated bindings, they seem fine...

//
// This file was auto-generated using the following command:
//
// jai generate.jai.first
//

RAYLIB_VERSION_MAJOR :: 5;
RAYLIB_VERSION_MINOR :: 5;
RAYLIB_VERSION_PATCH :: 0;
RAYLIB_VERSION :: "5.5";

PI :: 3.14159265358979323846;

DEG2RAD :: PI/180.0;

...

// Rectangle, 4 components
Rectangle :: struct {
    x:      float; // Rectangle top-left corner position x
    y:      float; // Rectangle top-left corner position y
    width:  float; // Rectangle width
    height: float; // Rectangle height
}

// Image, pixel data stored in CPU memory (RAM)
Image :: struct {
    data:    *void; // Image raw data
    width:   s32; // Image base width
    height:  s32; // Image base height
    mipmaps: s32; // Mipmap levels, 1 by default
    format:  s32; // Data format (PixelFormat type)
}

Let's build our helloworld.jai and see what happens:

#import "raylib";

main :: () {
  screen_width :: 640;
  screen_height :: 480;

  InitWindow(screen_width, screen_height, "Hello world");
  CloseWindow();
}
% jai helloworld.jai

  In Workspace 2 ("Target Program"):
  helloworld.jai:1,1: Error: Cannot load the module 'helloworld/modules/raylib' because it is missing the file 'module.jai'.
      #import "raylib";
  helloworld.jai:1,1: Error: Unable to find a module called 'raylib' in any of the module search directories.
      #import "raylib";

Oops, forgot to actually add a module.jai (in spite of what the tree above lied about)

#if OS == .LINUX {
  #load "bindings.jai";
} else {
  #assert false "Only linux support right now...";
}

Trying again...

% jai helloworld.jai

  Error: Undeclared identifier 'InitWindow'.

Damn.

The bad kind of stripping

What's going on here? Well, the bindings output spells it out pretty plainly:

raylib.h:968:12: Stripping function that's missing from foreign libs: InitWindow
raylib.h:969:12: Stripping function that's missing from foreign libs: CloseWindow
raylib.h:970:12: Stripping function that's missing from foreign libs: WindowShouldClose
...

It's caused by a configurable enum set (opts.strip_flags) and defaults to stripping out symbols where there is no associated lib (and also va_list stuff).

So, let's point to the library and see if we can get a better result:

#import "Basic";
#import "Bindings_Generator";
#import "Compiler";

#run build();

build :: () {
  set_build_options_dc({do_output = false});
  if !generate_bindings() {
      compiler_set_workspace_status(.FAILED);
      return;
  }
}

generate_bindings :: () -> bool {
  output_filename: string = "bindings.jai";
  opts: Generate_Bindings_Options;
  array_add(*opts.source_files, "raylib-5.5_linux_amd64/include/raylib.h");
  
  // Point to the other includes and the libs
  array_add(*opts.include_paths, "raylib-5.5_linux_amd64/include");
  array_add(*opts.library_search_paths, "raylib-5.5_linux_amd64/lib");
  array_add(*opts.libraries, {filename="libraylib"});

  return generate_bindings(opts, output_filename);
}

Annnnd re-generating...

% jai generate.jai

  generating 716 global scope members...
      581 functions
      36 structs
      21 enums
      2 system declarations were ignored.

  OK! generated 'bindings.jai'

Sweet, back in business. And yep, the functions are in bindings.jai and they point to Raylib for an implementation.

...
// Window-related functions
InitWindow :: (width: s32, height: s32, title: *u8) -> void #foreign libraylib;
CloseWindow :: () -> void #foreign libraylib;
WindowShouldClose :: () -> bool #foreign libraylib;
...

Let's compile our app and see how awesome it is:

% jai helloworld.jai

  lld-linux: error: undefined symbol: pow
  >>> referenced by rtext.c
  >>>               rtext.o:(stbtt__cuberoot) in archive helloworld/modules/raylib/raylib-5.5_linux_amd64/lib/libraylib.a
  >>> referenced by rtext.c
  >>>               rtext.o:(stbtt__cuberoot) in archive helloworld/modules/raylib/raylib-5.5_linux_amd64/lib/libraylib.a
  >>> referenced by rtextures.c
  >>>               rtextures.o:(stbi__loadf_main) in archive helloworld/modules/raylib/raylib-5.5_linux_amd64/lib/libraylib.a

  lld-linux: error: undefined symbol: sqrt
  lld-linux: error: undefined symbol: sqrtf
  ...

...Sigh... Well crap...

POW! Right in the sqrt

Though, this makes sense - RayMath imports <math.h> - and I haven't really told the bindings where to find that. So, I'll link that in a generated footer, at the bottom of the bindings file:

#import "Basic";
#import "Bindings_Generator";
#import "Compiler";

#run build();

build :: () {
  set_build_options_dc({do_output = false});
  if !generate_bindings() {
      compiler_set_workspace_status(.FAILED);
      return;
  }
}

generate_bindings :: () -> bool {
  output_filename: string = "bindings.jai";
  opts: Generate_Bindings_Options;

  array_add(*opts.source_files, "raylib-5.5_linux_amd64/include/raylib.h");
  array_add(*opts.include_paths, "raylib-5.5_linux_amd64/include");
  array_add(*opts.library_search_paths, "raylib-5.5_linux_amd64/lib");
  array_add(*opts.libraries, {filename="libraylib"});

  // Add this to the bottom of the generated file
  opts.footer = FOOTER;

  return generate_bindings(opts, output_filename);
}

FOOTER :: #string EOF
  #if OS == .LINUX {
    libm :: #library,system,link_always "libm";
  } else {
    #assert false "Only linux support right now...";
  }
EOF;

Re-trying the compilation...

% jai helloworld.jai

  Stats for Workspace 2 ("Target Program"):
  Lexer lines processed: 12651 (19884 including blank lines, comments.)
  Front-end time: 0.162626 seconds.
  llvm      time: 0.377990 seconds.

  Compiler  time: 0.540616 seconds.
  Link      time: 0.000477 seconds.
  Total     time: 0.541093 seconds.

Boom! Nailed it. Let's see it in action...

% ./helloworld

  INFO: Initializing raylib 5.5
  INFO: Platform backend: DESKTOP (GLFW)
  INFO: Supported raylib modules:
  INFO:     > rcore:..... loaded (mandatory)
  INFO:     > rlgl:...... loaded (mandatory)
  INFO:     > rshapes:... loaded (optional)
  INFO:     > rtextures:. loaded (optional)
  INFO:     > rtext:..... loaded (optional)
  INFO:     > rmodels:... loaded (optional)
  INFO:     > raudio:.... loaded (optional)
  ...
  INFO: Windows closed successfully

Well, not much to see - just a flash of a window opening and closing, but that's pretty good. Now, let's run a hello world we can see...

Now for an actual hello to the world

#import "raylib";

main :: () {
  screen_width :: 640;
  screen_height :: 480;

  InitWindow(screen_width, screen_height, "Hello world");

  SetTargetFPS(60);
  while !WindowShouldClose() {
    BeginDrawing();
    ClearBackground(RAYWHITE);
    DrawText("Congrats! You created your first window!", 190, 200, 20, LIGHTGRAY);
    EndDrawing();
  }

  CloseWindow();
}

Which compiles to...

% jai helloworld.jai 

  In Workspace 2 ("Target Program"):
  helloworld/helloworld.jai:12,21: Error: Undeclared identifier 'RAYWHITE'.

      while !WindowShouldClose() {
        BeginDrawing();
        ClearBackground(RAYWHITE);

Oh come on...

Intentional, but surprising

This seems to happen while trying to run C from several languges (notably Swift), but macro functions are often accidentally, or intentionally, not bound/ignored. That was a mild surprise, but I solved that by just pulling those colours into the module manually.

However, there is no warning that this was missed, so a handy option is opts.log_unsupported = true;

With that, I would have seen:

Macro RAYWHITE: Couldn't find reference for identifier CLITERAL
Macro GRAY: Couldn't find reference for identifier CLITERAL
Macro BLACK: Couldn't find reference for identifier CLITERAL
...

Ah, that makes sense. Jai couldn't find the CLITERAL... Some say it's a myth...

So, I'll just add those manually to the module.jai file:

...
WHITE     :: Color.{ 255, 255, 255, 255 };   // White
BLACK     :: Color.{ 0, 0, 0, 255 };         // Black
BLANK     :: Color.{ 0, 0, 0, 0 };           // Blank (Transparent)
MAGENTA   :: Color.{ 255, 0, 255, 255 };     // Magenta
RAYWHITE  :: Color.{ 245, 245, 245, 255 };   // My own White (raylib logo)

Also, small tweak to the example to make it more Jai-ish, by adding defers.

#import "raylib";

main :: () {
  screen_width :: 640;
  screen_height :: 480;

  InitWindow(screen_width, screen_height, "Hello world");
  defer {
    CloseWindow();
  }

  SetTargetFPS(60);
  while !WindowShouldClose() {
    BeginDrawing();
    defer {
      EndDrawing();
    }
    ClearBackground(RAYWHITE);
    DrawText("Congrats! You created your first window!", 190, 200, 20, LIGHTGRAY);
  }
}

Annnnnnnndddddd?

% jai helloworld.jai 

  Stats for Workspace 2 ("Target Program"):
  Lexer lines processed: 12662 (19893 including blank lines, comments.)
  Front-end time: 0.156325 seconds.
  llvm      time: 0.343080 seconds.

  Compiler  time: 0.499404 seconds.
  Link      time: 0.000475 seconds.
  Total     time: 0.499879 seconds.

Finally!

Upon running, we see something that looks like this (I grabbed this image from the Raylib website since my laptop is out of computing juice right now):

Screenshot of hello-world in Raylib

I haven't exercised the totality of Raylib in doing this, but it basically "just works" for the basics. As I need more functionality, I'll add it into the bindings.

Why is it so slow?

0.5 seconds might not seem slow, but for Jai, for a debug project this small, 0.5 seconds is an eternity (even pulling in the Raylib code).

... Ah bugger... llvm time: 0.343080 seconds.

Turns out that on this computer, I forgot to alias the -x64 backend.

% jai -x64 helloworld.jai

  Lexer lines processed: 12662 (19893 including blank lines, comments.)
  Front-end time: 0.161221 seconds.
  x64       time: 0.005103 seconds.

  Compiler  time: 0.166323 seconds.
  Link      time: 0.000435 seconds.
  Total     time: 0.166758 seconds.

Ahhhhh... There it is...

It's worth pointing out that I run the Jai compiler on my dual-core i5 Macbook Air from 2017 and it compiles apps faster than some of my Vite-served web UIs can re-render a text change on an Mac Mini M2 Pro.

Bells and whistles

There's a lot of cool stuff you can do when generating bindings. One of them is to grab out parts of the AST, in case there are items you want to ignore or modify. For instance, from Overlord System's libsodium bindings:

...
  opts.visitor = visitor;
...

DECLARATIONS_TO_OMIT :: string.[
  "_sodium_alloc_init",
  "_sodium_runtime_get_cpu_features",
  ...
];

visitor :: (decl: *Declaration, parent_decl: *Declaration) -> Declaration_Visit_Result {
  if !parent_decl && array_find(DECLARATIONS_TO_OMIT, decl.name) {
    decl.decl_flags |= .OMIT_FROM_OUTPUT;
    return .STOP;
  }
  return .RECURSE;
}

Anyways, I'll be hosting the handful of bindings I need in a monorepo cleverly called jai-bindings. I'm not sure exactly how I'll manage or maintain these going forward, but my guess is that I'll just keep a clone somewhere on my machine, and symlink projects to the relevant directories. Jai doesn't have a package manager, and I'm more and more coming to Ginger Bill's conclusion that Package Managers are Evil.