raylib on an Android phone, from Clojure

A gallery of seventeen scenes in a NativeActivity, drawn by raylib and driven by Jolt (Clojure on Chez Scheme) as native arm64 code. No JVM, no Kotlin, no Java anywhere in the app, and no dependencies.

Work in progress: this has not yet been run on Android hardware. What follows is read off the pinned raylib source and the code in this tree, not measured on a phone.

These docs are also a moving target: Jolt and the libraries this project sits on (raylib-jlt, jolt-lang/nrepl, the pinned raylib revision) are still evolving, so a detail here can go stale between one release and the next.

raylib's Android backend asks for one C function

src/platforms/rcore_android.c is the platform layer: EGL, GLES2, touch, the activity lifecycle and the ALooper pump, all upstream. What it asks of an app is extern int main(int, char **), which android_main calls after storing the android_app — and then it calls ANativeActivity_finish, so returning from main quits the app.

That main is twenty lines of C. It dlopens the library jolt build --library produced, calls jolt_library_init, resolves one name published with ffi/export! through jolt_lookup, and calls it. raylib is compiled from pinned source into the same libmain.so, statically, so every defcfn resolves against the process image — the one :jolt/native entry in deps.edn names libmain.so itself.

Who owns the owner thread

raylib and GLES may only be touched from the thread android_main runs on, because that is where the ALooper is pumped and the EGL context is current. Every arrow below stays on it, so the Clojure loop inherits the right thread by construction rather than by hopping onto it.

flowchart LR
  na["NativeActivity
android.app.NativeActivity"] am["android_main
raylib's rcore_android.c"] mc["our main()
20 lines of C"] jl["libjoltraylib.so
jolt_library_init · jolt_lookup"] loop["the raylib loop
InitWindow · BeginDrawing · EndDrawing"] na -->|"lib_name = main"| am -->|"main(1, {raylib})"| mc -->|"dlopen + jolt_lookup"| jl --> loop loop -->|"Back at the top: return the frame count"| mc mc -->|"return from main"| am -->|ANativeActivity_finish| na

An nREPL eval lands on jolt.nrepl's accept thread instead, which is why reads are free and anything calling raylib goes through raylib.host/on-next-frame! — a queue the loop drains at the top of a frame, on the owner thread.

What the device taught us

The FFI is not the bottleneck

Penrose makes ~2400 FFI calls a frame at 59 fps, while a scene doing 1800 draws managed 18. The cost was partition and map-indexed in the draw loop, not the calls. Fixing that was 3.5x.

Allocation costs more than the call

A helper returning [x y] allocates a vector per line. Removing a lazy sequence changed nothing until the allocation went too: 22 fps, then 47.

Reading source is a hypothesis

This project once believed for a day that raylib drew into framebuffer 0 and showed nothing, because that is what the code said. The device disagreed, and an upstream bug report was withdrawn before it was filed.

Build --dev, or redefinition lies

A release build inlines across call sites, so a var redefined over the nREPL updates what the REPL sees while the running loop keeps calling the original. Nothing announces the split.

An nREPL, on the phone

jolt's built-in server binds on Android loopback and adb forward brings it here. Read live scene state, queue work onto the owner thread, and open a card without a finger.

There is no stdout

An Android app's stdout goes to /dev/null, so every println writes into nothing. Diagnostics go through __android_log_write instead, and jolt log is the console this project has.

Back is yours, entirely

raylib records AKEYCODE_BACK and then eats the event, so it never reaches Android and never sets shouldClose. The gallery reads it as one level up, and quitting means returning from main.

The gallery

Every scene is pure .cljc that runs on a build host with no raylib, no NDK and no device, which is how they are tested. Three come byte-identical from the Jolt Android experiment, sha256 verified, and were written for a different platform entirely, along with three more namespaces carrying the scene contract itself. The other fourteen are ports from raylib-jlt.

There are no screenshots here yet, for the same reason there are none in the README: nothing has run on Android to photograph honestly. Every capture that stood here before was a real screen recording, phone status bar and all, but of raylib-ios, the sibling project this tree was ported from, not of this build. That project's own site has the captures and frame rates for what the scenes draw, and this section gets real Android ones once this build has actually met a phone.

Quickstart

jolt test                                  # 35 tests on the host, no device needed
jolt deps                                  # the pinned raylib source
jolt pack                                  # Chez, cross-built for Bionic. once, ~15 min

jolt release                               # raylib.gallery, no nREPL
jolt deploy
jolt log                                   # the console this app has

jolt live                                  # the gallery with an nREPL, on --dev
tools/android/nrepl repl 7888

Needs jolt 0.8.1+, JDK 17 with Gradle 8.9+, an Android SDK and NDK, and an arm64 device. Chez Scheme is not a prerequisite: pack.sh clones the pinned 10.4.1 and cross-builds it. No private repositories, and the default build has no dependencies at all.