Skip to content

Demo Porting Rules

This guide is for contributors porting Godot demos or writing Kanama gameplay code. It records rules learned from the Platformer, Match-3, FPS, and Racing ports.

Start Every Session

Read CONTRIBUTING.md before changing code and confirm the current task from the issue, pull request, or maintainer handoff. When you finish a slice, run the relevant validation and commit both Kanama and demo-repo changes if both repos changed.

Check the original GDScript and scene files before inventing a Kotlin shape. If a port exposes missing framework support, prefer fixing Kanama over adding a port-specific workaround that changes the original semantics.

High-Risk Changes

Use extra review time for final audit design, FFM marshalling core changes, Array/Dictionary/Variant policy, ownership/refcount semantics, or review of large risky changes before release. These areas are where small mistakes can corrupt data at the Godot/Kotlin boundary.

Extra review can help, but keep the scope narrow: read-only ABI sweeps, ownership-policy review, Array/Dictionary/Variant policy comparison, or reviewing a specific FFM change before commit. Broad ABI fixes should have a precise owner and a focused integration review.

Script Classes

Kanama scripts are normal Kotlin objects attached to Godot objects:

package fps

@ScriptClass(attachTo = "CharacterBody3D")
class Player(godotObject: MemorySegment) :
    KanamaScript<CharacterBody3D>(godotObject, ::CharacterBody3D) {

    @OnReady
    fun ready() {
        val camera = self.requireAs("Camera3D", ::Camera3D)
    }
}

Use a named package in demo scripts. The Kotlin this value is the JVM script object, not the Godot node. Prefer KanamaScript<Self> for new scripts so self is typed to the attachTo class. Use selfAs(::NodeType) for another compatible wrapper on the same Godot object. Treat selfAs(godotObject, ::NodeType) as legacy compatibility syntax; new ports should use KanamaScript<T>.

If a script directly extends KanamaScript<...>, keep the generic type aligned with @ScriptClass(attachTo = "..."). KSP emits a warning for mismatches. Treat that warning as a likely porting mistake unless the script intentionally wants a looser self type.

@ScriptClass(attachTo = "...") names the Godot base class the script is attached to. @GlobalClass makes a script usable as a typed value in Godot's inspector, including custom resource scripts such as Weapon and node scripts such as Vehicle.

Inherited Kotlin methods are not registered automatically for subclassed scripts yet. If a subclass must expose a callback or lifecycle method to Godot, add an annotated forwarding method on that subclass.

Exports And Scene Data

Keep exported properties aligned with the original GDScript type:

@ScriptProperty
var crosshair: TextureRect? = null

@ScriptProperty
var target: Vehicle? = null

Keep readable GDScript-style defaults when Kanama supports them. For example, prefer Math.toRadians(-60.0) over a long radian literal when porting deg_to_rad(-60.0); KSP preserves that as a static inspector default.

Godot may serialize exported object references as NodePath(...) in .tscn files, but that does not mean the Kotlin property should be a NodePath. If the GDScript declared @export var crosshair: TextureRect, keep the Kotlin property as TextureRect?. Use NodePath only when the original script exposed a path value intentionally.

For GDScript PackedStringArray exports used as string lists, use List<String> in Kotlin. Kanama exports that as Array[String] and accepts scene-authored PackedStringArray values during load.

When the original GDScript configures an instantiated scene before add_child(...), keep that flow. Object.set(...) values are applied to the Kanama script instance once Godot creates it:

val bullet = bulletScene?.instantiate()
bullet?.call("set", "velocity", direction * speed)
bullet?.call("set", "shooter", self)
parent.addChild(bullet)

For custom resources, use same-project @GlobalClass resource scripts and typed collections:

@ScriptClass(attachTo = "Resource")
@GlobalClass
class Weapon(val godotObject: MemorySegment) {
    @ScriptProperty
    var crosshair: Texture2D? = null
}

