Create your own
Lesson illustration

Registering a Rust Extension Entry Point and Root Godot Controller

Hello. The previous lesson made vn_godot the only crate allowed to depend on Godot; vn_engine and vn_content remain pure Rust. Now we establish the runtime boundary that makes that architecture real: Godot loads one Rust dynamic library, the library registers Rust-defined Godot classes, and the main scene instantiates a Rust EngineHost node. No GDScript is attached or used.

By the end of this lesson, opening the project in Godot 4.7.1 should show EngineHost as a selectable node type, and running the main scene should print a message from Rust. This is a deliberately small vertical slice: it proves extension loading and root-controller construction before we attach UI controls or introduce the narrative runtime.


The three pieces of the bootstrap

A zero-GDScript startup has three distinct responsibilities:

  1. The Rust extension entry point gives Godot a C-compatible function it can call when loading the shared library.
  2. The .gdextension descriptor tells Godot which library to load for the current platform and build mode.
  3. The root Rust Godot class is registered as a custom node type and instantiated by the main scene.

The entry-point marker is not the scene controller. It has no game state and should remain a small library-level registration type. EngineHost, in contrast, is a Godot Node that exists in the scene tree and will gradually become the Rust-owned presentation coordinator.

The loading sequence is:

  1. Godot finds vn_godot.gdextension in the project.
  2. It loads the platform-specific Rust dynamic library identified there.
  3. The gdext_rust_init entry symbol initializes the binding layer.
  4. The GodotClass derive registers EngineHost as a Godot node type.
  5. Godot instantiates EngineHost when it opens the main scene.
  6. Godot invokes Rust lifecycle callbacks such as init and ready.

The key architectural point is that Godot initiates this outer-layer work. EngineHost may call into vn_engine later, but vn_engine never needs to discover, create, or control a Godot node.

Godot’s Create New Node dialog showing a Rust-registered custom node (`GDExample`). After this lesson, searching for `EngineHost` should produce the equivalent result in your project.

Read the binding model before wiring the project

Hello World - The godot-rust book

Read the relevant parts of the official godot-rust book. It establishes the exact division between the extension marker, the .gdextension loader file, and a Rust-defined Godot class.

Read the complete “Rust entry point,” “The .gdextension file,” and “Creating a Rust class” subsections. Begin with the entry-point explanation, then read the loader-file discussion beginning at the loader introduction. Finish with the class declaration discussion from the class declaration. Focus on what each macro registers rather than copying the tutorial’s example names or directory paths.

The official example is intentionally minimal; this project needs one production-oriented adjustment. Its compiled libraries will live inside the Godot project under res://bin/, rather than relying on a development-only path such as res://../target/.... Godot exports cannot use paths outside res://, so establishing this convention now avoids a later desktop-to-export mismatch.


Register the GDExtension library

Confirm that vn_godot remains a dynamic library. This should already be present from the initial workspace setup:

# crates/vn_godot/Cargo.toml

[lib]
crate-type = ["cdylib"]

A cdylib produces the native library format Godot can load: .dll on Windows, .so on Linux, and .dylib on macOS.

Create this library entry point:

// crates/vn_godot/src/lib.rs

use godot::prelude::*;

mod engine_host;

/// Marker type for this GDExtension library.
///
/// Keep this type free of game and presentation state. Its purpose is to
/// establish the library boundary and provide a future home for library-level
/// initialization only.
struct VnGodotExtension;

#[gdextension]
unsafe impl ExtensionLibrary for VnGodotExtension {}

There are only two meaningful lines in this registration:

  • ExtensionLibrary identifies VnGodotExtension as the library marker type.
  • #[gdextension] generates the C-compatible initialization entry point that Godot will call.

The unsafe impl is required by the godot-rust API because Rust cannot itself verify all global engine and foreign-function lifecycle guarantees. It does not mean this is a place to write arbitrary unsafe Rust. Keep the implementation empty unless you eventually need well-defined library-level initialization behavior.

