User-facing documentation for b12n-raylib-clj: a collection of raylib game-development examples in Clojure, calling raylib's C library directly via coffi over JDK 22+'s Foreign Function & Memory API (Project Panama). No wrapper library, no codegen โ coffi's defcfn binds each raylib C function directly.
One idea โ a suite of raylib examples that reach the C library directly, with no wrapper layer in between โ explored on three Clojure runtimes, one repo each.
This is the JVM one: JDK 22+'s Panama Foreign Function & Memory API via coffi, where a binding is a defcfn form and a C struct arrives as a plain Clojure map. b12n-raylib-jlt does it on Chez Scheme through jolt's jolt.ffi, with no JVM at all. b12n-raylib-jnk does it in jank, which compiles through C++/LLVM to a native binary and so has no FFI layer to speak of โ it includes raylib.h and calls the C++ directly.
Reading them side by side is the interesting part: the same example, drawn three ways, shows exactly where each runtime puts the boundary. The pages below cover the JVM/Panama side โ what defcfn actually does, how structs and pointers cross, and how to add a new binding.
A .clj (JVM Clojure) project:
(require '[raylib.core.window :as rcw]
'[raylib.core.drawing :as rcd]
'[raylib.colors :as colors])
(rcw/init-window! 800 450 "Hello")
(loop []
(when-not (rcw/window-should-close?)
(rcd/begin-drawing!)
(rcd/clear-background! colors/raywhite)
(rcd/end-drawing!)
(recur)))
(rcw/close-window!)
78 examples ship in src/examples/ on top of the FFI bindings in src/raylib/ โ a mix of original games and ports of official raylib C examples across core/shapes/text/textures/shaders/audio/models categories. See example-catalog.md for the per-example breakdown of what's an original creation and what's ported from which raylib C source file.
getting-started.md โ install JDK 22+, the Clojure CLI, Babashka; running examples; IDE setuparchitecture.md โ module layout, the FFI/native library flow, bundled librariesadding-ffi-bindings.md โ defcfn/defalias, the C-to-coffi type table, pointer in/out params, a worked examplecoffi-panama-internals.md โ what happens under defcfn on the JDK Panama FFI, memory arenas, why JDK 22+example-architecture-patterns.md โ the shared example skeleton, state-as-atom, debug-stats/embedded nREPL integration, the porting reciperepl-workflow.md โ embedded vs standalone REPL, live game developmentexample-catalog.md โ all 78 examples, grouped and tabulateddemos.md โ the full-size demo gallery (every example's animated GIF, one-line description)troubleshooting.md โ common errors and fixesThe same suite on the other two Clojure runtimes:
b12n-raylib-jlt โ in Jolt (native Clojure on Chez Scheme, no JVM), over jolt.ffi. raylib-jlt.b12n.appb12n-raylib-jnk โ in jank (native Clojure via C++/LLVM), calling raylib as ordinary C++ through (:include "raylib.h") โ no FFI layer at all. raylib-jnk.b12n.appdefcfn patternEvery raylib binding is a defcfn form. Here are three real ones from src/raylib/core/window.clj lines 1-21:
(ns raylib.core.window
(:require
[raylib.core]
[raylib.internals :as ri]
[coffi.mem :as mem]
[coffi.ffi :refer [defcfn]]))
(defcfn init-window!
"Initialize window and OpenGL context"
{:arglists '([width height title])}
"InitWindow"
[::mem/int ::mem/int ::mem/c-string] ::mem/void)
(defcfn window-should-close?
"Check if KEY_ESCAPE pressed or Close icon pressed"
"WindowShouldClose"
[] ::ri/bool)
(defcfn close-window!
"Close window and unload OpenGL context"
"CloseWindow" [] ::mem/void)
defcfn (from coffi.ffi) always has the same four-part shape:
raylib.h's comment on the same line as the function signature.{:arglists '(...)} โ optional. Only needed when the function takes arguments, since defcfn's own parameter vector is a list of types, not names โ :arglists is what makes (doc init-window!) and editor autocomplete show meaningful argument names instead of [arg0 arg1 arg2]. window-should-close? and close-window! both take no arguments, so they skip it."InitWindow"), used to look up the function in the shared library.[param-types] return-type โ the parameter types vector followed by the return type, both from the type-mapping table below.Every binding namespace requires raylib.core first (see the :require above) โ that namespace is what loads the native libraylib shared library, and a defcfn can't resolve its C symbol until the library is loaded.
| C Type | Coffi Type |
|---|---|
int | ::mem/int |
float | ::mem/float |
double | ::mem/double |
bool | ::ri/bool (returns 0/1) |
unsigned char | ::ri/ubyte |
const char* | ::mem/c-string |
void | ::mem/void |
Color | ::rs/color |
Vector2 | ::rs/vector-2 |
Vector3 | ::rs/vector-3 |
Rectangle | ::rs/rectangle |
Camera2D | ::rc2d/camera-2d |
Camera3D | ::rc3d/camera3d |
| Pointer (in/out param) | ::mem/pointer |
::ri/bool and ::ri/ubyte are not built-in coffi types โ coffi only ships primitive types like ::mem/int and ::mem/byte out of the box. They're custom types this project defines in src/raylib/internals.clj:
(ns raylib.internals
(:require [coffi.mem :as mem]))
;; ubyte
(defmethod mem/primitive-type ::ubyte
[_type]
::mem/byte)
(defmethod mem/serialize* ::ubyte
[obj _type _scope]
(unchecked-byte obj))
(defmethod mem/deserialize* ::ubyte
[obj _type]
(Byte/toUnsignedLong obj))
;; bool
(defmethod mem/primitive-type ::bool
[_type]
::mem/byte)
(defmethod mem/serialize* ::bool
[obj _type _scope]
(byte (if obj 1 0)))
(defmethod mem/deserialize* ::bool
[obj _type]
(not (zero? obj)))
Raylib's C bool and unsigned char both travel over the FFI boundary as a single byte โ that's just how they're laid out in memory. coffi's mem/primitive-type multimethod tells coffi which real primitive (::mem/byte) to use on the wire for a custom type. mem/serialize* and mem/deserialize* then teach coffi how to box and unbox that raw byte into something Clojure-friendly: ::ubyte deserializes to an unsigned Byte/toUnsignedLong value (so a byte like -1 reads back as 255, not -1), and ::bool serializes a Clojure truthy/falsy value to 1/0 and deserializes 0/non-0 back to false/true. Without these three multimethod overrides, ::ri/bool and ::ri/ubyte wouldn't exist as usable coffi types at all.
defaliasC structs are defined with defalias in src/raylib/structs.clj. The full list at time of writing (grep -n "defalias" src/raylib/structs.clj): Color, Vector2, Vector3, Vector4, Texture, RenderTexture, Rectangle.
(defalias ::color
[::mem/struct
[[:r ::ri/ubyte]
[:g ::ri/ubyte]
[:b ::ri/ubyte]
[:a ::ri/ubyte]]])
(defalias ::vector-2
[::mem/struct
[[:x ::mem/float]
[:y ::mem/float]]])
Once defined, a struct behaves as a plain Clojure map: {:x 100.0 :y 200.0} for a ::vector-2, {:r 255 :g 0 :b 0 :a 255} for a ::color.
Field order must match the C struct layout exactly. coffi lays out the native memory segment for a ::mem/struct field-by-field, in the order you list them โ it has no way to know raylib's real field order except from what you tell it. If raylib.h declares Color as r, g, b, a and you write the defalias fields in a different order, every read and write against that struct silently misaligns.
Some raylib functions take a struct pointer and mutate it in place rather than returning a new struct โ UpdateCamera(Camera3D *camera, int mode) is one. The worked example for this pattern is update-camera in src/raylib/core/camera3d.clj:
;; Camera update function
(defcfn update-camera!
"Update camera position for selected mode"
{:arglists '([camera mode])}
"UpdateCamera"
[::mem/pointer ::mem/int] ::mem/void)
;; Helper function that updates camera and returns the new state
(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)))
The raw defcfn (update-camera!) takes ::mem/pointer โ coffi can't serialize a Clojure map directly as a pointer argument, so the wrapper function (update-camera) does the pointer dance by hand:
mem/confined-arena โ creates an arena that owns the lifetime of any native memory allocated from it, scoped to this block.mem/alloc-instance โ allocates a native memory segment inside that arena, sized and laid out for the ::camera3d struct.mem/serialize-into โ writes the Clojure camera map's fields into that segment, following the struct's field layout.update-camera! โ the raw defcfn call passes the segment as the pointer argument; raylib's UpdateCamera mutates the segment's bytes in place.mem/deserialize-from โ reads the (now-mutated) segment back out into a fresh Clojure map, which becomes the wrapper's return value.See Coffi & Panama Internals for what a confined arena actually is and when you need one at all.
DrawRectangleGradientV is a real raylib C function that isn't bound anywhere in this repo yet โ confirmed with:
grep -rn "DrawRectangleGradientV" src/raylib/
which returns nothing. It's a good pick for a worked example: a plain draw call with two Color arguments and no pointer trickery.
raylib.h: void DrawRectangleGradientV(int posX, int posY, int width, int height, Color color1, Color color2);
src/raylib/shapes/basic.clj (confirmed to exist) alongside its sibling draw-rectangle!, which already uses the same int int int int Color argument shape.defcfn form, following the type-mapping table above: (defcfn draw-rectangle-gradient-v!
"Draw a vertical-gradient-filled rectangle"
{:arglists '([pos-x pos-y width height color-1 color-2])}
"DrawRectangleGradientV"
[::mem/int ::mem/int ::mem/int ::mem/int ::rs/color ::rs/color] ::mem/void)
bb basic-shapes, which already connects an embedded nREPL on port 7888), connect your editor, and clj-nrepl-eval a call like (raylib.shapes.basic/draw-rectangle-gradient-v! 100 100 200 100 colors/red colors/blue) inside the running game's draw loop to confirm it renders instead of throwing.coffi-panama-internals.md โ what happens underneath defcfn when Clojure calls the resulting functionflowchart TB
subgraph Clojure["Clojure Application"]
Game["Game Code<br/>(examples/*.clj)"]
Bindings["Raylib Bindings<br/>(raylib/*.clj)"]
Structs["Struct Definitions<br/>(raylib/structs.clj)"]
end
subgraph FFI["Foreign Function Interface"]
Coffi["coffi library"]
Panama["JDK 22+ Panama API"]
end
subgraph Native["Native Libraries"]
Raylib["Raylib C Library<br/>(libs/*)"]
OpenGL["OpenGL"]
end
Game --> Bindings
Bindings --> Structs
Bindings --> Coffi
Coffi --> Panama
Panama --> Raylib
Raylib --> OpenGL
style Clojure fill:#4B8BBE,color:#fff
style FFI fill:#FFD43B,color:#000
style Native fill:#306998,color:#fff
flowchart LR
subgraph GameLoop["Main Game Loop"]
direction TB
Init["Initialize<br/>Window & State"]
Tick["Tick/Update<br/>Game Logic"]
Draw["Draw<br/>Render Frame"]
Check{"Window<br/>Closed?"}
Cleanup["Cleanup<br/>Resources"]
end
Init --> Tick
Tick --> Draw
Draw --> Check
Check -->|No| Tick
Check -->|Yes| Cleanup
subgraph State["Game State (Atom)"]
Ship["Ship Position/Velocity"]
Entities["Asteroids/Bullets"]
Screen["Screen State"]
end
Tick -.->|Read/Update| State
Draw -.->|Read| State
src/raylib/ โ FFI bindings (this is the library) - core.clj โ loads the native library; every binding namespace requires this first - structs.clj โ C struct definitions via defalias (Color, Vector2, Vector3, Vector4, Texture, RenderTexture, Rectangle) - colors.clj โ color constants (raywhite, red, etc.) - enums.clj โ keyboard/mouse enums - internals.clj โ internal helpers (ubyte, bool types) - utils.clj โ utility functions (random, fade, etc.) - audio.clj โ audio functions (Music, Sound) - lights.clj โ shader-lighting helpers, based on raylib's rlights.h - nrepl.clj โ embedded nREPL server startup, powers the port 7888 live-development workflow - core/ โ window, drawing, keyboard, mouse, timing, camera2d, camera3d, collision, gamepad, gestures, shaders - window.clj โ window management - drawing.clj โ drawing primitives - keyboard.clj โ keyboard input - mouse.clj โ mouse input - timing.clj โ frame timing (FPS, delta time) - camera2d.clj โ 2D camera - camera3d.clj โ 3D camera and rendering - collision.clj โ ray casting and collision detection - gamepad.clj โ gamepad input - gestures.clj โ touch gesture detection - shaders.clj โ shader loading and management - text/, shapes/, textures/ โ drawing/loading helperssrc/examples/ โ the 78 example namespaces (54 top-level + 3 in games/ + 21 in models/)src/debug_stats.clj โ F1 overlay plugin (see Example Architecture Patterns for usage)src/raylib_ext.clj โ extended/derived bindings not in core rayliblibs/ โ bundled native libraries per platformflowchart TB
subgraph src["src/"]
subgraph raylib["raylib/ - FFI Bindings"]
core["core.clj - Library loading"]
structs["structs.clj - C struct definitions"]
colors["colors.clj - Color constants"]
enums["enums.clj - Keyboard/mouse enums"]
subgraph coremod["core/"]
window["window.clj"]
drawing["drawing.clj"]
keyboard["keyboard.clj"]
mouse["mouse.clj"]
timing["timing.clj"]
end
end
subgraph examples["examples/ - Game Examples"]
hello["hello_world.clj"]
pongex["pong.clj"]
astex["asteroids.clj"]
tetex["tetris.clj"]
vampex["vampire_survivors.clj"]
end
debug["debug_stats.clj - FPS/Memory overlay"]
raylibext["raylib_ext.clj - Extended bindings"]
end
subgraph libs["libs/ - Native Libraries"]
macos["macos/"]
linux["linux_amd64/"]
win["win64_msvc16/"]
end
This project includes pre-built Raylib 5.5.0 libraries for different platforms:
| Platform | Directory | Library |
|---|---|---|
| macOS (Intel/ARM) | libs/macos | libraylib.5.5.0.dylib |
| Linux 64-bit | libs/linux_amd64 | libraylib.so.5.5.0 |
| Linux 32-bit | libs/linux_i386 | libraylib.a |
| Windows 64-bit | libs/win64_msvc16 | raylib.dll |
| Windows 32-bit | libs/win32_msvc16 | raylib.dll |
The correct library is loaded automatically based on your operating system.
On macOS, you might see a security warning about the library. Fix it with:
bb macos:sign-lib
Or manually:
codesign --force --sign - libs/macos/libraylib.5.5.0.dylib
This project uses coffi to call Raylib's C library directly from Clojure. Coffi is built on the JDK's Foreign Function & Memory API (Project Panama), and that API only reached stable (non-preview) status in JDK 22 โ earlier JDK versions only had it available behind a preview flag. Coffi depends on the stable API, which is why this project requires JDK 22 or newer:
This project uses coffi for calling native C code from Clojure. Coffi requires JDK 22+ because that's when the Foreign Function and Memory API (Project Panama) became stable. Earlier JDK versions had this API in preview mode.
defcfn is evaluatedsequenceDiagram
participant Clojure
participant Coffi
participant Panama as JDK Panama API
participant Raylib as Raylib C Library
Clojure->>Coffi: (defcfn draw-circle! "DrawCircle" ...)
Coffi->>Panama: Create method handle
Panama->>Raylib: Load symbol from .dylib/.so/.dll
Note over Clojure,Raylib: At runtime:
Clojure->>Coffi: (draw-circle! 100 100 50 red)
Coffi->>Coffi: Serialize Clojure map to C struct
Coffi->>Panama: Invoke foreign function
Panama->>Raylib: DrawCircle(100, 100, 50.0f, color)
Raylib-->>Panama: Return
Panama-->>Coffi: Return
Coffi-->>Clojure: Return (deserialized if needed)
defcfn form like (defcfn init-window! "Initialize window..." "InitWindow" [...] ::mem/void) is evaluated, coffi asks the JDK Panama API to create a method handle bound to the named C symbol ("InitWindow"). Panama resolves that symbol against the already-loaded libraylib shared library. This only works because raylib.core โ required first by every binding namespace โ has already loaded the native library by the time any defcfn in that namespace runs; without it, the symbol lookup has nothing to search.defcfn defined (e.g. (draw-circle! 100 100 50 red)), coffi serializes the Clojure arguments into native memory laid out according to their declared types โ a plain value like an int passes straight through, while a struct argument (a map like {:r 255 :g 0 :b 0 :a 255}) gets written into memory following the field layout its defalias declared. Coffi then invokes the foreign function through the method handle created at load time. If the C function returns a struct, coffi deserializes the returned native memory back into a Clojure map before handing control back to the caller.The update-camera example in Adding a new FFI binding uses mem/confined-arena to allocate a native memory segment by hand โ that page walks through each call; this section explains what the arena itself is.
A confined arena owns the lifetime of the native memory segments allocated from it. It's scoped to the thread and block that created it: the segment mem/alloc-instance allocates from a confined arena stays valid only until that arena closes, at which point the native memory is freed. This matters because Panama's native memory isn't garbage-collected by the JVM โ something has to own and release it explicitly, and the arena is that owner.
You only need to reach for an arena yourself when you're explicitly allocating a segment for an in/out pointer parameter โ the update-camera case, where raylib mutates a Camera3D* in place. For ordinary struct arguments passed by value (like draw-cube!'s ::rs/vector-3 and ::rs/color parameters), coffi manages the serialization memory automatically per-call; there's no arena to think about.
-XstartOnFirstThreadOpenGL on macOS requires all GL calls to happen on the process's main thread. This project configures that in deps.edn's jvm-opts (grep -n "XstartOnFirstThread" deps.edn), which every example alias carries:
:jvm-opts ["--enable-native-access=ALL-UNNAMED"
"-XstartOnFirstThread"
"-Djava.library.path=libs:libs/macos:..."]
The practical consequence: you cannot open a raylib window from a plain clj -M:dev REPL on macOS. This is not because the standalone :dev alias omits the flag โ it doesn't. grep -n "XstartOnFirstThread" deps.edn shows the flag present in every single alias in the file, :dev included:
:dev
{:jvm-opts ["--enable-native-access=ALL-UNNAMED"
"-XstartOnFirstThread"
"-Djava.library.path=libs:libs/macos:..."]
:main-opts ["-m" "nrepl.cmdline" "--port" "7999"]}
deps.edn flags the limitation itself, with a comment right above :dev:
;; Note: On macOS, you cannot run GUI code from this REPL due to -XstartOnFirstThread
Beyond that comment, this repo doesn't document the exact mechanism, so this guide won't invent one โ the flag is present either way, and having it present is not sufficient to make GUI calls work from :dev. What's verified is the practical rule: a raylib window works from the game aliases (bb asteroids, clj -M:hello-world, etc., which call init-window! directly from their own -main at process start) but not from a standalone :dev REPL session evaluating the same call interactively. If you need a raylib window, run an example directly and connect to its embedded nREPL (port 7888) instead of trying to open one from :dev's REPL (port 7999).
adding-ffi-bindings.md โ the practical guide to writing a new binding using these mechanicsrepl-workflow.md โ the live consequence of the macOS main-thread requirement on REPL workflowEvery example at full size โ linked from the example catalog's preview thumbnails. Every GIF here is committed. They are regenerated by bb record, a maintainer-only task driving a screen-capture tool that is not publicly released; the per-example capture settings and input timelines live in scripts/demo_manifest.edn.
the minimal raylib window + text (Q exits, F1 toggles debug stats)

