b12n-raylib-jnk
  • Home
  • Docs
  • GitHub

b12n-raylib-jnk — Guide

User-facing documentation for b12n-raylib-jnk: 209 raylib examples ported to jank — a native Clojure dialect (C++/LLVM), not the JVM. Each page below covers one interop pattern or raylib API surface, citing the example file that proves it.

Why this exists

jank is young, and almost nothing has been written about using it against a real C library at this scale. Porting 209 raylib examples surfaced a set of interop rules that are not obvious from jank's own documentation and that cost real debugging time to find. Each page here is one of those rules, written up with the committed example that proves it — so the next person does not have to rediscover it by bisecting a failing draw loop.

What b12n-raylib-jnk is

209 of the official raylib examples — shapes, core, text, textures, shaders, models, and audio — each a small jank namespace under raylib-examples/src/raylib_examples/, sharing one C-binding wrapper, jank-raylib-sys, that exposes raylib's C API directly.

It is the native-Clojure sibling of b12n-raylib-jlt (raylib in Jolt/Chez Scheme) and of an unreleased JVM-Clojure port over coffi/Panama. All three bind the same C library directly; what differs is the boundary each language draws between its own values and C's:

jolt and JVM Clojure cross the FFI boundary at the call: an FFI call marshals values in and out, but a raylib struct can otherwise live anywhere a normal value lives. jank draws the boundary at the value itself: a native C++ value (Color, Vector2, Model, ...) can be constructed and used inline, but it cannot cross a jank function boundary — not returned, not passed as a parameter, not carried through loop/recur. Every pattern in this guide is a consequence of that one rule.

Four things follow from it:

  1. The value can't leave the form that produced it — construct inline, bind in an enclosing let, or park frame-crossing mutable state in a cpp/raw static. (native-value-lifetimes.md)
  2. The compiler enforces it at compile time, strictly — if/cond branch type-checking, numeric coercion between jank and native number types, and struct construction all have sharp, well-defined rules. (type-checking-and-coercion.md)
  3. A C-interop toolbox reaches everything the rule seems to block — pointer interop (cpp/&, cpp/aget, cpp/new), out-params, callbacks defined inside cpp/raw, and shared C headers shipped by a wrapper. (cpp-interop-toolbox.md)
  4. The full raylib surface is reachable despite the rule — fonts, models and animations, audio, 3D mode, rlgl, and (platform-permitting) compute shaders all work, proven example by example. (raylib-api-coverage.md)

Nothing about jank's C++ interop is raylib-specific — (:include "header.h") and cpp/ reach any C/C++ library. This repo just happens to exercise it against one real, struct-heavy graphics API across 209 examples.

Capability pages

The interop core (the reason this repo is interesting)

  • native-value-lifetimes.md — the one rule that explains most crashes, frame-crossing mutable state via cpp/raw statics (and the per-fn-static duplication gotcha), and create-once resources via outer-let capture.
  • type-checking-and-coercion.md — if/cond branch type-checking (including the and/or gotcha), the numeric-traps table (mod/quot/cpp/float/min/max), and constructing native structs from jank data.
  • cpp-interop-toolbox.md — pointer interop (cpp/&, cpp/aget, cpp/new, cpp/raw), int * out-params, callback-taking APIs, shared C headers shipped by a wrapper (jank_rlights.h), shader-uniform shims, and current limitations (known-blocked constructs).

