Kotlin Multiplatform frontend for libretro: load emulator cores (melonDS, mGBA, DeSmuME, Genesis Plus GX, Snes9x…) and drive them from Kotlin — run frames, receive video and audio, send inputs, read and write the emulated memory, save and restore states. Supports Android, iOS, macOS, Linux, Windows, and JVM/Desktop from a single Kotlin API.
| Component | Version |
|---|---|
libretro API (libretro.h) |
1 (libretro-common master) |
| JNA (JVM/Desktop binding) | 5.15.0 |
| Kotlin | 2.4.20 |
| Platform | Integration |
|---|---|
| Android (arm64-v8a, armeabi-v7a, x86_64) | JNI bridge (bundled) + dlopen |
| iOS device (arm64) | cinterop + dlopen |
| iOS simulator (arm64) | cinterop + dlopen |
| macOS (arm64) | cinterop + dlopen |
| Linux (x64, arm64) | cinterop + dlopen |
| Windows (x64) | cinterop + LoadLibrary |
| JVM/Desktop | JNA |
libretro cores are not bundled: they are shared libraries (.dylib, .so, .dll) that you load from a path at
runtime. Prebuilt cores for every platform are available on the
libretro buildbot, e.g.
https://buildbot.libretro.com/nightly/apple/osx/arm64/latest/melonds_libretro.dylib.zip.
Add the dependency from Maven Central:
// build.gradle.kts
dependencies {
implementation("dev.kotlinds:libretro-kmp:0.1.2")
}No native dependency is needed: only download the cores you want to use.
- Android: ship the core's
.sofor each ABI in your app (e.g. injniLibs, then load it fromapplicationInfo.nativeLibraryDir), or download it to the app's files directory. - iOS: apps can only load code signed with the app, so embed cores as frameworks in your bundle and load them from there.
- macOS, Linux, Windows, JVM: any readable path works.
A core talks to its frontend (your application) through callbacks. Implement LibretroFrontend: only the two
directories are required, everything else has a default.
val frontend = object : LibretroFrontend {
// Where cores look for BIOS files, and where they write battery saves
override val systemDirectory = "/path/to/system"
override val saveDirectory = "/path/to/saves"
// Core options ("variables"): return null to keep the core's default
override fun variable(key: String): String? = when (key) {
"melonds_boot_directly" -> "enabled"
else -> null
}
// A frame is ready: ARGB pixels, whatever the core's pixel format
override fun onVideoFrame(frame: VideoFrame) {
display(frame.width, frame.height, frame.pixels)
}
// Interleaved stereo 16-bit samples (copy the array if you keep it)
override fun onAudio(samples: ShortArray, frames: Int) {
audioOutput.write(samples, frames)
}
// Inputs: 1 when the button is pressed
override fun inputState(port: Int, device: Int, index: Int, id: Int): Short =
if (port == 0 && device == Device.JOYPAD && id in pressedButtons) 1 else 0
override fun onLog(level: LogLevel, message: String) = println("[$level] $message")
}val core = LibretroCore("/path/to/melonds_libretro.dylib", frontend)
println("${core.systemInfo.libraryName} ${core.systemInfo.libraryVersion}")
core.loadGame("/path/to/game.nds")
core.setControllerPortDevice(port = 0, device = Device.JOYPAD)
val fps = core.avInfo.timing.fps // e.g. 59.83 for the Nintendo DS
// One call = one emulated frame; callbacks are invoked during the call.
// Pace it to `fps` for real time, or run it as fast as possible to fast-forward.
while (running) core.run()
// Memory (e.g. for RAM watchers, cheats, bots…)
val ram: ByteArray? = core.readMemory(MemoryType.SYSTEM_RAM)
// Save states
val state = core.saveState()
core.loadState(state!!)
// Always close when done (also lets the core flush its battery save)
core.close()A core must be driven from a single thread, and only one LibretroCore may be open at a time in a process (libretro
cores keep global state, and their callbacks carry no user data).
| Device | id values |
Result |
|---|---|---|
Device.JOYPAD |
JoypadButton.A, B, X, Y, L, R, START, SELECT, D-pad… |
1 when pressed |
Device.POINTER |
PointerId.X, PointerId.Y, PointerId.PRESSED |
Coordinates in -0x7FFF..0x7FFF over the whole frame; 1 while touching |
| Method / Property | Description |
|---|---|
systemInfo: SystemInfo |
Name, version, supported extensions of the core. |
avInfo: SystemAvInfo |
Geometry and timings (fps, audio sample rate), after loadGame. |
loadGame(gamePath: String) |
Loads a game (passed in memory when the core asks for it). |
run() |
Emulates one frame. |
reset() |
Resets the emulated console. |
setControllerPortDevice(port, device) |
Plugs a device type in a controller port. |
memorySize(type) / readMemory(type) / writeMemory(type, data) |
Access to SAVE_RAM, RTC, SYSTEM_RAM, VIDEO_RAM. |
saveState(): ByteArray? / loadState(state) |
Serializes / restores the whole emulator state. |
close() |
Unloads the game and the core. Must be called when done. |
| Member | Description |
|---|---|
systemDirectory / saveDirectory |
Directories given to the core (required). |
variable(key): String? |
Core option values (null = core default). |
onVideoFrame(frame) |
ARGB frame ready. |
onAudio(samples, frames) |
Interleaved stereo samples ready. |
onInputPoll() / inputState(…) |
Input polling and state queries. |
onLog(level, message) |
Core logs. |
language |
Language reported to the core (default English). |
Unit tests run everywhere. An end-to-end test against a real core runs on the JVM and on macOS when these environment variables are set:
LIBRETRO_TEST_CORE=/path/to/melonds_libretro.dylib LIBRETRO_TEST_GAME=/path/to/game.nds \
./gradlew :libretro-kmp:jvmTest :libretro-kmp:macosArm64TestThe Kotlin and C code in this library is licensed under the Apache License 2.0.
The bundled libretro.h header is © The RetroArch team, under the MIT license (see its header). Cores are not bundled:
each core has its own license (most are GPL), which applies when you distribute it with your application.
See LICENSE for details.
- ai-plays-pokemon: AIs (decision models, LLMs, MCP agents) playing Pokémon games in an emulator.
If you are using libretro-kmp in your project/library, please let us know by opening a pull request to add it to this list!