Hello. The extension now has a clean startup boundary: Godot loads vn_godot, Rust registers EngineHost, and no GDScript participates. The next architectural risk is platform creep: browser constraints, native file access, and thread-mode details can easily leak into the deterministic engine if they are treated as ordinary game configuration.
This lesson establishes a small, explicit build contract for the two deployment targets. Cargo feature flags will live in the Godot adapter crate only, select desktop or web integration code, and distinguish threaded from single-threaded web extensions. The pure vn_engine and vn_content crates will remain unaware of all of it.
Platform modes are build policy, not game state
A Cargo feature is resolved at compile time. It is therefore appropriate for choosing implementation code that cannot or should not exist in every build, such as a browser-only bridge or a desktop-specific integration. It is not appropriate for player settings, save-data decisions, or narrative rules.
For this engine, keep the distinction sharp:
| Concern | Appropriate home | Controlled by |
|---|---|---|
| Dialogue, quests, inventory, simulation | vn_engine | Engine commands and state |
| Compiled narrative and game definitions | vn_content | Rust source registration |
| Godot nodes, web build constraints, desktop integration | vn_godot | Cargo features and target configuration |
| Fonts, scenes, portraits, themes | Godot project assets | Godot resource loading |
The two pure Rust crates must not define desktop, web, or web-nothreads features. A conditional compilation flag inside the engine would advertise that the engine has platform-specific behavior. It should not: a command processed from a desktop build must yield the same deterministic result as the same command processed in a web build.
The adapter can differ because it is the outer boundary. For example, eventual platform storage or host integration may differ, but each adapter implementation must translate its work into the same Rust-owned persistence or engine-facing abstractions. We will define those abstractions in later modules; today, establish the build boundary that will contain their platform-specific implementations.
Cargo features and their limits
Read the relevant portions of the Cargo reference first. The important ideas are that features are named configuration switches, defaults are enabled unless deliberately disabled, and feature selection is unified across dependencies.
Features - The Cargo Book - Rust Documentation
Read the official Cargo documentation to ground the feature design in Cargo's actual resolution model, rather than treating features as ad hoc build profiles.
In “The [features] section,” read the opening explanation of defining features and conditional compilation. Then read “The default feature,” especially the default-feature guidance. In “Command-line feature options,” note --features and --no-default-features. Finally, in “Feature unification,” read the additive-features discussion. Focus on why a workspace should not casually use --all-features as a platform-build check.
Cargo features are normally additive: enabling one should generally add a capability rather than remove another. Platform selection is one of the few situations where the chosen build modes are inherently incompatible. A browser WebAssembly artifact cannot simultaneously be the native desktop library that Godot loads on Linux, Windows, or macOS.
That gives this project a useful rule:
Treat
desktopandwebas mutually exclusive application build modes in the root GDExtension package, and test them as a build matrix. Do not treat--all-featuresas a meaningful build ofvn_godot.
This is acceptable because vn_godot is a deployable edge package, not a general-purpose library intended for arbitrary downstream feature combinations. The engine and content crates should still follow the ordinary additive-feature principle if they ever gain optional capabilities.
Declare the adapter-only feature contract
In crates/vn_godot/Cargo.toml, add this feature table. Retain your existing godot dependency and pinned version or git revision; the web-nothreads feature only enables one feature inside that dependency.
# crates/vn_godot/Cargo.toml
[features]
# Normal local editor and desktop-export builds.
default = ["desktop"]
# Allows native desktop integration code to compile.
desktop = []
# Allows browser/WebAssembly integration code to compile.
web = []
# A WebAssembly build compatible with a Godot web export whose
# Thread Support option is disabled.
web-nothreads = [
"web",
"godot/experimental-wasm-nothreads",
]
These names have deliberately narrow meanings:
desktoppermits integrations compiled into native desktop targets.webpermits integrations compiled forwasm32-unknown-emscripten. It is the feature used for the threaded web extension variant.web-nothreadsincludesweband activates godot-rust’sexperimental-wasm-nothreadsbinding configuration. It is used for the single-threaded web extension variant.default = ["desktop"]keeps ordinary local development concise:cargo build -p vn_godotremains a desktop build.
A web command must explicitly remove the desktop default:
cargo +nightly build -p vn_godot --locked -Zbuild-std \
--target wasm32-unknown-emscripten \
--no-default-features --features web
The corresponding single-threaded web command changes the selected feature:
cargo +nightly build -p vn_godot --locked -Zbuild-std \
--target wasm32-unknown-emscripten \
--no-default-features --features web-nothreads
The full threaded build also requires the target-specific Rust flags described in the godot-rust web-export guidance. Do not put those threaded flags unconditionally in .cargo/config.toml: doing so would make the single-threaded variant invalid.
The important dependency relationship is:
web-nothreads enables web
web-nothreads enables godot experimental wasm nothreads support
web-nothreads is not a runtime “turn threads off” setting. It produces a separately compiled extension binary with an ABI and binding configuration suitable for a Godot export where thread support is disabled.
Enforce valid feature and target combinations
Features alone do not know which Rust target Cargo is compiling. Combine each feature with Rust’s target configuration, and make invalid combinations fail immediately.
Add these checks near the top of crates/vn_godot/src/lib.rs, after the existing imports and before the extension marker type:
#[cfg(all(feature = "desktop", feature = "web"))]
compile_error!(
"`desktop` and `web` are alternative vn_godot build modes. \
Use --no-default-features for web builds."
);
#[cfg(all(
feature = "desktop",
not(any(
target_os = "windows",
target_os = "linux",
target_os = "macos",
))
))]
compile_error!(
"`desktop` is supported only on Windows, Linux, or macOS targets."
);
#[cfg(all(feature = "web", not(target_arch = "wasm32")))]
compile_error!(
"`web` must be built for the wasm32-unknown-emscripten target."
);
These assertions communicate the contract better than a mysterious linker failure late in a web build:
- A normal editor build enables the default
desktopfeature and targets the host desktop OS. - A web build uses
--no-default-features, then explicitly selectsweborweb-nothreads. - Selecting
webon a native target, or accidentally retaining the desktop default for a web command, is a configuration error rather than a partially compiled hybrid artifact.
When the first real platform integration arrives, its module gate should include both the named feature and the target condition:
// crates/vn_godot/src/lib.rs
#[cfg(all(
feature = "desktop",
any(
target_os = "windows",
target_os = "linux",
target_os = "macos",
)
))]
mod desktop_integration;
#[cfg(all(feature = "web", target_arch = "wasm32"))]
mod web_integration;
Do not create empty integration modules merely to fill out this skeleton. Add desktop_integration or web_integration only when it contains actual boundary code. The gates shown here are the pattern to use at that time.
In particular, avoid this in vn_engine:
// Do not put this in vn_engine.
#[cfg(feature = "web")]
fn process_command(...) { /* different game behavior */ }
A command-to-event runtime that differs by platform would undermine deterministic testing, save compatibility, and later replay diagnostics. Browser constraints belong in the adapter’s implementation of external services, not inside the simulation.
Web needs two extension artifacts
The web target has an additional concern: Godot exports may be configured with Thread Support enabled or disabled. Rather than guessing which hosting environment a player will have, build both versions of the Rust extension. Godot selects the suitable extension based on the export setting.
Export to Web - The godot-rust book
Read the official godot-rust web-export guidance for the specific relationship among Cargo features, two WebAssembly binaries, .gdextension library keys, and Godot’s Thread Support export option.
In “Building both with and without multi-threading support,” begin with the two-build rationale. Follow steps 2 and 3, paying particular attention to feature propagation across Godot-dependent crates and the separate .threads.wasm library keys. Then read step 5, especially the conditional-code example. Map the guide’s nothreads name to this project’s more explicit web-nothreads feature.
At present, only vn_godot depends on godot, so it is the only crate that needs the web-nothreads feature. If a future workspace member also imports godot, it must define a forwarding feature of its own, and vn_godot must enable it through that_crate/web-nothreads. Otherwise Cargo could compile part of the Godot-binding graph with incompatible WebAssembly thread assumptions.
Extend godot/vn_godot.gdextension with web library entries. Use paths inside res://, consistent with the earlier desktop setup:
[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"
web.debug.threads.wasm32 = "res://bin/web/debug/vn_godot.threads.wasm"
web.release.threads.wasm32 = "res://bin/web/release/vn_godot.threads.wasm"
web.debug.wasm32 = "res://bin/web/debug/vn_godot.wasm"
web.release.wasm32 = "res://bin/web/release/vn_godot.wasm"
The artifact naming convention is intentional:
| Godot export setting | Cargo feature selection | Extension artifact |
|---|---|---|
| Thread Support enabled | web | vn_godot.threads.wasm |
| Thread Support disabled | web-nothreads | vn_godot.wasm |
The threaded artifact must be renamed after its build, before the single-threaded build writes its ordinary .wasm output. Both are then copied into the configured res://bin/web/... locations. A later release-pipeline lesson will automate that build-and-copy sequence; for now, the descriptor and feature contract make the eventual automation unambiguous.

The Extensions Support option must be enabled for either Rust GDExtension artifact to load. The Thread Support toggle is not simply a performance preference: it chooses which of the two compiled extension variants Godot will package and run.
Keep feature-specific code small and local
When a genuine web-only implementation eventually needs different behavior, gate only the minimal adapter code. For example:
fn initialize_host_integration() {
#[cfg(feature = "web-nothreads")]
{
// Web adapter path that must not require threaded host behavior.
}
#[cfg(all(feature = "web", not(feature = "web-nothreads")))]
{
// Threaded-web adapter path.
}
#[cfg(feature = "desktop")]
{
// Native desktop adapter path.
}
}
This is a local boundary decision. It should not result in platform feature checks scattered through EngineHost, scene-rendering code, or—most importantly—the pure engine.
Prefer module-level gates when an entire implementation differs:
#[cfg(all(feature = "web", target_arch = "wasm32"))]
mod web_integration;
Prefer a block-level #[cfg] only when a small part of an otherwise shared adapter function differs. This preserves a readable common path while making the exceptional platform behavior visible exactly where it occurs.
Avoid using operating-system checks as a substitute for feature selection:
// Too broad for this project’s build contract.
#[cfg(target_os = "linux")]
A target predicate says what platform Rust is compiling for; the Cargo feature says which integration contract the project intentionally selected. The safest gate combines both when the code is genuinely platform-bound.
Verify the build matrix now
Run the desktop check as the normal development configuration:
cargo check -p vn_godot --locked
Then, after the WebAssembly toolchain configuration from the godot-rust guide is in place, validate both web modes independently:
cargo +nightly check -p vn_godot --locked -Zbuild-std \
--target wasm32-unknown-emscripten \
--no-default-features --features web
cargo +nightly check -p vn_godot --locked -Zbuild-std \
--target wasm32-unknown-emscripten \
--no-default-features --features web-nothreads
Do not use this for vn_godot:
cargo check -p vn_godot --all-features
It should fail by design, because it selects desktop and web together. Instead, regard the three commands above as the initial compatibility matrix:
- Native desktop adapter build.
- Threaded browser extension build.
- Single-threaded browser extension build.
The pure crates should continue to compile and test without knowledge of this matrix:
cargo test -p vn_engine --locked
cargo test -p vn_content --locked
That independence is the architectural payoff. A web-only failure is isolated to the Godot boundary; it cannot quietly alter narrative logic or compiled content.
Key takeaways
- Cargo features in this project are confined to
vn_godot, the only crate allowed to know about Godot and deployment platforms. desktop,web, andweb-nothreadsdefine explicit adapter build modes;web-nothreadsforwards to godot-rust’s WebAssembly no-thread configuration.- Default desktop builds remain convenient, while every web build must use
--no-default-features. - Feature checks combined with target checks turn accidental hybrid builds into clear compiler errors.
- Godot web exports need two separately compiled
.wasmextension artifacts, selected by the export preset’s Thread Support setting. - The deterministic engine and compiled content must never branch on desktop or web features.
Next, you will define a typed command enum for requests entering the engine. That command boundary will give EngineHost and later platform integrations one stable, presentation-independent way to ask the pure Rust runtime to perform game actions.
Can't find a good explanation? Sign up and we'll make it for you
Sign up