Good to continue. Last lesson fixed the external compatibility contract: Godot 4.7.1, godot 0.5.4 with api-4-7, Rust 1.85.0, and a committed lockfile. That gives every contributor the same foundation.
This lesson turns that foundation into a repository shape that can survive growth. The engine will eventually contain deterministic narrative, world, quest, inventory, and mini-game rules; the project must make it difficult for those rules to become entangled with Godot scene objects, resource paths, or editor workflow. At the same time, the Godot-facing code must remain fully Rust-owned: no GDScript “glue” layer.
By the end, you will have a four-part partition:
- a reusable pure-Rust engine;
- compiled Rust game content;
- a Godot/GDExtension adapter written in Rust;
- a Godot project containing presentation assets and declarative scenes.
Why use separate crates?
A multi-crate workspace is not separation for its own sake. It assigns each kind of change a clear home.
Consider a future change such as “the player gains a quest item after accepting a dialogue choice.” It has at least four distinct concerns:
- The engine defines and applies the state transition.
- The content crate authors which particular dialogue choice grants which item.
- The Godot adapter turns a button press into an engine request and renders the resulting view.
- The presentation project supplies the button scene, portrait texture, theme, and animation resources.
Those concerns evolve at different rates. Dialogue authorship will change frequently; generic inventory invariants should change carefully; presentation layout will change in the Godot editor; browser versus desktop integration belongs at the edge. Putting them in one extension crate makes every edit capable of reaching every layer.
The architectural idea is familiar from clean architecture: code that embodies the game’s rules should remain more autonomous than code that talks to a framework.
Practical Clean Architecture in Rust [with Axum Template]
Watch “Practical Clean Architecture in Rust” by Green Tea Coding for a compact explanation of inward-facing dependencies. Treat its web-service examples as architecture vocabulary rather than a template for this game.
Watch the inner layers. Focus on the dependency rule: core domain and application rules are independent of delivery frameworks, while outer adapters may depend on the core.
For this project, “domain” and most “application” responsibilities live together in vn_engine. Godot is an external delivery and presentation framework, so it belongs at the outer edge.
The intended repository map
Use a virtual Cargo workspace at the repository root. A virtual workspace has no root Rust package; it organizes separately named member packages. That is appropriate here because there is no single Rust binary at the root: the actual deployable native library is the Godot adapter crate.
Workspaces - The Cargo Book - Rust Documentation
Read the relevant Cargo Book sections to confirm the workspace mechanics behind this layout. Cargo’s terminology is useful here: the repository root is the workspace root, while each Rust component is a member package.
In “The [workspace] section,” read the virtual-workspace distinction. Then go to “The members and exclude fields” and read the paragraph beginning member path selection. Notice that explicitly listed paths are preferable here: adding a new crate should be an intentional architectural decision, not an accidental match of a broad glob.
Create this top-level layout:
sandbox-vn/
├── Cargo.toml
├── Cargo.lock
├── rust-toolchain.toml
├── crates/
│ ├── vn_engine/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs
│ ├── vn_content/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs
│ └── vn_godot/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
├── godot/
│ ├── project.godot
│ ├── scenes/
│ │ └── main.tscn
│ ├── assets/
│ │ ├── audio/
│ │ ├── backgrounds/
│ │ ├── fonts/
│ │ └── portraits/
│ └── themes/
└── tools/
└── godot/
└── godot.toml
The godot/ directory is deliberately not a Cargo package. It is the Godot project root: its job is to contain source presentation resources, scene declarations, export configuration later in the project, and the GDExtension descriptor once the extension entry point exists.
Do not create a godot/scripts/ directory. In this engine, there is no GDScript to attach to controls, no signal relay scripts, and no gameplay scripts. A .tscn scene may describe a hierarchy of nodes, layout, theme assignment, and asset references, but its behavior is supplied by Rust in vn_godot.
The directory tree is an architectural map, not merely file organization:
| Component | Owns | Does not own |
|---|---|---|
vn_engine | Game state, deterministic rules, commands, events, validation, view-model projection | Godot objects, res:// paths, textures, scenes, UI callbacks |
vn_content | Rust-authored definitions for this game: dialogue, characters, locations, quests, items, schedules | Mutable runtime state, Godot resources, editor-authored logic |
vn_godot | GDExtension classes, Godot node access, signal callbacks, command dispatch, rendering of engine views | Canonical gameplay rules or authored story definitions |
godot/ | Scenes, UI layout, themes, audio, images, fonts, project and export settings | GDScript, Rust simulation logic, durable game state |
A useful litmus test is replacement:
- If you later replace Godot UI with a terminal simulator or test harness, can the engine and content still compile and run? They should.
- If you replace a portrait image or redesign the choice panel, do you need to modify quest logic? You should not.
- If a story author adds a dialogue branch, should they edit a
.tscnscene? No; that belongs in compiled Rust content.
The Rust dependency shape
The three crates have a small, intentional import graph:
Read each line as “the crate on the left may import the crate it points to.”
vn_engineis the innermost crate. It imports neither of the other workspace crates.vn_contentimportsvn_enginebecause authored definitions use engine-owned identifiers and definition types.vn_godotimports both crates. It constructs the engine with registered content, receives Godot input, and renders engine-produced view models.
The Godot project itself is not represented as a Rust dependency. At runtime, Godot loads the compiled vn_godot extension through a .gdextension descriptor, and the Rust adapter finds the required scene nodes. This is a runtime integration boundary, not a reason for the engine to know anything about Godot.
The crucial content decision is that authored data remains compiled Rust. A chapter can eventually be organized in modules such as:
vn_content/src/
├── dialogue/
│ ├── prologue.rs
│ └── town.rs
├── characters.rs
├── items.rs
├── quests.rs
└── lib.rs
These files are source code that construct typed definitions. They are not JSON, YAML, CSV, Godot Resources, or an embedded scripting language. Later modules will make this pleasant to author with builders, macros, registries, and validation; today, the boundary ensures there is a correct place for that work to live.
Configure the virtual workspace
Replace the preliminary root manifest from the previous lesson with this version. It preserves the version policy already established, while making each planned component an explicit member.
# Cargo.toml at repository root
[workspace]
members = [
"crates/vn_engine",
"crates/vn_content",
"crates/vn_godot",
]
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"] }
A few details matter:
- Keep the member list explicit rather than using
crates/*. A future utility crate, prototype, or code generator should not silently become a production workspace member. publish = falseis appropriate for this application workspace. You may later decide to publish a genuinely reusable, Godot-independent engine crate, but that should be a separate packaging decision.- The workspace declares
godotonce. Onlyvn_godotwill inherit it. - Although all crates share a workspace
target/directory, that shared output directory does not make their roles interchangeable. Compilation output placement is Cargo infrastructure, not an architectural dependency.
Each member can inherit the common metadata:
# Shared [package] section pattern for every member crate
[package]
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
Use this pattern in the three crate manifests, supplying only the package name that differs.
Create the three crate manifests
vn_engine: the deterministic core
# crates/vn_engine/Cargo.toml
[package]
name = "vn_engine"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
For now, an intentionally small library root is sufficient:
// crates/vn_engine/src/lib.rs
//! Deterministic, Godot-independent sandbox visual novel runtime.
#[derive(Debug, Default)]
pub struct Engine;
Do not add godot, godot-rust, or types such as Gd<Node> here. Do not put filesystem calls, res:// paths, textures, localization resources, or scene-switching code here either. The core’s public API will grow in subsequent lessons, beginning with typed commands and events, but it should grow from game rules outward.
vn_content: this game’s compiled definition set
# 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" }
Its starter library can state its purpose without prematurely inventing story structures:
// crates/vn_content/src/lib.rs
//! Compiled Rust definitions for this sandbox visual novel.
This crate is where the game becomes your game. The generic engine will eventually be able to run many content sets; vn_content defines your locations, people, narrative nodes, items, and quests using the engine’s types.
Keep content IDs and references logical. A character definition may later refer to an asset identifier such as portrait.mara.neutral; it should not contain a Godot-specific string such as res://assets/portraits/mara_neutral.png. The adapter will resolve logical presentation identifiers to actual Godot resources.
vn_godot: the Rust-owned presentation adapter
# 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" }
Add a minimal library root:
// crates/vn_godot/src/lib.rs
//! Godot 4.7.1 GDExtension adapter for the visual novel runtime.
crate-type = ["cdylib"] is essential. This tells Cargo to create a native dynamic library suitable for a GDExtension, rather than only an ordinary Rust library artifact.
Do not create the .gdextension descriptor yet merely to point at an empty library. The next lesson that registers the Rust extension entry point and root Godot controller will add that descriptor with real platform library paths. Until then, vn_godot is a compilable boundary crate, not a loadable extension.
Keep the Godot project presentation-only
Create a normal Godot 4.7.1 project with godot/project.godot as its project file. It is reasonable to add a skeletal main.tscn now, but it should be a layout artifact, not a behavior container.
For example, a future dialogue scene may contain:
- a root
Control; - labels for speaker and dialogue text;
- a portrait
TextureRect; - a
VBoxContainerreserved for choice controls; - theme and layout properties.
Those nodes do not decide whether a choice is available, advance time, grant an item, or mutate a character stat. The Rust controller will acquire the nodes, connect their signals, queue typed engine commands, and update their visible state.
Keep source and generated files distinct:
| Commit to version control | Ignore as generated or local state |
|---|---|
godot/project.godot | godot/.godot/ |
.tscn scenes, themes, and source art/audio | target/ |
.gdextension descriptor, once created | editor import caches |
Rust sources, all Cargo.toml files, Cargo.lock | platform-specific build products |
approved Godot artifact manifest in tools/ | local editor configuration not needed by the team |
The shared Cargo target directory will be at sandbox-vn/target/, not inside crates/vn_godot/. That is normal for a workspace and will matter when the later .gdextension descriptor locates the extension library. Do not manually copy native libraries into godot/assets/: it obscures which build profile and platform produced the binary, and it creates stale-library failures that are difficult to diagnose.
A short implementation checkpoint
After creating the manifests and library roots, run these commands from the repository root:
cargo fmt --check
cargo check --workspace --locked
cargo test --workspace --locked
At this point, the test suite may contain no substantive tests yet. That is acceptable: this checkpoint verifies that the package boundaries and dependency resolution are valid before substantial logic makes errors harder to localize.
Also inspect dependencies by package:
cargo tree -p vn_engine
cargo tree -p vn_content
cargo tree -p vn_godot
The intended result is simple:
vn_enginehas no Godot dependency.vn_contentdepends onvn_engine.vn_godotdepends ongodot,vn_engine, andvn_content.
This lesson establishes the structure and intended direction. The following lesson will make the “Godot never leaks inward” rule enforceable with compile-time and workspace-level safeguards, rather than relying only on discipline.
Key takeaways
- Use a virtual workspace with three explicit Rust crates:
vn_engine,vn_content, andvn_godot. - Treat
godot/as a separate presentation-asset component, not as a place for gameplay logic or scripts. - Put reusable deterministic rules in
vn_engine; put this game’s Rust-authored definitions invn_content. - Put every Godot API interaction, including UI signal binding and rendering, in the Rust
vn_godotadapter. - Keep presentation references logical in engine/content code; resolve them to Godot resources only at the adapter boundary.
- Build the adapter as a
cdylib, but defer the actual extension descriptor and registration until a real Rust entry point exists.
Next, we will turn this intended dependency graph into an enforced rule, so the pure engine and compiled content cannot accidentally import Godot APIs.
Can't find a good explanation? Sign up and we'll make it for you
Sign up