b12n-raylib-clj
  • Home
  • Docs
  • GitHub

b12n-raylib-clj โ€” Guide

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.

Why this exists

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.

What b12n-raylib-clj is

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.

Pages

Orientation

  • getting-started.md โ€” install JDK 22+, the Clojure CLI, Babashka; running examples; IDE setup
  • architecture.md โ€” module layout, the FFI/native library flow, bundled libraries

FFI internals

  • adding-ffi-bindings.md โ€” defcfn/defalias, the C-to-coffi type table, pointer in/out params, a worked example
  • coffi-panama-internals.md โ€” what happens under defcfn on the JDK Panama FFI, memory arenas, why JDK 22+

Working with examples

  • example-architecture-patterns.md โ€” the shared example skeleton, state-as-atom, debug-stats/embedded nREPL integration, the porting recipe
  • repl-workflow.md โ€” embedded vs standalone REPL, live game development
  • example-catalog.md โ€” all 78 examples, grouped and tabulated
  • demos.md โ€” the full-size demo gallery (every example's animated GIF, one-line description)

Support

  • troubleshooting.md โ€” common errors and fixes

See also

The 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.app
  • b12n-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.app

Adding a new FFI binding

The defcfn pattern

Every 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:

  1. Docstring โ€” describes what the C function does. Copy it from raylib.h's comment on the same line as the function signature.
  2. {: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.
  3. C function name string โ€” the exact symbol raylib exports (e.g. "InitWindow"), used to look up the function in the shared library.
  4. [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 -> coffi type mapping

C TypeCoffi 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.

Struct definitions with defalias

C 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.

Pointer in/out parameters

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:

  1. mem/confined-arena โ€” creates an arena that owns the lifetime of any native memory allocated from it, scoped to this block.
  2. mem/alloc-instance โ€” allocates a native memory segment inside that arena, sized and laid out for the ::camera3d struct.
  3. mem/serialize-into โ€” writes the Clojure camera map's fields into that segment, following the struct's field layout.
  4. update-camera! โ€” the raw defcfn call passes the segment as the pointer argument; raylib's UpdateCamera mutates the segment's bytes in place.
  5. 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.

Worked example: adding a new raylib function end-to-end

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.

  1. Find the C signature in raylib.h:
       void DrawRectangleGradientV(int posX, int posY, int width, int height, Color color1, Color color2);
       
  2. Pick the target namespace. This is a shape-drawing call, so it belongs in 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.
  3. Write the 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)
       
  4. Verify it. Start any example (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.

See also

  • coffi-panama-internals.md โ€” what happens underneath defcfn when Clojure calls the resulting function

Architecture

Overview

flowchart 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

Game Loop Architecture

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

Module layout

  • 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 helpers
  • src/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 raylib
  • libs/ โ€” bundled native libraries per platform

Project structure diagram

flowchart 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

Bundled libraries

This project includes pre-built Raylib 5.5.0 libraries for different platforms:

PlatformDirectoryLibrary
macOS (Intel/ARM)libs/macoslibraylib.5.5.0.dylib
Linux 64-bitlibs/linux_amd64libraylib.so.5.5.0
Linux 32-bitlibs/linux_i386libraylib.a
Windows 64-bitlibs/win64_msvc16raylib.dll
Windows 32-bitlibs/win32_msvc16raylib.dll

The correct library is loaded automatically based on your operating system.

macOS code signing

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

Coffi and the JDK Panama FFI

Why JDK 22+

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.

What happens when defcfn is evaluated

sequenceDiagram
    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)
  1. Load time. When a 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.
  2. Call time. Every time Clojure calls the function 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.

Memory arenas

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.

Why macOS needs -XstartOnFirstThread

OpenGL 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).

See also

  • adding-ffi-bindings.md โ€” the practical guide to writing a new binding using these mechanics
  • repl-workflow.md โ€” the live consequence of the macOS main-thread requirement on REPL workflow

Full-size demo gallery

Every 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.

๐ŸŽฎ Original games (9)

hello-world

the minimal raylib window + text (Q exits, F1 toggles debug stats)

hello-world

pong

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

pong

asteroids

the classic vector shooter (rotate/thrust/fire)

asteroids

asteroids2

an alternate asteroids build (rotate/thrust/fire)

asteroids2

tetris

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

tetris

vampire-survivors

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

vampire-survivors

snake

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

snake

floppy

flap through the pipe gaps (SPACE)

floppy

retro-maze-3d

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

retro-maze-3d

๐Ÿ“ฆ Core (23)

bouncing-ball

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

bouncing-ball

following-eyes

two eyes track the mouse

following-eyes

screen-manager

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

screen-manager

input-keys

steer a ball with the arrow keys

input-keys

input-mouse

a ball follows the mouse; click to recolor

input-mouse

input-gamepad

a live gamepad axis/button readout

input-gamepad

mouse-wheel

scroll a box with the mouse wheel

mouse-wheel

gestures-testbed

a testbed for raylib's touch/click gesture detection

gestures-testbed

scissor-test

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

scissor-test

random-values

a new random value every two seconds

random-values

camera-2d

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

camera-2d

camera-3d-free

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

camera-3d-free

split-screen-3d

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

split-screen-3d

first-person-3d

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

first-person-3d

camera-fps

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

camera-fps

world-screen

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

world-screen

picking-3d

click to raycast and pick a 3D cube

picking-3d

collision-area

AABB collision between two boxes (mouse moves, SPACE)

collision-area

colors-palette

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

colors-palette

logo-anim

the raylib logo animating in (R replays)

logo-anim

window-should-close

intercept the close button with a Y/N confirm

window-should-close

camera-2d-platformer

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

camera-2d-platformer

window-letterbox

resolution-independent rendering via letterboxing (SPACE, resize)

window-letterbox

๐Ÿ”ท Shapes (15)

logo-raylib

the raylib logo built from rectangles + text

logo-raylib

logo-raylib-anim

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

logo-raylib-anim

basic-shapes

circles, rectangles, triangles, polygons + an rlgl triangle

basic-shapes

rectangle-scaling

drag the bottom-right corner to resize a rectangle

rectangle-scaling

mouse-trail

a fading trail follows the cursor

mouse-trail

lines-bezier

a cubic bezier curve โ€” drag the endpoints

lines-bezier

easings-ball

a ball animating along an easing curve (ENTER replays)

easings-ball

ball-physics

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

ball-physics

simple-particles

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

simple-particles

dashed-line

a dashed line follows the mouse (arrows, C)

dashed-line

starfield-effect

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

starfield-effect

easings-box

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

easings-box

double-pendulum

chaotic double-pendulum motion + trail

double-pendulum

lines-drawing

click-drag to paint a rainbow fan of thick lines

lines-drawing

easings-rectangles

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

easings-rectangles

๐Ÿ“ Text (3)

writing-anim

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

writing-anim

format-text

padded score + MM:SS timer readouts

format-text

input-box

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

input-box

๐Ÿ–ผ๏ธ Textures (2)

background-scrolling

a parallax-scrolling background

background-scrolling

sprite-animation

a spritesheet character walk cycle (LEFT/RIGHT)

sprite-animation

โœจ Shaders (1)

basic-lighting

dynamic per-pixel lighting (mouse moves it, Y/R/G/B toggle lights)

basic-lighting

๐Ÿ”Š Audio (4)

audio-module

a music-driven waveform/spectrum visualizer

audio-module

sound-loading

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

sound-loading

music-stream

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

music-stream

sound-multi

fire the same sound multiple times, overlapping (SPACE)

sound-multi

๐Ÿ—ฟ Models (21)

geometric-shapes

3D primitive shapes on display

geometric-shapes

waving-cubes

an NxN grid of cubes rippling in 3D

waving-cubes

box-collisions

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

box-collisions

orthographic-projection

perspective vs orthographic (SPACE toggles)

orthographic-projection

tesseract-view

a rotating 4D hypercube projected to 2D

tesseract-view

solar-system

Sun/Earth/Moon orbiting via the rlgl matrix stack

solar-system

spinning-cubes

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

spinning-cubes

point-cloud

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

point-cloud

wireframe-shapes

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

wireframe-shapes

camera-modes

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

camera-modes

ray-picking

click to select cubes via ray-picking

ray-picking

bouncing-spheres

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

bouncing-spheres

rotating-cube

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

rotating-cube

particle-system

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

particle-system

dna-helix

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

dna-helix

first-person-maze

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

first-person-maze

yaw-pitch-roll

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

yaw-pitch-roll

lissajous-3d

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

lissajous-3d

lorenz-attractor

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

lorenz-attractor

terrain-generation

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

terrain-generation

mesh-generation

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

mesh-generation

Example architecture patterns

The shared skeleton

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.

State as an atom

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.

Plugging in debug-stats

src/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.

Plugging in the embedded nREPL

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.

Porting a new raylib C example

The recipe, as a numbered list:

  1. Find the C source in raylib's examples/ tree.
  2. Create src/examples/<name>.clj following the shared skeleton above.
  3. Add a 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"]}
       
  4. Add a 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"}
   

See also

  • example-catalog.md โ€” every example this pattern produced, in one table

The example catalog โ€” 78 raylib examples

A 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. The preview column below thumbnails straight from the same committed GIFs (docs/demos/*.gif), regenerated by the maintainer-only bb record task and configured by scripts/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

๐ŸŽฎ Original games (9)

previewbb nametitleshowscontrolsported from
hello-worldHello WorldBasic window testQ to exit, F1 for debug statscore_basic_window.c
pongPongTwo-player paddle gameW/S, K/J, Enterโ€”
asteroidsAsteroidsShoot asteroidsArrows, Spaceโ€”
asteroids2Asteroids 2Alternate versionArrows, Spaceโ€”
tetrisTetrisBlock-stacking puzzleArrows, Spaceโ€”
vampire-survivorsVampire SurvivorsSurvival actionWASDโ€”
snakeSnakeClassic snake gameArrows, P, ENTER, Qsnake.cยน
floppyFloppyFlappy bird cloneSPACE, P, ENTER, Qfloppy.cยน
retro-maze-3dRetro Maze 3DGameBoy-style maze escapeWASD, Mouse, SPACE, M, ENTER, Qretro_maze_3d.cยน

ยน 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).

๐Ÿ“ฆ Core (23)

previewbb nametitleshowscontrolsported from
bouncing-ballBouncing BallPhysics demoSPACE, Gshapes_bouncing_ball.c
following-eyesFollowing EyesMouse trackingMove mouseshapes_following_eyes.c
screen-managerScreen ManagerState machineENTERcore_basic_screen_manager.c
input-keysInput KeysKeyboard inputArrow keyscore_input_keys.c
input-mouseInput MouseMouse inputClick, movecore_input_mouse.c
input-gamepadInput GamepadGamepad demoConnect gamepadcore_input_gamepad.c
mouse-wheelMouse WheelScroll inputMouse wheelcore_input_mouse_wheel.c
gestures-testbedGestures TestbedTouch gesturesTouch/clickcore_input_gestures_testbed.c
scissor-testScissor TestScissor clippingS, Mousecore_scissor_test.c
random-valuesRandom ValuesRandom numbersWatchcore_random_values.c
camera-2dCamera 2D2D cameraArrows, A/S, Wheelcore_2d_camera.c
camera-3d-freeCamera 3D FreeFree 3D cameraMouse, Wheelcore_3d_camera_free.c
split-screen-3dSplit Screen 3DTwo-player 3DW/S, UP/DOWNcore_3d_camera_split_screen.c
first-person-3dFirst Person 3DFPS cameraWASD, Mouse, 1-4core_3d_camera_first_person.c
camera-fpsCamera FPSFPS with physicsWASD, Space, Ctrlcore_3d_camera_fps.c
world-screenWorld Screen3D to 2D coordsMouse, Wheelcore_world_screen.c
picking-3dPicking 3DRay castingClickcore_3d_picking.c
collision-areaCollision AreaCollision detectionMouse, SPACEshapes_collision_area.c
colors-paletteColors PaletteColor showcaseHover, SPACEshapes_colors_palette.c
logo-animLogo AnimationLogo animationR to replayshapes_logo_raylib_anim.c
window-should-closeWindow Should CloseCustom close confirmationY/N to confirm/cancelcore_window_should_close.c
camera-2d-platformerCamera 2D Platformer5 camera follow modesArrows, SPACE, C, R, Wheelcore_2d_camera_platformer.c
window-letterboxWindow LetterboxResolution-independent renderingSPACE, Resize windowcore_window_letterbox.c

๐Ÿ”ท Shapes (15)

previewbb nametitleshowscontrolsported from
logo-raylibLogo RaylibRaylib logo drawn with shapesESC to exitshapes_logo_raylib.c
logo-raylib-animLogo Raylib AnimAnimated logo constructionR to replayshapes_logo_raylib_anim.c
basic-shapesBasic ShapesCircles, rectangles, triangles, polygonsESC to exitshapes_basic_shapes.c
rectangle-scalingRectangle ScalingDrag to resize rectangleDrag bottom-right cornershapes_rectangle_scaling.c
mouse-trailMouse TrailCircles following mouse cursorMove mouseshapes_mouse_trail.c
lines-bezierLines BezierInteractive bezier curveDrag endpointsshapes_lines_bezier.c
easings-ballEasings BallEasing function animationENTER to replayshapes_easings_ball.c
ball-physicsBall PhysicsGrab and throw ballsClick, Right-click, Wheel, Middleshapes_ball_physics.c
simple-particlesSimple ParticlesWater/smoke/fire effectsArrows, Clickshapes_simple_particles.c
dashed-lineDashed LineInteractive dashed lineArrows, Cshapes_dashed_line.c
starfield-effectStarfield Effect3D starfield simulationSPACE, Wheelshapes_starfield_effect.c
easings-boxEasings BoxBox animation with easing functionsSPACE to resetshapes_easings_box.c
double-pendulumDouble PendulumChaotic pendulum simulationESC to exitshapes_double_pendulum.c
lines-drawingLines DrawingDraw rainbow lines on canvasClick, Right-click, Wheel, Middleshapes_lines_drawing.c
easings-rectanglesEasings RectanglesGrid animation with easing functionsSPACE to replayshapes_easings_rectangles.c

๐Ÿ“ Text (3)

previewbb nametitleshowscontrolsported from
writing-animWriting AnimationTypewriter text effectSPACE speed up, ENTER restarttext_writing_anim.c
format-textFormat TextFormatted score/timer displayESC to exittext_format_text.c
input-boxInput BoxText input fieldClick, type, Backspacetext_input_box.c

๐Ÿ–ผ๏ธ Textures (2)

previewbb nametitleshowscontrolsported from
background-scrollingBackground ScrollingParallax demoWatchtextures_background_scrolling.c
sprite-animationSprite AnimationSpritesheetLEFT/RIGHTtextures_sprite_animation.c

โœจ Shaders (1)

previewbb nametitleshowscontrolsported from
basic-lightingBasic LightingDynamic lightingMouse, Y/R/G/Bshaders_basic_lighting.c

๐Ÿ”Š Audio (4)

previewbb nametitleshowscontrolsported from
audio-moduleAudio ModuleMusic visualizationSPACE, P, Arrowsaudio_module_playing.c
sound-loadingSound LoadingWAV/OGG playbackSPACE, ENTERaudio_sound_loading.c
music-streamMusic StreamMP3 streamingSPACE, P, Arrowsaudio_music_stream.c
sound-multiSound MultiMultiple soundsSPACEaudio_sound_multi.c

๐Ÿ—ฟ Models (21)

previewbb nametitleshowscontrolsported from
geometric-shapesGeometric Shapes3D primitivesQ to exitmodels_geometric_shapes.c
waving-cubesWaving CubesAnimated cube waveQ to exitmodels_waving_cubes.c
box-collisionsBox Collisions3D collision detectionArrow keys, Qmodels_box_collisions.c
orthographic-projectionOrthographic ProjectionPerspective vs orthographicSPACE, Qmodels_orthographic_projection.c
tesseract-viewTesseract View4D hypercubeQ to exitmodels_tesseract_view.c
solar-systemSolar SystemOrbiting planetsQ to exitmodels_rlgl_solar_system.c
spinning-cubesSpinning CubesColor-cycling cubesQ to exitโ€”
point-cloudPoint CloudSpherical pointsUP/DOWN, Qmodels_point_rendering.c
wireframe-shapesWireframe ShapesCustom wireframesSPACE, Qโ€”
camera-modesCamera ModesFree/Orbital/FPS cameras1/2/3, WASD, Qโ€”
ray-pickingRay PickingClick to select cubesClick, Right-click, Qcore_3d_picking.c
bouncing-spheresBouncing SpheresPhysics in 3D boxSPACE, R, G, Qโ€”
rotating-cubeRotating Cube3D rotationArrows, +/-, R, Qmodels_rotating_cube.c
particle-systemParticle System3D particlesSPACE, G, W, R, Qโ€”
dna-helixDNA HelixDouble helixArrows, SPACE, R, Qโ€”
first-person-mazeFirst Person MazeNavigate 3D mazeWASD, Mouse, R, M, Qmodels_first_person_maze.c
yaw-pitch-rollYaw Pitch Roll3D rotation demoArrows, SPACE, R, Qmodels_yaw_pitch_roll.c
lissajous-3dLissajous 3DParametric curves1-5, Arrows, W/S, SPACE, Qโ€”
lorenz-attractorLorenz AttractorChaos theory1-3, Arrows, SPACE, R, Qโ€”
terrain-generationTerrain GenerationProcedural terrain1-3, Arrows, G, W, SPACE, Qโ€”
mesh-generationMesh GenerationProcedural 3D shapesLeft/Right, Click, SPACE, R, Qmodels_mesh_generation.c

Adding a new example

See example-architecture-patterns.md for the full recipe (source file, deps.edn alias, bb.edn task, bb/helpers.bb registry entry).

Getting Started

Prerequisites

  • JDK 22 or newer โ€” required for the Foreign Function API (see Coffi & Panama Internals for why JDK 22+ specifically)
  • Clojure CLI (recommended) or Leiningen
  • Babashka (optional, for task automation)

Installing JDK 22+

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

Installing the Clojure CLI

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

Installing Leiningen

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

Installing Babashka

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

Running examples

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

Running by alias (macOS only)

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

Setting JAVA_HOME

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

IDE Setup

Clojure development is best experienced with a good editor that supports REPL integration.

VS Code with Calva

  1. Install VS Code
  2. Install the "Calva" extension
  3. Open this project folder
  4. Press Ctrl+Alt+C then Ctrl+Alt+J (or Cmd on macOS) to start a REPL
  5. Select "deps.edn" when prompted

Calva provides syntax highlighting, inline evaluation, and a connected REPL. Evaluate code by placing your cursor on an expression and pressing Ctrl+Enter.

IntelliJ IDEA with Cursive

  1. Install IntelliJ IDEA (Community or Ultimate)
  2. Install the "Cursive" plugin
  3. Open this project folder
  4. Cursive will detect deps.edn and set everything up

To start a REPL, right-click on deps.edn and select "Run REPL".

Connecting to nREPL

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.

REPL workflow

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.

Two REPL modes

Embedded (game)Standalone
Port78887999
Startbb <example>bb nrepl
Can open a window (macOS)YesNo

Live game development (recommended)

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))

Why macOS can't open windows from a standalone REPL

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.

Standalone REPL for non-GUI work

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 ...)

REPL capability summary

CapabilityStandalone REPLConnected to Game
Load FFI bindingsโœ…โœ…
Inspect colors/enumsโœ…โœ…
Test pure game logicโœ…โœ…
Open windows/renderโŒ (macOS)โœ…
Modify running gameโŒโœ…
Hot-reload functionsโŒโœ…

Troubleshooting

"Library not found" error

Make sure you're running from the project root directory where libs/ folder exists.

macOS security warning

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.

"No matching method" or FFI errors

Ensure you're using JDK 22 or newer:

java -version  # Should show 22.x.x or higher

Window doesn't appear on macOS

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

A GUI example misbehaves when launched with clj

Use 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 called

Harmless 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 ...

Build tools comparison

This project supports both the Clojure CLI and Leiningen:

FeatureClojure CLI (deps.edn)Leiningen (project.clj)
Run game (macOS)clojure -M:asteroidslein run -m examples.asteroids
Run game (any OS)bb asteroidsN/A
Start REPLcljlein repl
Start nREPLbb 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.

Raylib game development examples in Clojure, using coffi/Panama FFI to call raylib directly from the JVM.