Gododin Gododin Documentation

Chapter 2 of 5

Writing native code

Getting started ran Ripple Pond. Now follow one instance from its Odin state to its Godot-visible API, then add a method to your configured tutorial copy. Full code stays in the authored package and pure subsystem; the excerpts here show the parts you maintain.

Godot scenes and GDScript send coarse calls through the Host and generated bridge to an authored Odin component. The component calls a pure Odin subsystem with ordinary requests and results, and makes scoped calls back to Godot. The subsystem has no engine imports.Open image at full size

One Godot object is associated with one Odin state struct. The Host and generated bridge handle dispatch; your subsystem remains ordinary Odin.

Define state and the public surface

Ripple Pond declares its signal payload and component:

odin
Vec2 :: struct {
	x: f32,
	y: f32,
}

Splash_Point :: struct {
	at:       Vec2,
	strength: f64,
}

@(gododin_class="Node2D")
Ripple_Pond :: struct {
	damping: f64 `godot:"property"`,
	splashed: gd.Signal(Splash_Point),
	allocator:    mem.Allocator,
	pond:         ^pond.Pond,
	last_error:   i64,
	splash_count: i64,
}

The file imports gd "gododin:bindings/typed/call", core:mem and the local pond package. Ripple_Pond is an Odin struct. Its marker derives the native Godot class RipplePond, based on Node2D; it does not add Odin inheritance. Tagged damping becomes an Inspector/storage property. Godot saves it in the scene and restores compatible values after editor reload. Private fields stay Odin state. The setting takes effect when configure copies it into the pond; reconfigure after changing it during a run.

gd.Signal(Splash_Point) derives splashed(at: Vector2, strength: float) from the ordered payload fields. The signal name differs from the splash method because Godot refuses that name collision. Unmarked helper procedures stay private. A marked free procedure becomes a method:

odin
@(gododin)
ripple_pond_step :: proc(self: ^Ripple_Pond) -> i64 {
	if self.pond == nil {
		self.last_error = 1
		return -1
	}
	pond.pond_step(self.pond)
	self.last_error = 0
	return self.pond.steps
}

Godot sees step() -> int. The default removes the lowercase class prefix; gododin_name supplies an explicit public name when needed. Keep published names stable after saving scenes that use them; the identity contract explains deliberate renames. Gododin generates registration, value conversion and dispatch from this package. Never edit generated/.

Choose Node/Node2D/Node3D for scene membership and transforms, Resource for saved/shared settings or assets, and RefCounted for reference-owned services without scene membership. Odin shares behavior through composition and procedures.

Values crossing between GDScript and Odin

The types in a public method describe its Godot arguments and result. Common forms from the authored-value matrix are:

  • bool, i64 and f64 become Godot bool, int and float.
  • string properties and method results become Godot String. The bridge owns stored property text; a returned string must remain valid while it is copied into Godot's value.
  • []u8 and []i64 method arguments/results become PackedByteArray and PackedInt64Array. Inputs are read-only borrows that expire on return; results are copied into engine-owned arrays. These slices cannot be saved as properties or used as signal payloads.
  • Ripple Pond's two-field Vec2 matches the locked Vector2 layout. That admitted shape can be a parameter, result, stored property or signal field; arbitrary structs are not automatically converted to Godot values.

Generated engine calls and your class's public signatures have different conversion rules. For example, generated APIs can use Color and Transform3D, but those values cannot currently be exposed in an authored method signature. Consult the matrix before exposing another type or member position. Keep an unsupported engine value inside your Odin adapter, or choose an admitted representation for the public API.

Try it: add a native method

Work in your configured copy of Ripple Pond. Add this complete procedure at the end of declarations/ripple_pond.odin, alongside the existing methods:

odin
@(gododin)
ripple_pond_revision :: proc(self: ^Ripple_Pond) -> i64 {
	_ = self
	return 1
}

The existing package/imports and Ripple_Pond struct stay in place. The marker exposes revision() -> int to Godot; this method needs no engine-call parameter because it only returns Odin data. GDScript will use the number to display which native code revision is running.

Open the game's main.gd under godot_project_root. At the end of _process, immediately after its existing hud.text = ... assignment and closing ], add this indented line:

gdscript
	hud.text += "\nnative code v" + str(ripple_pond.call("revision"))

Save both files. The command below builds the changed native class and starts the game; close any previous game first:

sh
"$sdk/bin/gododin" run --project-root "$project"

You should see native code v1 on a new line beneath the pond's existing HUD. The revision number has crossed from your Odin method to GDScript. Keep these two edits for the next chapter's rebuild exercise. The remaining sections explain how the component owns state and calls Godot.

Own allocation and cleanup

State that needs only zero initialization requires no lifecycle hooks. Ripple Pond allocates buffers, so its unmarked initialization hook records the allocator supplied by Gododin and sets defaults:

odin
ripple_pond_init :: proc(self: ^Ripple_Pond, allocator: mem.Allocator) -> bool {
	self.allocator = allocator
	self.damping = 0.994
	self.last_error = 0
	self.splash_count = 0
	return true
}

configure lazily calls pond_create(self.allocator) once. That pure owner allocates two height grids and an RGBA byte buffer at bounded capacity; later steps reuse them.

odin
ripple_pond_deinit :: proc(self: ^Ripple_Pond) {
	if self.pond != nil {
		_ = pond.pond_destroy(&self.pond)
	}
}