@ScriptProperty
var weapons: List<Weapon> = emptyList()

Kanama resolves live Kotlin script resource instances when reading exported resource references, so inspector-authored fields are preserved.

For runtime-created custom resources, do not translate GDScript DataMap.new() into ResourceLoader.load("res://...kt").call("new"). Generic Kanama script resources do not expose the GDScript-only new API. Create a plain Resource, attach the loaded script with setScript, and resolve the typed Kotlin instance:

val script = ResourceLoader.load("res://kotlin-src/DataMap.kt") ?: error("missing script")
val owner = Resource.create()
owner.asObject().setScript(script)
val map = owner.asObject().kotlinScriptInstance<DataMap>() ?: error("missing DataMap")

Treat Kanama .kt scripts as trusted executable project code. This is the same security model as GDScript, C#, native GDExtensions, and editor plugins; do not run untrusted Godot projects with their addons/extensions enabled.

Use @ExportGroup("...") and @ExportSubgroup("...") on the first exported property in a group when matching grouped GDScript inspector layout.

Use annotation constants for inspector metadata instead of private magic numbers in gameplay scripts:

@Export(hint = PropertyHint.RANGE, hintString = "0,100,1")
var health = 100

Mobile Runtime Warmup

Android can expose first-use hitches that are hard to see on desktop. When a mobile control path can trigger a scene, audio stream, particle effect, or generated mesh for the first time, load or cache that work during startup or a noninteractive pause screen instead of doing it on the first button press.

Prefer a small project-owned warmup helper for demo ports. Cache PackedScenes that are instantiated during combat, keep frequently played AudioStreams warm, and avoid synchronous load(...) calls in gameplay callbacks. For generated visuals such as aiming arcs or trajectory ribbons, reuse or throttle mesh generation rather than committing a new mesh every frame.

Keep desktop warmup lighter than mobile warmup unless the demo has a measured desktop hitch. Loading and caching PackedScene or AudioStream resources is safe on a pause screen, but adding hidden scene instances to a paused tree and immediately queueFree()-ing them can defer renderer/physics cleanup until the player resumes. On desktop Forward+/Metal this can show up as a crash or shader/resource teardown noise on the first unpause. Gate heavy instance pools or instantiate-and-free warmup behind mobile feature checks, and keep desktop startup to resource caches unless a targeted smoke test proves the heavier path is needed.

Resource And Tween Ownership

Treat live Godot Resource and RefCounted values as ownership-sensitive. Do not close a value just because a Kotlin wrapper is in scope if Godot is expected to keep using the underlying object.

For tweens, this means never closing a Tween or Tweener immediately after scheduling animation work:

// Correct: Godot owns and runs the active tween.
val tween = self.createTween() ?: return
tween.tweenProperty(icon, "modulate", Color.WHITE, 0.2)

// Wrong: this can release the live tween before the renderer/UI consumes it.
tween.tweenProperty(icon, "modulate", Color.WHITE, 0.2)?.close()
tween.close()

If the script tracks a tween to cancel it later, use kill() and drop the Kotlin reference. Closing active tweens early can show up as native renderer crashes on desktop Metal/MoltenVK rather than as a clean Kotlin exception.

The same rule applies when assigning generated resources into scene nodes. For example, after meshInstance.setMesh(mesh), do not immediately mesh.close() unless the wrapper/API contract says the setter retained a separate reference. Prefer keeping the resource live for as long as the scene node can render it. Calls to Resource.close() and RefCounted.close() are annotated with @ManualGodotLifetimeApi; demo gameplay should not opt in unless the value is clearly caller-owned and the original GDScript had an equivalent explicit release.

Signals And Callbacks

Scene-connected methods need to be callable from Godot:

@RegisterFunction("_on_body_entered")
fun onBodyEntered(body: GodotObject) {
    collect()
}

The string is the method name stored in the scene connection. Methods named with a leading underscore are just ordinary scene callback names unless they are Godot lifecycle methods.

