This library provides hand-written FFI bindings to Raylib for SBCL and ECL.
Writing them by hand and avoiding CFFI has proven to:
- have fewer dependencies
- avoid runtime issues involving ASDF / UIOP
- have much better performance
Keep in mind, however, that since these bindings are hand-written, not all functions are available. There may also occasionally be drift between the SBCL and ECL as well.
Games made with this:
The Raylib C code has been vendored into this repository. To build it, as well as the “shim” code necessary to work around Raylib’s pattern of passing all structs by-value, do:
make
This will produce liblisp-raylib.so
and liblisp-raylib-shim.so
in lib/
.
Luckily there is only one other dependency: trivial-garbage
. You can fetch it
with vend or similar tools:
vend get
Once the raylib
system builds and loads, you can test it with the small game
loop sample at the bottom of the package.lisp
file. If a window opens and you
see the FPS counter, then it works. Press ESC to close the window.
As mentioned, this library does not use CFFI, a “convenience” library generally advertised to simplify the process of binding to C libraries. Convenient though it is, it comes at a cost I deemed unacceptable for game development. Hence it was necessary to crack open the compiler manuals and write the bindings separately for each compiler. It’s honestly not that much work, especially if you know you’ll only ever bind to a subset of the entire underlying API.
Despite being a Lisp-in-Lisp compiler, its C handling is excellent.
The SBCL variant builds and loads as-is via a usual asdf:load-system
.
If you alter the bindings during development, it’s enough to dynamically call
the load-shared-objects
function to update what’s in your running image.
Let’s observe how the Vector2
type and its constructor _MakeVector2
are bound.
“Wait a minute,” I hear you thinking, “Raylib is C - it has no special
constructor for Vector2
.” And you’d be right: _MakeVector2
is a shim function
that heap-allocates a Vector2
for us and returns the pointer.
Vector2 *_MakeVector2(float x, float y) {
Vector2 *v = malloc(sizeof(Vector2));
v->x = x;
v->y = y;
return v;
}
“Hold on,” you pipe up again, “why pointers? Raylib passes everything around by-value.” Right again. Unfortunately, neither SBCL nor ECL support by-value struct passing at the moment. So instead we do everything with pointers to the structs we need:
(define-alien-type nil
(struct vector2-raw
(x float)
(y float)))
(define-alien-routine ("_MakeVector2" make-vector2-raw) (* (struct vector2-raw))
(x float)
(y float))
This isn’t quite useful, as we can’t easily access the inner fields without arcane calls, nor does the Garbage Collector know what to do with this. We wrap some more:
(defstruct (vector2 (:constructor @vector2))
(pointer nil :type (alien (* (struct vector2-raw
(x single-float :offset 0)
(y single-float :offset 32))))))
(declaim (ftype (function (&key (:x real) (:y real)) vector2) make-vector2))
(defun make-vector2 (&key x y)
(let* ((ptr (make-vector2-raw (float x) (float y)))
(v (@vector2 :pointer ptr)))
(tg:finalize v (lambda () (free-alien ptr)))))
Three things to note:
- It is critical for SBCL that the
:offset
values are set correctly within the type hint. Otherwise it has to do a lot of guessing at runtime and you’ll see a big performance hit. - We see
trivial-garbage:finalize
in action. This ensures that as our wrapper CL struct is getting cleaned up, it will free the underlying C memory. - We add a
declaim
mostly for documentation purposes, but also to express for convenience that this function can flexibly accept most number types as input, enabling:
(raylib:make-vector2 :x 0 :y 0) ; No need to pass 0.0
We use a macro:
(defmacro vector2-x (v)
"The X slot of a `Vector2'."
`(slot (vector2-pointer ,v) 'x))
Since slot
can be used with setf
as well, vector2-x
(etc.) naturally becomes
both a getter and a setter.
Other Raylib functions that require a Vector2
as input are bound in such a way
that they accept our wrapped vector2
and internally unwrap it before calling
down into C.
When interpreting a C bool back into Lisp, SBCL needs to be told exactly how
big, in bits, the underlying number value was. For stdlib
bools, this is 8 bits:
(define-alien-routine ("IsGamepadAvailable" is-gamepad-available) (boolean 8)
(gamepad int))
Otherwise you will get very strange overflowing behaviour, and calls that should
yield T
will not.
ECL is a bit more sensitive than SBCL, but still fully functional if you know what to be careful of.
The libffi
system dependency incurs a performance penalty. Further, with future
aims of compiling to WASM, we wish to avoid this dependency altogether. Hence
our ECL-based bindings are entirely “static” and avoid its :dffi
feature.
This means that during development, we need to load our system in a special way:
(progn
(let* ((path (merge-pathnames "lib/" (ext:getcwd)))
(args (format nil "-Wl,-rpath,~a -L~a" path path)))
(setf c:*user-linker-flags* args)
(setf c:*user-linker-libs* "-llisp-raylib -llisp-raylib-shim"))
(asdf:load-system :raylib :force t))
This code can be found in the repl.lisp
file, which you can run to load these
bindings in the expected way. After that, develop as normal. Keep in mind
however that when you compile a new function, do so at the file-level (with C-c
C-k
or otherwise) at not at the individual function level (C-c C-c
).
ECL transforms our bindings directly into C code. If we’re calling any external
functions, we need to tell ECL about them. clines
injects raw C into the
resulting compiled file:
;; For access to my various `_Foo' functions.
(ffi:clines "#include \"shim.h\"")
;; For access to `free'.
(ffi:clines "#include <stdlib.h>")
As with SBCL, let’s look at how we bind to Vector2
.
(ffi:def-struct vector2-raw
(x :float)
(y :float))
(ffi:def-function ("_MakeVector2" make-vector2-raw)
((x :float)
(y :float))
:returning (* vector2-raw))
These are actually macros that call down into similar primitives for injecting raw C right into the file.
(defstruct (vector2 (:constructor @vector2))
(pointer nil :type si:foreign-data))
(defun make-vector2 (&key x y)
(let* ((ptr (make-vector2-raw x y))
(v (@vector2 :pointer ptr)))
(tg:finalize v (lambda () (free! ptr)))))
Somewhat simpler than the SBCL, as we don’t need to hand-hold the :type
hint.
Garbage Collection, however, requires special attention.
Note the free!
within the finalizer above.
;; NOTE: 2025-01-03 This is highly bespoke and comes directly from the maintainer of ECL.
(defun free! (ptr)
"A custom call to C's `free' that ensures everything is properly reset."
(ffi:c-inline (ptr) (:object) :void
"void *ptr = ecl_foreign_data_pointer_safe(#0);
#0->foreign.size = 0;
#0->foreign.data = NULL;
free(ptr);" :one-liner nil))
It’s magic but it works. Without this, you will get segfaults.
(defmacro vector2-x (v)
"The X slot of a `Vector2'."
`(ffi:get-slot-value (vector2-pointer ,v) 'vector2-raw 'x))
As with SBCL, this can be used as both a getter and a setter.
ECL doesn’t seem to interpret C stblib
bools back into a friendly Lisp type, so
we need to help it:
(ffi:def-function ("IsGamepadAvailable" is-gamepad-available-raw)
((gamepad :int))
:returning :unsigned-byte)
(defun is-gamepad-available (n)
(= 1 (is-gamepad-available-raw n)))
Your Makefile
in a project that depends on this could look this:
PLATFORM ?= PLATFORM_DESKTOP_GLFW
dev: lib/ lib/liblisp-raylib.so lib/liblisp-raylib-shim.so
lib/:
mkdir lib/
lib/liblisp-raylib.so:
cd vendored/raylib/ && $(MAKE) PLATFORM=$(PLATFORM)
cp vendored/raylib/lib/liblisp-raylib.so lib/
lib/liblisp-raylib-shim.so: lib/liblisp-raylib.so
cp vendored/raylib/lib/liblisp-raylib-shim.so lib/
clean:
rm -rf lib/
cd vendored/raylib/ && $(MAKE) clean
This copies the underlying .so
files into a lib/
local to your application, so
that when the raylib
system loads, it will find them where it expects.