Rust Warrior - Beginner Extraordinaire

October 14, 2019

The 9th and final beginner level of Rust Warrior is at last completed! And it happened on the 100th commit! So far the gameplay has stayed almost entirely true to the original Ruby Warrior, with some minor tweaks here and there. At PDX Rust a couple weeks ago I was able to demo Rust Warrior and that was a lot of fun!

Since this feels like a nice milestone, I'll review some of the things that make Rust Warrior different from its predecessor and some things I've learned while working on it. Some of what you're about to read can also be found in commits and releases on Github. I tend to write mini blogs in my commit messages and release notes, and they're great notes to pull from for this recap of the project!

Porting a game engine written in Ruby -- which appears to be the most free-spirited programming language ever -- to Rust -- which some claim has a high learning curve because of its unique restrictiveness -- was interesting. I haven't written a ton of Ruby in the past, but I know enough to read it and have a general understanding of what's happening. Much of the code for Ruby Warrior required multiple readings in order to grasp what was really happening. If I can be critical of the Ruby syntax very briefly, why must it be allowed for method names to be bare words? In my opinion it greatly hurts readability when you're not sure if something is a local variable or a method...

Going a little deeper than the surface syntax, the first obvious difference I encountered between Ruby and Rust is that one is interpreted and one is compiled. Basically, the player is not going to be able to fire up an interpreter and load in their Rust code at run time. I had to devise a new way for players to write Rust code and interact with this game engine. I liked the way Ruby Warrior could be run from the command line and would generate the files needed to start a game, so I set up Rust Warrior so that the main.rs would do exactly that. I theorized that the player's Rust code could import the game engine as a library. I knew it was possible to set up a Rust project this way, so I tried it out. By generating a Cargo.toml and src/main.rs (just like cargo new), I was able to produce a working Rust Warrior player program that depended on the Rust Warrior crate. Much of the Rust Warrior code that generates the new player files, starts up the game, and transitions between levels is noticeably different from the Ruby Warrior code that it's based on.

Another thing that made this port particularly challenging was, wait for it... the BORROW CHECKER!!! Ha!! Ruby doesn't care at all about shared mutable state! More specifically, Ruby doesn't care about ownership. It's totally fine for two objects to point at each other. This hurdle appeared very early on. While trying to add support for level 2, I struggled to manage the state of the warrior and the sludge. The sludge needed a way to remove health from the warrior, and vice versa. Basically, I coded myself into a corner where I could only proceed by either having two mutable references to one piece of state or a mutable and immutable reference to a piece of state. I don't recall exactly what state I was juggling at the time, but if I recall there was Level and it had references to each Unit and would mutate them to move them, etc. I decided to incorporate ECS into the game to help with managing state, and there's a meaty commit 64a7241 where I implemented that. Shortly after, I was able to (relatively) easily finish coding level 2!

One thing probably nobody would ever notice if I didn't point it out is that level 4 of Rust Warrior is not the same size as level 4 of Ruby Warrior. Around the time when I was working on level 7, I was play testing earlier levels and died while playing level 4. I was trying to back away from the archer and ended up with my back against the wall while still in range of his attacks! So rather than changing my player code I altered the level so that doesn't happen, by moving the archer back one space.

Level 4 was hard to beat in Rust Warrior. Another reason I was dying while play testing is that Ruby Warrior unfairly prevents damage to the player when they first approach an enemy. The game is turn-based, and a critical element in turn-based combat is deciding turn order. Based on all other actions that had occurred in the game thus far, the player always goes first. But in Ruby Warrior if the player moves into an enemy's attack range, when that enemy takes its turn it does not attack. What?!! This meant that in Rust Warrior, approaching the thick sludge always meant death because it had more health than the warrior and because there was no special rule about skipping the first attack. When I reduced this enemy's health from 24 to 18, the level became just barely survivable.

A very noticeable difference between Ruby Warrior and Rust Warrior appears at level 6. In Ruby, you can omit arguments to a method (like direction). But in Rust Warrior, I needed to come up with a way to allow the player to move forwards with walk() and also move backwards. For directional actions, I decided to add walk_toward(Direction) as well as a directional counterpart for every other action method. It's also worth mentioning that the Warrior interface in Rust Warrior is defined once for all levels. In Ruby Warrior, however, there are different methods available at each level. Not long ago I finally implemented some restriction on which of those methods can be used, depending on which level is being played. So at the end of the day, although the Warrior in Rust Warrior has a lot more methods on it, and some of them are named differently, it's basically the same functionality as Ruby Warrior.

As I've worked on this project, I've leveled up my development tools a bit here and there. I thoroughly enjoy enabling formatOnSave in VS Code and always having my code formatted (via rustfmt). I also have clippy integrated with VS Code, and one of the lints that I encountered was new_without_default. If you write new(), clippy says you should just make that default() instead. I wanted to get rid of yellow squiggly lines in my editor, so I followed those instructions. But eventually I decided that I didn't like always doing Struct::default() when Struct::new() seemed like a more clear method name. Nowadays, if I see this warning I just add #[derive(Default)] and have a method like this:

fn new() -> Self {
    Self::default()
}

I'm curious if anyone else has run into that...

Recently I've been learning quite a bit about macros (including derive macros), and I saw an example of using serde_derive for serialization and deserialization between a type and a text format. Somehow it got me thinking about some TOML serialization and deserialization I've done in the past. One of my very first Rust projects (about 2 years ago) used the toml crate to read from and write to a config file. I remembered it being a little tricky, so when I wanted to do the same thing in Rust Warrior I just copied that code. But this serde_derive example made me wonder why I was manually constructing and deconstructing this struct instead of just deriving that functionality. It turns out I had no reason! I greatly simplified the config file reading and writing by simply adding #[derive(Deserialize, Serialize)]!

There is probably a lot more that I've learned while doing this project, but I think I've covered the most significant stuff. I hope any Rust beginners who stumble into Rust Warrior are able to use it to learn more about this wonderful language and ecosystem. There is still more that can be added to Rust Warrior (check out the issues page!), and I'm hoping to open it up to contributors soon.