Do not write your own extern "C" initialization function, and do not manually register EngineHost with a Godot class-registration call. The #[gdextension] and #[derive(GodotClass)] macros cooperate to perform those tasks.


Define a non-visual Rust root controller

EngineHost should inherit from Node, rather than Control. It is a coordinator, not a layout container: later, it will acquire presentation controls from the scene and render engine-owned view models into them. Keeping it non-visual prevents the controller from becoming a second, Rust-built UI layout system.

Create the controller module:

// crates/vn_godot/src/engine_host.rs

use godot::prelude::*;

#[derive(GodotClass)]
#[class(base=Node)]
pub struct EngineHost {
    base: Base<Node>,
}

#[godot_api]
impl INode for EngineHost {
    fn init(base: Base<Node>) -> Self {
        Self { base }
    }

    fn ready(&mut self) {
        let node_name = self.base().get_name();

        godot_print!(
            "{node_name} initialized by Rust; no GDScript is attached."
        );
    }
}

This small type establishes several rules that will remain useful as the engine grows:

Code elementResponsibility
#[derive(GodotClass)]Registers EngineHost as a Godot class.
#[class(base=Node)]Declares that the custom class inherits Godot’s Node.
Base<Node>Provides controlled access to APIs on the underlying Godot node.
INodeGives Rust implementations of Godot’s node lifecycle callbacks.
#[godot_api]Makes the lifecycle implementation visible to Godot.
ready()Confirms that the scene instantiated the Rust controller successfully.

init is construction time: Godot supplies the base Node, and Rust returns the initial Rust-side fields. ready runs once the node has entered the scene tree and its descendants are ready. In a later module, ready will be the appropriate place to acquire required UI nodes and initialize presentation coordination.

Notice what is intentionally absent:

  • No #[func] methods. Those expose callable methods to the Godot scripting environment; there is no GDScript caller to support.
  • No signals for a temporary bootstrap message.
  • No vn_engine instance yet. The deterministic runtime, commands, events, and content registry do not exist yet, so creating placeholder game state here would only encourage a premature API.
  • No UI lookups or resource loading. Those belong to the later presentation bridge.

The pub visibility on EngineHost is ordinary Rust module visibility. It is not what makes the class visible to Godot; the GodotClass derive performs that registration.

Functions - The godot-rust book

Read the official godot-rust explanation of lifecycle interface traits and the #[godot_api] attribute. It clarifies why ready() is callable by Godot without creating a GDScript-visible API.

In “Godot special functions,” read from the registration overview, then continue through the lifecycle-method example. Focus on the distinction between implementing an interface trait such as INode and exposing an ordinary custom method with #[func].


Tell Godot where the library lives

Create this descriptor in the Godot project:

; godot/vn_godot.gdextension

