raylib on an iPhone, from Clojure

The platform under a gallery of a hundred and thirty-seven scenes that all measured 52 to 60 fps on an iPhone 17 Pro (one dips to 31 in its heaviest view). raylib 6.0 draws over SDL2, and Jolt (Clojure on Chez Scheme) drives it 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 platform, and what runs on it

This repo is the platform: the host loop, the gallery shell that takes its scenes as data, the single-scene runner, the bindings and the build tools. Its own scene is Hello, the basic window, which is the proof that all of it works. The hundred and thirty-seven scenes are in raylib-ios-demo, one sub-project each, and every one builds an app of its own or sits in the gallery behind a two-level menu. 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 from the Jolt Android experiment, identical apart from namespace names and whitespace, and the other hundred and thirty-four are ports from raylib-jolt-demo's demos, originally raylib-jlt's.

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

Quickstart

jolt test                                  # the host tests, no device needed
SDK=device jolt deps                       # SDL 2.32.10 + raylib 6.0, static
NS=net.b12n.raylib-ios.gallery TARGET=device jolt build-app   # Hello, the platform gallery
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.