Building a Rust Game Engine: Part 1 - Project Setup
So you want to build a game engine in Rust. Bold choice. Let’s start with the foundation: project structure.
The Vision
We want three main components:
- Engine - The core library. Rendering, audio, physics, the works.
- Editor - A visual tool for building games (think Unity or Unreal Editor).
- Sandbox - A lightweight test app for rapid iteration.
For development, we want fast iteration. For shipping, we want optimized single-file executables. And we want clean output directories—each app isolated with only its dependencies.
Cargo Workspaces
Cargo workspaces let you manage multiple related crates in one repository. Create a root Cargo.toml:
[workspace]
members = ["editor", "engine", "sandbox", "xtask"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
[workspace.dependencies]
engine = { path = "engine" }
[profile.release]
lto = true
The workspace.dependencies section is key—it lets child crates reference the engine with just engine.workspace = true instead of repeating the path everywhere.
The Engine Crate
Here’s where it gets interesting. The engine’s Cargo.toml:
[package]
name = "engine"
version.workspace = true
edition.workspace = true
[lib]
crate-type = ["dylib", "rlib"]
[dependencies]
We’re building both a dynamic library (dylib) and a static library (rlib). Why?
dylib vs rlib
-
rlib (Rust library) - Static linking. Gets baked directly into the executable. One file, no dependencies, maximum optimization with LTO.
-
dylib (Dynamic library) - Produces a
.dll(Windows),.so(Linux), or.dylib(macOS). Separate file that executables load at runtime.
By building both, we keep our options open. The DLL is there when we want it for hot-reloading scenarios. The rlib is there for release builds.
A Note on prefer-dynamic
You might think “I’ll just use -C prefer-dynamic in debug builds!”
Tried it. Here’s the catch: prefer-dynamic makes your executables depend on the Rust standard library DLLs too, not just your engine. Those live in your Rust toolchain directory, making distribution a headache.
For a game engine, the practical approach is:
- Debug builds include the DLL alongside executables (for future hot-reload support)
- Release/Dist builds use static linking—single executable, no DLL needed
- Release builds use LTO for maximum optimization
When you’re ready for true hot-reloading, you’ll want to use cdylib (C-compatible DLL) with runtime loading via libloading. That’s a topic for another post.
Editor and Sandbox
These are straightforward binary crates:
# editor/Cargo.toml
[package]
name = "editor"
version.workspace = true
edition.workspace = true
[dependencies]
engine.workspace = true
// editor/src/main.rs
fn main() {
engine::init();
println!("Editor started");
engine::run();
engine::shutdown();
}
Same pattern for sandbox. They depend on the engine, Cargo handles the rest.
The xtask Pattern
Raw cargo build dumps everything into target/debug/ or target/release/. Fine for simple projects, but game engines need more structure. We want:
bin/
└── Debug-x64/
├── Engine/
│ └── engine.dll
├── Editor/
│ ├── editor.exe
│ └── engine.dll
└── Sandbox/
├── sandbox.exe
└── engine.dll
Each app gets its own folder with only what it needs to run. Clean. Distributable.
Enter xtask—a convention where you add a helper binary to your workspace:
# xtask/Cargo.toml
[package]
name = "xtask"
version = "0.1.0"
edition = "2021"
Then alias it in .cargo/config.toml:
[alias]
xtask = "run --package xtask --"
Our xtask handles:
- Running
cargo build - Creating the output directory structure (
Debug-x64/,Release-x64/) - Copying executables (and DLLs for debug builds only)
- Including PDB files for debugging on Windows
The core logic:
fn build(args: &[String]) {
let is_release = args.iter().any(|a| a == "--release");
let profile = if is_release { "release" } else { "debug" };
let arch = "x64"; // or detect from cfg!(target_arch)
// Build with cargo
Command::new("cargo").arg("build").status()?;
// Create output structure under bin/
let out_dir = format!("bin/{}-{}",
if is_release { "Release" } else { "Debug" },
arch
);
fs::create_dir_all(format!("{}/Editor", out_dir))?;
fs::create_dir_all(format!("{}/Sandbox", out_dir))?;
fs::create_dir_all(format!("{}/Engine", out_dir))?;
// Copy artifacts
fs::copy("target/debug/editor.exe", format!("{}/Editor/editor.exe", out_dir))?;
// Only copy DLL for debug builds (release uses static linking)
if !is_release {
fs::copy("target/debug/engine.dll", format!("{}/Editor/engine.dll", out_dir))?;
}
// ... etc
}
This is the foundation for:
- Asset pipeline integration
- Shader compilation
- Platform-specific bundling
- CI/CD helpers
Build Commands
With everything set up:
# Debug build (with DLL + PDBs for development)
cargo xtask build
# Release build (static linking, LTO optimized)
cargo xtask build --release
# Distribution build (stripped, with assets)
cargo xtask build --dist
# Build and run
cargo xtask run --bin editor
cargo xtask run --bin sandbox --release
# Clean everything
cargo xtask clean
What We Get
bin/
├── Debug-x64/
│ ├── Engine/
│ │ ├── engine.dll
│ │ └── engine.pdb
│ ├── Editor/
│ │ ├── editor.exe
│ │ ├── editor.pdb
│ │ ├── engine.dll
│ │ └── engine.pdb
│ └── Sandbox/
│ ├── sandbox.exe
│ ├── sandbox.pdb
│ ├── engine.dll
│ └── engine.pdb
│
├── Release-x64/
│ ├── Editor/
│ │ └── editor.exe (statically linked)
│ └── Sandbox/
│ └── sandbox.exe (statically linked)
│
└── Dist-x64/
├── Editor/
│ ├── editor.exe (stripped)
│ └── assets/
│ ├── engine/ (from engine/assets)
│ └── editor/ (from editor/assets)
└── Sandbox/
├── sandbox.exe (stripped)
└── assets/
├── engine/ (from engine/assets)
└── game/ (from sandbox/assets)
Three build configurations:
- Debug - DLL + PDBs for development and future hot-reload
- Release - Static linking for testing optimized builds
- Dist - Stripped symbols, bundled assets, ready to ship
Project Structure
GGEngine-rs/
├── .cargo/
│ └── config.toml # xtask alias
├── .gitignore
├── Cargo.toml # Workspace root
├── editor/
│ ├── Cargo.toml
│ └── src/main.rs
├── engine/
│ ├── Cargo.toml
│ └── src/lib.rs
├── sandbox/
│ ├── Cargo.toml
│ └── src/main.rs
└── xtask/
├── Cargo.toml
└── src/main.rs
What’s Next
With the project structure in place, we can start building actual engine functionality:
- Window creation and input handling
- Rendering abstraction (Vulkan? wgpu?)
- Entity Component System
- Asset loading and hot-reloading
The foundation is boring but necessary. Now the fun begins.
This is part 1 of a series on building a game engine in Rust. Special thanks to Cherno for getting me interested in this stuff.