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.
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:
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:
@(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,i64andf64become Godotbool,intandfloat.stringproperties and method results become GodotString. The bridge owns stored property text; a returned string must remain valid while it is copied into Godot's value.[]u8and[]i64method arguments/results becomePackedByteArrayandPackedInt64Array. 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
Vec2matches the lockedVector2layout. 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:
@(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:
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:
"$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:
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.
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:
@(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:
@onready var ripple_pond: Node2D = $RipplePondripple_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:
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:
ripple_pond_splash :: proc(self: ^Ripple_Pond, call: gd.Main_Call, x, y: i64, strength: f64) -> i64Inside the marked body it uses:
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:
import raw "gododin:bindings/raw"
import godot "gododin:bindings/typed/godot"Then add this complete method, using the Vec2 already defined in the package:
@(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:
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:
"$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_countComments 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.