They wanted to see substance I didn't include. They saw through the sparks and realized I stayed on the surface. I didn't understand the depth of programming Rust, and it made a more thorough presentation impossible.
A decade after my style-over-substance presentation, I decided to revisit this language. It has come a great way in the past decade, and makes for an excellent learning opportunity.
As mental exercise, I decided to get truly familiar with the programming language Rust. Combining modern paradigms (from languages like Python) with the ruthless efficiency of machine code (from languages like C), Rust harbors potential for future-proof code in critical software like the Linux Kernel.
During my university presentation, I barely went beyond "Hello World". There is so much depth to this language, and this post is my opportunity to engage more meaningfully with it.
A real use case is the best learning vehicle. Choosing a project is always a challenge, especially when looking at a backlog of unfinished projects like Gnomebrew. Balancing fun and reasonable scope, I want to build a turn-based combat system that can eventually feed back into Gnomebrew. The basic idea is:
This way, the inherently 'idle' gameplay can connect seamlessly with my vision for the larger project Gnomebrew, while also serving as a standalone experience.
Under its name Tussle Hustle, my project is publically accessible in this Github repo.
A lot of additional design decisions must be made to build this, but since my focus is on learning Rust, I'll introduce these as needed.
More than the end result of a fun game, I care about the learning journey of getting accustomed to Rust's unique aspects that often trip up developers coming from other languages. With the enormous freedom that comes from utilizing modern concepts in machine code come restrictions on how to write code that are difficult to get used to, at least with pre-established programming habits.
From writing code to public speaking, in this type of learning discomfort lies the greatest growth potential: Unlearning established habits and effectively replacing them (i.e. relearning) requires huge mental effort, but the investment is almost always worth it if you value personal growth of any kind.
How we deal with the discomfort from learning challenges determines how fast we can overcome old patterns, be it patterns about how we engage with others or patterns in how we write software: It's about facing the challenges (and the discomfort that comes with it) head on. I want to talk about some of the hurdles I overcame learning Rust.
This is undoubtably every Rust beginner's first big adjustment: where most programming languages give you great freedom on how to use references or pointers in your code, Rust introduces restrictions that limit when and how references can be used by defining ownership and requiring non-owners to borrow data:
With these lifetime restrictions come great performance wins: because Rust can tell exactly at compile time how long each reference 'lives' (and whether it can change state), critical tasks of higher-level languages (such as managing memory) are much easier. Where languages like Java need an active Garbage Collection process requiring even more computing resources, Rust can tell from its code alone when it's time to delete data, a significant speed-up in practice.
In my experience, this was a concept easy to understand and pretty straightforward to apply - until it wasn't, because I ran into an 'immutable wall'. Coming from Python with its enormous flexibility, I stumbled into a few coding challenges trying my established way of thinking in this new language.
A great example is combat and turn resolution: central to the turn-based fighting game, I need an elegant way to determine how actions are processed, giving my combatants the ability to react to the actions of others. For example:
My first, naive implementation of this resolution process was very much based on how I would achieve this using a language like Python, implementing these features in separate function calls:
Looking at this diagram, it's already clear to see that this implementation is not ideal: A web of function calls is difficult to understand, maintain and audit, especially as complexity increases. Python would let us continue writing this functional, but messy code. Rust, however, stops me in my tracks before I continue with this bad design pattern, because it breaks borrowing rules:
mut
) at the same time, so this code provokes a compiler error.What might feel frustrating in the moment (I have to redesign my code) yields a very high payoff: By design, Rust encourages improving the architecture of bad code and actively discourages error-prone patterns as they break during compilation.
Rather than letting me solve problems any way I want (even inefficient ways), Rust forced me to improve upon my design before I could even run my spaghetti code.
This is an excellent example of how Rust also encourages self-growth: erring on the side of caution, Rust's compiler disallows (by default) code even though it would work just fine, because it breaks the borrowing and ownership rules.
As someone who built - and then multiple time re-built - complex software systems, this value is truly striking to me: In Python, I could have expanded this clunky implementation by thousands of lines of code before my original architecture reveals its weakness. Before this technical debt occurs, the Rust compiler audited my code and prevents me from digging an even deeper grave for myself.
The key issue here is subtle, and can best be explained from Rust's Mutability Perspective. The naive implementation breaks Rust rules, because only one mutable reference/pointer to the same object may exist at one time: As soon as one character acts/reacts more than once in this function-call based implementation, more than one mutable reference would be active at the same time.
With this, Rust's design subtly pushes me to separate concerns:
If you played a turn-based trading card game like Yu-Gi-Oh or Magic The Gathering, you've seen that this can be nicely represented with a card stack:
Now the stack has been built bottom to top, but the effects will be resolved the other way around, starting at the last used 'card' at the top finishing with the original attack at the bottom. My ActionStack
structure can be built and resolved as such:
Now this implementation separates concerns clearly in two phases:
ActionStack
structure is mutable during turn resolution, allowing the Characters to remain immutable as the stack is built bottom to top. This is when our characters place their 'cards'.One interesting observation is that while this stack-based solution runs differently, it directly mirrors how my first Python-esque solution worked, just using the function stack rather than a dedicated stack structure to manage the resolution.
Now this two-step process works fantastically on paper: First build the stack, then resolve it, mutably affecting the Characters. However, as implementations grow, typical issues from mutable/immutable limitations emerge:
I now want my characters to manage a resource Action Points that they must spent every time in order to make a reaction. Even if a reaction gets canceled or countered by another reaction, the Character must pay the associated Action Point cost.
This creates a problem because:
For these "exceptions to the immutability rule", Rust provides a key data structure, RefCell
, allowing an immutable structure to contain mutable data. Wrapping my characters' Action (and Magic) Points in a RefCell, I can change these values at any point, even during immutable access.
All I need to do is wrap my mutable data in RefCell
:
/// The data structure to describe any character contains 'normal' types (only mutable when the character is),
/// but also
pub struct Character {
// snipped...
// ~ Basic Fields of a Character ~
/// Current Character Hit Points
/// -> Can only be changed if Character is MUTABLE
hp: i64,
// ~ Internally mutable fields of a Character ~
// ---> RefCell allows changing the i64 value even from mutable contexts
/// Current Character Mental Points
mp: RefCell<i64>,
/// Current Action Points of this Character.
ap: RefCell<i64>,
}
While hp
can only be be updated when the respective character reference is mutable, even during immutable access can we update the character's ap
and mp
, as they are wrapped in a RefCell
, which makes these values mutable, even during Stack creation:
/// This function is defined on an IMMUTABLE character reference
/// Still, we can change the character's stats, provided they are wrapped in `RefCell`
fn apply_reaction(self: &Character, reaction: &Action) {
// snip...
// Request the mutable borrow from immutable context to update this character's current AP
*self.ap.borrow_mut() -= reaction.ap_cost();
// Do the same for MP
let mp_cost = reaction.mp_cost();
if mp_cost > 0 {
*self.mp.borrow_mut() -= mp_cost;
}
// Continue applying
// snip...
}
RefCell
is a perfect example for Rust-specific data structures: other languages do not have a strict concept of ownership and mutability locks. Instead, data can change at any time in any place, so the need to encapsulate mutable data within immutable contexts does not exist.
Now my turn resolution is implemented elegantly: concerns are clearly separated between setting up and resolving the chain of effects that entails the turn, and despite characters being immutable during that setup, I can still change the specific data fields I need to update anytime.
With that, I have a solid, albeit basic flow for gameplay defined. Adding a few first equipment pieces, I can see the core gameplay unfold:
This screenshot gives an insight into my base vision for this game, but also leaves many challenges unaddressed that I tackled as well while building my Tussle Hustle prototype, for example:
Beyond these solved challenges, I'm thinking of outstanding features to complete a minimal gameplay experience:
The beauty (and the terror) of projects like Tussle Hustle is there's always more to do. Rather than the vision of a final product however, this experience is truly about the (learning) journey to me.
Its rigid set of programming paradigms makes Rust more cumbersome to learn than Python, with the compiler screaming an emphatic (and surprisingly verbose) "No, you can't do that" where other languages would let me follow my coding bliss. I am taken away by how this turns the Rust language into a learning vehicle.
Enacting growth always requires behavioral change: when we can sustainably replace old actions with new and better ones, we enrich our life and ourselves. Other than adding a new tool under my coding belt, my journey with Rust let me experience and appreciate both the frustrations and triumphs any learning process entails.