[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = "4.7"
reloadable = false

[libraries]
linux.debug.x86_64 = "res://bin/debug/libvn_godot.so"
linux.release.x86_64 = "res://bin/release/libvn_godot.so"

windows.debug.x86_64 = "res://bin/debug/vn_godot.dll"
windows.release.x86_64 = "res://bin/release/vn_godot.dll"

macos.debug = "res://bin/debug/libvn_godot.dylib"
macos.release = "res://bin/release/libvn_godot.dylib"
macos.debug.arm64 = "res://bin/debug/libvn_godot.dylib"
macos.release.arm64 = "res://bin/release/libvn_godot.dylib"

A few details matter:

  • entry_symbol = "gdext_rust_init" must match the entry point generated by #[gdextension].
  • compatibility_minimum = "4.7" rejects older Godot versions rather than running against an engine outside this project’s pinned compatibility target.
  • The debug and release paths are distinct. A release export must not accidentally package yesterday’s debug artifact.
  • Every configured library path is inside res://. That is necessary for eventual export packaging.
  • reloadable = false is conservative. Hot-reloading a native library can be convenient early on, but it becomes risky once a root controller owns live runtime state. Restart the editor after rebuilding during this bootstrap phase.

For now, configure only the desktop platforms you can build and verify. The web build needs a separately validated output arrangement and will be treated as a deliberate platform concern rather than guessed from desktop library keys.

Build the extension from the workspace root:

cargo build -p vn_godot --locked

Then copy the generated dynamic library into the configured debug folder. Create godot/bin/debug/ first if necessary.

Development hostCargo outputDestination
Linuxtarget/debug/libvn_godot.sogodot/bin/debug/libvn_godot.so
Windowstarget\debug\vn_godot.dllgodot\bin\debug\vn_godot.dll
macOStarget/debug/libvn_godot.dylibgodot/bin/debug/libvn_godot.dylib

For example, on Linux:

mkdir -p godot/bin/debug
cp target/debug/libvn_godot.so godot/bin/debug/libvn_godot.so

On Windows PowerShell:

New-Item -ItemType Directory -Force godot\bin\debug
Copy-Item target\debug\vn_godot.dll godot\bin\debug\vn_godot.dll

This copy is a small manual build step for now. Later, the release pipeline will automate desktop and web artifact placement and verify that the correct extension library is packaged.


Make the Rust controller the main-scene root

Open the godot/ directory with Godot 4.7.1. If the extension loaded successfully, create a new scene using Other Node, search for EngineHost, and select the custom type rather than plain Node.

Name the root node EngineHost and save the scene as:

res://scenes/main.tscn

Set it as the project’s main scene in Project Settings. The resulting scene is conceptually this:

[gd_scene format=3]

[node name="EngineHost" type="EngineHost"]

There must be no attached script resource and no .gd file. In particular, do not “solve” an unavailable custom class by attaching a GDScript to a plain Node; that would hide an extension-loading failure and violate the project constraint.

Run the scene. The Godot Output panel should contain:

EngineHost initialized by Rust; no GDScript is attached.

This one line confirms the entire startup boundary:

  • Godot found the .gdextension file.
  • The descriptor selected the correct library for the current platform.
  • The generated gdext_rust_init symbol loaded.
  • The extension registered EngineHost.
  • The scene instantiated the custom Rust class.
  • Godot called INode::ready.

Startup failures: diagnose the boundary, not the symptom

SymptomLikely causeFirst check
Godot cannot find or load the libraryCopy destination and .gdextension path disagreeConfirm the native file exists exactly at the configured res://bin/debug/... path.
gdext_rust_init is missingThe extension was built from stale or incorrect Rust codeConfirm #[gdextension] unsafe impl ExtensionLibrary is present in vn_godot/src/lib.rs, then rebuild and copy again.
EngineHost is absent from Create New NodeGodot did not load the extension, or the class module is not compiledRead the Godot Output panel’s startup errors; confirm mod engine_host; is present.
Scene root silently becomes NodeThe extension was unavailable when the scene was loadedRestore type="EngineHost" from version control or recreate the node after fixing the loader failure.
Rust changes do not appearreloadable = false intentionally disables hot reloadRebuild, copy the new library, then restart the Godot editor.

Keep main.tscn, vn_godot.gdextension, and the Rust source in version control. Treat generated files under godot/bin/debug/ and godot/bin/release/ as build artifacts unless your deployment policy later specifies otherwise.


Key takeaways

  • #[gdextension] on an ExtensionLibrary marker generates the entry point Godot calls to load the Rust library.
  • #[derive(GodotClass)] registers EngineHost; #[class(base=Node)] makes it a scene-tree node without making it a visual container.
  • #[godot_api] impl INode lets Godot call lifecycle methods such as init and ready entirely through Rust.
  • The .gdextension file connects Godot’s platform selection to the compiled native library and should use paths inside res:// for export compatibility.
  • The main scene must instantiate EngineHost directly, with no attached GDScript.
  • At this stage, EngineHost proves the Godot boundary but does not yet own narrative state or UI binding logic.

Next, you will define Cargo feature flags that isolate desktop-only and web-specific integration code, so the extension can support both required deployment targets without allowing platform details to leak into the pure Rust engine.

Can't find a good explanation? Sign up and we'll make it for you

Sign up