Use lifecycle annotations for Godot lifecycle hooks:

@OnReady
fun ready() {
    ...
}

@OnPhysicsProcess
fun physicsProcess(delta: Double) {
    ...
}

Declare custom signals with @Signal and emit them through the generated helper:

@Signal
fun healthUpdated(value: Long) = Unit

PlayerSignals.healthUpdated(this, health)

Animation call-track methods and custom signals are separate concepts. If GDScript declares a signal and an animation track calls a method that emits it, map both pieces explicitly:

signal stepped

func _step() -> void:
    stepped.emit()
@RegisterFunction("_step")
fun step() {
    CharacterSkinSignals.stepped(this)
}

@Signal
fun stepped() = Unit

Signals are instance-scoped. A receiver knows which object emitted the signal through the scene connection or explicit connect call, not only by the signal name.

Use generated signal and method names when connecting or emitting Kanama script signals without the generated helper:

events.signal(EventsNames.Signals.flagReached).connect(self, argumentCount = 0) {
    onFlagReached()
}

events.signal(EventsNames.Signals.killPlaneTouched).emit(body)

Use built-in signal constants for engine signals:

area.signal(Area3D.Signals.bodyEntered).connectObject(self) { body ->
    ...
}

Avoid raw string signal and method names in demo ports when generated constants exist. Raw strings are acceptable only for dynamic GDScript/engine calls that Kanama cannot type yet, and the parity audit should record why.

Do not use GodotObject.call("get", ...), GodotObject.call("set", ...), or string method calls in demo gameplay code when the target is a known Godot class or known Kanama script. Add or use a typed Kanama wrapper first, then update the demo to call the typed method/property. Accept dynamic calls only at true dynamic boundaries, such as untyped Godot callback bodies, intentionally duck-typed damage receivers, mixed GDScript/Kanama edges, or smoke-test probes.

For AnimationTree parameter paths, use AnimationMixer.getParameter, setParameter, and getStateMachinePlayback. Keep the parameter path strings as named constants close to the original GDScript, but do not leak raw Object.get/Object.set calls into demo scripts.

For known Kanama scripts attached to nodes, resolve the script instance with kotlinScriptInstance<T>() and call the Kotlin method directly. Do not call script methods through GodotObject.call("method_name") unless the original is intentionally duck typed.

For GDScript await get_tree().process_frame, use MainThread.awaitNextFrame() inside a KanamaScope coroutine. That helper is the Kanama equivalent of yielding until Godot's next process frame while continuing on the main thread.

Do not schedule engine-wide follow-up work in a coroutine owned by the scene being destroyed. In particular, after SceneTree.unloadCurrentScene(), do not launch kanamaScope.launch { ... SceneTree.quit() ... } from a script attached to that scene. The scene unload can free the script and cancel the coroutine before the quit runs. Use MainThread.postAfterFrames(...) for this kind of process-level handoff.

Porting Style

Keep the Kotlin port close to the original GDScript. Preserve original comments whenever they are still applicable, especially comments that explain gameplay rules, timing, scene relationships, resource ownership, exported inspector values, or non-obvious Godot behavior. These ports are examples for new Kanama users, so comments from the source demo are part of the teaching surface. Avoid copying only comments that became false after the port or comments that merely describe syntax already obvious in Kotlin.

Before calling a demo port done, do a side-by-side parity audit against the original GDScript files. Check lifecycle callbacks, signal wiring, emitted signal names, comments, queue/pool ownership, tween ownership, resource ownership, input/action names, animation names, exported defaults, type checks, dynamic call/signal usage, and self access.

Do not hide live scene instances behind companion-object singleton facades unless the original project used a true singleton/autoload with matching semantics. Prefer the original node/autoload lookup flow, then call the typed Kanama script instance from that node.

Do not embed environment-gated smoke-test branches in normal gameplay scripts. Put smoke behavior in dedicated Smoke.kt or SmokeQuit.kt scripts/nodes so the gameplay port stays close to the source demo.

