Create your own
Lesson illustration

Decoupling Engine and Content from Godot APIs

Hello. In the previous lesson, you established the workspace partition: vn_engine for deterministic rules, vn_content for compiled Rust definitions, and vn_godot for the GDExtension and all Godot interaction. That shape is useful, but a directory layout alone is only a convention.

This lesson turns the convention into a checked architectural rule: neither vn_engine nor vn_content may depend on, import, or expose Godot APIs. Godot remains a Rust-owned outer adapter in vn_godot; it is not permitted to become a shortcut inside the game runtime or authored content.

By the end, you will have both the Cargo-level boundary that makes an accidental use godot::... fail to compile and a workspace policy test that detects forbidden Godot crates in the protected components’ dependency graphs.


The dependency rule in this engine

The core question is not whether Godot is useful. It clearly is: it supplies the scene tree, UI controls, resources, input, audio, rendering, export pipeline, and a file API that the Rust adapter will use later. The question is which component is allowed to know that Godot exists.

For this project, the answer is deliberately narrow:

ComponentMay know about Godot?Reason
vn_engineNoIt owns deterministic game rules and runtime state.
vn_contentNoIt authors logical game definitions in compiled Rust.
vn_godotYesIt owns GDExtension classes, Godot node access, signals, and rendering.
godot/ projectYesIt owns scenes, layout, themes, and presentation assets.
tools/vn_architecture_guardIndirectly, for inspection onlyIt checks the workspace dependency graph; it is not runtime code.

The practical benefit is larger than “clean code.” A Godot-independent core can run in ordinary Rust unit tests without a Godot executable, scene tree, or renderer. It can also support a headless simulation harness useful for testing schedules, branching narratives, quest reactions, and save migrations later in the course.

A Clean Architecture diagram in which external input and output adapters surround the application and domain core. In this engine, `vn_godot` occupies the adapter role, while `vn_engine` and `vn_content` remain on the core side of the boundary.

The important distinction is between runtime interaction and compile-time dependency:

  • At runtime, a Rust Godot controller will receive a button signal, call into vn_engine, then render the resulting presentation data.
  • At compile time, only vn_godot is allowed to import the godot crate.
  • The engine never calls a Godot node, creates a Variant, loads a Resource, or constructs a res:// path.
  • Content never contains a Texture2D, NodePath, .tscn reference, or Godot resource path. It will eventually use logical identifiers such as portrait.mara.neutral, which the adapter maps to presentation resources.

The external host calls the core. The core returns domain data. That asymmetry is intentional.

rust-architecture-patterns - Skill | Smithery

Read the architecture rationale from Smithery before applying the Cargo rules below. It frames the boundary as protection from volatile framework code, rather than as an abstract preference for layers.

In Section 1, “Architecture Philosophy,” read the opening principle. Then move to Section 3, “Hexagonal Architecture,” and its “The Dependency Rule” subsection. Read the ports explanation, focusing on the ownership of an interface: the inner component defines what it needs, while outer technology implements it.

The resource discusses business applications, databases, and HTTP services. Translate its terms carefully rather than literally:

Architecture termThis visual-novel engine
Domain and application corevn_engine, plus compiled definitions in vn_content
Presentation and infrastructurevn_godot and the Godot project
External frameworkGodot 4.7.1 and the godot Rust crate
PortA pure Rust trait owned by the core only when the core genuinely needs an external capability
AdapterRust code that translates Godot callbacks, resources, and APIs to or from core-owned types

Do not create traits merely because an architecture diagram contains ports. The UI boundary does not need an engine trait that says “render this label” or “play this animation.” That would make the engine speak in presentation concepts. The adapter can simply call the engine’s public API and interpret the engine’s returned view models and events.

Practical Clean Architecture in Rust [with Axum Template]

Watch “Practical Clean Architecture in Rust” from Green Tea Coding for a concise explanation of inward dependencies and the role of ports in Rust.

Watch the dependency rule to distinguish an autonomous core from framework details. Then watch dependency inversion. Apply the port idea only when an inner component needs an external service; do not treat Godot UI types as a service the engine should request.


Let Cargo enforce the first boundary

Cargo already provides the primary enforcement mechanism: a Rust package can import only crates it declares as direct dependencies. Merely placing godot in [workspace.dependencies] does not grant every workspace member access to it. A member must explicitly opt in.

Your manifests should retain this relationship:

# Cargo.toml at the repository root

[workspace]
members = [
    "crates/vn_engine",
    "crates/vn_content",
    "crates/vn_godot",
    "tools/vn_architecture_guard",
]
resolver = "3"