pond_destroy consumes and clears the pointer using the same allocator. Acquiring state requires a paired, partial-safe deinit: failed initialization runs cleanup once, and normal destruction releases state after disabling dispatch. Godot owns a parented Node's lifetime; an unparented test instance must be freed explicitly. Node.owner controls scene serialization, not allocation ownership. RefCounted references and native owners require their own retain/release or destroy operations; copying bits never retains them.

Authored init and deinit run without Godot call authority. Use deinit for CPU-owned allocations and worker teardown. Release engine-owned resources while a gd.Main_Call is valid, through an explicit shutdown method or the NOTIFICATION_PREDELETE callback. Slime Colony's notification and cleanup show the paired paths for ordinary destruction. Native editor reload does not send PREDELETE: an editor tool must release standalone engine references through shutdown before a rebuild/reload can discard its private state. Prefer scene-owned Resources with scoped borrows for that flow. A callback's context and temporary scratch expire on return; the saved allocator above is for the instance's owned state.

Editor reload destroys and reconstructs this private pond. Stored damping survives; the simulation grids and counters do not. init runs before Godot restores stored properties, so use it for defaults and allocator setup. Rebuild property-dependent caches lazily on their next use or in a _notification callback for NOTIFICATION_EXTENSION_RELOADED, which arrives after restoration. Reconstruct a scenario deliberately; _ready does not run again merely because native code reloaded.

Lifecycle callbacks opt in separately with the engine virtual's exact signature. The native-component fixture uses, for example:

odin
@(gododin_virtual="_ready")
native_region_2d_ready :: proc(self: ^Native_Region_2D, call: gd.Main_Call) {
	if gd.validate_main_call(call) == .None do self.ready_count += 1
}

Generation validates the declaring base and signature against the locked engine. Ripple Pond needs no native _process: GDScript chooses when to call step.

Wire the scene

In Godot, add a RipplePond native node beneath your scene root, name it RipplePond, set damping, and save. No Odin Script is attached. Ripple Pond's scene saves only that extra native child relative to its before-state.

The parent GDScript resolves it and, in _ready, configures it and connects its signal:

gdscript
@onready var ripple_pond: Node2D = $RipplePond
gdscript
ripple_pond.call("configure", GRID, GRID)
var _connected: bool = ripple_pond.splashed.connect(_on_native_splash)

The native arm calls step, receives frame_bytes as a PackedByteArray, and updates the same Godot Image/texture used by the baseline. Its signal handler receives ordinary Godot values:

gdscript
func _on_native_splash(at: Vector2, strength: float) -> void:
	last_signal_text = "signal(%d,%d %.1f)" % [int(at.x), int(at.y), strength]

Make a scoped Godot call

Unlike step, splash emits a Godot signal. Its Odin signature includes the temporary engine-call parameter supplied by the bridge:

odin
ripple_pond_splash :: proc(self: ^Ripple_Pond, call: gd.Main_Call, x, y: i64, strength: f64) -> i64

Inside the marked body it uses:

odin
emit_error := gd.emit(call, self.splashed, Splash_Point{
	at       = Vec2{x = f32(x), y = f32(y)},
	strength = strength,
})

gd.emit returns an error value; check it when successful delivery is required. GDScript passes only x, y and strength; call is absent from its signature. Its authority expires on return. Never store it, hand it to a worker, or retain a Godot borrow obtained from it. A Render callback instead takes gd.Render_Call, which does not grant scene/editor mutation.

You can also call methods inherited from the engine base. For example, add these two imports to declarations/ripple_pond.odin alongside its existing gd import:

odin
import raw "gododin:bindings/raw"
import godot "gododin:bindings/typed/godot"

Then add this complete method, using the Vec2 already defined in the package:

odin
@(gododin)
ripple_pond_move_right :: proc(self: ^Ripple_Pond, call: gd.Main_Call, distance: f64) -> bool {
	_ = self
	object, ok := gd.self(call)
	if !ok do return false
	engine_call := gd.godot_main_call(call)
	node, cast_ok := godot.cast_Node2D(engine_call, &object)
	if !cast_ok do return false
	storage, read_error := godot.node2d_get_position(engine_call, node)
	if read_error != .None do return false
	position := transmute(Vec2)storage
	position.x += f32(distance)
	moved := transmute(raw.Vector2)position
	return godot.node2d_set_position(engine_call, node, moved) == .None
}

self is your Odin state pointer. gd.self(call) obtains a temporary handle to the associated Godot object, and the checked cast gives it the Node2D type. raw.Vector2 is the binding's engine storage form; transmute interprets those same bytes as the matching two-f32 Vec2, without allocating or changing their precision. The handle and call parameter are valid only during this method. Generation prepares the referenced engine operations automatically.

To try it, add these lines in main.gd's _ready, immediately after its configure call, then save and relaunch:

gdscript
	assert(ripple_pond.call("move_right", 16.0))
	print("Native node position: ", ripple_pond.position)

The printed position should have x = 16 and y = 0. This moves the RipplePond node; the water texture is drawn by its sibling Pond sprite. The existing native-component fixture exercises this receiver/read/write sequence, and the API design explains its full contract.

Inspect the derived API and use existing generated lookup:

sh
"$sdk/bin/gododin" generate --project-root "$project"
"$sdk/bin/gododin" check --project-root "$project"
cd "$project"
"$sdk/bin/gododin" doc RipplePond
"$sdk/bin/gododin" doc godot.class/Node/method/get_child_count

Comments on your classes and methods become project API documentation and Godot class-reference pages. Engine descriptions come from the locked official Godot documentation. Generated records describe the lifetimes and costs the engine metadata establishes; additional guarantees appear only where the recorded implementation has been verified. Check those records before relying on an ownership or performance guarantee.

Next: The development loop.