When the original GDScript calls Godot math, random, or value-type helpers, prefer the Kanama/Godot wrapper (GD, Mathf, Vector3.moveToward, Vector3.signedAngleTo, etc.) over hand-written Kotlin math inside the demo. If the wrapper is missing, add it to Kanama first instead of adding a local helper in the port.

Put demo gameplay scripts under kotlin-src/. Once a scene is rewired to Kotlin, remove stale gameplay .gd files and their .uid files so Godot does not resolve old script UIDs back to the original implementation. Keep the Kanama tools plugin scripts.

Do not hard-code scene paths, resource paths, or inspector values as a workaround for missing wrappers or marshalling support unless the user asks for a temporary workaround. Real demo ports should drive real framework fixes.

Do not make required autoloads or scene nodes optional just to avoid a crash. If the original GDScript directly uses an autoload such as Events, the Kotlin port should fail loudly when it is missing:

fun Node.eventsNode(): Node =
    getTreeRootNode().getAsOrNull("Events", ::Node)
        ?: error("Events autoload is missing")

Silent fallback should not be the default in Kanama examples. Kotlin's value here is compile-time and early-runtime protection, so required scene relationships should use typed wrappers, generated names, requireAs(...), or explicit error(...) paths that expose misconfigured scenes immediately. Optional lookup belongs only where the original GDScript behavior was optional.

Preserve original type checks. If GDScript says body is Player, do not broaden it to hasMethod("damage"), isInGroup("damageables"), or a generic node check unless the original code used that broader rule. When Kanama cannot express a script class check directly yet, centralize the closest equivalent in a small helper and document why it matches the original scene's intent.

Build And Smoke Checklist

For a Kanama demo checked out beside the Kanama repo, build scripts with:

../kanama/gradlew -p ../kanama-demos/<DemoName> buildScripts

The Godot editor can also run the same flow through the Kanama tools plugin's Build Scripts button.

For public demo repo changes, run the aggregate guardrails from the demo repo:

./gradlew check

That task runs the parity audit, runtime node/RPC guardrail, and replicated script-property guardrail across the public demo set.

Validate with the active Godot preview baseline and any compatibility build needed by the task:

/path/to/godot-4.7-beta-3/bin/godot.macos.editor.arm64
/path/to/godot-4.7-stable/bin/godot.macos.editor.arm64

Headless smoke tests should load the real scene and exercise a small behavior slice, such as signal wiring, exported resource hydration, typed object exports, or physics entry points. Manual playtesting is still required for feel-sensitive demos such as FPS and Racing.

Known shutdown diagnostics such as RID leaks, particle shader cleanup logs, or lingering embedded game audio are tracked separately. Record them, but do not treat them as proof that a gameplay port failed unless they leave a process alive or break repeatable validation.

Before chasing shutdown leaks, separate owned resources from borrowed scene state. Good cleanup candidates are project-owned warmup caches, explicitly created Tween/Tweener wrappers after configuration, pooled nodes that will not be reused, and active AudioStreamPlayers that should stop before unloadCurrentScene(). Do not close resources returned from scene-owned nodes just because the wrapper is closeable.

Resource-returning getters create closeable temporary wrappers. Do not write checks such as crosshair.texture != null and discard the wrapper. Store it, check it, and close it:

val texture = crosshair.texture
check(texture != null)
texture?.close()

Be careful with resources borrowed from scene-owned nodes. For example, AnimationPlayer.getAnimation() returns an animation owned by the player/scene. Use the returned wrapper immediately, but do not use { ... } or close() it just to silence a shutdown warning:

animationPlayer.getAnimation("walk")?.setLoopMode(Animation.LOOP_LINEAR)

If a shutdown warning names a borrowed scene resource, prefer fixing the scene lifetime, stopping active playback, or documenting the residual warning over releasing a resource you do not own.