
Cross-platform SDL3 bindings offering a curated common API, embedding static SDL3 per target, automatic JNI extraction, Emscripten wasm assets, and graphics/audio examples.
Kotlin Multiplatform bindings for SDL3, with a curated common API backed by three implementations:
SDL submodule into a JNI shared library (libsdl_jni) that is built by CMake (jni/) and shipped as per-OS/arch sdl-kmp-jni-jvm-* artifacts. NativeLoader extracts the matching binary at runtime, so consumers need nothing beyond the normal dependencies (no LWJGL, no system SDL).SDL submodule is compiled per target with CMake and embedded into the published klib, so consumers get a fully self-contained binary (no dynamic SDL3 dependency). This includes the Android native targets (androidNative*), cross-compiled with the Android NDK.sdl-kmp plus its sdl-kmp-android-jvm companion AAR package the Kotlin bindings, SDL's own android-project Java layer (org.libsdl.app.SDLActivity and friends) and the per-ABI libsdl_jni.so (SDL3 + JNI bridge, built with the NDK from the same jni/ sources). Depend on sdl-kmp alone in commonMain; an application written entirely in Kotlin extends org.libsdl.app.SDLActivity, returns "sdl_jni" from getLibraries() and overrides main() with its own loop — no native code, no SDL Java code to copy.All three implementations build SDL3 from the pinned SDL submodule; the fixes that must not live in the submodule history are kept under patches/ and applied idempotently at build time (applySubmodulePatches) before any task that compiles the SDL sources.
| Platform | Targets | Implementation |
|---|---|---|
| JVM |
jvm (Linux/macOS/Windows) |
JNI shared library (libsdl_jni), SDL3 compiled from source |
| macOS |
macosArm64, macosX64
|
cinterop + embedded static SDL3 |
| Linux |
linuxX64, linuxArm64
|
cinterop + embedded static SDL3 |
| Windows | mingwX64 |
cinterop + embedded static SDL3 |
| iOS |
iosArm64, iosX64, iosSimulatorArm64
|
cinterop + embedded static SDL3 |
| tvOS |
tvosArm64, tvosSimulatorArm64
|
cinterop + embedded static SDL3 |
| Android (native) |
androidNativeArm64, androidNativeArm32, androidNativeX64, androidNativeX86
|
cinterop + embedded static SDL3 (built with the NDK) |
| Android (JVM) |
android target (sdl-kmp-android + sdl-kmp-android-jvm AARs; arm64-v8a, armeabi-v7a, x86_64, x86) |
Kotlin/JVM bindings over SDL's android-project Java layer + per-ABI libsdl_jni.so
|
| Web (browser) | wasmJs |
SDL3 compiled to a standalone Emscripten module, driven through a JS bridge |
Not supported: watchOS (SDL3 has no watchOS support) and visionOS (Kotlin/Native has no visionOS targets yet).
build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("cn.enaium.sdl:sdl-kmp:1.0.13")
}
}
}import cn.enaium.sdl.*
fun main() {
SDL.setMainReady()
if (!SDL.init(SDLInitFlags.VIDEO)) {
error("SDL_Init failed: ${SDL.error()}")
}
SDL.createWindow("hello sdl-kmp", 800, 600).use { window ->
SDL.createRenderer(window).use { renderer ->
var running = true
while (running) {
while (true) {
val event = SDL.pollEvent() ?: break
when (event) {
is SDLEvent.Quit -> running = false
is SDLEvent.Key ->
if (event.down && event.keycode == SDLKeycode.ESCAPE) running = false
else -> Unit
}
}
renderer.drawColor = SDLColor(18, 18, 24)
renderer.clear()
renderer.drawColor = SDLColor(255, 0, 128)
renderer.fillRect(SDLRect(100, 100, 200, 200))
renderer.present()
SDL.delay(16)
}
}
}
SDL.quit()
}The SDL3 GPU API is bound in cn.enaium.sdl.SDLGPU (see SDLGPU.kt). A device
owns every other GPU object; each object releases itself with close():
SDLGPU.createDevice().use { device ->
SDL.createWindow("gpu", 800, 600).use { window ->
device.claimWindow(window)
val texture = device.createTexture(
SDLGPUTextureCreateInfo(
format = SDLGPUTextureFormat.R8G8B8A8_UNORM,
usage = SDLGPUTextureUsage.SAMPLE,
width = 256,
height = 256,
),
) ?: error(SDL.error())
texture.upload(rgbaBytes, bytesPerRow = 256 * 4, x = 0, y = 0, width = 256, height = 256)
device.beginCommandBuffer()?.use { commandBuffer ->
val swapchain = device.acquireSwapchainTexture(commandBuffer, window) ?: return@use
val target = swapchain.texture ?: return@use
commandBuffer.blit(
SDLGPUBlitInfo(
source = SDLGPUBlitRegion(texture = texture, width = 256, height = 256),
destination = SDLGPUBlitRegion(
texture = target,
width = swapchain.srcRect.width,
height = swapchain.srcRect.height,
),
),
)
device.submit(commandBuffer)
}
device.releaseDrawable(window)
}
}beginCommandBuffer() / submit() /
submitAndAcquireFence(). A buffer must be submitted or cancelled; close()
cancels when it was neither.beginRenderPass(colorTargets) returns an SDLGPURenderPass
(pipelines, vertex/index buffers, samplers, storage textures/buffers,
uniforms, draws); beginComputePass(storageTextures, storageBuffers) returns
an SDLGPUComputePass (pipelines, samplers, storage, uniforms, dispatch).blit(SDLGPUBlitInfo) scales a source region onto a
destination region in one call (SDL_BlitGPUTexture, no pipeline needed);
copyTextureToTexture(...) copies between textures.upload writes CPU pixels into a subrectangle (bytes-per-row
is converted to SDL3's pixel-based pixels_per_row using the format's texel
size); download reads a region back as RGBA8 (blocking on a fence).
adoptTexture(rawPtr, bytesPerPixel) wraps a texture created by another
library (e.g. SDL_image's IMG_LoadGPUTexture) so it gets the same
upload/download/close behaviour.createGraphicsPipeline(SDLGPUGraphicsPipelineCreateInfo)
and createComputePipeline(SDLGPUComputePipelineCreateInfo), with shader
bytecode from createShader(code, format, stage, entryPoint, ...). The
shader format (see SDLGPUShaderFormat) must match the device
(device.shaderFormats): MSL on Metal, SPIR-V on Vulkan, DXIL/DXBC on
D3D12.SDL.setMainReady() before SDL.init on the main thread. It is only
required on Apple platforms (macOS/iOS/tvOS); on Linux/Windows it is a harmless
no-op that records the calling thread as the main thread and never blocks.linuxArm64 target is built on Linux aarch64 hosts or cross-compiled from x86_64 with the aarch64-linux-gnu toolchain (gcc-aarch64-linux-gnu g++-aarch64-linux-gnu); SDL3's dlopen-based drivers only need the arch-agnostic headers, so no multiarch sysroot is required.DISPLAY, SDL.init(SDL_INIT_VIDEO)
can block while XOpenDisplay tries to connect. Set SDL_VIDEO_DRIVER=dummy (hint or
environment variable) before init, or export DISPLAY correctly.IrLinkageError
("No function found for symbol ...") at the first SDL call. Keep the consumer's
Kotlin version in sync.SDL_VIDEO_DRIVER=dummy hint (environment variable or SDL.setHint) makes SDL run headless — useful for CI and servers.-XstartOnFirstThread JVM argument (so AppKit/Cocoa can initialise). The example runJvm tasks already set this.sdl-kmp-jni-jvm-{os}-{arch} artifact is a transitive runtime dependency of sdl-kmp; NativeLoader extracts the bundled libsdl_jni from the classpath and System.load()s it, so no java.library.path setup is needed.dlopen), so the published klib has no link-time dependency on X11.androidNative* target requires an installed Android NDK (found under $ANDROID_HOME/ndk); the SDL3 static library is cross-compiled with its CMake toolchain. At runtime the app must be launched through org.libsdl.app.SDLActivity (or a subclass), which loads the shared library and calls its exported SDL_main (see the examples/sdl_renderer/android module).sdl-kmp's Android target resolves through sdl-kmp-android + sdl-kmp-android-jvm, which bundle the Kotlin bindings, SDL's android-project Java layer (matching the statically linked SDL3 version) and libsdl_jni.so for all four NDK ABIs — a commonMain-only dependency brings everything in. An application written entirely in Kotlin extends org.libsdl.app.SDLActivity, returns "sdl_jni" from getLibraries() (the one shared object that carries SDL3, the JNI bridge and SDL's Java layer) and overrides main(): SDL calls it on its dedicated thread, inside SDL's own nativeInitMainThread/nativeCleanupMainThread handshake, instead of loading a native SDL_main, so no native code and no NDK build are needed (see examples/sdl_renderer/android-jvm-app).The SDL3 static library is embedded in each target's published klib (built per target by the sdl-kmp/native/CMakeLists.txt wrapper). The required frameworks/system libraries are recorded in the cinterop klib as linkerOpts (see sdl.def) and are applied automatically when the consumer's final binary is linked.
All examples live under examples/ as standalone KMP modules; each provides
commonMain logic and thin platform entry points (main() / SDL_main).
examples/sdl_renderer — "bouncing box" demo using SDL_Renderer
(renderer, textures, audio, input). Runs on JVM, macOS, Linux, Windows
(MinGW) and Android, with two app submodules: android (Kotlin/Native
libmain.so) and android-jvm-app (pure Kotlin/JVM, no native code).examples/sdl_vulkan — minimal Vulkan triangle (gradient shaders) on
JVM, macOS, Linux and Windows. On the JVM the renderer uses the LWJGL
Vulkan bindings (the example's own dependency - the sdl-kmp library itself
does not use LWJGL), wired to SDL's SDL_Vulkan_GetVkGetInstanceProcAddr;
on native targets a small C helper builds the pipeline.examples/sdl_opengl — minimal OpenGL 3.3 core / GLES 3 triangle on
JVM, macOS, Linux and Windows.examples/sdl_opengl_es — minimal OpenGL ES 3.0 gradient triangle
(the browser-capable GL profile: WebGL2 on wasm). Runs on JVM (GL calls go
through the LWJGL OpenGL bindings, an example-only dependency), macOS,
Linux, Windows and Android, with browser (wasmJs) and android
submodules.examples/sdl_gpu — triangle rendered through the SDL3 GPU API
(cross-backend: Metal on macOS, Vulkan on Android) entirely from
commonMain. Runs on JVM, macOS, Linux, Windows and Android (with its
android submodule APK).examples/sdl_renderer/browser — browser (wasmJs) runner for the
sdl_renderer demo. SDL3 is compiled to a standalone Emscripten module
(:sdl-kmp:linkWasmSdl) and loaded before the Kotlin/Wasm module runs.Kotlin/Wasm cannot embed C libraries (and does not merge library resources
into the web output), so for the wasmJs target SDL3 is compiled with
Emscripten into a standalone module (sdl_wasm.js + sdl_wasm.wasm)
exposing the whole sdl-kmp API as flat functions. A JS glue layer
(sdl-kmp/wasm/sdl_kmp_glue.js) instantiates that module and bridges it
to the Kotlin wasmJs actuals. Building it requires the Emscripten SDK (see
gradle.properties/the wasm.emsdk property; the CI installs it).
The module is published separately as cn.enaium.sdl:sdl-kmp-wasm-assets
(a jar with sdl_wasm.js, sdl_wasm.wasm and sdl_kmp_glue.js at its root,
built by :sdl-kmp:wasm:jar from :sdl-kmp:linkWasmSdl). A wasmJs
consumer unpacks that jar into the web root next to the Kotlin/Wasm output
and loads the glue before running the Kotlin module:
A wasmJs consumer must load the SDL module before running the Kotlin module:
<script type="module">
import { initSdlKmp } from './sdl_kmp_glue.js';
await initSdlKmp(); // instantiate SDL3
await import('./index.mjs'); // Kotlin module; main() auto-runs
</script>See examples/sdl_renderer/browser for a complete runnable page (its
browser-node-test.mjs runs the demo headlessly in Node with the dummy
drivers).
# Publish the library to the local Maven repository first (macOS builds all
# Apple targets + JVM + the darwin JNI artifacts; Linux builds the
# linuxX64/mingwX64 klibs and the linux-x86_64 JNI artifact; Windows (or
# Linux with the MinGW x86_64-w64-mingw32 toolchain) builds the
# windows-x86_64 JNI artifact).
./gradlew :sdl-kmp:publishToMavenLocal
./gradlew :jni-jvm-darwin-aarch64:publishToMavenLocal :jni-jvm-darwin-x86_64:publishToMavenLocal # macOS
./gradlew :jni-jvm-linux-x86_64:publishToMavenLocal # Linux
./gradlew :jni-jvm-linux-aarch64:publishToMavenLocal # Linux (aarch64 host or cross)
./gradlew :jni-jvm-windows-x86_64:publishToMavenLocal # Windows (MinGW host)
./gradlew :android-jvm:publishToMavenLocal # Android JVM AAR (SDK + NDK)
# JVM (pass SDL_VIDEO_DRIVER=dummy for headless mode)
./gradlew :examples:sdl_renderer:jvmRun
SDL_VIDEO_DRIVER=dummy ./gradlew :examples:sdl_renderer:jvmRun
# Native
./gradlew :examples:sdl_renderer:runDebugExecutableMacosArm64
SDL_VIDEO_DRIVER=dummy ./gradlew :examples:sdl_renderer:runDebugExecutableLinuxX64
# GPU examples (macOS: needs a display; Vulkan needs a Vulkan driver)
./gradlew :examples:sdl_vulkan:jvmRun
./gradlew :examples:sdl_opengl:jvmRun
./gradlew :examples:sdl_opengl_es:jvmRun
./gradlew :examples:sdl_gpu:jvmRun
./gradlew :examples:sdl_vulkan:runDebugExecutableLinuxX64
./gradlew :examples:sdl_opengl:runDebugExecutableLinuxX64
./gradlew :examples:sdl_opengl_es:runDebugExecutableLinuxX64
./gradlew :examples:sdl_gpu:runDebugExecutableMacosArm64Kotlin/Native (native SDL_main): the sdl_renderer, sdl_gpu and
sdl_opengl_es examples each have an android submodule. The KMP module
builds libmain.so for every androidNative ABI (exporting SDL_main from
the shared androidNativeMain source set); the app copies those into its
jniLibs and its MainActivity extends org.libsdl.app.SDLActivity (loaded
from the SDL submodule so it matches the statically linked SDL3 version),
which loads libmain.so and calls SDL_main.
Kotlin/JVM (no native code): examples/sdl_renderer/android-jvm-app runs
the same commonMain demo entirely on the JVM/ART runtime. Its MainActivity
extends org.libsdl.app.SDLActivity, returns "sdl_jni" from
getLibraries() and overrides main() with the shared blocking loop; SDL, its
Java layer and libsdl_jni.so all arrive through :sdl-kmp's Android variant,
and main() executes on SDL's dedicated thread — nothing is compiled with
Kotlin/Native or the NDK.
# Build the APKs (requires Android SDK 36; the Kotlin/Native apps also need an
# Android NDK; install on a device/emulator with adb).
./gradlew :examples:sdl_renderer:android:assembleDebug
./gradlew :examples:sdl_renderer:android-jvm-app:assembleDebug
./gradlew :examples:sdl_gpu:android:assembleDebug
./gradlew :examples:sdl_opengl_es:android:assembleDebug
adb install -r examples/sdl_renderer/android-jvm-app/build/outputs/apk/debug/android-jvm-app-debug.apkThe SDL submodule stays pinned to an upstream commit; fixes live under patches/SDL.patch and are applied by the root applySubmodulePatches task before any task that configures or compiles the SDL sources (native static libs, per-OS JNI, Android JNI, wasm). The apply is idempotent (git apply --reverse --check skips when already applied), so both CI and local builds work from a clean checkout. To update a fix, edit the SDL working tree and regenerate the patch:
git -C SDL diff > patches/SDL.patch
git -C SDL checkout -- .Current fixes: X11 remote-injected clicks (drop the keyboard-focus requirement for slave pointer buttons) and Cocoa GCMouse/NSEvent duplicate handling (always deliver NSEvent button events; GCMouse is used for raw motion only, so synthetic clicks from remote-control / accessibility tools are no longer dropped).
# Unit + integration tests on the host platform
./gradlew :sdl-kmp:jvmTest :sdl-kmp:macosArm64Test # macOS
./gradlew :sdl-kmp:jvmTest :sdl-kmp:linuxX64Test # Linux
# Build the Android JVM library (requires the Android SDK + NDK)
./gradlew :android-jvm:assembleDebug
./gradlew :android-jvm:publishToMavenLocalBuilding the Linux native SDL3 library (any linuxX64 task) requires the
Wayland, X11 and audio development packages: on Debian/Ubuntu that is
libwayland-dev libwayland-bin libxkbcommon-dev libegl-dev libdecor-0-dev
plus the X11 libx* dev packages and
libpipewire-0.3-dev libpulse-dev libasound2-dev (these also gate the
PipeWire/Pulse/ALSA audio drivers at build time — without them the published
klib falls back to X11 and has no audio drivers). The GitHub Actions workflows
install these automatically.
.github/workflows/test.yml — manual trigger: macOS builds all Apple klibs and the darwin JNI artifacts and runs JVM + native tests; Linux runs linuxX64Test, cross-compiles linuxArm64/mingwX64, builds the linux-* JNI artifacts, and runs the renderer example headless; Windows builds the windows-x86_64 JNI artifact natively (MinGW) and runs JVM tests; Android installs the NDK, builds the four androidNative klibs and assembles the sdl_renderer, sdl_gpu and sdl_renderer:android-jvm-app APKs; Web installs the Emscripten SDK, builds the wasmJs klib and the browser example..github/workflows/publish.yml — manual workflow that publishes the metadata + JVM + Apple klibs and the sdl-kmp-jni-jvm-darwin-* artifacts from macos-14, the linuxX64/linuxArm64/mingwX64 klibs and the sdl-kmp-jni-jvm-linux-* artifacts from ubuntu-latest, sdl-kmp-jni-jvm-windows-x86_64 from windows-latest (native MinGW build), and the four androidNative klibs from ubuntu-latest (with the NDK) to Maven Central.Required secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD, SIGNING_KEY (base64 GPG keyring), SIGNING_KEY_ID, SIGNING_PASSWORD.
MIT. The bundled SDL3 submodule is licensed under the zlib license.
Kotlin Multiplatform bindings for SDL3, with a curated common API backed by three implementations:
SDL submodule into a JNI shared library (libsdl_jni) that is built by CMake (jni/) and shipped as per-OS/arch sdl-kmp-jni-jvm-* artifacts. NativeLoader extracts the matching binary at runtime, so consumers need nothing beyond the normal dependencies (no LWJGL, no system SDL).SDL submodule is compiled per target with CMake and embedded into the published klib, so consumers get a fully self-contained binary (no dynamic SDL3 dependency). This includes the Android native targets (androidNative*), cross-compiled with the Android NDK.sdl-kmp plus its sdl-kmp-android-jvm companion AAR package the Kotlin bindings, SDL's own android-project Java layer (org.libsdl.app.SDLActivity and friends) and the per-ABI libsdl_jni.so (SDL3 + JNI bridge, built with the NDK from the same jni/ sources). Depend on sdl-kmp alone in commonMain; an application written entirely in Kotlin extends org.libsdl.app.SDLActivity, returns "sdl_jni" from getLibraries() and overrides main() with its own loop — no native code, no SDL Java code to copy.All three implementations build SDL3 from the pinned SDL submodule; the fixes that must not live in the submodule history are kept under patches/ and applied idempotently at build time (applySubmodulePatches) before any task that compiles the SDL sources.
| Platform | Targets | Implementation |
|---|---|---|
| JVM |
jvm (Linux/macOS/Windows) |
JNI shared library (libsdl_jni), SDL3 compiled from source |
| macOS |
macosArm64, macosX64
|
cinterop + embedded static SDL3 |
| Linux |
linuxX64, linuxArm64
|
cinterop + embedded static SDL3 |
| Windows | mingwX64 |
cinterop + embedded static SDL3 |
| iOS |
iosArm64, iosX64, iosSimulatorArm64
|
cinterop + embedded static SDL3 |
| tvOS |
tvosArm64, tvosSimulatorArm64
|
cinterop + embedded static SDL3 |
| Android (native) |
androidNativeArm64, androidNativeArm32, androidNativeX64, androidNativeX86
|
cinterop + embedded static SDL3 (built with the NDK) |
| Android (JVM) |
android target (sdl-kmp-android + sdl-kmp-android-jvm AARs; arm64-v8a, armeabi-v7a, x86_64, x86) |
Kotlin/JVM bindings over SDL's android-project Java layer + per-ABI libsdl_jni.so
|
| Web (browser) | wasmJs |
SDL3 compiled to a standalone Emscripten module, driven through a JS bridge |
Not supported: watchOS (SDL3 has no watchOS support) and visionOS (Kotlin/Native has no visionOS targets yet).
build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("cn.enaium.sdl:sdl-kmp:1.0.13")
}
}
}import cn.enaium.sdl.*
fun main() {
SDL.setMainReady()
if (!SDL.init(SDLInitFlags.VIDEO)) {
error("SDL_Init failed: ${SDL.error()}")
}
SDL.createWindow("hello sdl-kmp", 800, 600).use { window ->
SDL.createRenderer(window).use { renderer ->
var running = true
while (running) {
while (true) {
val event = SDL.pollEvent() ?: break
when (event) {
is SDLEvent.Quit -> running = false
is SDLEvent.Key ->
if (event.down && event.keycode == SDLKeycode.ESCAPE) running = false
else -> Unit
}
}
renderer.drawColor = SDLColor(18, 18, 24)
renderer.clear()
renderer.drawColor = SDLColor(255, 0, 128)
renderer.fillRect(SDLRect(100, 100, 200, 200))
renderer.present()
SDL.delay(16)
}
}
}
SDL.quit()
}The SDL3 GPU API is bound in cn.enaium.sdl.SDLGPU (see SDLGPU.kt). A device
owns every other GPU object; each object releases itself with close():
SDLGPU.createDevice().use { device ->
SDL.createWindow("gpu", 800, 600).use { window ->
device.claimWindow(window)
val texture = device.createTexture(
SDLGPUTextureCreateInfo(
format = SDLGPUTextureFormat.R8G8B8A8_UNORM,
usage = SDLGPUTextureUsage.SAMPLE,
width = 256,
height = 256,
),
) ?: error(SDL.error())
texture.upload(rgbaBytes, bytesPerRow = 256 * 4, x = 0, y = 0, width = 256, height = 256)
device.beginCommandBuffer()?.use { commandBuffer ->
val swapchain = device.acquireSwapchainTexture(commandBuffer, window) ?: return@use
val target = swapchain.texture ?: return@use
commandBuffer.blit(
SDLGPUBlitInfo(
source = SDLGPUBlitRegion(texture = texture, width = 256, height = 256),
destination = SDLGPUBlitRegion(
texture = target,
width = swapchain.srcRect.width,
height = swapchain.srcRect.height,
),
),
)
device.submit(commandBuffer)
}
device.releaseDrawable(window)
}
}beginCommandBuffer() / submit() /
submitAndAcquireFence(). A buffer must be submitted or cancelled; close()
cancels when it was neither.beginRenderPass(colorTargets) returns an SDLGPURenderPass
(pipelines, vertex/index buffers, samplers, storage textures/buffers,
uniforms, draws); beginComputePass(storageTextures, storageBuffers) returns
an SDLGPUComputePass (pipelines, samplers, storage, uniforms, dispatch).blit(SDLGPUBlitInfo) scales a source region onto a
destination region in one call (SDL_BlitGPUTexture, no pipeline needed);
copyTextureToTexture(...) copies between textures.upload writes CPU pixels into a subrectangle (bytes-per-row
is converted to SDL3's pixel-based pixels_per_row using the format's texel
size); download reads a region back as RGBA8 (blocking on a fence).
adoptTexture(rawPtr, bytesPerPixel) wraps a texture created by another
library (e.g. SDL_image's IMG_LoadGPUTexture) so it gets the same
upload/download/close behaviour.createGraphicsPipeline(SDLGPUGraphicsPipelineCreateInfo)
and createComputePipeline(SDLGPUComputePipelineCreateInfo), with shader
bytecode from createShader(code, format, stage, entryPoint, ...). The
shader format (see SDLGPUShaderFormat) must match the device
(device.shaderFormats): MSL on Metal, SPIR-V on Vulkan, DXIL/DXBC on
D3D12.SDL.setMainReady() before SDL.init on the main thread. It is only
required on Apple platforms (macOS/iOS/tvOS); on Linux/Windows it is a harmless
no-op that records the calling thread as the main thread and never blocks.linuxArm64 target is built on Linux aarch64 hosts or cross-compiled from x86_64 with the aarch64-linux-gnu toolchain (gcc-aarch64-linux-gnu g++-aarch64-linux-gnu); SDL3's dlopen-based drivers only need the arch-agnostic headers, so no multiarch sysroot is required.DISPLAY, SDL.init(SDL_INIT_VIDEO)
can block while XOpenDisplay tries to connect. Set SDL_VIDEO_DRIVER=dummy (hint or
environment variable) before init, or export DISPLAY correctly.IrLinkageError
("No function found for symbol ...") at the first SDL call. Keep the consumer's
Kotlin version in sync.SDL_VIDEO_DRIVER=dummy hint (environment variable or SDL.setHint) makes SDL run headless — useful for CI and servers.-XstartOnFirstThread JVM argument (so AppKit/Cocoa can initialise). The example runJvm tasks already set this.sdl-kmp-jni-jvm-{os}-{arch} artifact is a transitive runtime dependency of sdl-kmp; NativeLoader extracts the bundled libsdl_jni from the classpath and System.load()s it, so no java.library.path setup is needed.dlopen), so the published klib has no link-time dependency on X11.androidNative* target requires an installed Android NDK (found under $ANDROID_HOME/ndk); the SDL3 static library is cross-compiled with its CMake toolchain. At runtime the app must be launched through org.libsdl.app.SDLActivity (or a subclass), which loads the shared library and calls its exported SDL_main (see the examples/sdl_renderer/android module).sdl-kmp's Android target resolves through sdl-kmp-android + sdl-kmp-android-jvm, which bundle the Kotlin bindings, SDL's android-project Java layer (matching the statically linked SDL3 version) and libsdl_jni.so for all four NDK ABIs — a commonMain-only dependency brings everything in. An application written entirely in Kotlin extends org.libsdl.app.SDLActivity, returns "sdl_jni" from getLibraries() (the one shared object that carries SDL3, the JNI bridge and SDL's Java layer) and overrides main(): SDL calls it on its dedicated thread, inside SDL's own nativeInitMainThread/nativeCleanupMainThread handshake, instead of loading a native SDL_main, so no native code and no NDK build are needed (see examples/sdl_renderer/android-jvm-app).The SDL3 static library is embedded in each target's published klib (built per target by the sdl-kmp/native/CMakeLists.txt wrapper). The required frameworks/system libraries are recorded in the cinterop klib as linkerOpts (see sdl.def) and are applied automatically when the consumer's final binary is linked.
All examples live under examples/ as standalone KMP modules; each provides
commonMain logic and thin platform entry points (main() / SDL_main).
examples/sdl_renderer — "bouncing box" demo using SDL_Renderer
(renderer, textures, audio, input). Runs on JVM, macOS, Linux, Windows
(MinGW) and Android, with two app submodules: android (Kotlin/Native
libmain.so) and android-jvm-app (pure Kotlin/JVM, no native code).examples/sdl_vulkan — minimal Vulkan triangle (gradient shaders) on
JVM, macOS, Linux and Windows. On the JVM the renderer uses the LWJGL
Vulkan bindings (the example's own dependency - the sdl-kmp library itself
does not use LWJGL), wired to SDL's SDL_Vulkan_GetVkGetInstanceProcAddr;
on native targets a small C helper builds the pipeline.examples/sdl_opengl — minimal OpenGL 3.3 core / GLES 3 triangle on
JVM, macOS, Linux and Windows.examples/sdl_opengl_es — minimal OpenGL ES 3.0 gradient triangle
(the browser-capable GL profile: WebGL2 on wasm). Runs on JVM (GL calls go
through the LWJGL OpenGL bindings, an example-only dependency), macOS,
Linux, Windows and Android, with browser (wasmJs) and android
submodules.examples/sdl_gpu — triangle rendered through the SDL3 GPU API
(cross-backend: Metal on macOS, Vulkan on Android) entirely from
commonMain. Runs on JVM, macOS, Linux, Windows and Android (with its
android submodule APK).examples/sdl_renderer/browser — browser (wasmJs) runner for the
sdl_renderer demo. SDL3 is compiled to a standalone Emscripten module
(:sdl-kmp:linkWasmSdl) and loaded before the Kotlin/Wasm module runs.Kotlin/Wasm cannot embed C libraries (and does not merge library resources
into the web output), so for the wasmJs target SDL3 is compiled with
Emscripten into a standalone module (sdl_wasm.js + sdl_wasm.wasm)
exposing the whole sdl-kmp API as flat functions. A JS glue layer
(sdl-kmp/wasm/sdl_kmp_glue.js) instantiates that module and bridges it
to the Kotlin wasmJs actuals. Building it requires the Emscripten SDK (see
gradle.properties/the wasm.emsdk property; the CI installs it).
The module is published separately as cn.enaium.sdl:sdl-kmp-wasm-assets
(a jar with sdl_wasm.js, sdl_wasm.wasm and sdl_kmp_glue.js at its root,
built by :sdl-kmp:wasm:jar from :sdl-kmp:linkWasmSdl). A wasmJs
consumer unpacks that jar into the web root next to the Kotlin/Wasm output
and loads the glue before running the Kotlin module:
A wasmJs consumer must load the SDL module before running the Kotlin module:
<script type="module">
import { initSdlKmp } from './sdl_kmp_glue.js';
await initSdlKmp(); // instantiate SDL3
await import('./index.mjs'); // Kotlin module; main() auto-runs
</script>See examples/sdl_renderer/browser for a complete runnable page (its
browser-node-test.mjs runs the demo headlessly in Node with the dummy
drivers).
# Publish the library to the local Maven repository first (macOS builds all
# Apple targets + JVM + the darwin JNI artifacts; Linux builds the
# linuxX64/mingwX64 klibs and the linux-x86_64 JNI artifact; Windows (or
# Linux with the MinGW x86_64-w64-mingw32 toolchain) builds the
# windows-x86_64 JNI artifact).
./gradlew :sdl-kmp:publishToMavenLocal
./gradlew :jni-jvm-darwin-aarch64:publishToMavenLocal :jni-jvm-darwin-x86_64:publishToMavenLocal # macOS
./gradlew :jni-jvm-linux-x86_64:publishToMavenLocal # Linux
./gradlew :jni-jvm-linux-aarch64:publishToMavenLocal # Linux (aarch64 host or cross)
./gradlew :jni-jvm-windows-x86_64:publishToMavenLocal # Windows (MinGW host)
./gradlew :android-jvm:publishToMavenLocal # Android JVM AAR (SDK + NDK)
# JVM (pass SDL_VIDEO_DRIVER=dummy for headless mode)
./gradlew :examples:sdl_renderer:jvmRun
SDL_VIDEO_DRIVER=dummy ./gradlew :examples:sdl_renderer:jvmRun
# Native
./gradlew :examples:sdl_renderer:runDebugExecutableMacosArm64
SDL_VIDEO_DRIVER=dummy ./gradlew :examples:sdl_renderer:runDebugExecutableLinuxX64
# GPU examples (macOS: needs a display; Vulkan needs a Vulkan driver)
./gradlew :examples:sdl_vulkan:jvmRun
./gradlew :examples:sdl_opengl:jvmRun
./gradlew :examples:sdl_opengl_es:jvmRun
./gradlew :examples:sdl_gpu:jvmRun
./gradlew :examples:sdl_vulkan:runDebugExecutableLinuxX64
./gradlew :examples:sdl_opengl:runDebugExecutableLinuxX64
./gradlew :examples:sdl_opengl_es:runDebugExecutableLinuxX64
./gradlew :examples:sdl_gpu:runDebugExecutableMacosArm64Kotlin/Native (native SDL_main): the sdl_renderer, sdl_gpu and
sdl_opengl_es examples each have an android submodule. The KMP module
builds libmain.so for every androidNative ABI (exporting SDL_main from
the shared androidNativeMain source set); the app copies those into its
jniLibs and its MainActivity extends org.libsdl.app.SDLActivity (loaded
from the SDL submodule so it matches the statically linked SDL3 version),
which loads libmain.so and calls SDL_main.
Kotlin/JVM (no native code): examples/sdl_renderer/android-jvm-app runs
the same commonMain demo entirely on the JVM/ART runtime. Its MainActivity
extends org.libsdl.app.SDLActivity, returns "sdl_jni" from
getLibraries() and overrides main() with the shared blocking loop; SDL, its
Java layer and libsdl_jni.so all arrive through :sdl-kmp's Android variant,
and main() executes on SDL's dedicated thread — nothing is compiled with
Kotlin/Native or the NDK.
# Build the APKs (requires Android SDK 36; the Kotlin/Native apps also need an
# Android NDK; install on a device/emulator with adb).
./gradlew :examples:sdl_renderer:android:assembleDebug
./gradlew :examples:sdl_renderer:android-jvm-app:assembleDebug
./gradlew :examples:sdl_gpu:android:assembleDebug
./gradlew :examples:sdl_opengl_es:android:assembleDebug
adb install -r examples/sdl_renderer/android-jvm-app/build/outputs/apk/debug/android-jvm-app-debug.apkThe SDL submodule stays pinned to an upstream commit; fixes live under patches/SDL.patch and are applied by the root applySubmodulePatches task before any task that configures or compiles the SDL sources (native static libs, per-OS JNI, Android JNI, wasm). The apply is idempotent (git apply --reverse --check skips when already applied), so both CI and local builds work from a clean checkout. To update a fix, edit the SDL working tree and regenerate the patch:
git -C SDL diff > patches/SDL.patch
git -C SDL checkout -- .Current fixes: X11 remote-injected clicks (drop the keyboard-focus requirement for slave pointer buttons) and Cocoa GCMouse/NSEvent duplicate handling (always deliver NSEvent button events; GCMouse is used for raw motion only, so synthetic clicks from remote-control / accessibility tools are no longer dropped).
# Unit + integration tests on the host platform
./gradlew :sdl-kmp:jvmTest :sdl-kmp:macosArm64Test # macOS
./gradlew :sdl-kmp:jvmTest :sdl-kmp:linuxX64Test # Linux
# Build the Android JVM library (requires the Android SDK + NDK)
./gradlew :android-jvm:assembleDebug
./gradlew :android-jvm:publishToMavenLocalBuilding the Linux native SDL3 library (any linuxX64 task) requires the
Wayland, X11 and audio development packages: on Debian/Ubuntu that is
libwayland-dev libwayland-bin libxkbcommon-dev libegl-dev libdecor-0-dev
plus the X11 libx* dev packages and
libpipewire-0.3-dev libpulse-dev libasound2-dev (these also gate the
PipeWire/Pulse/ALSA audio drivers at build time — without them the published
klib falls back to X11 and has no audio drivers). The GitHub Actions workflows
install these automatically.
.github/workflows/test.yml — manual trigger: macOS builds all Apple klibs and the darwin JNI artifacts and runs JVM + native tests; Linux runs linuxX64Test, cross-compiles linuxArm64/mingwX64, builds the linux-* JNI artifacts, and runs the renderer example headless; Windows builds the windows-x86_64 JNI artifact natively (MinGW) and runs JVM tests; Android installs the NDK, builds the four androidNative klibs and assembles the sdl_renderer, sdl_gpu and sdl_renderer:android-jvm-app APKs; Web installs the Emscripten SDK, builds the wasmJs klib and the browser example..github/workflows/publish.yml — manual workflow that publishes the metadata + JVM + Apple klibs and the sdl-kmp-jni-jvm-darwin-* artifacts from macos-14, the linuxX64/linuxArm64/mingwX64 klibs and the sdl-kmp-jni-jvm-linux-* artifacts from ubuntu-latest, sdl-kmp-jni-jvm-windows-x86_64 from windows-latest (native MinGW build), and the four androidNative klibs from ubuntu-latest (with the NDK) to Maven Central.Required secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD, SIGNING_KEY (base64 GPG keyring), SIGNING_KEY_ID, SIGNING_PASSWORD.
MIT. The bundled SDL3 submodule is licensed under the zlib license.