[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
publish = false

[workspace.dependencies]
godot = { version = "=0.5.4", features = ["api-4-7"] }

The addition in this lesson is tools/vn_architecture_guard, a development-only policy-test crate. It has no role in the shipped game library and does not belong to the runtime architecture.

The protected crate manifests remain intentionally sparse:

# crates/vn_engine/Cargo.toml

[package]
name = "vn_engine"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true

[dependencies]
# crates/vn_content/Cargo.toml

[package]
name = "vn_content"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true

[dependencies]
vn_engine = { path = "../vn_engine" }

Only the Godot adapter inherits the workspace godot dependency:

# crates/vn_godot/Cargo.toml

[package]
name = "vn_godot"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true

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

[dependencies]
godot = { workspace = true }
vn_engine = { path = "../vn_engine" }
vn_content = { path = "../vn_content" }

With these manifests in place, this code in vn_engine or vn_content cannot compile:

use godot::prelude::*;

There is no direct godot dependency in either package, so Cargo and the Rust compiler reject the import. That is a better protection than a comment saying “please do not use Godot here.”

The permitted outgoing dependency set is now precise:

PackagePermitted direct dependencies at this stage
vn_engineNone
vn_contentvn_engine
vn_godotgodot, vn_engine, vn_content
tools/vn_architecture_guardTest-only metadata inspection dependency

Later, vn_engine may acquire carefully selected framework-independent crates, such as serialization support. vn_content may similarly require pure Rust helpers for content construction. The rule is not “the engine must forever have zero dependencies.” The rule is that engine and content must not acquire a dependency on Godot or its lower-level binding crates.

A related rule follows from the same boundary:

No engine or content public type may expose a Godot type.

Even if a method signature could somehow be made to compile through an indirect re-export, a type such as Gd<Control>, Variant, GodotString, or NodePath in a core API would force every caller to understand Godot. It would make the framework part of the engine’s contract.

The engine should expose its own types instead. A later dialogue view might use a logical portrait identifier and text key, not Gd<Texture2D> or a loaded Resource.


Guard the manifest and the resolved dependency graph

Cargo prevents an undeclared import, but a future contributor could add this line to a protected manifest:

godot = { workspace = true }

That is why a production-oriented workspace should test its architecture policy. The test should catch both:

  1. a direct declaration of godot or a lower-level godot-rust crate; and
  2. a transitive path from vn_engine or vn_content to one of those crates.

Create the guard crate:

# tools/vn_architecture_guard/Cargo.toml

[package]
name = "vn_architecture_guard"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true

[dev-dependencies]
cargo_metadata = "=0.19.2"

Give it a minimal library target:

// tools/vn_architecture_guard/src/lib.rs

//! Workspace architecture policy tests.

Then add this integration test:

// tools/vn_architecture_guard/tests/dependency_direction.rs

use cargo_metadata::{MetadataCommand, PackageId};
use std::collections::{HashMap, HashSet};

const PROTECTED_PACKAGES: &[&str] = &["vn_engine", "vn_content"];

const FORBIDDEN_PACKAGES: &[&str] = &[
    "godot",
    "godot-core",
    "godot-ffi",
    "godot-macros",
];

#[test]
fn engine_and_content_do_not_depend_on_godot() {
    let metadata = MetadataCommand::new()
        .exec()
        .expect("Cargo metadata should be available while running workspace tests");

    let resolve = metadata
        .resolve
        .as_ref()
        .expect("Cargo metadata should contain a resolved dependency graph");

    let package_names: HashMap<PackageId, String> = metadata
        .packages
        .iter()
        .map(|package| (package.id.clone(), package.name.to_string()))
        .collect();

    let dependencies: HashMap<PackageId, Vec<PackageId>> = resolve
        .nodes
        .iter()
        .map(|node| {
            (
                node.id.clone(),
                node
                    .deps
                    .iter()
                    .map(|dependency| dependency.pkg.clone())
                    .collect(),
            )
        })
        .collect();

    for protected_name in PROTECTED_PACKAGES {
        let protected_package = metadata
            .packages
            .iter()
            .find(|package| package.name.as_str() == *protected_name)
            .unwrap_or_else(|| panic!("missing protected package: {protected_name}"));

        let directly_declared = protected_package
            .dependencies
            .iter()
            .map(|dependency| dependency.name.to_string())
            .find(|name| is_forbidden(name));

        assert!(
            directly_declared.is_none(),
            "{protected_name} directly declares a forbidden Godot dependency: \
             {directly_declared:?}"
        );

        let reachable_forbidden = first_forbidden_dependency(
            &protected_package.id,
            &dependencies,
            &package_names,
        );

        assert!(
            reachable_forbidden.is_none(),
            "{protected_name} reaches forbidden dependency \
             {reachable_forbidden:?} in its resolved dependency graph"
        );
    }
}

fn is_forbidden(package_name: &str) -> bool {
    FORBIDDEN_PACKAGES.contains(&package_name)
}

fn first_forbidden_dependency(
    root: &PackageId,
    dependencies: &HashMap<PackageId, Vec<PackageId>>,
    package_names: &HashMap<PackageId, String>,
) -> Option<String> {
    let mut pending = vec![root.clone()];
    let mut visited = HashSet::new();

    while let Some(package_id) = pending.pop() {
        if !visited.insert(package_id.clone()) {
            continue;
        }

        if package_id != *root {
            if let Some(package_name) = package_names.get(&package_id) {
                if is_forbidden(package_name) {
                    return Some(package_name.clone());
                }
            }
        }

        if let Some(next_dependencies) = dependencies.get(&package_id) {
            pending.extend(next_dependencies.iter().cloned());
        }
    }

    None
}

This test belongs outside vn_engine and vn_content for a reason. The protected crates should not know that their location in a workspace is being inspected, nor should they need a dependency on Cargo’s metadata model. The guard is an outer development tool checking an architectural contract from the outside.

The test has two complementary checks:

  • Declared dependency check: catches a direct or optional Godot dependency even when a feature has not enabled it in the current build.
  • Resolved graph check: catches a normal dependency path that eventually reaches a Godot binding crate.

The transitive check is intentionally strict. Rust would not normally let vn_engine directly import an arbitrary transitive crate, but allowing Godot anywhere in its resolved dependency closure is still an unnecessary coupling risk. It can also conceal procedural macros, generated types, or an unwanted framework-specific helper.

Run the policy test from the workspace root:

cargo test -p vn_architecture_guard --locked

Then run the protected crates independently:

cargo check -p vn_engine --all-targets --locked
cargo check -p vn_content --all-targets --locked
cargo test --workspace --locked

Inspecting the dependency trees remains useful during review:

cargo tree -p vn_engine
cargo tree -p vn_content
cargo tree -p vn_godot

At this point:

  • vn_engine should have no dependency tree beyond future approved pure-Rust libraries.
  • vn_content should include vn_engine and its own approved content-authoring support.
  • vn_godot is the only runtime crate expected to show the godot ecosystem.

The guard is not a replacement for review. A contributor with permission to change both the protected crate and the guard could weaken the test. But it makes an ordinary accidental dependency addition visible in local development and in the future CI pipeline, where it should be run as part of every change.


Use ports only for real outward needs

The rule “Godot stays outside” does not mean the engine can never need an external capability. It means that when it does, the core owns the abstraction in framework-neutral Rust.

Persistence will be a future example. If the engine needs an abstraction for storing validated save bytes, it can define a trait in a core-owned module. The Godot adapter can later implement that trait with Godot’s Rust-accessed file API.

A deliberately minimal sketch looks like this:

// Illustrative future core-owned interface.

#[derive(Debug)]
pub struct StoreError;

pub trait SnapshotStore {
    fn write(&mut self, slot: &str, bytes: &[u8]) -> Result<(), StoreError>;
}

The key design facts are:

  • The trait uses core-owned concepts: slots, bytes, and a core-owned error.
  • The trait mentions no FileAccess, Variant, Node, or Godot filesystem path.
  • A Godot-specific implementation belongs in vn_godot.
  • A test implementation can store bytes in memory without starting Godot.

For the immediate presentation boundary, prefer an even simpler arrangement: vn_godot receives a Godot signal, translates it into a future engine command, invokes the engine, and renders the core’s resulting view model. The engine does not need a “Godot UI” trait because it does not initiate UI work.

This keeps gameplay decisions independent of presentation details:

Gameplay concernCorrect owner
Is a dialogue choice available?vn_engine
Which authored choice grants an item?vn_content
Which Button emitted a signal?vn_godot
Which Texture2D represents a portrait ID?vn_godot
Where a portrait sits in a dialogue panelgodot/ scene and theme assets

Key takeaways

  • The dependency rule is concrete: vn_engine and vn_content must not declare, resolve, import, or expose Godot APIs.
  • A workspace-level godot dependency is only a shared declaration; it does not make Godot available to every workspace member.
  • Keep godot = { workspace = true } exclusively in vn_godot.
  • Protect the boundary with an architecture guard that checks both direct manifest declarations and resolved dependency paths.
  • Runtime calls from the Rust Godot adapter into the engine are expected; compile-time imports from the engine back into Godot are forbidden.
  • Define pure Rust ports only when the core genuinely requires an outward capability. Do not add UI-oriented ports that leak presentation concepts inward.

Next, you will use the permitted outer boundary to register the Rust GDExtension entry point and create the root Godot controller, still with no GDScript involved.

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

Sign up