raylib on an iPhone, from Clojure

A gallery of twenty-nine scenes running at 58 fps on an iPhone 17 Pro, drawn by raylib 6.0 over SDL2, driven by Jolt (Clojure on Chez Scheme) as threaded portable bytecode. No JIT, nothing generated at run time, and no dependencies.

raylib has no iOS backend, and does not need one

raylib 6.0 ships platform layers for Android, GLFW, RGFW, SDL, Win32, DRM and web. There is no rcore_ios.c, and writing one means a UIWindow, a GLES layer, touch handling and a loop: several hundred lines of Objective-C.

The shortcut is SDL. Built PLATFORM=SDL with GRAPHICS_API_OPENGL_ES2 against an SDL2 compiled for iOS, SDL's own iOS support becomes the platform layer, which collapses the whole problem to "build SDL". Both libraries then go into the executable as static archives, because jolt build --target emits the whole binary and Chez owns main: there is no host process to dlopen anything, and every defcfn resolves against the process image at first call.

Who owns thread 0

UIKit insists its window work happens on the main thread, so the whole design turns on who owns it. Here the answer is our own executable, and the loop is handed back to us on the thread UIKit wants.

flowchart LR
  main["Chez owns main()
thread 0"] run["SDL_UIKitRunApp
:blocking FFI call"] uikit["UIApplicationMain
SDLUIKitDelegate"] cb["our :collect-safe callback
= SDL_main"] loop["the raylib loop
InitWindow · BeginDrawing · EndDrawing"] main --> run --> uikit -->|"postFinishLaunch, on thread 0"| cb --> loop loop -->|"EndDrawing pumps CFRunLoop
so a blocking loop is legal"| uikit

A blocking while loop is normally wrong on iOS. It works here only because EndDrawing reaches SDL_PumpEvents, which spins the run loop, so UIKit gets its turn every frame.

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

raylib's SDL platform binds no framebuffer, so it "must" draw into framebuffer 0 and show nothing. 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 iOS since jolt#829. Read live scene state, queue work onto the main thread, and open a card without a finger.

The simulator cannot show this

From iOS 17.5 the simulator presents black for OpenGL ES. Pixels reach the framebuffer and nothing appears, so every picture here is hardware.

The gallery

Every scene is pure .cljc that runs on a build host with no raylib, no SDL 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 twenty-six are ports from raylib-jlt.

generative 6 · fractals 3 · toys 5 · games 1 · three of them byte-identical to the Android originals. Every one came off the phone unattended, the moving ones recorded from a live iPhone Mirroring window. Nobody touched the device for any of them: Flappy Bird is flapped by a loop running inside the app, started from the nREPL. The bezel is the mirror's own.

ScenePer framefps
Kaleidoscope708 lines58
Fireworks~120 circles58
Penrose340 triangles, ~2400 FFI calls57
Spirograph~1000 lines55
Boids2025 distance tests52
Flappy Bird, Following Eyes, Touch Trailbyte-identical from Android58

Quickstart

jolt test                                  # 23 tests on the host, no device needed
SDK=device jolt deps                       # SDL 2.32.10 + raylib 6.0, static
NS=raylib.gallery TARGET=device jolt build-app
UDID=... CONSOLE=0 jolt deploy             # sign, install, launch detached

DEV_BUILD=1 jolt live                      # the same, with an nREPL you can redefine into
jolt proxy                                 # forward it over USB

Needs jolt 0.8.0+, Chez Scheme 10.4.1 on PATH as chez, Xcode, cmake, and a paired iPhone. No private repositories, and the default build has no dependencies at all.