Architecture¶
How the pieces fit together.
The runtime layers¶
Desktop and Android share the same high-level Kanama model: Kotlin user code is registered through generated metadata, the binding layer implements Godot's script/resource contracts, and an FFM layer calls the GDExtension ABI. The platform split is the runtime underneath that FFM layer.
Desktop¶
flowchart TB
subgraph PROJECT["Godot project"]
USER["User Kotlin code<br/>@ScriptClass / @GlobalClass"]
KSP["KSP-generated registrars<br/>KanamaRegistry / KanamaClassRegistry / KanamaScriptRegistry"]
end
subgraph JVM["Desktop JVM (JDK 25)"]
BINDING["Kanama binding layer<br/>ScriptLanguage, resources, ObjectRegistry"]
FFM["Panama FFM<br/>java.lang.foreign Linker / MethodHandle"]
end
BOOT["bootstrap.c<br/>JNI_CreateJavaVM once"]
GODOT["Godot Engine 4.7 beta 4<br/>GDExtension ABI"]
USER -->|"compile-time metadata"| KSP
KSP -->|"static registration"| BINDING
GODOT -->|"dlopen .dylib / .so / .dll<br/>GDExtension entry symbol"| BOOT
BOOT -->|"starts JVM and calls KanamaBinding.init"| BINDING
BINDING -->|"downcalls / upcall stubs"| FFM
FFM <-->|"C ABI: gdextension_interface.h"| GODOT
On desktop, everything above bootstrap.c is JVM bytecode. Everything below is
Godot. The native bootstrap exists only because Godot loads a native
.dylib/.so/.dll and we need somewhere to call JNI_CreateJavaVM from.
After that one call, all boundary crossings happen through Panama
MethodHandles, not JNI.
Android¶
flowchart TB
subgraph PROJECT["Godot Android project"]
USER["User Kotlin code<br/>demo scripts + generated KSP registrars"]
GDAP["Android plugin descriptor<br/>KanamaAndroid.gdap"]
end
subgraph APK["Exported Android APK"]
PLUGIN["Godot Android plugin AAR<br/>KanamaAndroid"]
ART["Android ART runtime<br/>Kanama runtime + scripts"]
PORT["PanamaPort FFM<br/>com.v7878.foreign"]
SO["libkanama_bootstrap.so<br/>Android native bootstrap"]
end
GODOT["Godot Engine Android<br/>GDExtension ABI"]
USER -->|"packaged into AAR / APK"| ART
GDAP -->|"enables plugin during export"| PLUGIN
PLUGIN -->|"loads native library<br/>captures Android JavaVM"| SO
SO -->|"initializes Kanama on ART"| ART
ART -->|"registration, resources, script instances"| PORT
PORT <-->|"C ABI: gdextension_interface.h"| GODOT
GODOT -->|"loads Android GDExtension entry"| SO
Android does not embed a desktop JDK. The exported game runs on Android ART and uses PanamaPort as the FFM layer. The Android plugin AAR packages the native bootstrap, Kanama runtime, project scripts, and generated registrars so Godot can initialize the same script language/resource-loader model inside the APK.
Initialization (one-time, when Godot loads us)¶
sequenceDiagram
autonumber
participant G as Godot Engine
participant B as bootstrap.c
participant J as "JVM (libjvm)"
participant K as KanamaBinding.kt
G->>B: dlopen libkanama_bootstrap.dylib
G->>B: kanama_entry(procAddr, library, init*)
B->>J: JNI_CreateJavaVM
B->>K: static KanamaBinding.init(procAddr, library, init*)
Note over K: Wrap procAddr as a Panama MethodHandle. Call it for every GDExtension function we need, cache each as a Linker.downcallHandle in GodotFFI.
K->>K: Fill *init with upcall stubs (initialize / deinitialize callbacks)
K-->>B: return true
B-->>G: return GDEXTENSION_INITIALIZATION_SCENE
Note over G,K: From here on, all calls are direct Godot ⇄ JVM via Panama. Bootstrap is done.
G->>K: initialize(level=SCENE) [upcall]
K->>G: classdb_register_extension_class2(...) [downcall]
Key points:
bootstrap.cruns once and stops. It does not stay in the call path. After step 6 the C frame returns and is never re-entered.- The proc-address getter is the only thing C hands to Kotlin. A single
long. Everything else Kotlin needs, it asks for itself via that getter. - Class registration happens during the
initializeupcall, not duringbootstrap.c. Godot calls us back when it's ready for us to register — we don't push.
Runtime crossings¶
Two directions, both via Panama, each with its own mechanism:
flowchart LR
subgraph JVM["JVM process"]
UK["User Kotlin<br/>(MyNode.takeDamage)"]
BIND["Binding layer<br/>(ClassRegistrar, Variant)"]
SB["ScriptBridge<br/>generated dispatch slots"]
FFI["GodotFFI<br/>downcall handles"]
STUBS["KanamaBinding<br/>upcall stubs"]
end
subgraph NAT["Native"]
GODOT["Godot Engine"]
end
UK -->|"Kotlin call"| BIND
BIND -->|"MethodHandle.invokeExact"| FFI
FFI ==>|"Panama downcall"| GODOT
GODOT ==>|"Panama upcall"| STUBS
STUBS -->|"script callback"| SB
SB -->|"direct _process / _physics_process"| UK
SB -->|"generic method/property dispatch"| BIND
- Downcalls (JVM → Godot) —
Linker.downcallHandle(addr, descriptor)produces aMethodHandlethe JIT can inline through. Used for everything:classdb_register_extension_class2,object_method_bind_call,variant_new_copy, etc. - Upcalls (Godot → JVM) —
Linker.upcallStub(handle, descriptor, arena)produces a function pointer that, when called from C, lands inside a JVM method. Used for: lifecycle callbacks (initialize,deinitialize), per-instance virtual dispatch (_ready,_process), and class create/free hooks.
Hot runtime paths keep the same ABI but avoid unnecessary JVM-side work:
- KSP-generated script registrars provide direct lambdas for
_process(delta)and_physics_process(delta).ScriptBridge.call_funcchecks those slots before the generic StringNamewhendispatch, so frame callbacks still enter through Godot's ScriptInstance vtable but skip the generated method switch. ObjectRegistrykeeps the monotonic handle map as the source of truth and mirrors dense low handles into an atomic array. ScriptInstance callbacks use the array for the common handle range and fall back to the map for oversized handles, preserving handle0invalidity and unregister cleanup semantics.ObjectCallsuses small thread-local scratch buffers for selected high-rate ptrcall shapes, such as scalarfloat,Vector2, and draw-texture calls. These buffers are ordinary FFMMemorySegments reused by the current JVM thread, replacing freshArena.ofConfined()allocations for calls that do not retain argument memory after the ptrcall returns.- Typed object-array decoders can wrap elements directly as the requested
type. For example,
Array[Node]results no longer need a temporaryGodotObjectwrapper for each element before becomingNode.
The scratch buffers are a per-thread cache of tiny native argument/return slots, not a cache of Godot objects or Variant values. A ptrcall wrapper may use them only when the call is synchronous and Godot consumes the argument memory before returning. If Godot can retain the memory, the wrapper must still use an owned arena with the correct lifetime.
For real demo hot paths, separate downcall-heavy code from callback-heavy code before adding more helpers. Scratch buffers are most useful when Kotlin is calling many small Godot methods each frame, such as viewport, transform, input, or draw calls. If profiling points at repeated Godot→JVM ScriptInstance callbacks instead, the larger win is usually to reduce callback count, batch state on a parent script, or move repeated queries out of per-node callbacks; the registry and dispatch fast paths only run after the FFM upcall boundary has already been crossed.
Where JNI shows up — and where it doesn't. Panama (FFM) handles the
JVM→native direction beautifully, but it has no story for creating a JVM
from C in the first place. So bootstrap.c makes exactly three JNI
Invocation API calls, once, during startup:
JNI_CreateJavaVM— bring a JVM into existence inside Godot's processFindClass("KanamaBinding")— locate the Kotlin entry pointCallStaticVoidMethod(init)— hand off control
The moment that init call returns, JNI is done. Forever. No
RegisterNatives, no further FindClass, no more JNI in any call path.
From that point on, Godot ⇄ JVM traffic is only Panama: downcallHandle
for JVM→Godot calls and upcallStub for Godot→JVM callbacks.
The only way to eliminate even those three JNI calls would be GraalVM native-image (AOT-compile Kotlin to a native library that exports real C symbols). That's a different runtime — no JIT, no hot reload — and explicitly not the path Kanama is taking.
FFM Scalar Precision¶
Godot uses two related but different floating-point layouts at the FFM boundary:
- Value-type components use
real_t.Vector2,Vector3,Basis,Transform3D, and similar built-ins use the generatedGodotReallayout:Floatfor normal single-precision Godot builds, andDoublefor futureprecision=doublebuilds. - Scalar
floatmethod parameters and return values use the Variant float ABI slot, which is 64-bit. InObjectCalls, helpers for scalar method arguments namedfloatmust allocate/readJAVA_DOUBLE, notJAVA_FLOAT. Colorcomponents remain fixed 32-bit floats. Color is notreal_t.
This distinction matters because Godot API docs and extension_api.json call
both scalar method values and value-type components "float". Wrapper work must
check the ABI shape, not only the display name. JAVA_FLOAT in
ObjectCalls.kt should be limited to fixed 32-bit component storage such as
Color, while scalar method float helpers should use JAVA_DOUBLE.
Object lifetime¶
Godot is reference-counted on the native side. A Godot object can be freed the moment its refcount drops to zero — which can happen on any thread, at any time, while a JVM-side wrapper still holds a reference to its raw pointer. Touching that pointer after free is undefined behaviour.
The defence is one Arena.ofShared() per Godot object:
Godot creates instance
→ Kanama allocates Arena.ofShared()
→ ObjectRegistry maps instanceId → (Arena, JVM wrapper)
→ All MemorySegments for this object are allocated in its arena
Godot frees instance (refcount → 0)
→ free_instance upcall fires
→ ObjectRegistry.release(instanceId)
→ Arena.close()
→ Any subsequent MemorySegment access throws IllegalStateException
This is the single most important reason to use Panama instead of raw JNI:
Arena gives us deterministic, scoped invalidation of every pointer
derived from a freed object, with no cooperation needed from the user code
or the GC. Use-after-free becomes a thrown exception, not a segfault.
Script class model¶
@ScriptClass Kotlin types are separate script objects that hold the
Godot-owned object handle. They do not inherit from the attach type. User code
gets a typed non-owning view with KanamaScript<CharacterBody3D> and self.
Secondary compatible views can be cached with selfAs(::Node3D) or another
wrapper constructor.
This is intentional for the current architecture. Godot owns the native node;
Kanama owns the JVM script instance and invalidates native handles through the
object-lifetime machinery above. Making class Player : CharacterBody3D would
blur those two lifetimes and make hot reload harder, because the JVM object
would appear to be the native wrapper while still being a reloadable script
instance.
The accepted direction is to keep the separate script object model and improve ergonomics around it incrementally. There are three tiers, in ascending complexity:
Tier 1 — KSP-generated per-type base classes (no compiler plugin needed)
KSP already sees @ScriptClass(attachTo = "CharacterBody3D"). It can emit a
thin abstract base once per unique attachTo type:
// KSP generates:
abstract class CharacterBody3DScript(godotObject: MemorySegment) :
KanamaScript<CharacterBody3D>(godotObject, ::CharacterBody3D)
// User writes:
@ScriptClass(attachTo = "CharacterBody3D")
class Player(godotObject: MemorySegment) : CharacterBody3DScript(godotObject) {
fun physicsProcess(delta: Double) {
if (self.isOnFloor()) { ... } // self. still required
}
}
Eliminates the <T>(godotObject, ::T) redundancy without any runtime cost.
Tier 2 — KSP-generated extension functions (no compiler plugin needed)
For each attach type, KSP generates extension functions on its script base
that forward to self:
// KSP generates on CharacterBody3DScript:
fun CharacterBody3DScript.isOnFloor(): Boolean = self.isOnFloor()
fun CharacterBody3DScript.moveAndSlide(): Boolean = self.moveAndSlide()
// ... one per method of CharacterBody3D
Because Kotlin resolves extension functions for this, users can call
isOnFloor() directly with no qualifier — the script body reads exactly like
class Player : CharacterBody3D() inheritance, with none of the lifetime
confusion:
@ScriptClass(attachTo = "CharacterBody3D")
class Player(godotObject: MemorySegment) : CharacterBody3DScript(godotObject) {
fun physicsProcess(delta: Double) {
if (isOnFloor()) moveAndSlide() // no self. needed
}
}
This makes a Kotlin compiler plugin unnecessary. A plugin (IR/FIR) would give the same call-site DX at the cost of significant toolchain complexity and fragility across Kotlin compiler versions. The KSP extension-function approach achieves the same result purely through code generation, with no compiler changes and no new build dependencies.
Runtime cost: zero — the JIT inlines one-liner static forwarders completely
within the first few hundred calls; self is a cached field, so no lookup
overhead. Build cost: longer KSP + kotlinc time and a slightly larger jar
(both negligible).
Script-language no-op callbacks¶
Godot calls script-language virtuals such as _add_global_constant,
_add_named_global_constant, and _remove_named_global_constant on every
registered script language to broadcast autoload and singleton names. GDScript
stores them in its name-resolution table so user scripts can write an autoload
as a free identifier.
Kanama scripts are compiled Kotlin, so there is no Kanama-side free-identifier resolution layer today. Autoloads are reached through the scene tree, generated wrappers, or explicit project code. The callbacks are still implemented so Godot sees a complete script-language surface, but they are intentionally no-ops unless Kanama later adds generated autoload bindings.
Why no engine module, no JNI glue, no mirrored object graph¶
Three things this project deliberately does not do, and why:
| Choice we rejected | Why we rejected it |
|---|---|
Engine module (forking Godot, à la modules/mono) |
Couples us to a specific Godot build. Pure GDExtension keeps Kanama on the stock Godot release path without an engine fork. |
| Hand-written JNI glue | jextract reads gdextension_interface.h and emits all 300+ binding stubs in one pass. JNI would need ~300 hand-maintained C functions. |
| Mirrored object graph | Some JVM/Godot bindings pair native objects with long-lived JVM mirror objects. Kanama instead keeps a JVM script instance plus lightweight, non-owning wrappers around Godot-owned handles. This keeps object lifetime, hot reload, and editor placeholders simpler in the current Panama/GDExtension architecture. |
The cost we accept in exchange: Panama's managed↔unmanaged hop on every
call. For per-frame hot paths (_process), the same advice applies as in
C# Godot — keep per-frame logic lean, batch when you can.