Siren Devlog #1 - Switching to Rust

Siren Devlog #1 - Switching to Rust

Dec 16, 2024

In the first devlog, I didn't mention what language I'm using to write Siren. That was intentional. At that time, I was playing with the idea of switching from C++ to Rust. So I didn't want to state what language I was using until I'd decided. The decision has been made and Siren will be built in Rust going forward. I'd been hearing good things about Rust for a while, but didn't really dig into it until earlier this year. As I learned more, it became clear that Rust would be a perfect fit for the technical goals I have for Siren.

Memory and Thread Safety

Games, even relatively simple ones, are complex systems and memory management can be a major challenge. Languages that lack memory safety features open the door to memory leaks, dangling pointers, null references, and other forms of corruption. These kinds of issues result in crashes and inconsistent behavior that can be difficult to troubleshoot and reproduce. Some languages, such as C#, try to alleviate the memory management burden with a garbage collector. But this comes with a runtime cost and introduces another point of unpredictable behavior in your game. Instead, Rust aims to solve memory management issues with it's borrow checker.

Borrow Checker

The borrow checker is a key feature of Rust. It consists of a set of constraints that govern memory ownership and ensure that references can never outlive the data they point to or be mutated in unsafe ways. Such constraints protect the developer from introducing common memory errors. The borrow checker's memory access rules are enforced at compile-time, so we get memory safety without incurring a run-time cost. This means the developer (or really, the compiler) is more likely to catch critical issues long before the code ends up on the player's machine.

Games rely on predictability. Behavior should be deterministic, performance should be stable, and frame times should be consistent. Unexpected dips in performance can ruin the player’s experience. So we want to take advantage of any memory management features our tools offer.

