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 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.
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.
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.
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.
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.
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.
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.
From iOS 17.5 the simulator presents black for OpenGL ES. Pixels reach the framebuffer and nothing appears, so every picture here is hardware.
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.
| Scene | Per frame | fps |
|---|---|---|
| Kaleidoscope | 708 lines | 58 |
| Fireworks | ~120 circles | 58 |
| Penrose | 340 triangles, ~2400 FFI calls | 57 |
| Spirograph | ~1000 lines | 55 |
| Boids | 2025 distance tests | 52 |
| Flappy Bird, Following Eyes, Touch Trail | byte-identical from Android | 58 |
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.