Why I Chose Rust

Rust is an amazing language. It provides modern features such as lambdas and generics, and futuristic features such as Hindly-Milner type inference, hygienic macros, and ADTs. It has a coherent ethos and design philosophy, and keeps a very specific development style in mind while nonetheless allowing for enough flexibility for personal preference and contextual situation. Besides its somewhat awkward (if familiar) syntax, however, Rust has one major controversial feature: the borrow checker (and its associated ownership model). As someone who has had some experience with low level systems programming languages, I know enough to understand the dangers of C++, but am not familiar enough with them to avoid them a priori. Because of this, Rust’s borrow checker actually looked quite attractive, and while C++ was the more mainstream and mature choice for developing what would have to be a performant and large indie game, I chose to go with Rust.

Rust was the most logical choice at the time for several reasons. I was fairly familiar with it, having used it for a number of small, unpublished projects and having made some (also small) contributions to the compiler. Moreover, it had the combination of high level features that I was used to from Lisp and ML, which were my primary languages, as well as the low level capabilities and performance of the more usual game programming languages. I needed these high level features for developing the kind of goal-oriented, constraint-solving AI that the game needed to be even marginally playable, and I needed the performance of something like C++, so Rust’s zero-cost abstractions were perfect.

While C++ could emulate many of these high-level constructs, the final deciding factor in my choice of Rust was the borrow checker. While garbage collected and reference counted languages are perfectly doable for PC game programming, they’re not the norm and would require a lot of tweaking to make performant and avoid frame rate drops. In addition, GC’d languages tend to be slower in other ways that I couldn’t afford. This means my choices were pretty much manual memory management or reference counting in C++, or the compile-time borrow checking in Rust.

Once I’d chosen Rust as my programming language of choice, I had to slowly and painstakingly assemble a series of Rube Goldberg-machine like plugins and executable to get a working IDE for Rust in Emacs. I was used to Emacs and loathe to leave it, considering one of its greatest strengths its ability to become an IDE for any language. In the end I got this working, and for most of the development process this was the editor that I used. However, a few months ago the Rust Language Server began to bog Emacs down, and the plugins I was using were single threaded, so that means the whole editor would be down until I opened my Activity Monitor and “force quit” the process. RLS is still a beta and nightly-only feature for Rust, so I couldn’t wait to fix it. Instead, I was forced to leave Emacs behind and move to something else. I checked this rather useful website, Are We IDE Yet? for a clue as to what was the most well-supported IDE for Rust. As it turns out, it was Visual Studio Code, an editor that I had had my eye on for some time. I’ve since switched over, using the Rust Plugin to Rule Them all, and have had an absolutely lovely experience with it. VSCode is modular and flexible enough to handle whatever I need, and comes with many novel features and a pleasing interface. At this point, as much as I hate to admit it, I may never return to Emacs.

The first game engine, or really, graphics engine that I chose for the game was LibTCOD, which is powered by the Simple MediaDirect Layer. It could only handle a single colored tile in a grid of character-like tiles, with some rudimentary color tinting and “frame” management. This was a design choice made out of convenience and I didn’t intend to continue using this library for long. Will, the graphic designer, sunk an admirable amount of time into trying to make this hilariously outdated graphics setup look nice, in order to make it a more attractive beta. About a year ago, we made the transition from that to a more powerful and fully featured modular game engine, Piston, which has been an amazing experience.

Now, at this point it would be reasonable to ask why I didn’t just choose an established game engine like Unity, Godot, or Unreal Engine. There are a couple reasons for this, but the main one was that most of the graphical and UI capabilities that the game would require were somewhat simple, and as such the extra boilerplate, debugging issues, and complexity of traditional game engines would be overkill. I’ve written one fully featured game in Unity and a slew of half-baked ideas in Unreal Engine, and while these engines are amazing for some things, they’re not for us.

Rust isn’t a silver bullet however. While a lot of the time, it will prevent me from shooting myself squarely in the foot, and will make the multithreaded aspect of Astra Terra much easier to do, the borrow checker also sometimes makes things worse. A specific example of this is in the tree spawning system, which I’ve been working with a lot lately due to having a bunch of new tree-related graphics. The central hub of world generation looks like this:

/// Generates a new unit map for World from the given (incomplete) World.
/// The steps go as follows:
/// * Generate bedrock and mountains/hills from terrain info
/// * Replace low rock with water (for sea, pools)
/// * Generate biomes (temp, percipitation) from altitude and a noise
/// * Generate vegitation based on what survives where in the biomes
/// * Generate the soil (and snow) based on plant and biome.
fn map_from(
    size: Point2D,
    ws: &World,
    props: &GenProps,
) -> WorldMap {
    let mut map = World::rock_from_terrain(ws, size, props);
    World::water_from_low(&map, props);
    World::biomes_from_height_and_noise(&mut map, ws, props);
    World::add_soil(&mut map, ws.seed, props);
    World::vegetation_from_biomes(&mut map, ws.seed, props);
    World::add_walkspace(&mut map);
    map
}

Now, before you burn me at the stake, I realize that I’m progressively mutating a map. As it turns out, this is much more syntactically clean and performant than passing an iterator around (due to Rust having no currying and composition primitives), and while its ugly, its also necessary. Inserting trees happens in the vegetation_from_biomes step, of course, but how it happens is interesting. You see, vegetation from biomes iterates through each surface tile (the top tile of each stack in the map), and adds the return value of a select_vegetation function there, based on what’s currently there and the biome currently there:

for (y, row) in world.iter().enumerate() {
    for (x, unit) in row.iter().enumerate() {
        let arc = unit.tiles.clone();
        let mut ut = arc.write().unwrap();
        if ut.len() > props.sea_level as usize
           && unit.biome.unwrap().biome_type
              != BiomeType::Water
        {
            let vego = World::get_vegetation(
                 &vnoise,
                 (x, y),
                 unit.biome.unwrap(),
            );
            if let Some(veg) = vego {
                // ... Insert the vego tile here....

However, since this function isn’t modifying the outside world (the map, in this case), it can’t change anything but what’s the top tile is. So, when it needs to insert a tree, it returns an abstract tree with the necessary information, and the outer function breaks it up into the component tree-parts and inserts them into the map. The issue here is that we’ve already borrowed the Unit that contains the current tile, so when we need to insert the tree’s branches, which need to go on the adjacent unit, we can’t borrow a new one, because borrowing the current Unit also borrowed the map.

As a way to solve this, I’ve started pushing the coordinate I want to write to, and the tile I want to place there, to a HashMap and iterating over it outside of the loop:

branches.insert(
    (
        offset.0,
        offset.1,
        ut.len(),
    ),
    Tile::Vegetation(
        vt.convert_to_branches(
            i,
        ),
        height as i32,
        State::Solid,
        ),
 );

This is a non-ideal solution on a number of levels, but it was a quick and dirty one. Rust tends to lead to these kind of solutions in the short term, but I’m in the process of a refactor right now that would restructure the part that actually does the testing, decoupling it form the expantion part, and allowing everything to reside inside the same loop. In the long run, Rust always provides the right capabilities to get a good, clean result, it just might take you longer.

And here’s the central point: Rust is not the perfect language for everything. It is not a silver bullet, and the borrow checker does require extra effort. However, it was the right choice for us, and I’m happy with it.

One thought on “Why I Chose Rust”

Leave a Reply

Your email address will not be published. Required fields are marked *