
Embeds Lua 5.5 interpreter; evaluate scripts, load/call chunks, expose live registry-backed tables, register marshalled host functions, configure stdlib sandboxing, and explicit error/lifetime semantics.
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.
LuaState is 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. All Table/Function handles die with their state.close(). Holding very many handles delays
collection of those values; an explicit per-handle release() 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:
global x); see §3.3.9 of the manual.0.1 no longer prints as 0.1 rounded to 14 digits).table.create, extended
utf8.offset, and incremental major GC. Binary chunks are refused by this
binding (load accepts 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 AARnative/revendor.sh re-downloads and verifies the pinned Lua release into
native/lua/ (update LUA_VERSION/LUA_SHA256 there to move versions).src/nativeInterop/cinterop/lua.def), CMake builds
the Android .so, and a per-host Gradle task builds the JVM JNI library
into build/jniStaging/, which is bundled into the jvm jar under
native/<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).
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.
LuaState is 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. All Table/Function handles die with their state.close(). Holding very many handles delays
collection of those values; an explicit per-handle release() 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:
global x); see §3.3.9 of the manual.0.1 no longer prints as 0.1 rounded to 14 digits).table.create, extended
utf8.offset, and incremental major GC. Binary chunks are refused by this
binding (load accepts 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 AARnative/revendor.sh re-downloads and verifies the pinned Lua release into
native/lua/ (update LUA_VERSION/LUA_SHA256 there to move versions).src/nativeInterop/cinterop/lua.def), CMake builds
the Android .so, and a per-host Gradle task builds the JVM JNI library
into build/jniStaging/, which is bundled into the jvm jar under
native/<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).