Kotlin Multiplatform bindings for the reference Lua 5.5 interpreter — one interpreter, one language version, on every target. The vendored Lua C sources are compiled into each artifact (via cinterop on Kotlin/Native, via JNI on the JVM and Android), so scripts behave identically everywhere, including iOS.
LuaState().use { lua ->
lua.eval("return 1 + 1") // [LuaValue.Integer(2)]
}| Target | Backend | Native code |
|---|---|---|
jvm (macOS arm64/x64, Linux x64/arm64, Windows x64) |
JNI | bundled in the jar, extracted at runtime |
androidTarget (arm64-v8a, armeabi-v7a, x86_64; minSdk 24) |
JNI | built by the NDK, packaged in the AAR |
iosArm64, iosSimulatorArm64 |
cinterop | compiled into the klib |
macosArm64, macosX64 |
cinterop | compiled into the klib |
linuxX64 |
cinterop | compiled into the klib |
mingwX64 |
cinterop | compiled into the klib |
Not planned for v1: LuaJIT (cannot follow 5.5 semantics), JS/wasm targets, and automatic reflective binding of Kotlin objects — host functions are explicit.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.seanproctor:lua-kmp:<version>")
}
}
}import com.seanproctor.lua.*
LuaState().use { lua ->
// eval returns everything the chunk returns, marshalled to LuaValue.
val results = lua.eval("return 1 + 1, 'hello', 3.0")
// [Integer(2), Str("hello"), Number(3.0)] — the 5.5 integer/float
// distinction is preserved: 3 is Integer, 3.0 is Number.
lua.setGlobal("greeting", LuaValue.Str("hi"))
lua.eval("assert(greeting == 'hi')")
// load compiles without running; call it later, with arguments.
val chunk = lua.load("local a, b = ... ; return a + b")
lua.call(chunk, listOf(LuaValue.Integer(2), LuaValue.Integer(3))) // [Integer(5)]
}Tables cross the boundary as opaque handles backed by the Lua registry, so reads and writes are always live — no copying unless you ask for it.
val t = lua.eval("return {greeting = 'hello'}").single() as LuaValue.Table
t[LuaValue.Str("greeting")] // Str("hello")
t[LuaValue.Str("count")] = LuaValue.Integer(1) // visible to Lua immediately
t.size // the # operator
t.toMap() // shallow copy as a Kotlin MapRegister Kotlin functions and call them from Lua. Arguments arrive already
marshalled; returned values are pushed back. Handlers may throw — the
exception becomes a regular Lua error (catchable with pcall), and if the
script does not catch it, it surfaces to the caller as LuaRuntimeError.
lua.register("greet") { args ->
val name = (args.first() as LuaValue.Str).value
listOf(LuaValue.Str("hello, $name"))
}
lua.eval("print(greet('world'))")
// wrap creates a function value without naming it, e.g. to put in a table:
val api = lua.eval("api = {} ; return api").single() as LuaValue.Table
api[LuaValue.Str("double")] = lua.wrap { args ->
listOf(LuaValue.Integer((args.single() as LuaValue.Integer).value * 2))
}Host functions work inside Lua coroutines too.
By default only a safe subset of the standard library is opened. Selection
uses Lua 5.5's luaL_openselectedlibs — unselected libraries are never
loaded, not loaded-then-hidden.
// Default: BASE, COROUTINE, TABLE, STRING, MATH, UTF8 (no io/os/package/debug).
LuaState()
// Everything, including io and os:
LuaState(LuaConfig(stdlibs = StdLib.ALL))
// Or any explicit subset:
LuaState(LuaConfig(stdlibs = setOf(StdLib.BASE, StdLib.MATH)))Caveat: the base library itself includes dofile and loadfile, which read
from the filesystem. If your sandbox must forbid all file access, clear them
after construction:
lua.eval("dofile = nil ; loadfile = nil")All failures surface as one hierarchy:
| Exception | Meaning |
|---|---|
LuaSyntaxError |
chunk failed to compile |
LuaRuntimeError |
script/function raised at run time (incl. uncaught host-function exceptions) |
LuaMemoryError |
allocation failure in the interpreter |
LuaException |
base class (also: unsupported value marshalling) |
Using a closed state, or touching it from the wrong thread, throws
IllegalStateException. Passing another state's handle throws
IllegalArgumentException.
- A
LuaStateis confined to the thread that created it; there is no internal locking. Cross-thread use throws immediately. close()frees the interpreter and every pinned host-function reference. Idempotent. AllTable/Functionhandles die with their state.- Handles are registry-backed: they stay valid across stack operations but
pin their Lua values until
close(). Holding very many handles delays collection of those values; an explicit per-handlerelease()is planned post-v1.
lua-kmp embeds Lua 5.5 (currently 5.5.0, vendored and pinned by checksum; moving to 5.5.1 once it leaves release candidate is tracked as a follow-up). If your scripts come from 5.4, the main script-facing changes are:
- Declarations for global variables — chunks can declare globals
explicitly (
global x); see §3.3.9 of the manual. - For-loop control variables are read-only — assigning to them is now a compile-time error.
- Float printing changed — floats print in decimal with enough digits to
read back exactly (
0.1no longer prints as0.1rounded to 14 digits). - Also new: deeper table constructors,
table.create, extendedutf8.offset, and incremental major GC. Binary chunks are refused by this binding (loadaccepts text only).
A runnable feature tour lives in sample/: the same commonMain
demo runs on the JVM (./gradlew :sample:runJvm) and as a native executable
(./gradlew :sample:runDebugExecutableLinuxX64, ...MacosArm64, …),
printing identical output on every backend.
Requirements: JDK 17+, a C toolchain (cc/gcc/clang; MinGW gcc on Windows),
and for the Android target an SDK with NDK (set ANDROID_HOME or
local.properties; without one, the Android target is skipped).
./gradlew build # everything buildable on this host, plus tests
./gradlew linuxX64Test jvmTest # the local test matrix on Linux
./gradlew assembleRelease # the Android AAR- Apple targets build and test on macOS hosts only.
native/revendor.shre-downloads and verifies the pinned Lua release intonative/lua/(updateLUA_VERSION/LUA_SHA256there to move versions).- The vendored sources compile once per target: cinterop packs them into each
Kotlin/Native klib (see
src/nativeInterop/cinterop/lua.def), CMake builds the Android.so, and a per-host Gradle task builds the JVM JNI library intobuild/jniStaging/, which is bundled into the jvm jar undernative/<os>-<arch>/.
Tag v* and CI publishes every target to Maven Central (portal API) from a
macOS runner, merging the JVM native libraries staged by the Linux, macOS,
and Windows jobs. Required repository secrets: MAVEN_CENTRAL_USERNAME,
MAVEN_CENTRAL_PASSWORD, SIGNING_IN_MEMORY_KEY (base64 of the binary
secret keyring, e.g. base64 -w0 < secring.gpg), SIGNING_IN_MEMORY_KEY_ID
(the short 8-char key id), and SIGNING_IN_MEMORY_KEY_PASSWORD.
MIT. Bundles the Lua 5.5 sources, © 1994–2025 Lua.org, PUC-Rio, also MIT;
the Lua copyright notice ships in all distributions (native/lua/LICENSE).