What's proven to work

  • raylib-api-coverage.md — fonts, models and animations, audio, 3D mode, rlgl + textures, and compute shaders (with the platform caveat — see the root README's Known limitations).
  • jvm-surface-gaps.md — what replaces the missing JVM surface (Math/*, format, char literals), what's actually available (clojure.core/clojure.string, with caveats), and a few gotchas that save a recompile.

Orientation

  • getting-started.md — requirements, cloning with the submodule, and the bb task surface.
  • porting-workflow.md — the end-to-end process for porting one example: source of truth, file layout, the five-place registration, the headless smoke test.
  • example-catalog.md — a tour of all 209 examples grouped by raylib category, and how to add one (now with a preview GIF per recorded example — see docs/demos/README.md for the full gallery).
  • raygui-to-keyboard.md — the pattern for porting raygui-based examples (sliders/checkboxes) to keyboard controls.

See also

  • b12n-raylib-jlt — the same idea in Jolt (Chez Scheme) over jolt.ffi. Its FFI boundary is per-call, not per-value — a Camera3D can live in an ordinary variable between FFI calls, unlike jank's native values.
  • An unreleased JVM-Clojure port over coffi/Panama takes the same per-call boundary as Jolt, plus a garbage collector jank doesn't have to work around.

Guide

Start at index.md — the full page map and the "why this repo is interesting" framing.

The pages under docs/guide/ are the long-form reference: the interop rules with worked examples, citations, and the committed example files that prove each one.

The C++ interop toolbox

The native-value-lifetime rule seems to block a lot — a native value can't be returned, passed as a parameter, or carried through loop/recur. This page is the toolbox that reaches around those constraints: pointer interop, out-params, callbacks, shared C headers, and shader-uniform shims — and, in the last section, what's still blocked. Each lesson names the committed example that proves it.

Shader uniforms: prefer per-type scalar C setters (from the shader arc, 2026-07-05)

SetShaderValue takes a const void* pointing at native float[] data, which jank can't form from a jank vector. Two shims solve it; the second is now the preferred one:

  • Static staging buffer (first approach). A file-level static float jank_uf_buf[4], an index setter jank_uf_set(int i, double v), and a jank_set_uniform(Shader s, int loc, int type) that calls SetShaderValue on the buffer. To push a VEC2 you fill slots 0 and 1 then call the setter with SHADER_UNIFORM_VEC2. Works, but every uniform is 2–5 jank calls and all of them must stay in one fn (the buffer is shared mutable state — the per-fn static rule). INT needs a parallel int slot + setter (texture_outline.jank, texture_waves.jank, mandelbrot_set.jank).
  • Per-type scalar setters (preferred). Give each uniform type its own C fn that takes the components as double/int scalars and builds the small array inside C:
      static void jank_set_vec4(Shader s, int loc, double a, double b, double c, double d) {
        float v[4] = { (float)a, (float)b, (float)c, (float)d };
        SetShaderValue(s, loc, v, SHADER_UNIFORM_VEC4);
      }
      
    Now each uniform is one cpp/ call — (cpp/jank_set_vec4 shader loc r g b a) — with no shared buffer, so it reads cleanly and the per-fn-static constraint goes away (rounded_rectangle_shader.jank for VEC4/VEC2/FLOAT, color_correction.jank for FLOAT). Pass jank doubles straight in; the (float) cast happens in C, so you dodge the cpp/float/(+ 0.0 …) boxing dance. - VEC uniforms sourced from a native struct (a Camera3D's position/target): don't read .-x/.-y/.-z field-by-field in jank — hand the setter the struct pointer via (cpp/& camera) (the pointer-taking-APIs pattern) and read the fields in C: jank_set_view(Shader s, int eyeLoc, int centerLoc, Camera3D* c) fills two float[3]s from c->position/c->target (raymarching.jank, the first VEC3 uniforms). - Array uniforms (SetShaderValueV) revive the staging buffer. A per-type scalar setter can't take an 8 x ivec3 palette as arguments, so arrays go back to approach one: a file-level static int jank_ui_buf[24], an index setter, and a jank_set_uniform_v(Shader s, int loc, int type, int count) that calls SetShaderValueV on the buffer with the element count. Fill the buffer from a flat jank vector with a loop/nth, then send once (palette_switch.jank, the first array uniform — the same static-buffer caveat applies: all calls in one fn). - GetShaderLocation returns a plain int — (int (cpp/GetShaderLocation …)) boxes it for a let. LoadShader cpp/nullptr path uses the default vertex shader; pass a real base.vs path as the first arg when the example ships one (rounded_rectangle_shader.jank).

Callback-taking APIs: define the callback in cpp/raw (2026-07-11)

jank cannot form a C function pointer, but a callback DEFINED inside a cpp/raw block is ordinary C — a sibling wrapper in the same block attaches its pointer:

static void jank_process_audio(void *buffer, unsigned int frames) { ... }
static void jank_attach_processor(void) { AttachAudioMixedProcessor(jank_process_audio); }

Proven with the audio-thread DSP callback in mixed_processor.jank (the callback mutates/reads C statics; jank tunes parameters through setters and reads results through accessors — never touching the callback thread directly). The same shape unlocks any callback-registering raylib API. SetTraceLogCallback is now proven too (custom_logging.jank): a void (int, const char*, va_list) callback defined in cpp/raw (with its own time/strftime/vprintf va_list machinery) is handed to SetTraceLogCallback by a sibling wrapper, installed before InitWindow, and reformats every raylib log line. Raw audio streams (LoadAudioStream + UpdateAudioStream via a C-shim refill) landed in the same arc (raw_stream.jank).

Gotcha — a custom C callback that writes via printf must fflush(stdout) itself. raylib's own TraceLog flushes after every line (rcore.c), but your replacement callback doesn't inherit that. stdout is fully buffered when redirected to a file (as the headless smoke recipe does, > log 2>&1), so without a flush the buffered log sits unwritten and is lost entirely when timeout SIGTERM-kills the process — the smoke log comes back empty and the port looks broken when it isn't. Add fflush(stdout); at the end of any shim callback that prints. Trace: custom_logging.jank first smoke returned zero log lines; adding fflush surfaced all 42 reformatted lines.

Shared C helpers ship as wrapper headers (from the rlights arc, 2026-07-11)

When several examples need the same C helper (rlights.h's Light array, the per-type uniform setters), per-file cpp/raw duplication is not the only option: the -sys wrapper can SHIP a header and emit a second jank-build::include-dir= directive pointing at it. Proven with jank-raylib-sys/include/jank_rlights.h + basic_lighting.jank, which has no cpp/raw block at all — just (:include "raylib.h" "jank_rlights.h").

Design constraints for such headers:

  • jank fns can neither take nor return native values, so a "shared jank namespace" wrapping lights is impossible; the reusable unit is a C header whose state (the Light array) is module-local and whose API is index-based with scalar parameters (jank_rl_create_light(type, px, py, pz, ..., shader) -> int).
  • Mark everything static (functions AND state): each including module gets a private copy, so two modules in one binary never collide at link time.
  • Wrapper packaging: add the dir to :verbatim-paths in the wrapper's project.clj, emit the directive from jank-build.bb off (:src-dir *input*) (NOT the cmake out-dir), and bb install.
  • Cache gotcha (cost one debug cycle): consumer projects cache the emitted directives in target/_cache/...-out-.../jank-build-cache.txt and do NOT re-run jank-build.bb just because the artifact changed. After editing a wrapper's jank-build.bb or headers: bb install, then delete the consumer's target/_cache/ (this forces a full raylib rebuild, ~2-4 min). Symptom of staleness: fatal error: 'jank_rlights.h' file not found even though the new jar extracted.

Pointer-taking APIs work via (cpp/& x) (from the image arc, 2026-07-03)

jank has native pointer interop — the pointer-taking raylib APIs were never actually blocked. The whole Image* mutation family (ImageFormat, ImageColorGrayscale/Tint/Invert/Contrast/Brightness, ImageBlurGaussian, ImageFlipVertical/Horizontal, ImageDrawCircle, ImageDrawRectangle, ...) takes an Image * first arg. Form it with (cpp/& img) — the address-of a mutable let-local — exactly mirroring the C ImageColorInvert(&imCopy):

(let [img (cpp/GenImageColor 256 256 cpp/BLANK)]
  (cpp/ImageDrawCircle (cpp/& img) 128 128 100 cpp/RED) ; mutates img in place
  (let [tex (cpp/LoadTextureFromImage img)] ...))       ; picks up the change

Proven end-to-end in image_processing.jank (nine filters) and the spike that preceded it. No -sys wrapper change is needed — every raylib fn is already callable through (:include "raylib.h"); the only missing piece was knowing the address-of form.

The wider jank cpp-interop toolbox — see the jank book's cpp-interop chapter for the authoritative reference on each of these:

  • (cpp/& x) — address-of (unary). (cpp/* p) — dereference (unary).
  • (cpp/aget arr (cpp/int i)) / (aset arr (cpp/int i) v) — array element get/set. Likely unblocks font.glyphs[i] / int* indexing.
  • (cpp/new T init) — heap allocation returning a pointer; type DSL (:* T) for pointer types, (cpp/cast (:* void) x) / (cpp/unbox (:* T) box).
  • (cpp/raw "…C++…") — embed arbitrary C++ (helper fns, out-param shims, constant arrays) right in the .jank file. The fallback when a jank form for some pointer dance doesn't exist yet. PROVEN in image_kernel.jank: the three float[9] convolution kernels + a jank_normalize_kernel helper are declared as C globals in a cpp/raw block, and each global array decays to float* when passed straight to (cpp/ImageKernelConvolution (cpp/& img) cpp/jank_sharpen_kernel 9). This is the general escape hatch for the Vector2 *points / int * array APIs (DrawSplineLinear, etc.) — build/fill the array in cpp/raw and pass the global.

int * out-params (PROVEN in gif_player.jank). Some raylib fns write a scalar back through a pointer arg — LoadImageAnim(fileName, int *frames) returns the image AND writes the frame count into *frames. jank makes a mutable native int, passes its address, then boxes the result for jank code:

(let [frames (cpp/int 0)                                   ; a native int lvalue
      img    (cpp/LoadImageAnim path (cpp/& frames))       ; writes *frames
      nframes (int (+ 0.0 frames))]                        ; box back to jank int
  ...)

Note (+ 0.0 frames) re-boxes the native int into a jank object before (int …) — a bare (int frames) on the raw native value can trip codegen the same way an all-native f64 chain does (the all-native-chain codegen trap — see type-checking-and-coercion.md).

Runtime-arg pointer arithmetic via cpp/raw. gif_player streams each GIF frame from image.data + w*h*4*frame. jank's cpp/cast uses convert, not reinterpret_cast, so it can't turn the void* image.data into a unsigned char* for byte math. A one-line cpp/raw shim taking the runtime pointer + offset does the cast+arithmetic in C and hands the frame pointer straight to UpdateTexture:

(cpp/raw "static const void* jank_gif_frame_ptr(void* data, int offset) {
  return (const void*)(((unsigned char*)data) + offset);
}")
;; ... per frame:
(cpp/UpdateTexture tex (cpp/jank_gif_frame_ptr (.-data img) (int offset)))

Keep offset an int at the call site — mod/quot on the frame index return reals (the mod/quot-returns-reals trap), which the shim's int offset param rejects at runtime (expected integer found small_real). Wrap the frame advance: (int (mod (+ cur 1) nframes)).

Lifecycle caveat (interaction with the native-value-can't-cross-loop/recur rule): a mutated Image is still a native value, so it can't be carried in loop/recur state. Keep the whole build-mutate-read-unload cycle inside a let in the frame that needs it (image_processing's reload block rebuilds imCopy from imOrigin, processes it via (cpp/& imCopy), reads it back with LoadImageColors → UpdateTexture, and UnloadImages it — all in one block; only the process index lives in loop state).

Known-blocked constructs

  • Native array indexing — NO LONGER BLOCKED (2026-07-04): (cpp/aget p (cpp/int i)) on an unsigned int* is proven in compute_hash.jank, which reads the static u32 arrays returned by ComputeMD5/SHA1/SHA256 and matches the canonical CRC32/SHA1/SHA256 test vectors (the u32 elements box to jank ints without sign damage). codepoints_loading.jank adds a load-bearing int* walk (LoadCodepoints' array snapshotted into a jank vector). Struct arrays work too: (cpp/aget (.-glyphs font) (cpp/int i)) on a GlyphInfo* returns the struct by value into a let-local with working field reads (.-value, .-advanceX), and the same on the Rectangle* in font.recs — verified by a throwaway probe against GetFontDefault (2026-07-04, glyph 1 = codepoint 33, rec width 1.0); not yet load-bearing in a committed example, so keep the probe habit when reaching for it. The earlier cpp/raw subscript shims (core_random_sequence's int*, the char** walks) remain fine but are no longer the only way in; text_rectangle_bounds predates the probe and uses the MeasureTextEx re-wrap instead. (int * out-params were already proven — see gif_player in the pointer-interop section.)
  • APIs taking a Vector2 *points array — DrawSplineLinear, DrawTriangleStrip, etc.: for int* the write direction IS now proven — codepoints_loading.jank fills a cpp/raw static int[512] element-wise through a one-line setter shim (jank_cp_set) and hands the pointer to LoadFontEx. A Vector2[] should work the same way (a setter shim taking x,y scalars), but that's still unprobed; cpp/new + aset also remain unprobed. Workarounds that keep the visual identical still apply: draw per-segment DrawLineEx between consecutive points (math_sine_cosine.jank's waves), or recompute each primitive's vertices inline instead of filling an array (triangle_strip.jank draws each wedge's two DrawTriangles from the angle formulas directly — the wrap-around falls out of cos/sin periodicity). By-value struct APIs like DrawLineDashed (raylib 6.0) work fine.
  • Image pixel manipulation — NOT blocked (2026-07-03): the pointer-taking Image* / ImageColor* / ImageDraw* APIs all work via (cpp/& img) (image_processing.jank). See the pointer-interop section above.
  • The rlgl API — NOT blocked after all (2026-07-03): jank-raylib-sys installs rlgl.h next to raylib.h and the rlgl functions are compiled into libraylib, so (:include "raylib.h" "rlgl.h") just works — rlBegin/rlColor4ub/rlVertex2f/rlEnd and the culling toggles all run directly (rlgl_triangle.jank).
  • Mutable C string buffers — TextCopy and friends; not worth simulating (text_strings_management skipped on these grounds).

The example catalog — 209 raylib demos in jank

A map of the whole suite. Each example is one namespace under raylib-examples/src/raylib_examples/, runnable by a friendly bb <name> task or the underlying lein with-profile +<name> run --disable-sandbox. bb info prints this grouping live; this page adds the "how it's wired" recipe at the end.

Run one, list them, or reel through all of them:

bb <name>          # e.g. bb starfield   (opens a window)
bb examples        # flat list with descriptions
bb info            # the grouped cheat-sheet below
bb run-all [secs]  # every example, N seconds each (unattended)

core — window, input, cameras, files (46)

previewbb nameshows
input-keysSteer a ball with the arrow keys
input-mouseA ball follows the mouse; click to recolor
input-mouse-wheelScroll a box with the mouse wheel
random-valuesA new random value every two seconds
camera-2dA free 2D camera over a skyline
basic-windowThe minimal raylib window + text
scissor-testA scissor rectangle reveals text
window-should-closeConfirm-before-exit on window close
delta-timeDelta-time vs per-frame movement
basic-screen-managerA LOGO/TITLE/GAMEPLAY/ENDING flow
camera-2d-platformerA platformer with 5 camera-follow modes
—input-gesturesLog detected mouse/touch gestures
—window-letterboxA fixed 640x480 game letterboxed on resize
camera-2d-split-screenTwo players, two cameras, split screen
smooth-pixelperfectSub-pixel smoothing of upscaled pixel art
camera-2d-mouse-zoomPan + zoom-to-cursor a 2D camera
world-screenA 2D label tracking a 3D cube (GetWorldToScreen)
camera-3dA red cube on a grid through a fixed 3D camera
picking-3dClick a 3D box to pick it with a world-space ray
input-multitouchA ball at every active touch/mouse point
input-virtual-controlsAn on-screen D-pad moving a player circle
—window-flagsToggle window state flags live with a bouncing ball
render-textureA ball bouncing inside a rotated off-screen render texture
monitor-detectorA scaled map of every attached monitor with its specs
input-actionsRemappable abstract actions (WASD/arrows) via a keyset map
highdpi-demoLogical-points vs physical-pixels grids with live DPI scale
—highdpi-testbedA HighDPI diagnostic overlay: grid, monitor/DPI info, crosshair
random-sequenceColored bars in a random no-repeat permutation (LoadRandomSequence)
clipboard-textType + cut/copy/paste with the system clipboard
undo-redoA grid player with a 26-slot undo/redo ring buffer
directory-filesA keyboard file browser over the working directory
custom-loggingA custom trace-log callback timestamps + tags every raylib log line
drop-filesDrag files onto the window to list their paths
text-file-loadingLoad + word-wrap a text file, scroll it
compute-hashCRC32/MD5/SHA1/SHA256 + Base64 of typed text
storage-valuesSave/load a score pair to a binary storage file
keyboard-testbedAn on-screen ENG-US keyboard highlighting held keys
input-gestures-testbedA gesture dashboard with log, indicators and protractor
viewport-scalingA fixed game resolution scaled into a resizable window
camera-3d-freeA free-look 3D camera around a cube
camera-3d-first-personWalk a yard of random columns in first person
camera-3d-split-screenTwo players, two 3D cameras, split screen
camera-3d-fpsA physics FPS controller with head-bob, lean and strafe-accel
vr-simulatorA 3D scene in stereo through a simulated Oculus Rift + lens-distortion shader
automation-eventsA 2D platformer with input record/replay via AutomationEventList
input-gamepadA live controller diagram: buttons/sticks/triggers light up (Xbox/PS/generic)

shapes — 2D drawing, easing, rlgl (41)

previewbb nameshows
bouncing-ballA ball bouncing with optional gravity
colors-paletteEvery named raylib color in a grid
starfieldA perspective starfield flying at you
mouse-trailA fading trail follows the cursor
logo-animThe raylib logo assembling itself
double-pendulumChaotic double-pendulum motion + trail
particlesWater / smoke / fire particle effects
collision-areaAABB collision between a bouncing + mouse box
ball-physicsGrab and throw balls under gravity
easings-rectanglesA grid shrinks and spins via easing fns
following-eyesTwo eyes track the mouse cursor
lines-bezierDrag endpoints to reshape a Bezier curve
rectangle-scalingResize a rectangle by its corner
dashed-lineA dashed line follows the mouse
basic-shapesA gallery of raylib's basic shapes
logo-raylibThe raylib logo from rectangles + text
easings-ballA ball animated through easing stages
easings-boxA box animated through five easing stages
math-angle-rotationFixed-angle lines + a spinning line
ellipse-collisionOverlap test between two ellipses
vector-angleTwo ways to measure an angle
penrose-tileA Penrose tiling grown with an L-system
digital-clockA live clock (digital + analogue modes)
clock-of-clocksDigits drawn from grids of little clocks
lines-drawingA paint canvas (RenderTexture)
easings-testbedAn interactive testbed for all 28 easings
bullet-hellA magic circle spraying bullet spirals
ring-drawingA ring/annulus with adjustable angles
circle-sector-drawingA circle sector with adjustable angles
rounded-rectangleA rounded rectangle, size/roundness knobs
recursive-treeA binary fractal tree with live knobs
triangle-stripA rainbow triangle-strip fan
math-sine-cosineA live unit-circle trig visualization
hilbert-curveA rainbow Hilbert space-filling curve
pie-chartAn interactive pie chart with hover pop
kaleidoscopeDraw strokes mirrored with 6-fold symmetry
splines-drawingDraggable spline points, 4 spline types
rlgl-triangleA rainbow triangle via rlgl immediate mode
rlgl-color-wheelAn HSV color picker wheel via rlgl
top-down-lights2D lights casting shadow volumes off boxes
rectangle-advancedRounded gradient rectangles via rlgl

text — fonts, unicode, layout (16)

previewbb nameshows
format-textZero-padded score/time text readouts
writing-animA message types itself out
input-boxA hover-to-type text input box
words-alignmentAlign a word inside a rectangle
font-loadingLoad a BMFont and a TTF font (DrawTextEx)
font-filtersScale a TTF word, switch texture filters
font-spritefontThree colored sprite fonts from PNG atlases
sprite-fontsA gallery of raylib's eight bundled sprite fonts
rectangle-boundsWord-wrapped text in a mouse-resizable container
codepoints-loadingJapanese text rasterized to a minimal TTF font atlas
unicode-rangesGrow a multilingual font atlas by unicode range
inline-stylingText with inline color style tags
unicode-emojisClick emojis for multilingual speech bubbles
text-3d-drawingA bitmap font drawn as textured quads in 3D, waving the ~~World~~-marked span
strings-managementDrag/slice/shatter/glue text particles; 1-6 run raylib's TextTo* fns
font-sdfBitmap vs SDF font scaling, the SDF drawn through a shader

textures — images, sprites, render textures (31)

previewbb nameshows
image-generationNine procedural textures (gradients/noise)
logo-textureThe raylib logo loaded from a PNG file
sprite-animationScarfy runs: 6-frame spritesheet animation
srcrec-dstrecRotate + scale a sprite frame (DrawTexturePro)
background-scrollingParallax-scrolling cyberpunk street layers
image-loadingLoadImage (RAM) then LoadTextureFromImage (VRAM)
blend-modesFour 2D blend modes over the cyberpunk street
particles-blendingSpark particles trail the mouse (alpha/additive)
mouse-paintingA paint program on a RenderTexture canvas
sprite-buttonA 3-state sprite button with a click sound
bunnymarkThe classic bunny-spawning batching benchmark
fog-of-warA tile map hidden by smooth fog of war
tiled-drawingTile a texture pattern with scale/rotation/color
sprite-explosionClick to play a 5x5 explosion spritesheet + sound
sprite-stackingA voxel booth from 122 stacked rotated slices
npatch-drawingStretchable 9-patch / 3-patch UI panels
image-processingNine CPU image filters via pointer-taking Image* APIs
image-drawingOne texture composed from several CPU images
image-textText baked into an image with a TTF font
image-rotateThe logo rotated +45/+90/-90 in CPU memory
image-channelRGBA channels split + alpha-masked
image-kernelSharpen/sobel/gaussian convolution kernels
cellular-automataWolfram rule cellular automaton, editable rule
magnifying-glassA circular magnifier revealing hidden bunnies
to-imageRound-trip an image VRAM<->RAM (LoadImageFromTexture)
polygon-drawingA cat texture mapped onto a spinning polygon
raw-dataA .raw pixel dump + a code-generated checkerboard
textured-curveA road texture swept along a draggable Bezier
gif-playerAn animated GIF streamed frame-by-frame to a texture
framebuffer-renderingAn observer camera watching a subject camera + frustum
screen-bufferThe classic DOS fire effect in a palette-indexed buffer

models — meshes, 3D, OBJ/GLB (29)

previewbb nameshows
geometric-shapes3D cubes/spheres/cylinders/capsules on a grid
box-collisionsA player cube colliding with 3D obstacles
billboard-renderingCamera-facing billboards + an orbiting camera
—waving-cubes3375 rainbow cubes waving in 3D
orthographic-projectionToggle perspective vs orthographic camera
tesseract-viewA rotating 4D hypercube projected to 3D
rlgl-solar-systemSun/Earth/Moon via the rlgl matrix stack
textured-cubeTwo rlgl textured 3D cubes from a shared atlas
directional-billboardA sprite-sheet billboard that turns as the camera orbits
basic-voxelAn 8x8x8 beige voxel grid; click to ray-pick and remove cubes
rotating-cubeA textured cube spinning on a tilted axis (rlgl matrix stack)
model-loadingThe castle OBJ model loaded from disk, ray-pick selection
heightmap-renderingTerrain generated from a grayscale heightmap image
cubicmap-renderingA cube maze generated from a tiny black-and-white image
mesh-generationAll nine procedural mesh generators, checked-textured
first-person-mazeWalk the cubicmap maze in first person, wall collision + radar
loading-gltfThe animated glTF robot, switchable animations
yaw-pitch-rollFly a WWI plane through pitch/yaw/roll, easing back to level
mesh-pickingA mouse ray picks the closest quad/triangle/sphere/box/mesh hit
loading-iqmThe classic IQM guy walking on loop
loading-m3dThe Cesium Man in Model3D format, skeleton view on SPACE
loading-voxFour MagicaVoxel models under a fly camera + voxel lighting
animation-timingThe robot with a playback timeline + adjustable speed
bone-socketA hat, sword and shield riding the greenman's skeleton bones
point-renderingUp to 10 million points: GPU point mode vs per-point draws
skybox-renderingA cubemap skybox drawn from inside a unit cube
animation-blendingSPACE cross-fades the robot between two animations
animation-blend-customPer-bone blending: walking legs + attacking upper body
decalsClick to splat logo decals clipped onto a character's surface

shaders — GLSL, uniforms, postprocess, lighting (35)

previewbb nameshows
shapes-textures-shaderA grayscale fragment shader over shapes + a sprite
texture-outlineA shader-drawn outline around a sprite
texture-wavesA space texture rippled by an animated wave shader
julia-setA Julia set fractal computed in a fragment shader
eratosthenes-sieveThe Sieve of Eratosthenes per-pixel in a shader
mandelbrot-setThe Mandelbrot set in a shader, deep-zoom presets
rounded-rectangle-shaderSDF rounded rectangles (fill/shadow/border) in a fragment shader
raymarchingA raymarched SDF scene in a fragment shader, first-person fly-cam
color-correctionA post-process shader tuning contrast/saturation/brightness of a picture
custom-uniformA mouse-steered swirl post-process over a render-textured 3D scene
ascii-renderingA post-process shader re-rendering the scene as ASCII glyphs
postprocessingTwelve full-screen post-process shaders cycled over a 3D scene
texture-renderingA blank texture painted and animated entirely by a fragment shader
multi-sample2dTwo textures blended in a shader via a second sampler2D
palette-switchPalette-indexed bands recolored by an ivec3-array shader uniform
hot-reloadingHot-swap the reload.fs fragment shader while it runs
spotlight-renderingThree spotlights alpha-masked over a star field + sprite swarm
depth-writingInverted gl_FragDepth into a custom depth-texture framebuffer
depth-renderingThe scene's depth buffer visualized through a shader
hybrid-renderingRaymarched spheres + rasterized cubes in one depth-tested scene
texture-tilingA generated cube model with its texture tiled 3x3 by a shader
model-shaderThe watermill OBJ drawn grayscale via a material-bound shader
basic-lightingA plane + cube lit by four toggleable colored point lights
fog-renderingTorus/cube/sphere models fading into exponential fog
cel-shadingA GLB car toon-shaded with quantized bands + outline
normalmap-renderingA spinning plane lit through a tangent-space normal map
simple-maskAn animated mask texture eats holes in two models' plasma skin
vertex-displacementA plane mesh riding Perlin-noise waves in the vertex shader
—rlgl-computeGame of Life stepped entirely on the GPU by compute shaders
mesh-instancingTen thousand lit cubes in one draw call (DrawMeshInstanced)
lightmap-renderingA plane lit by a baked lightmap through a second UV channel
shadowmap-renderingReal shadows: an animated robot under the shadowmapping algorithm
basic-pbrThe rusty car under physically-based rendering (PBR maps)
deferred-renderingA three-target G-buffer + full-screen deferred lighting pass
game-of-lifeConway's Life on a 2048x2048 world: pan/zoom, presets, draw mode

audio — sounds, music streams (11)

previewbb nameshows
sound-loadingPlay a WAV and an OGG sound
music-streamStream an MP3 with pan/volume/progress controls
module-playingA chiptune XM module + pulsing circle waves
sound-multiOverlapping sound playback via sound aliases
sound-positioningSpatial audio around an orbiting 3D sphere
raw-streamA sine wave generated sample-by-sample into a raw audio stream
mixed-processorA DSP distortion callback on the whole audio mix
stream-effectsStackable lowpass + delay effects on one music stream
stream-callbackA pull-model synth: sine/square/triangle/sawtooth on demand
amp-envelopeAn ADSR amplitude envelope on a tone, with a live shape graph
spectrum-visualizerA live FFT spectrum of the music through a shader

Adding an example — the four touchpoints

See porting-workflow.md for the full end-to-end process (source of truth, docstring format, the headless smoke test). The short version: a new example touches four places in the same commit — raylib-examples/project.clj (a :profiles entry), bb.edn (a bb <name> task), bb/helpers.clj (a row in the examples registry vector, including its :cat), and raylib-examples/README.md (move it from the "not yet ported" queue into the ported table). The repo-root README.md carries no per-example catalog, so it needs no change.

See also

  • porting-workflow.md — the full registration recipe and the headless smoke-test technique this catalog's examples were all verified with.
  • native-value-lifetimes.md — the interop rule every example in this catalog is written against.

Getting started

This mirrors the root README's Quick start section with a bit more context — if you only need the commands, the README's shorter version is enough.

Requirements

  • Recent install of the jank compiler and the lein-jank Leiningen plugin (2026.06-1 or newer — older versions lack the native-build middleware)
  • A C++ compiler
  • CMake
  • Babashka

Verified on macOS with jank 0.1-alpha and lein-jank 2026.06-1.

Clone with the submodule

raylib is vendored as a git submodule of the jank-raylib-sys wrapper, so clone with it:

git clone --recurse-submodules git@github.com:burinc/b12n-raylib-jnk.git
# or, after a plain clone:
git submodule update --init --recursive

The bb task surface

bb info              # grouped cheat-sheet of everything (start here)
bb examples          # list every runnable example
bb starfield         # run one (installs jank-raylib-sys on first use)
bb run particles     # same, by argument
bb run-all           # cycle through every example, ~15s each (a demo reel)
bb run-all 40        # ...longer per example (also covers first-run compiles)

bb install           # install jank-raylib-sys into ~/.m2
bb clean             # remove */target build dirs

If the lein on your PATH can't bootstrap, set LEIN=/path/to/lein.

Manual usage (without bb)

cd jank-raylib-sys && lein update-in :prep-tasks empty -- install
cd raylib-examples  && lein with-profile +<example> run --disable-sandbox

macOS

There is no bwrap on macOS, so the native build must run with sandboxing disabled. Every bb/lein invocation above already passes --disable-sandbox for you.

Known limitation: rlgl-compute needs OpenGL 4.3 compute-shader support that macOS's native GL backend cannot provide (capped at 4.1). This repo builds jank-raylib-sys at OPENGL_VERSION "3.3" by default — every other example works; rlgl-compute does not run out of the box on any platform against this build. See the root README's "Known limitations" section for the manual override recipe (which still won't work on macOS).

Troubleshooting

A handful of examples fail to compile, naming a raylib header you don't recognise

If a compile error cites a header outside this repo — most often /opt/homebrew/include/raylib.h or /usr/local/include/raylib.h — you have a system-wide raylib installed that is shadowing the vendored one, and it is an older version than the 6.0 this repo pins.

The tell is the compiler's own diagnostic pointing at the wrong file:

/opt/homebrew/include/raylib.h:1155:21: note: 'ComputeSHA1' declared here

...while compiling a call to ComputeSHA256, which only exists in 6.0.

Fix: unlink the system package for the duration of the build.

brew unlink raylib      # macOS; on Linux, remove or unlink the distro package
bb clean && bb install
brew link raylib        # restore it afterwards if you want it back

Why it happens, and why the project can't fix it from its build config: jank's compiler has at least two C++ resolution pathways. The main one correctly honours the project's own -I flags (which jank-build.bb emits as jank-build::include-dir= directives pointing at the vendored raylib 6.0 headers). A secondary pathway — used for certain overload-resolution and diagnostic scenarios rather than for every call — does not inherit those flags and falls back to clang's default system include search, which finds the Homebrew header instead. jank exposes no flag or environment variable to control that second pathway's search order.

This is why the failure looks so arbitrary: only examples that call a function which is new in 6.0 or changed signature since 5.5 can trip it. On one machine it hit 8 of 209 — basic-shapes, top-down-lights and shapes-textures-shader (all call DrawCircleGradient, whose signature changed), math-sine-cosine (DrawLineDashed), compute-hash (ComputeSHA256), strings-management, font-sdf (LoadFontData), and point-rendering (rlDisablePointMode). The other 201 compiled fine with the same stray header present, which makes this very easy to misdiagnose as a bug in one example.

Verified as a closed loop on jank 0.1-alpha against Homebrew raylib 5.5: unlink → all 8 compile clean; re-link → all 8 fail again, identically.

The missing JVM surface, and other odds and ends

jank has no Math/*, format, rand-int, char literals, or String methods — this page covers what replaces them, what's actually available (more of clojure.core/clojure.string than it looks), and a few miscellaneous gotchas that save a recompile.

Filling the missing JVM surface

No Math/*, format, rand-int, char literals, or String methods. The replacements, all proven in committed examples:

JVM habitjank replacementProof
Math/sin etc.(:include "math.h") + cpp/sin, cpp/cos, cpp/atan2, cpp/sqrt, cpp/hypot, cpp/pow, cpp/ceil, cpp/floor, cpp/trunc, cpp/exp, cpp/log (all double)throughout
Math/PI(def PI 3.141592653589793)easings_testbed.jank
rand-intcpp/GetRandomValuecamera_2d.jank
(format "%08d" n)a zero-pad str loopformat_text.jank
(format "%.2f" x)round ×100, split with quot/modformat_text.jank fmt2
char literals / (char c)subs into an ASCII table string: chars 32..126 in order, (subs ASCII (- c 32) (- c 31))input_box.jank
TextSubtextsubs with the end clamped to countwriting_anim.jank
string as tokensvector of one-char strings via a subs looppenrose_tile.jank

Typed input: (int (cpp/GetCharPressed)) in an inner loop until 0 (input_box.jank). System time: (cpp/time cpp/nullptr), (cpp/& t), (cpp/localtime ...) + .-tm_* fields (digital_clock.jank).

What IS available: the full clojure.core seq API and clojure.string. The examples in this repo lean on index-based loop/recur + nth/count, which can read as if the higher-level collection API is missing. It is not. jank's clojure/core.jank defines and self-uses first, rest, next, seq, empty?, second, map, filter, reduce, into, concat, some, every?, mapv, range, repeat, partition, doseq, dotimes, when-let/if-let, etc. — the ordinary Clojure surface. clojure.string ships too (split, split-lines, join, includes?, index-of, trim, upper-case/lower-case, ...), backed by native C++.

Caveat: not every clojure.string / clojure.core fn is implemented yet. The var exists (it's declared in string.jank / core.jank) but some native backers are stubs that throw at runtime — str/replace currently dies with TODO: port clojure.string/replace (hit in rectangle_bounds.jank, worked around by baking the substitution into the source string), and core's flush dies with TODO: port flesh (sic; hit probing compute_hash.jank — stdout is block-buffered when redirected, so println output can vanish if the process is killed; there is no working in-jank flush, shim fflush(stdout) via cpp/raw if a probe needs it). So a function being present in the source is not proof it runs; if in doubt, probe it, or grep its native impl for TODO. split/split-lines/join are confirmed working, as are peek/pop/filterv/into (rectangle_bounds.jank). Pull it in the normal way — :require coexists with a C++ :include in one ns form (jank's own shell.jank does exactly this):

(ns raylib-examples.foo
  (:require [clojure.string :as str])
  (:include "raylib.h"))
;; then (str/split-lines text), (str/join " " xs), (first coll), etc.

Index-based loops are still fine (and sometimes clearer for tight draw loops), but reach for the seq API / clojure.string when it reads better. text_file_loading.jank is the proof in this repo: it (:require [clojure.string :as str]) beside (:include "raylib.h") and word-wraps with str/split-lines, str/split line #"\s+" (regex literals work) and filterv — compiled and ran clean. Source of truth: jank's own compiler+runtime/src/jank/clojure/ (core.jank, string.jank) — what is implemented there is what you can call.

const char * returns fold into str directly (from the core arc, 2026-07-03). A raylib fn that returns a C string (GetMonitorName, GetClipboardText, GetWindowTitle, ...) can be passed straight to jank's str, which turns it into a jank string:

(str "[" (cpp/GetMonitorName 0) "]")   ; => "[Built-in Retina Display]"

Proven in monitor_detector.jank. No conversion helper needed — the native const char * becomes a jank string at the str boundary. (You can also pass it straight to another C fn that wants const char *, e.g. (cpp/DrawText (cpp/GetMonitorName 0) ...), since that's C->C.)

GOTCHA: don't wrap an already-boxed jank int in (int x) INSIDE a str call. monitor_detector cost real debugging over this. When x is already a jank int (e.g. destructured from a map), writing (str "Position: " (int x)) made jank emit C++ that member-accesses an i64, and the WHOLE FILE failed to compile with the misleading member reference base type 'i64' (aka 'long long') is not a structure or union — reported at an unrelated generated line, with no .- in the source at all. Dropping the redundant cast fixed it:

;; BAD  — redundant (int x) on an already-jank int inside str -> i64 codegen error
(cpp/DrawText (str "Position: " (int x) " x " (int y)) ...)
;; GOOD — pass the boxed value directly
(cpp/DrawText (str "Position: " x " x " y) ...)

The plain .-x reads and the const char * fold in the same file were both fine; the cast-inside-str was the sole trigger. When a file fails with member reference base type 'i64' and you can't find a matching .- access, suspect an (int ...)/cast folded into a str (or other builder) call — the error line is generated-code position, not source, so don't trust it.

Misc that saves a recompile

  • C bools work directly in conditionals: (cpp/! (cpp/WindowShouldClose)), (if (cpp/IsKeyDown cpp/KEY_Q) ...).
  • C constants resolve as cpp/NAME: colors, keys, cpp/MOUSE_CURSOR_IBEAM, cpp/TEXTURE_FILTER_BILINEAR, gesture enums (compare as ints: (int (cpp/GetGestureDetected)), values 1/2/4/.../512 — input_gestures.jank).
  • Flag ORs aren't needed: SetConfigFlags ORs each call into its state, so call once per flag (window_letterbox.jank).
  • When camera rotation is 0, skip GetWorldToScreen2D/GetScreenToWorld2D (native Vector2 returns) — the transforms reduce to screen = (world - target)*zoom + offset in jank math (camera_2d_platformer.jank does all five camera modes this way). When rotation matters, both GetScreenToWorld2D and GetWorldToScreen (3D) DO work — bind the returned native Vector2 to a local and read .-x/.-y (camera_2d_mouse_zoom.jank, world_screen.jank).
  • A jank fn takes at most 10 parameters (analyze/invalid-fn-parameters: This function has too many parameters. The max is 10). Bundle extra args into a vector and destructure inside — tiled_drawing.jank's tiling helper passed source/dest as two 4-vectors instead of eight scalars. (Moot there in the end, since the native-Texture2D-param rule forced full inlining, but the cap is real and independent.)
  • A side-effecting draw-helper defn shared by several passes should end with an explicit nil (camera_2d_split_screen.jank's draw-scene).
  • \n inside a DrawText string works (window_letterbox.jank).
  • Multi-header include: (:include "raylib.h" "math.h" "time.h").

Compile-time cost of deeply nested loops

A triple-nested doseq with a fat body (waving_cubes.jank's 15x15x15 cube lattice) compiles in ~3-4 MINUTES, versus ~30-60s for a typical example module. The generated C++ for nested seq iteration with a large inlined body appears to grow multiplicatively. Budget smoke-test alarms accordingly (the standard 40s alarm kills such a build mid-compile and looks like a hang - re-run with a 260s+ alarm before diagnosing). If compile time matters more than faithfulness, hoist the inner body into a defn taking only jank values.

Native value lifetimes

jank is native Clojure (C++/LLVM) — no JVM, no Java interop, no REPL. The compiler statically type-checks the boundary between jank objects and native C++ values, and this page is about the single rule that boundary enforces: a native cpp value only stays native within the form that produced it. Every lesson below is a consequence of that rule. Each lesson names the committed example that proves it — those files are the running test suite for this document.

The one rule that explains most crashes

A native cpp value only stays native within the form that produced it. A Color, Vector2, Rectangle, Camera2D, ... may be:

  • constructed inline as a call argument — (cpp/DrawCircleV (cpp/Vector2 ...) ...) ✅
  • bound to a let-local and used in the same scope ✅
  • bound in a let OUTSIDE the frame loop and used inside it (lexical capture) ✅ — this is how create-once GPU resources live (lines_drawing.jank's RenderTexture, words_alignment.jank's Font)

But it may NOT cross a jank fn boundary:

  • returned from a fn ❌ — returning a native object of type 'Color', which is not convertible to a jank runtime object. Even via nested if (dashed_line.jank learned this).
  • passed as a fn parameter and then used in a native call ❌ — it boxes to an object_ref and the native call rejects it (digital_clock.jank's draw-hand; tiled_drawing.jank hit this trying to pass a Texture2D to a draw-tiled helper — No matching call to 'DrawTexturePro' ... argument 0 having type 'jank::runtime::object&#95;ref &' — and had to inline the helper so the texture stayed a captured let-local).
  • carried through loop/recur state ❌ (input_mouse.jank).

Fix: thread plain jank data (ints, reals, keywords, maps) and resolve the native value inline at the use site. camera_2d_platformer.jank threads the camera as five scalars and rebuilds (cpp/Camera2D ...) each frame; input_mouse.jank threads a color-id int and picks the Color with a nested if at draw time.

Frame-crossing mutable native state: park it in a cpp/raw static

When a native resource must BOTH persist across frames AND be recreated at runtime with computed sizes, neither of the two usual homes works: loop/ recur state can't carry a native value (the same rule as above — a native value can't cross a fn boundary — applied to loop/recur state), and a create-once outer-let local can't be rebound. Park the value in a cpp/raw static with tiny accessor fns instead:

(cpp/raw "static RenderTexture2D jank_target = { 0 };
static void jank_resize_target(int w, int h) {
  UnloadRenderTexture(jank_target);
  jank_target = LoadRenderTexture(w, h);
}
static RenderTexture2D jank_get_target(void) { return jank_target; }")

jank calls (cpp/jank_resize_target w h) on change events and re-fetches (let [target (cpp/jank_get_target)] ...) each frame -- the struct comes back by value into a let-local and never crosses a jank fn boundary. Proof: viewport_scaling.jank, whose RenderTexture is recreated on every window resize / resolution / viewport-mode change with sizes computed from the current window state. (UnloadRenderTexture guards id 0 internally, so the first call against the zero-initialized static is safe.) Reach for this only when recreation is genuinely dynamic; a fixed-size resource should stay a create-once outer-let local (lines_drawing.jank).

CRITICAL: cpp/raw statics are duplicated PER JANK FN. Every jank fn that references the shims gets its OWN copy of the raw block's statics – a helper fn that writes the "same" static writes a private copy the other fns never see. Probe evidence (2026-07-04): after -main called a load-into-static shim, -main read .glyphCount 95 from its copy while a helper defn- read 0 from its own; the helper's writes were likewise invisible to -main. Failure modes are nasty: state silently "resets" across fn boundaries, and reading through a zeroed struct's pointer field segfaults. Rule: route EVERY read/write of a mutable raw static through one single jank fn (in practice -main), inlining helper logic there -- unicode_ranges.jank inlines the C's AddCodepointRange into -main's rebuild block for exactly this reason. Pure-jank helpers (no shim calls) remain safe to factor out. (viewport_scaling.jank was unaffected only because all its shim calls already sat in -main; compute_hash.jank / storage_values.jank / codepoints_loading.jank are safe because their statics are written and read within one fn call's dynamic extent, not across fns.)

Create-once native resources

LoadRenderTexture / GetFontDefault style resources bind in a let outside the frame loop and get used inside it — lexical capture keeps them native. Unload after the loop.

(let [canvas (cpp/LoadRenderTexture WIDTH HEIGHT)]
  (loop [...]
    ... (cpp/BeginTextureMode canvas) ...)
  (cpp/UnloadRenderTexture canvas))

Two RenderTextures at once work (camera_2d_split_screen.jank). Blit a RenderTexture with a negative source height — RTs are stored upside down (lines_drawing.jank, window_letterbox.jank).

Porting workflow

This guide is the end-to-end process for porting one official raylib example to jank, as practiced across the first 50 ports. Follow it top to bottom and a port lands as one self-contained, tested, registered commit.

1. Pick from the queue

raylib-examples/README.md keeps the prioritized queue under "Not yet ported". Markers tell you the cost up front:

  • (no marker) — pure raylib, port directly (these are all done now)
  • 🖼️ — uses a RenderTexture (supported; see lines_drawing.jank)
  • 🎛️ — uses raygui controls; swap them for keyboard controls (see raygui-to-keyboard.md)
  • ⚙️ — uses the low-level rlgl API. Turns out to work directly: rlgl.h is installed next to raylib.h and its functions live in libraylib, so (:include "rlgl.h") is all it takes (proof: rlgl_triangle.jank)

2. Port from the definitive C source

The authoritative originals are raylib's own example programs, under examples/{core,shapes,text,...}/ (easing functions in examples/shapes/reasings.h). You already have a checkout: the vendored submodule at jank-raylib-sys/raylib/examples/ is pinned to the same raylib this repo builds against, so it is the copy whose API actually matches.

Port from the C, never from an intermediate binding. Ports-of-ports drift — if you find an existing Clojure or Lisp translation of an example, treat it as a hint and check it against the C.

Keep formulas and update ordering faithful to the C (a code review of the easings testbed verified all 28 easing formulas term-by-term against reasings.h — that fidelity is the standard). When jank forces a deviation (no mutable arrays, capped pool sizes, keyboard instead of raygui), say so in the namespace docstring:

(ns raylib-examples.bullet-hell
  "raylib [shapes] example - bullet hell, ported to jank.
  ...controls...
  Based on raylib/examples/shapes/shapes_bullet_hell.c
  (jank-native: bullets are a vector of maps rebuilt per frame instead
  of a mutable C array, and MAX-BULLETS is 5000 rather than the C's
  500000 calloc headroom - the reset-at-cap behavior is the same.)"
  (:include "raylib.h" "math.h"))

Docstring format: title line, controls, Based on <C file>, then a (jank-native: ...) note for intentional deviations. File names use underscores, namespaces use kebab: bullet_hell.jank → raylib-examples.bullet-hell. Comments must be ASCII (an em-dash trips the lexer).

Before writing a new construct, grep the existing examples for a sibling that already uses it — every proven idiom has at least one committed example, and the guide pages index them by theme.

Every example sets (cpp/SetConfigFlags cpp/FLAG_WINDOW_HIGHDPI) before InitWindow so windows scale with the monitor DPI (drawing stays at the C's logical resolution); include it in new ports. SetConfigFlags ORs each call into its state, so it stacks with FLAG_MSAA_4X_HINT etc. Exceptions: window-flags (a flag-state demo) and the two highdpi-* examples (which manage DPI flags themselves).

3. Register in all four places (same commit)

  1. raylib-examples/project.clj — a :profiles entry
  2. bb.edn — a bb <name> task
  3. bb/helpers.clj — a row in the examples registry vector, including its :cat (the raylib category keyword — drives the bb info grouping)
  4. raylib-examples/README.md — move the example from the queue into the ported table, bump the progress counts

The repo-root README.md carries no per-example table; it delegates the catalog to raylib-examples/README.md, so nothing there needs touching.

Do not defer any of these; the registration IS part of the port.

4. Smoke-test headless

Check paren balance BEFORE the first compile. A jank compile costs 30-60 s; a strict reader loop is instant and catches the classic extra-close-paren at the recur tail (which cost one wasted compile on input_gestures_testbed). jank sources read fine with the JVM reader:

cd raylib-examples
clojure -M -e "
(let [text (slurp \"src/raylib_examples/<name>.jank\")
      r (java.io.PushbackReader. (java.io.StringReader. text))]
  (try
    (loop [forms []]
      (let [form (read {:read-cond :allow :eof ::eof} r)]
        (if (= form ::eof)
          (println :total-forms (count forms) :ok)
          (recur (conj forms form)))))
    (catch Exception e (println :ERROR (.getMessage e)))))"

:total-forms N :ok means balanced; :ERROR Unmatched delimiter means fix before compiling. (A strict read loop, not read-string - the single-form and (do ...)-wrapped variants both miss trailing imbalance.)

macOS has no timeout and this harness blocks a foreground sleep, so the reliable one-shot is a perl alarm:

cd raylib-examples
perl -e 'alarm 25; exec @ARGV' \
  lein with-profile +<name> run --disable-sandbox > /tmp/run.log 2>&1
echo "exit=$?"
grep -icE "error|exception|Mismatched|small_real|small_integer|invalid object" /tmp/run.log

Reading the result:

  • exit=142 (SIGALRM) — the example compiled, opened its window, and survived 25 s of the frame loop. This is the success signal.
  • exit=1 or an early exit — compilation or startup failed; the log has the compiler error.
  • The grep must print 0. The markers are the jank/raylib failure vocabulary: Mismatched (if-branch type clash), small_real / small_integer (int/real API mismatch), invalid object (bad conversion).

First compile of a new example takes ~60–75 s; cached recompiles ~15 s.

Key-gated paths need a probe run

A 25 s headless run only exercises code that runs unconditionally. If the interesting path hides behind input (a hover, a key, a generation count), temporarily force the state, run, then revert before committing:

  • penrose_tile.jank — forced gen 2 + prebuilt tokens to exercise the L-system, then reverted.
  • input_box.jank — forced on-text? true and seeded the name from the ASCII table so the caret/MeasureText path ran, then reverted.

Note the probe in the commit message so reviewers know the gated path was actually executed.

5. Commit

  • One example per commit when practical (registry rows interleave if you batch two — fine occasionally, but singles keep history greppable).
  • Subject: raylib-examples: port <official_source_name>.
  • Body: the interesting jank-native decisions, and any NEW interop lesson the port surfaced.
  • Stage files by explicit path; never git add -A/./-u.
  • If the port surfaced a new lesson, add it to the relevant guide page in the same commit.

Debugging a port that won't compile

Bisect: cut the example down to a minimal draw loop, then add one construct back at a time — each lein run recompiles the one changed module in ~30–60 s. The compiler error vocabulary and what each message actually means is in type-checking-and-coercion.md and cpp-interop-toolbox.md.

raygui → keyboard

Many official shapes examples build their UI with raygui sliders and checkboxes. jank-raylib-sys doesn't wrap raygui, so those examples get a keyboard-driven port: sliders become held-key adjustments, checkboxes become toggle keys, and the raygui panel becomes plain DrawText lines showing live values. The pattern was established by easings_testbed.jank and refined across ring_drawing.jank, circle_sector_drawing.jank, rounded_rectangle.jank, and recursive_tree.jank.

The adj helper

One small clamp helper covers every slider:

(defn adj
  "Adjust v by step while dn/up are held, clamped to lo..hi."
  [v dn up step lo hi]
  (let [v (if dn (- v step) v)
        v (if up (+ v step) v)]
    (if (< v lo) lo (if (> v hi) hi v))))

Call it once per slider in the frame let, feeding IsKeyDown (continuous, slider-like) or IsKeyPressed (stepped, for coarse values like a depth of 1..10):

(let [sa   (adj sa (cpp/IsKeyDown cpp/KEY_LEFT) (cpp/IsKeyDown cpp/KEY_RIGHT) 2.0 -450.0 450.0)
      segs (adj segs (cpp/IsKeyDown cpp/KEY_O) (cpp/IsKeyDown cpp/KEY_P) 0.5 0.0 100.0)
      depth (adj depth (cpp/IsKeyPressed cpp/KEY_Z) (cpp/IsKeyPressed cpp/KEY_X) 1.0 1.0 10.0)]
  ...)

Pick the step so a slider's full range takes ~2–4 seconds of holding at 60 fps (range / step / 60).

Checkboxes → toggle keys

ring? (if (cpp/IsKeyPressed cpp/KEY_R) (not ring?) ring?)

The panel → text lines

Keep the C's panel geometry (the divider line and tinted rectangle), and replace each GuiSliderBar with a DrawText line naming the value, its current reading, and its keys:

(cpp/DrawText (str "StartAngle: " (fmt1 sa) "  (LEFT/RIGHT)") 560 40 10 cpp/DARKGRAY)
(cpp/DrawText (str "[R] Draw Ring: " (if ring? "ON" "OFF")) 560 320 10 cpp/DARKGRAY)

fmt1/fmt2 are the small decimal formatters (there's no format in jank — see jvm-surface-gaps.md). Keep derived readouts from the C, like the MANUAL/AUTO segments mode, including its color switch: (if (>= segs min-segs) cpp/MAROON cpp/DARKGRAY).

Key allocation conventions

  • LEFT/RIGHT and DOWN/UP for the two most-adjusted values (angles, primary size).
  • A/S, Z/X, O/P as additional +/- pairs (down-key on the left of the physical keyboard pair).
  • Single letters for toggles, echoing the raygui checkbox label ([R] Draw Ring, [B] Bezier).
  • Q quits — the repo-wide convention — UNLESS the example needs Q or types free text: - easings_testbed.jank keeps the C's Q/W duration keys, so only ESC quits there. Document that clearly in every registry surface. - input_box.jank accepts typed characters, so it quits on Q only while the mouse is OUTSIDE the box.
  • Always list the full mapping in three places: the namespace docstring, the bb/helpers.clj controls string, and the README table row. Drift between those surfaces is a real failure mode a code review caught (the root README's easings-testbed row omitted the quit key while helpers said "ESC quit").

Porting checklist for a 🎛️ example

  1. Map every GuiSliderBar to a key pair + adj call (note range and a sensible step).
  2. Map every GuiCheckBox to a toggle key.
  3. Keep the panel background/divider, replace controls with text lines.
  4. Preserve derived readouts (mode text, computed minimums) and their colors.
  5. Check Q is actually free before binding it to quit.
  6. Smoke-test, registering, committing per porting-workflow.md.

raylib API coverage

What raylib surface area is proven working against this port, and how. Each section cites the committed example that proves it — these examples double as the running test suite for the claims below.

Fonts load like textures (from the text arc, 2026-07-03)

Custom font loading works with zero wrapper changes. LoadFont (BMFont .fnt + its .png atlas) and LoadFontEx (a TTF rasterized at load, with a base size + glyph count) both return a Font native struct — treat it exactly like a Texture2D: bind it in the outer let, draw with DrawTextEx inside the loop, UnloadFont after. LoadFontEx's codepoint-array arg is passed as cpp/nullptr for the default set. .-baseSize reads back fine (nth-box it for the cpp/float size arg). Proof lines: FONT: ... Font loaded successfully, FONT: Data loaded successfully (32 pixel size | 184 glyphs) (font_loading.jank). String literals MAY be UTF-8 (disproving an earlier version of this note): codepoints_loading.jank defs the Japanese Iroha pangram in-source, and the lexer, LoadCodepoints, and DrawTextEx all handle it. The ASCII-only restriction is about COMMENTS (the em-dash lex/invalid-unicode trip).

Models load like fonts (from the model arc, 2026-07-11)

Mesh/Model loading works with zero wrapper changes — the long-assumed "LoadModel blocker" was never real, exactly as with Font. Proven in texture_tiling.jank:

  • (cpp/GenMeshCube (cpp/float 1.0) ...) returns a Mesh by value; passing it inline to cpp/LoadModelFromMesh yields a Model that binds as an outer-let local (pointers inside and all) and draws with cpp/DrawModel inside the loop. Proof line: VAO: [ID 2] Mesh uploaded successfully to VRAM (GPU).
  • Material field writes need a pointer shim. The C idiom model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = tex (and .shader = s) has no jank spelling; a two-line C shim taking (Model* m, Texture2D tex) does the assignment, called with (cpp/& model) — the same address-of pattern as Image* / UpdateCamera (cpp-interop-toolbox.md).
  • UnloadModel after the loop, as usual for create-once resources.
  • LoadModel from a FILE works too (model_loading.jank, the castle OBJ + its .png diffuse): same shape, just returns a Model. Reading model.meshes[0] for GetMeshBoundingBox stays a one-line shim through (cpp/& model) (a struct-array cpp/aget would probably also work per the GlyphInfo probe, but the shim is certain).
  • GLB works too (cel_shading.jank, the old_car_new.glb toon car): MODEL: ... Model basic data (glb) loaded successfully. Reading a Shader back off the material (m->materials[0].shader) is a Shader-returning shim bound to a let-local, like any create-once native.
  • Animations, deep Mesh edits and bare Materials work too (2026-07-11): LoadModelAnimations' ModelAnimation* + int* out-param stay behind C statics with index-based wrappers, and UpdateModelAnimation runs per frame (shadowmap_rendering.jank); a texcoords2 channel can be RL_MALLOC'd, filled and wired to a vertex attribute through (cpp/& mesh) (lightmap_rendering.jank); LoadMaterialDefault binds by value with (cpp/& material) field-write shims (mesh_instancing.jank, which also proves DrawMeshInstanced over a C-static Matrix array).
  • Every model format is proven (2026-07-11): OBJ (model_loading), GLB (cel_shading), IQM incl. separate animation files (loading_iqm), M3D incl. skeleton access (loading_m3d), and VOX (loading_vox, which also proves UpdateCameraPro with inline movement/rotation Vector3s and GetModelBoundingBox).

Compute shaders work (GL 4.3 wrapper build, 2026-07-11)

rlgl_compute.jank proves the whole compute pipeline: compile (rlLoadShader src RL_COMPUTE_SHADER + rlLoadShaderProgramCompute, kept in a path-taking C shim), SSBOs (rlLoadShaderBuffer with cpp/nullptr data), rlBindShaderBuffer, and rlComputeShaderDispatch — all direct rlgl calls with jank-int ids. Prerequisites and patterns:

  • jank-raylib-sys must be built with OPENGL_VERSION "4.3" (set in its jank-build.bb since 2026-07-11): under the default 3.3 the rlgl compute functions compile to no-ops. 4.3 is the same GL 3.3 feature set plus compute, so the GLSL-330 examples are unaffected (regression checked). Proof line: GL: Compute shaders supported.

Added in this repo: this project's own default was later changed to OPENGL_VERSION "3.3" (macOS's native GL backend caps at 4.1, so a global 4.3 build broke window creation for every example on macOS). rlgl-compute now needs the manual 4.3 override described above, which will not work on macOS regardless of the override — see the root README's "Known limitations" section.
  • SSBO ids are plain unsigned ints — hold them as jank ints and the classic ping-pong buffer swap (ssboA <-> ssboB) is just recur with the loop vars exchanged. No native value crosses the loop.
  • A CPU-side staging struct uploaded with rlUpdateShaderBuffer (&struct + sizeof) stays a cpp/raw static behind buffer/count/flush wrappers, like any frame-crossing native state.

rlgl and textures (from the shapes-completing arc)

  • rlgl immediate mode works directly — (:include "raylib.h" "rlgl.h") gives rlBegin/rlColor4ub/rlColor4f/rlVertex2f/rlEnd (rlgl_triangle.jank), custom blend pipelines via rlSetBlendFactors + rlSetBlendMode cpp/BLEND_CUSTOM + rlDrawRenderBatchActive (top_down_lights.jank), and full vertex-colored batches (rectangle_advanced.jank). Raw GL constants pass as plain ints.
  • The rlgl matrix stack works in 3D: rlPushMatrix/rlPopMatrix/ rlRotatef/rlTranslatef/rlScalef nest hierarchical transforms inside BeginMode3D, and regular raylib draws (DrawSphere) render through the same batch so the stack applies to them (rlgl_solar_system.jank - Sun/Earth/Moon).
  • Color field read-back works: bind a returned Color to a let-local and read (.-r c)/(.-g c)/(.-b c)/(.-a c) — feed them native-to-native into rlColor4ub, or box with (int (+ 0.0 ...)) to store as jank ints (rlgl_color_wheel.jank).
  • Image → Texture loading works: (cpp/GenImageChecked ...) → (cpp/LoadTextureFromImage img) → (cpp/UnloadImage img), with the Texture2D held in the outer let like a RenderTexture (top_down_lights.jank). All nine GenImage* algorithms work (image_generation.jank).
  • LoadTexture from a PNG file works — a jank string coerces to the const char* path. Resource files come from the vendored raylib submodule via a path relative to the raylib-examples/ working dir: ../jank-raylib-sys/raylib/examples/textures/resources/... (logo_texture.jank; the run log's FILEIO: ... File loaded successfully is the proof to grep for).
  • N textures = N outer-let bindings + a nested-if dispatch on the current index — the array-of-textures idiom has no direct jank shape (image_generation.jank's nine).
  • Per-entity RenderTexture caches don't map to jank — native handles can't live in a jank vector. Restructure to one reused scratch RT plus a rebuild-on-dirty pass (top_down_lights.jank replaces the C's 16 cached per-light masks this way).
  • Variable-winding fans: when a quad's winding depends on runtime geometry (shadow volumes), draw each triangle in BOTH windings or backface culling eats half of them (top_down_lights.jank's draw-quad).

Audio (from the textures arc, 2026-07-03)

raylib audio works with zero wrapper changes. InitAudioDevice, LoadSound, PlaySound, UnloadSound and CloseAudioDevice are plain raylib.h functions compiled into libraylib (miniaudio / Core Audio backend on macOS). The Sound value is a native struct — same rules as Texture2D: bind it in the outer let, use it inside the frame loop via lexical capture, unload after (sprite_button.jank). The run log's AUDIO: Device initialized successfully + WAVE: Data loaded successfully are the proof lines to grep for. OGG decoding works too (sound_loading.jank), and so do music streams: LoadMusicStream (MP3), UpdateMusicStream once per frame, Play/Stop/Pause/Resume, SetMusicPan/SetMusicVolume (jank real through cpp/float), and GetMusicTimePlayed/GetMusicTimeLength boxed at the binding site (music_stream.jank; proof line STREAM: Initialized successfully). The remaining audio surface to probe is the callback-taking APIs (SetAudioStreamCallback, audio processors) — likely a real blocker, same class as C function pointers elsewhere.

3D mode (from the sound-positioning port, 2026-07-03)

Basic 3D works. cpp/Camera3D constructs inline from three nested cpp/Vector3 args + fovy + cpp/CAMERA_PERSPECTIVE, binds as an outer-let local, and drives BeginMode3D/EndMode3D; DrawGrid and DrawSphere (Vector3 built inline as a call arg) render inside it (sound_positioning.jank). Two constraints shape 3D ports:

  • Free-look cameras WORK now (2026-07-05): UpdateCamera ((cpp/& camera) mode) forms the pointer with the image-processing address-of pattern (cpp-interop-toolbox.md) on an OUTER-let Camera3D, and the mutation persists across frames (probe: 100 orbital frames drifted position.x from 10.0 to 14.03) — camera_3d_free.jank. Struct FIELD writes still have no jank syntax; a one-line pointer shim does them (jank_cam_retarget). The older per-frame-rebuild workaround remains valid and simpler when the camera path is fully jank-driven: billboard_rendering.jank constructs the whole cpp/Camera3D as a frame-let local from an accumulated angle, and DrawBillboard* accept it by value. BoundingBox also constructs inline from two nested cpp/Vector3s for CheckCollisionBoxes / CheckCollisionBoxSphere (box_collisions.jank).
  • No raymath vector helpers through jank fns: Vector3Subtract etc. return native structs, fine inline, but a chain of them can't thread jank helper fns — do the vector math as scalar jank arithmetic on plain reals instead (the attenuation/pan math in sound_positioning.jank replaces five raymath calls this way).

Type-checking and coercion

jank's compiler enforces the native-value-lifetime rule (native-value-lifetimes.md) strictly at compile time. This page covers the sharp edges that fall out of that strictness: if/cond branch type-checking, numeric coercion between jank and native number types, and constructing native structs from jank data. Each lesson names the committed example that proves it.

if/cond branch type-checking

jank type-checks every if branch. cond/case expand with an implicit trailing nil, which clashes with a native value type: Mismatched 'if' branch types 'Color' and 'nil'.

  • cond returning jank values (maps, keywords, strings, vectors) is fine — state machines and [x vx]-vector returns all work.
  • Picking a native value needs hand-nested ifs where every branch ends in a concrete value: (if on-text? cpp/RED cpp/DARKGRAY) as a call argument is fine (input_box.jank, bullet_hell.jank).

and/or are ifs too — a native struct-field bool clashes with a jank bool. (and native-bool jank-bool) expands to (let [a native-bool] (if a jank-bool a)), so its two implicit branches are jank-bool (plain bool) and native-bool. A struct-field read like (.-hit collision) types as bool & (a reference), not bool, so the combined form errors with Mismatched 'if' branch types 'bool' and 'bool &'. The read is only a problem when a boolean-combining macro forces the two types to unify — using (.-hit c) straight as an if/when condition is fine (picking_3d.jank). Fix: don't and a native field bool with a jank bool; hand-nest the ifs so the native bool is always just a condition, never a returned branch value (basic_voxel.jank's ray-pick loop: (if (.-hit coll) (if (< d best-d) ...) ...)).

Numeric traps

TrapSymptomFixProof
mod/quot return realsexpected integer found small_real at an int param, or a broken nthwrap in (int ...)everywhere; writing_anim.jank
cpp/float wants a REAL argexpected real found small_integer(cpp/float (+ 0.0 n))lines_drawing.jank boxes GetMouseX
(/ int int) shape is unreliablesubtleprecompute constants or (int (quot ...))window_letterbox.jank uses (int (/ GAME-H 10))
min/max reject a raw C doubleinvalid operands to binary expression deep in math.hppbox first with (+ 0.0 x), or clamp with ifdashed_line.jank; isolated to min/max only — + - * / < <= = all take raw doubles (ellipse_collision.jank)
min/max also reject a boxed int mixed with (int ...)'s unboxed i64same math.hpp template error (oref<small_integer> vs long long)clamp with if comparisons instead (< / >= take the mix fine)first_person_maze.jank cell clamp
str with >10 args + a raw (int ...) in the tailcodegen error: member reference base type 'i64' ... .erase()build long strings in two str calls of ≤10 argsbullet_hell.jank status line
cpp/float on an ALL-native arithmetic chaincodegen error: convert<float>::from_object — no known conversion from 'f64'route one operand through a boxed source, e.g. a vector lookup: (nth [0 fh (* 2 fh)] state)sprite_button.jank frame offset
(int cpp/KEY_*) on a C enum constanttemplate error: member reference base type 'const KeyboardKey' in to_intcast the enum to a native int first with (cpp/int cpp/KEY_*) — the result then boxes fine into jank maps/vectors and round-trips through int params like IsKeyDownkeyboard_testbed.jank ROW data

Boxing idiom: (+ 0.0 x) turns a raw C int/float/double into a jank real; (int x) truncates to a jank integer. cpp/GetFrameTime, cpp/GetTime, cpp/GetMouseWheelMove returns are routinely boxed at the binding site.

The all-native chain trap is the subtle cousin of the expected real one: (+ 0.0 expr) only boxes when at least one input is already a jank object. When every value in the chain derives from literals and native reads ((.-height tex), if-of-literals), jank keeps the whole expression as an unboxed native f64, and cpp/float's generated from_object call cannot take it. The same shape compiles fine when a loop/recur variable feeds the chain (loop vars are boxed) — that is why sprite_animation.jank's near-identical frame math never hit it. Any jank collection operation re-boxes: nth on a vector of the possible offsets is the cheapest escape hatch.

Constructing colors and structs from data

  • cpp/Color (the struct ctor) wants native unsigned char fields — jank ints don't match: No matching call to 'Color' constructor. Struct ctors demand exact native types; functions coerce jank ints happily.
  • So build Colors through functions: pack RGBA into an int and call cpp/GetColor — (cpp/GetColor (+ (* r 16777216) (* g 65536) (* b 256) a)) (camera_2d.jank skyline, recursive_tree.jank panel) — or use cpp/ColorFromHSV, cpp/ColorLerp, cpp/Fade. The C idiom (Color){0,0,0,200} becomes (cpp/Fade cpp/BLACK (cpp/float 0.784)) (bullet_hell.jank).
  • Struct ctors compose inline: cpp/Camera2D takes two nested cpp/Vector2 plus two cpp/float args, passed straight to cpp/BeginMode2D (camera_2d.jank).
  • Struct int fields need (cpp/int n) casts — the same "struct ctors demand exact native types" rule that bites cpp/Color also bites int fields, but here there IS a cast helper (unlike unsigned char). A jank int literal reaches the ctor as small_integer_ref, which doesn't convert to native int in the generated braced-init: No matching call to 'NPatchInfo' constructor ... argument 1 having type ...small&#95;integer&#95;ref. Wrap each int field in (cpp/int n) (mirror of cpp/float for reals): (cpp/NPatchInfo (cpp/Rectangle ...) (cpp/int 12) (cpp/int 40) (cpp/int 12) (cpp/int 12) cpp/NPATCH&#95;NINE&#95;PATCH) (npatch&#95;drawing.jank). The trailing enum constant (cpp/NPATCH_NINE_PATCH) converts to the int layout field on its own. Note the asymmetry vs cpp/Color: int-field structs get the cpp/int escape hatch, so no GetColor-style function detour is needed.
  • Field access works with .-: (.-texture render-texture), (.-x measured-vec2), and on pointer returns (.-tm_hour lt) (lines_drawing.jank, words_alignment.jank, digital_clock.jank).

b12n-raylib-jnk — raylib examples in jank. 209 raylib examples ported to native Clojure (C++/LLVM) over jank's C++ interop.