Modern C++ tries to make memory management easier with things like smart pointers. Smart pointers are great, and they do help reduce memory issues. But they still fall short of the borrow checker. They don't really provide blanket protection, and even when using smart-pointers, C++ still gives you plenty of opportunities to shoot yourself in the foot. C++ smart pointers are a library-level tool, so they also bring along runtime overhead. Whereas Rust’s borrow checker is a foundational part of the language. You literally have to write memory-safe code or else it won't even compile (`unsafe` Rust is a thing, but that's another topic.)

Multi-Threading

Games are inherently parallel: rendering, AI, physics, audio, networking, and scripting often run simultaneously on multiple threads. However, multi-threading is challenging and can easily lead to race conditions and nondeterministic bugs. The borrow checker, along with Rust’s ownership rules, provides a compile-time guarantee that no two threads will access the same data in conflicting ways. Just like our memory-safe code, we can also guarantee thread-safe code without run-time costs. This is an absolutely massive advantage!

Performance in a game engine is not a feature, it's a requirement. Being able to confidently run multiple subsystems in parallel will allow me to extract the maximum performance out of the machine. I'll be able to do this without the headaches of synchronization logic and concurrency bugs. So this should also help development move a little faster as well.

Data-Oriented Design

Like most modern game engines, Siren takes a Data-Oriented Design (DOD) approach, favoring composition over inheritance. DOD focuses on maximizing memory efficiency and CPU-cache utilization. Rust seems like a natural fit for implementing these design principles.

As you know, Rust is a systems-level language with manual memory management. That means we can layout our memory however we want in order to achieve maximal cache utilization. To minimize the number of CPU-cache misses, we typically want to arrange large collections of data in contiguous memory (arrays of components.) A simple example might be storing all of the coordinate positions and current velocities of all entities in the current scene. We can then loop through this array and update the positions of every entity. The contiguous nature of our data structure will result in a high number of cache hits which will greatly boost the performance of the game.

As the game runs and data changes, we may find ourselves in situations where we need to rearrange our data to continue to make the best use of the cache. Thanks to the borrow checker and thread safety guarantees, we don't have to worry about memory corruption. We are able to rearrange our data structures as we see fit without the risk of introducing undefined behavior.

Even in it's early state, memory management was becoming a challenge in Siren. This should be obvious, but it's important that any issues in a game be because of the game's code, not the engine's. Siren absolutely needs to be reliable if I expect anyone to use it for their game. So once I started learning about Rust's memory and thread safety features, I immediately started leaning towards making the switch. But it was Rust's tooling that ultimately sealed the deal for me.

Cargo

Cargo is Rust's build tool and package manager. As I dug more into Rust and learned about Cargo's features, the shortcomings of my C++ build process became much more apparent.

Cargo is secretly Rust's killer feature, not the borrow checker.

Package Management

I'm trying to keep Siren's dependencies to a minimum. But there are plenty of places where using a third party library makes sense. Managing dependencies in C++ is an awful experience. Luckily, we have Cargo's package manager to aid with this.

The C++ version of Siren had precompiled static SDL libs in it's repo. Other packages were included as git submodules. Keeping all of these up to date is cumbersome. Plus you need a build system to manage everything. I was using Premake for this, which I found to be a much better experience than using CMake. But Premake was also yet another dependency that I had to include in my repo, along with it's setup scripts for each platform. Managing all of this was hard enough as the project owner. I would never want to burden the end users with all of this chaos.

I want Siren developers to be able to just download the code and run it. That's it; setting up the project should be a non-issue. With Rust, I'm able to eliminate all of those dependency management headaches with a simple toml config.

[dependencies]
sdl2 = { version = "0.37.0", features = ["raw-window-handle", "static-link", "bundled"] }
wgpu = { version = "22.1.0", features = ["spirv"] }
serde = { version = "1.0.215" }
erased-serde = { version = "0.4.5" }
bincode = { version = "1.3.3" }

This should make sense to anyone who's used a modern package manager, such as npm. No precompiled binaries, no git submodules, and no extra setup scripts. I just specify my dependencies and Cargo pulls them from crates.io during the build process.

Modularity

I envision Siren as being a collection of modules (or `crates` now that we're in Rust.) These modules are managed by the engine's Core module which defines the common interfaces that are implemented by the individual feature modules. There are several advantages to a modular architecture:

  • Developers are able to swap out modules for alternate implementations. Don't like Siren's pathfinding module and want to roll your own? Great! You can absolutely do that!
  • Developers can easily add features without modifying existing code. Since new features can be built as new modules, the feature list will be able to grow without compromising the existing codebase. This will be especially useful once the project becomes open source.
  • Loosely coupled modules can be used outside of the engine. Modules can be published as standalone crates on crates.io. So thing's like Siren's ECS can potentially be used in other projects that have nothing to do with Siren.

Before switching to Rust, Siren's modularity was a bit nebulous. In this early state, a `module` basically just meant a separate `folder` and it was still unclear how I'd truly decouple them. Being able to ship standalone modules, was basically a pipedream. I could have potentially implemented every module as a separate static lib, but that would be a nightmare to manage. Now, thanks to Rust's crate system, I can maintain a completely modular architecture right from the start.

Example Projects

When developing a library, you need some kind of client project, or a driver, that utilizes the library. This gives the developer and the user of the library a testbed. In the C++ project, that client was a "sample game" executable that used the Siren static lib. These were two projects within one Visual Studio (or XCode) solution. This worked, but wasn't very flexible. What if I wanted multiple example projects and needed to be able to switch between them? It wasn't impossible, but it was certainly impractical. Luckily, Cargo has a solution: Example Projects.

Within Siren's toml config, I can define multiple example projects. Example projects are divided into their own subfolders. 

[[example]]
name = "siren_test"
path = "examples/siren_test.rs"

[[example]]
name = "event_test"
path = "examples/event_test.rs"

[[example]]
name = "audio_test"
path = "examples/audio_test.rs"

Then, when I run my program, I just specify which example project to run.
ie, `cargo run --example audio_test`

I can create any number of example projects to test various modules individually. As the project matures, these micro-projects will be able to evolve into small game examples. 

Portability

Siren is primarily being developed on Windows, but porting to Mac and Linux is proving to be trivial. In fact, the first time I tried to build it on a Mac, it ran with zero code changes! There is one big issue with using Rust though: Consoles.

My dream is that I'll be able to get a game built with Siren published to consoles someday. I don't have access to the SDKs or documentation right now and information on them is scarce due to NDAs. But I do have some vague ideas on how Siren could potentially run on consoles:

  • I'm assuming I could write a new platform layer in C/C++ that wraps all of the platform-specific API calls. Then let the rest of the engine sit on top of that layer and utilize it via language bindings.
  • Another similar idea is to build the game, and any platform-agnostic Siren code, as a large static lib. Then build a small C++ bootstrap application using the platform API. The bootstrap application would then basically kick off the game as a static lib. Of course, doing this would require the console to support any dependencies being used by the big static lib. For example, I see a lot of console games using fmod for audio. So if I have an fmod implementation of Siren's audio module, in theory, that should just work (but in reality, I know it's never that simple.)

Without access to the toolkits, I can't really say how this would all work. For now, I'm just focusing on Windows/Mac/Linux. While I'm thinking (dreaming) about consoles, I wouldn't expect to do anything serious with them until Siren is available and there's at least one commercial game released. So it's still pretty far off. But Rust's popularity is increasing every day; it's even in the Linux kernel now! So maybe console manufacturers will provide proper Rust toolchains by the time I'm ready? One can only hope.

Moving Forward

C++ has been feeling bloated for a long time. I remember reading about experienced developers feeling that way 20 years ago! New features, like smart-pointers, keep getting packed into C++ to "fix" it. But to me, it feels like it just needs a total refresh... or a replacement. For me, Rust has become that replacement.

In some ways, I've started over on a large project and that can feel defeating. But I think that this is the right move and it will ultimately result in a better product. Plus, since I started this devlog around the same time as the switch, I'm now in a better position to share Siren's progress a bit closer to real-time.

-Matt ☕