Three Clojures, One C Library: 422 raylib Examples
Three suites of raylib examples, written in three different Clojures, calling the same C game library. 97 examples in Jolt on Chez Scheme, 212 in jank through C++/LLVM, and 113 in JVM Clojure over Project Panama. That is 422 programs doing roughly the same kinds of things, and the interesting part is where each one puts the boundary between Lisp and C.
raylib is a good library to pick a fight with here. It is plain C, it is small enough to read, and it passes structs by value almost everywhere. That last habit is comfortable in C and awkward in most foreign-function interfaces, so the three suites end up disagreeing in ways that show you exactly what each runtime is doing underneath.
Here is the same demo, bouncing-ball, running in all three:
![]() Jolt on Chez Scheme | ![]() jank via C++/LLVM | ![]() JVM Clojure over Panama |
Same ball, same physics, same 60 frames a second. Three completely different routes to DrawCircle.
One C function, three bindings
DrawCircle(int, int, float, Color) is the smallest call that shows the whole problem. Three ints and a float are boring. The Color is the problem, because it is a struct of four unsigned bytes and raylib wants it by value.
jank does not bind it at all. The namespace form pulls in raylib's own header:
(ns raylib-examples.bouncing-ball
"raylib [shapes] example - bouncing ball, ported to jank.
A ball bounces around the window with optional gravity.
Controls: SPACE pause/resume, G toggle gravity, Q quit.
Based on raylib/examples/shapes/shapes_bouncing_ball.c"
(:include "raylib.h"))
After that, raylib is ordinary interop. There is no binding layer in this project to speak of, because the C++ compiler already has the declarations:
(cpp/BeginDrawing)
(cpp/ClearBackground cpp/RAYWHITE)
(cpp/DrawCircle (int fx) (int fy) (cpp/float BALL-RADIUS) cpp/MAROON)
cpp/MAROON is raylib's own constant, cpp/DrawCircle is raylib's own function, and the by-value Color that costs the other two suites a binding decision is just an argument. jank compiles through C++/LLVM, so a struct passed by value is a problem the C++ compiler solves on the way past.
Jolt packs it into a register. Chez Scheme's foreign-procedure cannot pass a struct by value, so the binding declares an unsigned 32-bit integer and the four bytes get folded into it by hand:
(defn rgba
"Pack an RGBA color into the little-endian uint32 that raylib's `Color` struct
is (r | g<<8 | b<<16 | a<<24), so it can cross the FFI boundary as a :uint."
[r g b a]
(bit-or (int r) (bit-shift-left (int g) 8)
(bit-shift-left (int b) 16) (bit-shift-left (int a) 24)))
The binding then reads as four scalars, and the C compiler on the other side unpacks the register back into {r, g, b, a} because that is what the ABI says a 4-byte composite does:
(ffi/defcfn draw-circle "DrawCircle" [:int :int :float :uint] :void)
This works because Color is exactly small enough. Four bytes fit in one register, so packing them by hand and letting the ABI do the unpacking is the calling convention written out longhand.
JVM Clojure describes the layout and lets the runtime do it. coffi takes a struct definition as data:
(defalias ::color
[::mem/struct
[[:r ::ri/ubyte]
[:g ::ri/ubyte]
[:b ::ri/ubyte]
[:a ::ri/ubyte]]])
and the binding names the C symbol with its argument types, including that struct:
(defcfn draw-circle!
"Draw a color-filled circle"
{:arglists '([center-x center-y radius color])}
"DrawCircle"
[::mem/int ::mem/int ::mem/float ::rs/color] ::mem/void)
Panama builds the downcall from that description at runtime. So a color here is an ordinary Clojure map, {:r 255 :g 0 :b 0 :a 255}, serialized on the way out. The hand-packing Jolt does in rgba still happens, just lower down and written by somebody else.
Three answers to one question. Push it to the C++ compiler, do it yourself, or describe it and delegate. Each is the honest answer for its host.
Where the register trick runs out
Packing Color into a :uint is fine at four bytes. Camera2D is 24, and that is where the Jolt suite has to know something specific about the machine it is running on:
;; --- Camera2D: a struct passed BY VALUE (the one non-Color by-value struct) ---
;; raylib's BeginMode2D(Camera2D) takes {Vector2 offset; Vector2 target; float
;; rotation; float zoom}, 24 bytes, passed by value. On the AArch64 (Apple) ABI a
;; composite larger than 16 bytes is passed INDIRECTLY: the caller allocates a
;; copy and passes a POINTER to it, so the binding is [:pointer] and we build the
;; struct (six little-endian floats) in native memory. NOTE: this is AArch64-
;; specific: on the x86-64 SysV ABI the 24 bytes are passed on the stack, which
;; a [:pointer] binding does NOT do (see README). For a portable alternative,
;; apply the same transform with the scalar rlgl matrix ops instead.
(ffi/defcfn ^:private begin-mode-2d-ptr "BeginMode2D" [:pointer] :void)
Read that comment closely, because it is the most honest thing in any of the three repos. On AArch64, a composite bigger than 16 bytes is passed indirectly, so the caller allocates a copy and hands over a pointer. A [:pointer] binding is therefore correct on Apple silicon. On x86-64 SysV the same 24 bytes go on the stack instead, and a [:pointer] binding does not do that, so the same code is wrong on a different machine.
That constraint is written down, in the source, next to the binding it governs, with the portable alternative named. The portable route is to skip the by-value call entirely and apply the same transform through rlgl's scalar matrix operations, which is what the 3D examples do: Camera3D is 44 bytes, DrawCube takes a Vector3 by value, and rather than fight either one the suite draws geometry through rlgl immediate mode where every argument is a scalar.
So the Jolt suite spends most of its effort finding the scalar path through raylib rather than binding the by-value one, and there almost always is a scalar path, because rlgl is sitting underneath doing the work anyway.
Where the JVM's abstraction stops hiding
The Panama suite gets to write {:r 255 :g 0 :b 0 :a 255} and not think about registers. The place it has to start thinking again is lifetime, and specifically any call where raylib mutates through a pointer:
(defn update-camera
"Update camera position for selected mode. Returns updated camera map.
mode: CAMERA_FREE, CAMERA_ORBITAL, CAMERA_FIRST_PERSON, CAMERA_THIRD_PERSON"
[camera mode]
(let [arena (mem/confined-arena)
seg (mem/alloc-instance ::camera3d arena)]
(mem/serialize-into camera ::camera3d seg arena)
(update-camera! seg mode)
(mem/deserialize-from seg ::camera3d)))
UpdateCamera takes a Camera3D* and writes to it. So the Clojure side allocates a segment in a confined arena, serializes the camera map into it, makes the call, and deserializes the result back out. The map goes in, a map comes out, and in between there is a block of native memory with a scope you have to name.
An arena is the one thing Panama will not hide. It cannot, because "when is this memory allowed to go away" is a question only the caller can answer. Everything else about the struct, the field offsets, the padding, the endianness, is handled from the defalias description. Ownership is not.
Compare that to the Jolt suite, where the by-value marshalling is hand-written and therefore obvious, and to jank, where the question mostly does not arise because a C++ compiler is managing the call frame. Three different amounts of visible machinery, all doing the same job.
The three suites are not the same project
It would be tidy to say these are one example set translated three times. They are not, and the differences say something about why each exists.
jank is a completeness project. 212 of raylib's own official examples are ported, counted against raylib 6.1-dev's 220. Against the 6.0 the binaries link against, the denominator is 217 and the shapes category is complete at 41 of 41. Four categories are finished outright: shaders at 35, textures at 32, text at 16, and audio at 11. When a project's goal is "port the official suite," the score is the point, and the remaining gaps are documented individually rather than rounded away.
The Jolt and JVM suites are mixed. 97 and 113 examples, blending ports of raylib's C examples with original games that were never upstream. Tetris, asteroids, snake and pong exist in both of those and in neither of jank's, because they were never official examples to begin with. 42 demos exist in all three suites, which is enough overlap to compare and far from a full mirror.
That difference is worth noticing before reading any of the code. The jank suite answers "can this dialect do everything raylib does." The other two answer "what is it like to build things with raylib from here."
![]() Jolt · bb rlgl-solar-systemnested transforms, all scalar | ![]() jank · bb basic-pbrphysically based rendering | ![]() JVM Clojure · bb lorenz-attractora strange attractor in 3D |
![]() Jolt · bb tetris | ![]() jank · bb shadowmap-rendering | ![]() JVM Clojure · bb solar-system |
What reading the same example three times is good for
The obvious use is picking a runtime, and for that the summary is short. jank has the least friction against a C library and the longest compile. Jolt starts fast and asks you to understand the calling convention. JVM Clojure sits in between, with the biggest ecosystem behind it and a garbage collector you now have to think about at the boundary.
The better use is that the differences are a map of what an FFI does. Every one of these suites draws a red circle. One of them says cpp/MAROON, one says (rgba 190 33 55 255) and means a specific uint32, and one says {:r 190 :g 33 :b 55 :a 255} and hands it to a serializer. The C function receiving all three is identical, byte for byte, and reading the three call sites together tells you more about calling conventions than any amount of documentation about calling conventions.
There is a caveat I have not resolved. Three suites is a small sample, and all three are mine, which means they share my habits and my blind spots as much as they differ in their runtimes. A fourth port by somebody else would probably disagree with all of this in at least one place worth knowing about.
Each suite has its own documentation site with the full example catalog and every demo at full size: raylib-jlt.b12n.app, raylib-jnk.b12n.app and raylib-clj.b12n.app. The code is at b12n-raylib-jlt, b12n-raylib-jnk and b12n-raylib-clj, all open source.