two-paddle classic, P1 (W/S) vs P2 (K/J)

the classic vector shooter (rotate/thrust/fire)

an alternate asteroids build (rotate/thrust/fire)

the block-stacking puzzle (move/rotate/drop)

auto-fire survival: move (WASD), waves chase you

the classic snake (arrow keys, grow, don't crash)

flap through the pipe gaps (SPACE)

a GameBoy-style 3D maze escape (WASD + mouse look)

a ball bouncing around the window (SPACE pauses, G toggles gravity)

two eyes track the mouse

a LOGO/TITLE/GAMEPLAY/ENDING state flow (ENTER advances)

steer a ball with the arrow keys

a ball follows the mouse; click to recolor

a live gamepad axis/button readout

scroll a box with the mouse wheel

a testbed for raylib's touch/click gesture detection

a scissor rectangle clips a grid (S toggles, mouse moves it)

a new random value every two seconds

a 2D camera over a scene (arrows pan, A/S rotate, wheel zooms)

a free-orbit 3D camera (mouse look, wheel zoom)

two 3D viewports, one per player (W/S, UP/DOWN)

a first-person camera walkthrough (WASD + mouse, 1-4 switch modes)

an FPS camera with jump/crouch physics (WASD, Space, Ctrl)

project 3D world points to 2D screen space (mouse, wheel)

click to raycast and pick a 3D cube

AABB collision between two boxes (mouse moves, SPACE)

every named raylib color in a grid (hover, SPACE)

the raylib logo animating in (R replays)

intercept the close button with a Y/N confirm

5 platformer camera-follow styles (SPACE cycles, C/R/wheel)

resolution-independent rendering via letterboxing (SPACE, resize)

the raylib logo built from rectangles + text

the logo animating together, piece by piece (R replays)

circles, rectangles, triangles, polygons + an rlgl triangle

drag the bottom-right corner to resize a rectangle

a fading trail follows the cursor

a cubic bezier curve โ drag the endpoints

a ball animating along an easing curve (ENTER replays)

grab, throw, and resize bouncing balls (click, right-click, wheel)

water/smoke/fire particle effects (arrows switch, click emits)

a dashed line follows the mouse (arrows, C)

a 3D starfield flying toward the camera (SPACE, wheel)

a grid of boxes, each on a different easing curve (SPACE resets)

chaotic double-pendulum motion + trail

click-drag to paint a rainbow fan of thick lines

a grid of rectangles animating on easing curves (SPACE replays)

a message types itself out (SPACE speeds up, ENTER restarts)

padded score + MM:SS timer readouts

type into a text box (click to focus, Backspace to edit)

a parallax-scrolling background

a spritesheet character walk cycle (LEFT/RIGHT)
![]()
dynamic per-pixel lighting (mouse moves it, Y/R/G/B toggle lights)

a music-driven waveform/spectrum visualizer

load and play a WAV/OGG sound effect (SPACE, ENTER)

stream an MP3 with play/pause/seek (SPACE, P, arrows)

fire the same sound multiple times, overlapping (SPACE)

3D primitive shapes on display

an NxN grid of cubes rippling in 3D

a player cube colliding with 3D boxes (arrow keys)

perspective vs orthographic (SPACE toggles)

a rotating 4D hypercube projected to 2D

Sun/Earth/Moon orbiting via the rlgl matrix stack

a row of cubes, each spinning with a color-cycling phase offset

~1500 points forming a rotating sphere (UP/DOWN adjusts)

pyramid/octahedron/torus/helix in 3D lines (SPACE cycles)

Free/Orbital/FPS camera modes (1/2/3 switches, WASD)

click to select cubes via ray-picking

spheres bouncing inside a 3D box (SPACE, R, G)

a single cube spinning via the rlgl matrix stack (arrows, +/-, R)

a 3D particle emitter (SPACE bursts, G toggles gravity, W/R reset)

a rotating double-helix structure (arrows, SPACE, R)

walk a 3D maze in first person (WASD + mouse, M for map)

yaw/pitch/roll rotation on a 3D model (arrows, SPACE, R)

3D Lissajous parametric curves (1-5 picks a pattern, W/S, SPACE)

the Lorenz attractor's chaotic butterfly path (1-3, arrows, SPACE, R)

procedurally generated terrain (1-3 picks an algorithm, G, W, SPACE)

procedurally generated 3D meshes (Left/Right cycles, click, SPACE, R)

Most examples follow the same shape: start the embedded nREPL, open a window, loop until the user closes it, clean up (67 of 78 โ 11 examples, including pong, camera-2d, and music-stream, skip the embedded nREPL; grep -rL "nrepl/start" src/examples/*.clj src/examples/*/*.clj lists them). Here's src/examples/asteroids.clj's -main (around line 523), verbatim:
(defn -main [& args]
(nrepl/start {:port 7888})
(init)
(loop []
(let [game (tick (update-fps @game-atom))]
(when-not (rcw/window-should-close?)
(reset! game-atom game)
(draw game)
(recur))))
;; Cleanup
(when @render-target
(ext/unload-render-texture! @render-target))
(rcw/close-window!))
nrepl/start runs first, before the window even opens โ so you can connect a REPL to a game that's still starting up. (init) does the one-time setup (init-window!, config flags, and โ in asteroids' case โ allocating the letterboxed render texture and calling debug-stats/enable!). Then the loop: compute the next game state (tick), check window-should-close?, and โ while the window is still open โ commit the new state to game-atom and draw the frame, before recurring. When the loop exits (the user closed the window), asteroids releases its render texture and calls close-window!.
Most examples are a variation on this shape: start nREPL once, init the window once, loop update -> draw -> check-close until the window closes, then clean up. Simpler examples skip the parts specific to asteroids (the render texture, the letterboxing) but follow the same overall skeleton โ except for the 11 examples noted above, which skip the nREPL step entirely.
Asteroids keeps its entire game state in one atom, game-atom, seeded from initial-state:
(defn initial-state []
{:dt 0
:time (System/nanoTime)
:time-acc [1]
:frame-counter -1
:screen :title
:ship (make-ship WIDTH HEIGHT)
:asteroids (map (fn [_] (make-asteroid)) (range INITIAL_ASTEROIDS))
:bullets []
:alive true})
(def game-atom (atom (initial-state)))
Ship, asteroids, bullets, and the current screen all live in this one map. The -main loop above reads it, computes a new value with tick, and reset!s it back โ the atom is the single source of truth for "what's happening right now."
The functions that compute the next state are pure โ deterministic, no game-state mutation โ even where they lean on an FFI call underneath. vector-add and check-point-circle are two the README calls out as testable straight from a standalone REPL:
(defn vector-add [v1 v2]
[(+ (v1 0) (v2 0))
(+ (v1 1) (v2 1))])
vector-add is plain Clojure arithmetic; check-point-circle delegates its actual geometry to ext/check-collision-point-circle? (an FFI-backed call) but is still deterministic and doesn't touch game-atom or draw anything โ you can call either at a REPL with made-up arguments and get the same answer every time. The draw phase is the opposite: draw calls rcd/begin-drawing!, a sequence of raylib draw calls, and rcd/end-drawing! โ every one of those is a side effect (it writes pixels to the screen), and calling draw twice with the same game state does not give you back a value to compare, it paints a frame. Keeping the state-update functions pure is what makes them REPL- and test-friendly; the draw phase can't be, because rendering is inherently a side effect.
debug-statssrc/debug_stats.clj is an optional F1 overlay plugin. Its own docstring is the usage guide, verbatim:
Debug stats overlay plugin.
Usage:
1. Require this namespace in your game ns
2. Call (debug-stats/enable!) once at startup
3. Call (debug-stats/update!) in your game tick function
4. Call (debug-stats/draw!) at the end of your draw function (inside begin/end-drawing)
5. Press F1 to toggle the stats overlay
Example:
(ns my-game
(:require [debug-stats]))
(defn init []
(debug-stats/enable!))
(defn tick [game]
(debug-stats/update!)
;; ... your game logic
)
(defn draw [game]
(rcd/begin-drawing!)
;; ... your drawing code
(debug-stats/draw!)
(rcd/end-drawing!))
asteroids.clj follows this exactly: (debug-stats/enable!) at the end of init, (debug-stats/update!) in its tick function, and (debug-stats/draw!) as the last call inside each begin-drawing!/end-drawing! pair.
src/raylib/nrepl.clj wraps nrepl.server/start-server:
(defn start
"Start a network repl for debugging on specified port followed by
an optional parameters map. The :bind, :transport-fn, :handler,
:ack-port and :greeting-fn will be forwarded to
nrepl.server/start-server as they are.
If the port is already in use, logs a warning and returns nil
instead of throwing - this allows games to still run when another
nREPL server is already using the port."
[{:keys [port bind transport-fn handler ack-port greeting-fn]}]
(try
(log/info "starting nREPL server on port" port)
(nrepl/start-server :port port
:bind bind
:transport-fn transport-fn
:handler handler
:ack-port ack-port
:greeting-fn greeting-fn)
(catch java.net.BindException e
(log/warn (str "nREPL port " port " already in use - continuing without embedded nREPL. "
"You can connect to the existing nREPL server if one is running."))
nil)
(catch Throwable t
(log/error t "failed to start nREPL")
(throw t))))
Called once in -main as (nrepl/start {:port 7888}). The BindException catch is what makes port 7888 safe to reuse: if another example (or another instance of the same one) is already listening there, start logs a warning and returns nil instead of crashing โ the second game still runs, it just doesn't get its own nREPL server. Any other exception during startup is logged and re-thrown.
The recipe, as a numbered list:
examples/ tree.src/examples/<name>.clj following the shared skeleton above.deps.edn alias, mirroring any existing one: :my-example
{:jvm-opts ["--enable-native-access=ALL-UNNAMED"
"-XstartOnFirstThread"
"-Djava.library.path=libs:libs/macos:..."]
:main-opts ["-m" "examples.my-example"]}
bb.edn task. Every task calls the shared h/run-example! helper, which looks up the example's title, description, and controls from the registry (step 5) and prints them itself โ so the task body stays a single line. The real asteroids task: asteroids {:doc "๐ฎ Asteroids - shoot asteroids and survive"
:task (h/run-example! "asteroids")}
Because run-example! looks the example up by alias, this task only prints the right header/controls text once the registry entry in step 5 exists. 5. Add the example's entry to bb/helpers.bb's examples registry โ this is what makes bb examples, run-example!'s header text, and this guide's own example-catalog.md pick it up. One real entry, as the shape to copy:
{:alias "asteroids"
:category :games
:title "Asteroids"
:desc "Shoot asteroids"
:controls "Arrows, Space"}
example-catalog.md โ every example this pattern produced, in one tableA map of the whole suite. Each example is one namespace under src/examples/ (or src/examples/games/, src/examples/models/), runnable via bb <name> or clj -M:<alias>. bb examples prints this same grouping live from bb/helpers.bb's examples registry โ this page is that registry rendered as a browsable table, plus (where known) which official raylib C example a given Clojure example ports. Nearly every "ported from" cell cites a file in raysan5/raylib's own examples/ tree; the 3 cells marked ยน instead cite the companion raysan5/raylib-games repo โ see the note under the games table below.
Full-size preview gallery:
demos.mdโ every example at full size, one-line description included. Thepreviewcolumn below thumbnails straight from the same committed GIFs (docs/demos/*.gif), regenerated by the maintainer-onlybb recordtask and configured byscripts/demo_manifest.edn.
Run one, or see them all grouped:
bb <name> # e.g. bb asteroids (opens a window)
bb examples # this same grouping, printed from the terminal
ยน Ported from raysan5/raylib-games โ a companion repo of classic-game clones and game-jam entries, separate from raysan5/raylib's own examples/ tree that every other "ported from" cell on this page cites. snake.c and floppy.c come from its classics/ collection; retro_maze_3d.c from its retro_maze_3d/ GGJ 2021 entry (header comment: "GGJ 2021 - RETRO MAZE 3D โฆ Copyright (c) 2021 Ramon Santamaria (@raysan5)", matching this example's own docstring credit).
| preview | bb name | title | shows | controls | ported from |
|---|---|---|---|---|---|
![]() | basic-lighting | Basic Lighting | Dynamic lighting | Mouse, Y/R/G/B | shaders_basic_lighting.c |
See example-architecture-patterns.md for the full recipe (source file, deps.edn alias, bb.edn task, bb/helpers.bb registry entry).
Clojure runs on the JVM, so you need Java installed. This project requires JDK 22 or later because we use the new Foreign Function API to call native code.
On macOS with Homebrew:
brew install openjdk@22
On Linux (Ubuntu/Debian):
sudo apt install openjdk-22-jdk
Alternatively, you can use SDKMAN which works on macOS, Linux, and Windows (WSL):
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java 22.0.2-open
The Clojure CLI is the modern way to run Clojure projects using deps.edn.
On macOS with Homebrew:
brew install clojure/tools/clojure
On Linux:
curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh
chmod +x linux-install.sh
sudo ./linux-install.sh
Verify installation:
clj --version
If you prefer Leiningen over the Clojure CLI:
On macOS with Homebrew:
brew install leiningen
On Linux:
curl -O https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein
chmod +x lein
sudo mv lein /usr/local/bin/
lein # This will download the rest automatically
Babashka provides fast task automation for Clojure projects.
On macOS with Homebrew:
brew install borkdude/brew/babashka
On Linux:
bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
Verify installation:
bb --version
Clone this repository:
git clone https://github.com/burinc/b12n-raylib-clj.git
cd b12n-raylib-clj
bb <example> (recommended)If you have Babashka installed, running games is simple:
bb help # Show all available commands
bb asteroids # Run Asteroids game
bb tetris # Run Tetris game
clojure -M:asteroids # Run Asteroids
clojure -M:tetris # Run Tetris
clojure -M:pong # Run Pong
clojure -M:hello-world # Run Hello World
Two things to know about this path:
Use clojure, not clj. clj wraps the same launcher in rlwrap for line editing, which interferes with a GUI app's event loop. Every bb task here shells out to clojure for exactly this reason.
These aliases only work on macOS. Every example alias in deps.edn carries -XstartOnFirstThread, which macOS requires to run OpenGL on the main thread. It is a macOS-only flag, and the JVM treats an unrecognized -X option as fatal โ so on Linux the same command dies before it starts:
Unrecognized option: -XstartOnFirstThread
Error: Could not create the Java Virtual Machine.
On Linux, use bb <name> (below), which builds a flag-free command line for you. If you'd rather not install Babashka, that command is:
clojure -J--enable-native-access=ALL-UNNAMED \
-J-Djava.library.path=libs:libs/linux_amd64:/usr/local/lib:/usr/lib \
-M -m examples.asteroids
lein run -m examples.<ns>lein run # Run default (Asteroids)
lein run -m examples.tetris # Run Tetris
If you have multiple Java versions installed, you may need to set JAVA_HOME:
export JAVA_HOME=/path/to/jdk-22
On macOS with Homebrew:
export JAVA_HOME=/opt/homebrew/opt/openjdk@22
Clojure development is best experienced with a good editor that supports REPL integration.
Ctrl+Alt+C then Ctrl+Alt+J (or Cmd on macOS) to start a REPLCalva provides syntax highlighting, inline evaluation, and a connected REPL. Evaluate code by placing your cursor on an expression and pressing Ctrl+Enter.
deps.edn and set everything upTo start a REPL, right-click on deps.edn and select "Run REPL".
For live game development (recommended):
bb asteroids # Starts game + nREPL on port 7888
Then connect your editor to localhost:7888.
For standalone REPL (non-GUI work):
bb nrepl # Starts nREPL on port 7999
Then connect your editor to localhost:7999.
See REPL Workflow for the full live-development workflow, including hot-reloading running games from the connected REPL.
One of the best things about Clojure is the REPL workflow: you can change code while a game is running and see the change immediately.
| Embedded (game) | Standalone | |
|---|---|---|
| Port | 7888 | 7999 |
| Start | bb <example> | bb nrepl |
| Can open a window (macOS) | Yes | No |
Most games start an embedded nREPL server on port 7888 (67 of the 78 examples โ 11, including pong, camera-2d, and music-stream, don't call nrepl/start). This is the proper way to do live development for the examples that do:
sequenceDiagram
participant Terminal
participant Game
participant nREPL as nREPL:7888
participant Editor
Terminal->>Game: bb asteroids
Game->>nREPL: Start embedded nREPL on 7888
Game->>Game: Open window & run
Editor->>nREPL: Connect to localhost:7888
loop Live Development
Editor->>nREPL: Modify & eval function
nREPL->>Game: Hot-reload code
Game-->>Editor: See changes instantly!
end
Step 1: Start a game (it launches nREPL automatically):
bb asteroids # or: clojure -M:asteroids
You'll see in the logs:
INFO: starting nREPL server on port 7888
Step 2: Connect your editor to localhost:7888: - VS Code/Calva: Run "Calva: Connect to a Running REPL Server" - IntelliJ/Cursive: Run โ Edit Configurations โ Remote REPL
Step 3: Modify code live! Try these from your connected REPL:
;; Access the running game state
@examples.asteroids/game-atom
;; Reset the game
(reset! examples.asteroids/game-atom (examples.asteroids/initial-state))
;; Make the ship bigger
(swap! examples.asteroids/game-atom assoc-in [:ship :size] 50)
;; Spawn more asteroids
(swap! examples.asteroids/game-atom update :asteroids
concat (repeatedly 5 examples.asteroids/make-asteroid))
On macOS, you cannot open a raylib window from the standalone :dev REPL โ only from a game alias (bb asteroids, clj -M:hello-world, etc.) started fresh, which is why live game development connects to the game's own embedded nREPL instead of running the game from :dev. See Coffi & Panama Internals for the -XstartOnFirstThread flag and exactly what is (and isn't) verified about why this is the rule.
For exploring code, testing logic, or non-GUI work, use the standalone REPL:
bb nrepl # or: clj -M:dev (starts on port 7999)
Port note: Standalone REPL uses port 7999 to avoid conflicts with games that use 7888.
What works from standalone REPL:
;; Load and explore FFI bindings
(require '[raylib.colors :as colors])
(require '[raylib.enums :as enums])
;; Colors are just Clojure maps!
colors/red
;; => {:r 230, :g 41, :b 55, :a 255}
;; Create custom colors
(def my-purple {:r 128 :g 0 :b 255 :a 255})
;; Test game logic (pure functions)
(require '[examples.asteroids :as ast])
(ast/vector-add [1 2] [3 4])
;; => [4 6]
(ast/check-point-circle [100 100] [100 100] 50)
;; => true (collision!)
;; Explore game state structure
(keys (ast/initial-state))
;; => (:bullets :screen :dt :alive :asteroids :ship ...)
| Capability | Standalone REPL | Connected to Game |
|---|---|---|
| Load FFI bindings | โ | โ |
| Inspect colors/enums | โ | โ |
| Test pure game logic | โ | โ |
| Open windows/render | โ (macOS) | โ |
| Modify running game | โ | โ |
| Hot-reload functions | โ | โ |
Make sure you're running from the project root directory where libs/ folder exists.
Run bb macos:sign-lib or manually sign the library:
codesign --force --sign - libs/macos/libraylib.5.5.0.dylib
See Architecture: macOS code signing for more detail.
Ensure you're using JDK 22 or newer:
java -version # Should show 22.x.x or higher
The -XstartOnFirstThread flag is required. This is already configured in deps.edn and project.clj.
See Coffi & Panama Internals for why this flag is necessary and what it does.
Unrecognized option: -XstartOnFirstThread (Linux)Unrecognized option: -XstartOnFirstThread
Error: Could not create the Java Virtual Machine.
You ran clojure -M:<alias> on Linux. Every example alias in deps.edn carries -XstartOnFirstThread because macOS requires it to run OpenGL on the main thread โ but it is a macOS-only flag, and the JVM treats any unrecognized -X option as fatal rather than ignoring it.
Use bb <name> instead. It detects the platform and builds a flag-free command line on Linux. The equivalent raw command, if you'd rather not install Babashka:
clojure -J--enable-native-access=ALL-UNNAMED \
-J-Djava.library.path=libs:libs/linux_amd64:/usr/local/lib:/usr/lib \
-M -m examples.asteroids
cljUse clojure, not clj. clj wraps the same launcher in rlwrap for line editing, which does not play well with a GUI app holding the main thread. Every bb task here shells out to clojure for this reason, and deps.edn carries the same note above its example aliases.
clj remains the better choice for a plain REPL, where the line editing is what you want.
WARNING: A restricted method in java.lang.foreign.Linker has been calledHarmless in itself, but it tells you something: you are running without the project's JVM flags. Every alias in deps.edn passes --enable-native-access=ALL-UNNAMED, which suppresses this warning entirely. Seeing it means you invoked a bare clojure -e ... or a plain REPL instead.
The full block looks like this, and appears only when the flag is missing:
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by
coffi.ffi$downcall_handle in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for
callers in this module
WARNING: Restricted methods will be blocked in a future release unless
native access is enabled
Nothing breaks today โ but note the last line. A future JDK will block these calls rather than warn, so get the flag onto your command line rather than learning to ignore the message:
clojure -J--enable-native-access=ALL-UNNAMED -J-Djava.library.path=libs:libs/macos ...
This project supports both the Clojure CLI and Leiningen:
| Feature | Clojure CLI (deps.edn) | Leiningen (project.clj) |
|---|---|---|
| Run game (macOS) | clojure -M:asteroids | lein run -m examples.asteroids |
| Run game (any OS) | bb asteroids | N/A |
| Start REPL | clj | lein repl |
| Start nREPL | bb nrepl (or clojure -M:dev) | lein repl |
clj is fine for a plain REPL โ the rlwrap line editing it adds is useful there. It is only GUI examples that need clojure.