Exports and Resources¶
Kanama exposes inspector-visible properties with annotations and maps common Godot resources to Kotlin wrapper types.
Exported Properties¶
Use @ScriptProperty on @ScriptClass scripts and @RegisterProperty on
registered classes. @Export is available as a migration-friendly alias. Use
PropertyHint and PropertyUsage constants instead of raw Godot integers when
you need inspector metadata.
@ScriptClass(attachTo = "Node")
class Player(godotObject: MemorySegment) :
KanamaScript<Node>(godotObject, ::Node) {
@ExportCategory("Tuning")
@ExportGroup("Movement")
@ScriptProperty(hint = PropertyHint.RANGE, hintString = "0,20,0.1")
var speed: Double = 5.0
@ExportSubgroup("Jump")
@ScriptProperty
var jumpVelocity: Double = 8.0
}
@ScriptProperty(name = "...") and @Export(name = "...") override the
Godot-facing snake_case property name. usage = PropertyUsage.READ_ONLY or
PropertyUsage.EDITOR and other usage flags are available for advanced
inspector behavior, but ordinary exported gameplay data should keep the
default usage.
Simple source-literal defaults are preserved in generated script registrars:
numeric, boolean, string, enum entry, NodePath("..."), and
Math.toRadians(<number>) initializers show up as inspector defaults.
Export Hints¶
Inspector hints are set with a PropertyHint constant plus a hint string.
This is the same shape Godot C# uses — Kanama's
@Export(hint = PropertyHint.RANGE, hintString = "0,100,1") is the direct
equivalent of C#'s [Export(PropertyHint.Range, "0,100,1")]. Prefer the
PropertyHint constants over raw PROPERTY_HINT_* integers.
If you are porting from GDScript, its dedicated @export_* annotations map to
the hint form as follows:
| GDScript | Kanama |
|---|---|
@export_range(0, 100, 1) |
@Export(hint = PropertyHint.RANGE, hintString = "0,100,1") |
@export_range(0, 100, 1, "or_greater") |
@Export(hint = PropertyHint.RANGE, hintString = "0,100,1,or_greater") |
@export_file("*.png", "*.jpg") |
@Export(hint = PropertyHint.FILE, hintString = "*.png,*.jpg") |
@export_dir |
@Export(hint = PropertyHint.DIR) |
@export_global_file("*.txt") |
@Export(hint = PropertyHint.GLOBAL_FILE, hintString = "*.txt") |
@export_global_dir |
@Export(hint = PropertyHint.GLOBAL_DIR) |
@export_multiline |
@Export(hint = PropertyHint.MULTILINE_TEXT) |
@export_placeholder("name") |
@Export(hint = PropertyHint.PLACEHOLDER_TEXT, hintString = "name") |
@export_color_no_alpha |
@Export(hint = PropertyHint.COLOR_NO_ALPHA) |
@export_exp_easing |
@Export(hint = PropertyHint.EXP_EASING) |
@export_flags("Fire", "Water", "Earth") |
@Export(hint = PropertyHint.FLAGS, hintString = "Fire,Water,Earth") on an Int |
@ScriptClass(attachTo = "Node")
class Tuning(godotObject: MemorySegment) :
KanamaScript<Node>(godotObject, ::Node) {
@Export(hint = PropertyHint.RANGE, hintString = "0,100,1,or_greater")
var health: Long = 100
@Export(hint = PropertyHint.FILE, hintString = "*.png,*.jpg")
var icon: String = ""
@Export(hint = PropertyHint.MULTILINE_TEXT)
var description: String = ""
}
The RANGE hint string accepts Godot's suffix flags after min,max[,step]:
or_greater, or_less, exp, hide_slider, radians_as_degrees,
degrees, and suffix:<unit>. The hint-string grammar is Godot's own, so a
malformed string is accepted as-is and simply renders no special editor — the
hint form is an escape hatch, not a validated builder. Enum-typed properties
do not need a hint: a Kotlin enum class exports as a dropdown automatically
(see below).
Exporting Enums¶
Kotlin enum class properties export directly and render as an inspector
dropdown of the entry names:
enum class Difficulty { EASY, NORMAL, HARD }
@ScriptClass(attachTo = "Node")
class GameRules(godotObject: MemorySegment) :
KanamaScript<Node>(godotObject, ::Node) {
@ScriptProperty
var difficulty = Difficulty.NORMAL
}
This mirrors C# enum exports: the property registers as an int with
PROPERTY_HINT_ENUM, the stored value is the entry ordinal, and .tscn-stored
ints deserialize back into the enum slot (out-of-range values clamp to a valid
entry instead of crashing). Because storage is ordinal-based, reordering or
removing enum entries silently remaps values already saved in scenes — append
new entries at the end, the same trade-off C# has with implicit enum values.
Enum exports are supported on @ScriptClass scripts (@ScriptProperty /
@Export) on desktop, Android, and iOS; @RegisterProperty on
@RegisterClass classes does not accept enum types yet.
Lists of enums export too, as a typed int array whose elements render the
same dropdown (C# Difficulty[] parity):
@ScriptProperty
var unlockedModes: List<Difficulty> = emptyList()
Elements are stored as ordinals with the same clamp-on-stale-value and
reordering trade-off as the scalar form. Defaults must be empty
(emptyList() / listOf()); populate initial values in the scene or
inspector. Enum-list delivery uses the same conversion and clamping semantics
on desktop, Android, and iOS.
Exporting Dictionaries¶
Map<K, V> exports as a typed Godot Dictionary (registered with
PROPERTY_HINT_DICTIONARY_TYPE), so the inspector shows the matching key/value
type pickers:
@ScriptProperty
var mapRegions: Map<Long, UnitMetadata> = mapOf()
Supported key types are String, Long / Int, Double / Float,
Boolean, Godot value types (Vector2, Vector2i, Vector3, Vector3i,
Color), and enum class.
Supported value types match the typed-array element set plus scalars:
scalars (Long, Int, Double, Float, Boolean), String, Godot value
types (Vector2, Vector2i, Vector3, Vector3i, Color), engine
resource/node wrappers, custom @ScriptClass scripts (resource or node), and
enum class. Resource-typed values are ownership-managed (retained while the
script holds them, released on cleanup), exactly like List<Resource>. Enum
keys and values store as ordinals with the same reorder/clamp trade-off as the
scalar and list enum exports.
Defaults must be empty (emptyMap() / mapOf()); populate initial entries in
the scene or inspector. A plain Map<String, Any?> also works as an untyped
Dictionary export.
Dictionary exports are supported on @ScriptClass scripts (@ScriptProperty /
@Export); @RegisterProperty on @RegisterClass classes does not accept
Map (or List) types.
Nil values and malformed entries¶
Decoding a Dictionary from the engine is fail-soft: an entry whose key or
value does not match the declared types (a hand-edited value, or a stale .tscn
saved before the types changed) is skipped rather than throwing — a wrong-typed
entry can never crash the script.
What happens to a nil value depends on whether the value type is nullable,
mirroring Godot C#'s engine-backed Dictionary:
Map<K, V?>(nullable value) — the key is kept with anullvalue, and a wrong-typed value also decodes tonull. This matches C#, whose Dictionary value slots are always nullable references.Map<K, V>(non-null value) — Kotlin cannot hold anullthere, so a nil entry is dropped (the same way typed arrays drop nil elements).
Nullable scalar / value-type / enum values are supported. A nullable
object/resource value (Map<K, Texture2D?>) is rejected at build time —
declare it non-null; its nil entries drop.
iOS¶
Typed-Dictionary property delivery is deferred on iOS: the codegen warn-skips
Map properties on the Kotlin/Native backend, so they are not read/written on
iOS yet (desktop and Android are fully supported). Shared game code still
compiles for iOS — the property simply keeps its Kotlin default there.
Inspector Buttons¶
Use @ToolButton on a zero-argument function in a @Tool @ScriptClass to
show a clickable button in the inspector:
@ScriptClass(attachTo = "Node")
@Tool
class Spawner(godotObject: MemorySegment) :
KanamaScript<Node>(godotObject, ::Node) {
@ToolButton(text = "Rebuild", icon = "Reload")
fun rebuild() {
rebuildPreview()
notifyInspectorChanged()
}
}
@ExportToolButton is available as an alias for C# migration. The generated
button is editor-facing metadata only; keep the script in @Tool mode and
guard editor-only work the same way you would for any other tool script.
Node References¶
Exported node references should use the target wrapper type when the original GDScript exported an object reference:
@ScriptProperty
var crosshair: TextureRect? = null
Use NodePath only when the original script intentionally exposed a path value:
@ScriptProperty
var viewPath: NodePath = NodePath("../View")
Releasing Resources You Create¶
The one rule, and the categories it produces, live in
Godot API → Resource Ownership:
close() releases your reference, it does not destroy the object. This
section covers the case you hit most while writing gameplay — a resource you
created yourself — and why the JVM cannot clean it up for you.
Godot Resource and RefCounted wrappers returned by Kanama APIs are
closeable Kotlin handles. When you load a temporary resource and hand it to a
Godot node, release the temporary wrapper with use or close after the node
has accepted the value:
ResourceLoader.loadAudioStream("res://sounds/jump.ogg")?.use { stream ->
player.setStream(stream)
}
Close what you create¶
The same rule applies to anything you make with an X.create() factory: create
it, hand it to the engine, then release your handle with use { } (or close()).
The engine keeps its own reference, so the resource lives on — you are only
letting go of your copy.
for (keycode in keycodes) {
InputMap.addAction(action)
InputEventKey.create().use { inputKey -> // you own this handle…
inputKey.keycode = keycode
InputMap.actionAddEvent(action, inputKey) // …InputMap takes its own reference…
} // …use { } releases yours
}
If you coded games in GDScript or C#, note that the same code there needs no cleanup — and it is not leaking:
# GDScript
var input_key = InputEventKey.new()
input_key.keycode = keycode
InputMap.action_add_event(action, input_key)
# input_key's reference is dropped automatically when it leaves scope
// C#
var inputKey = new InputEventKey();
inputKey.Keycode = keycode;
InputMap.ActionAddEvent(action, inputKey);
// the .NET GC drops the reference for you when inputKey is collected
GDScript's script VM drops your reference the moment the variable leaves scope,
and C#'s garbage collector drops it when the wrapper is collected. Kanama talks
to Godot over an FFI boundary and deliberately does not hook the JVM garbage
collector to native Godot lifetimes (that would risk freeing engine objects on
the wrong thread), so it makes that one step explicit: the use { } above does
exactly what GDScript's scope-exit and C#'s GC do invisibly. A created resource
you never close leaks its reference — and Godot prints Leaked instance: <Class>
at exit in editor/debug runs, which is your signal that a use { }/close() is
missing.
The Web (Kotlin/Wasm) backend reaches Godot over a JavaScript handle bridge
rather than an FFI pointer boundary, but the rule is identical: close()/use { }
emits a release-handle command that drops the engine-side reference, and the bridge
does not GC handles for you either — so the same code, unchanged, is correct on
Web. Only the mechanism differs; see
Web internals → RefCounted resource ownership.
For more detail, see Calling Godot APIs.
Resource Slots¶
Exported properties can hold engine resources. Use a nullable wrapper type
with a = null default; base-typed slots accept engine subtypes (an
AudioStreamWAV or AudioStreamOggVorbis lands in an AudioStream slot):
@ScriptProperty
var stream: AudioStream? = null
@ScriptProperty
var icons: List<Texture2D> = emptyList()
Supported resource slot types: PackedScene, Texture, Texture2D,
NoiseTexture2D, Material, ShaderMaterial, Curve, FastNoiseLite,
ButtonGroup, LightmapGIData, AudioStream, Mesh, Shape2D, Shape3D,
Font, Animation, and StyleBox. Resource-typed reads are
ownership-managed (retained while the script holds them, released on script
cleanup), so the set is a deliberate allowlist — file an issue when a common
slot type you need is missing.
Avoid lateinit var for exports: it has no inspector default, and a read
before assignment crashes into the engine. The build warns and points to the
nullable = null shape.
Custom Resources¶
To create your own resource type (the Kotlin equivalent of GDScript's
extends Resource / C#'s [GlobalClass] public partial class X : Resource),
declare a plain class — do not subclass the Resource wrapper — and
attach it with @ScriptClass(attachTo = "Resource"):
@ScriptClass(attachTo = "Resource")
@GlobalClass
class Weapon(val godotObject: MemorySegment) {
@Export
var damage: Long = 10
@Export
var displayName: String = "Sword"
@Export
var swingSound: AudioStream? = null
}
@GlobalClass registers Weapon in the editor's global class list: it shows
up in the Create New Resource dialog, its .tres instances match typed export
slots, GDScript can statically type against it (@export var w: Weapon,
var copy: Weapon = w, w is Weapon), and another script can export
Weapon? or List<Weapon> — Kanama
resolves the live Kotlin resource script instances when reading the property;
values (including nested resource slots like swingSound) round-trip through
.tscn/.tres storage.
One naming constraint: the file must be named after the class (Weapon.kt),
or the class cannot be mapped back to its script file and stays out of the
global class list — the build warns when they diverge.
Generated wrappers (net.multigesture.kanama.api.Resource, AudioStream,
...) are non-owning views with internal constructors and cannot be
subclassed — attempting it fails the build with a pointer to the pattern
above. The wrapper surface would otherwise drift from the script surface;
KanamaScript<T> is the only supported base class.
Saving Custom Resources¶
Because a custom resource script is a plain class rather than a Resource
subtype, APIs with Resource-typed parameters (such as ResourceSaver.save)
do not accept it directly. Wrap the script's own godotObject handle with
Resource.fromHandle instead:
@ScriptClass(attachTo = "Node")
@Tool
class WeaponForge(godotObject: MemorySegment) : KanamaScript<Node>(godotObject, ::Node) {
@Export
var weapon: Weapon? = null
@ToolButton(text = "Save weapon")
fun saveWeapon() {
val weapon = weapon ?: return
ResourceSaver.save(Resource.fromHandle(weapon.godotObject), "res://weapon.tres")
}
}
Resource.fromHandle is a non-owning view over the same engine object; do
not close() it — the script instance still uses that handle.
Calling a script class constructor yourself —
Weapon(someOtherObject.godotObject) — does not create a new Weapon
resource; it only wraps an existing handle in a Kotlin view, and passing another
object's handle (a node's, for example) produces a view of the wrong object.
Creating Custom Resources from Kotlin¶
To mint a brand-new resource from code (the equivalent of GDScript's
Weapon.new() or C#'s new Weapon()), use newScriptInstance<T>(). It returns
an OwnedScriptResource<T> holding the live instance and its owning resource:
newScriptInstance<Weapon>().use { owned ->
owned.instance.damage = 25
owned.instance.displayName = "Excalibur"
ResourceSaver.save(owned.resource, "res://excalibur.tres")
}
It constructs a fresh engine Resource, attaches the Kanama script, and returns
the live Kotlin instance. It works at runtime and from @Tool editor code (a real
instance is built even though the resource class itself is not @Tool).
Requirements: T must be a @ScriptClass(attachTo = "Resource") class, and it
must be @GlobalClass with a matching file name (Weapon.kt) so its res://
path is discoverable for saving — the same constraint the global class list
already imposes.
Ownership mirrors GDScript .new(): the returned resource comes back with one
owning reference, so it survives the ResourceSaver.save call above (which wraps
it in a transient Ref<> internally). Release that reference with
owned.close() — or use { } as above — once you are done, unless you have
handed ownership on by assigning owned.resource into a node/scene or an
exported Resource slot (then the engine keeps it alive). Pass owned.resource
directly to Resource-typed APIs such as ResourceSaver.save; there is no
Resource.fromHandle view to manage.
iOS: newScriptInstance is deferred on iOS (Kotlin/Native) — the
declaration exists so shared game code compiles, but calling it throws at
runtime. Construct script-backed resources on desktop/Android, or guard the call
by platform. Its sibling kotlinScriptInstance<T>() (resolving the Kotlin
instance of an existing resource) is supported on all three backends.
Not ClassDB.instantiate: ClassDB.instantiate("Weapon") returns null
(with Cannot get class 'Weapon') for a custom resource class, even though
ClassDB.can_instantiate("Weapon") returns true. This is upstream Godot
behaviour, not a Kanama limitation — script global classes (GDScript, C#, and
Kanama alike) live in the script server, not the engine ClassDB registry, so
ClassDB.instantiate cannot construct them and can_instantiate answers from a
different lookup. Construct with newScriptInstance<T>() from Kotlin, or from
GDScript attach the loaded script to a base resource:
var r = Resource.new(); r.set_script(load("res://Weapon.kt")).