A Rust Case Study


I thought it might be nice to walk through an example of "doing something in Rust," as a way to illustrate why I think it's nice to work in. This is part of a game I'm writing, I wanted a component for an ECS to represent a one-shot animation. It's a fairly short and self-contained piece of code.


So to start with, we want a struct for the component itself:

pub struct OneShotAnimation {
    frames: Vec<Sprite>,
    rate: Duration,
    timer: Duration
}

One nice thing here is that Rust actually has an included type for a duration of time, so rather than have confusion over whether it should be seconds or msec, I just used that one.

Every type in Rust can implement different traits, some do very useful things, like debug output, and some of the more useful ones have macros that auto-generate default implementations for them. If you don't have a good reason not to, and you're able to, you should use those, so I'll add this above my struct declaration:

#[derive(Clone, Debug)]

Normally I'd include Copy and PartialEq in that list too, but because I have a Vec in there I can't this time. I'll implement them later if I need them.

Next, I'm eventually going to run rustdoc to generate docs for this, so let's put in doc comments:

/// An animation that runs a list of sprite frames once, and then removes itself.
/// A breathe animation has three qualities:
/// - `frames` is the list of frames, which are displayed in sequence over and over
/// - `rate` is the length of time to show each frame before passing to the next
/// - `timer` is an internal clock of how long the animation has been running

Now I'll know later on what this struct actually means / does, if I forget. Anyway on to the actual code.


First I need a constructor. I can always create one of these (in this file) by doing it manually, like

OneShotAnimation { frames: vec![], rate: /* etc */ }

but that's a pain; I'll write a real function:

impl OneShotAnimation {
    /// A new animation with a default rate of 80ms and a timer of 0.
    pub fn new(frames: Vec<Sprite>) -> Self {
        Self {
            frames,
            rate: Duration::from_millis(80),
            timer: Duration::from_millis(0)
        }
    }

Doc comment again, and I can call this with OneShotAnimation::new(some_frames).


Need to know which frame it's on, so let's add a method for that. In the same impl block:

    pub fn current_frame(&self) -> Option<Sprite> {
        let t = self.timer.as_millis() as usize;
        let idx = t / self.rate.as_millis() as usize;
        self.frames.get(idx).copied()
    }

Couple things about this. One, there might not be a frame, if the animation is over. So we return Option<Sprite> instead of Sprite. Two, this function takes &self as a first parameter, which means it's a method on an instance, not just a function. There's some stuff about syntactic sugar here, it's all just functions, turbofish operator, etc, but I want to point out that difference between this and the constructor above. That constructor isn't anything special, it's just a function that returns an instance of this; we can define more than one.

Three, the borrow checker rears its head: to understand the meaning of copied at the very end, we need to know a little bit about Rust's concept of memory:

  • Every variable is "owned" by something. Right now, the Vec owns the frame we're going to return
  • So, get(idx) returns an Option<&Sprite>, an option of a "borrow" of the value that's still owned by the vec.
  • This is a little inconvenient for us, and the Sprite type implements Copy anyway, which just means "if you do a straight memcpy of this value you get something valid." This is true for stuff that's just a collection of ints and floats (like Sprite happens to be) but not for things containing pointers (like us with our Vec).
  • So the copied method turns an Option<&T> into an Option<T>, but only works if the type T implements Copy (compile error otherwise).

Next, the actual system to update these. The idea behind an ECS is that every tick of the game loop, you run "systems" that query for "entities" that meet criteria and update them. Let's make a function (not an instance method; it doesn't take &self) to do that:

    /// Run the animations:
    /// - Anything `Visible` and not `Frozen` will get updated
    /// - Anything that's displayed all the frames gets removed
    pub fn system(world: &mut World, dt: Duration) -> Result<(), ComponentError> {
        let mut graveyard = vec![];
        for (ent, (anim, visible, frozen)) in world.query_mut::<(&mut OneShotAnimation, &mut Visible, Option<&Frozen>)>() {
            if frozen.is_some() { continue } // Skip frozen ones
            anim.timer += dt;
            if let Some(frame) = anim.current_frame() {
                visible.0 = frame;
            } else {
                graveyard.push(ent);
            }
        }

        for e in graveyard.into_iter() {
            world.remove_one::<OneShotAnimation>(e).map(|_| ())?
        }

        Ok(())
    }

A lot of this is thorny to read because of how the ECS library I'm using (hecs) works, but I want to hit a couple things:

  • I'm looping through the results of a query on a World
  • I want all entities that have a OneShotAnimation, a Visible (both of those I want to mutate), and which might have a Frozen (don't need to mutate that one).
  • Looping over these is tricky because I might delete some, which, if the loop is written wrong, might cause me to skip things. We've all had this bug, right?
  • Which is why Rust won't let me mutate the world (remove components from it) in the loop. I have to add things to a graveyard and loop over that afterwards.

Also a word about Result. This function might fail, because it's calling remove_one on World, which might fail. (In practice it can't possibly fail so I could skip this, but I chose not to as an example). So, I'm returning a Result type. The default case is that I return Ok with a value of () (an empty tuple, the Rust equivalent of void).

But I might get an Err from remove_one. Rather than making me deal with that by hand, which would get tedious with a lot of nesting, Rust lets me just put a ? at the end of that call... as long as my function returns Result with an Err type that matches how that can fail. Most of the convenience of exceptions, except you can see what might throw and there's no weird invisible control flow jumping around.


Now it would be nice to have some tests. Right in the same file (or elsewhere if I want, but here is convenient) I'll make a new module:

#[cfg(test)]
mod tests {
    use super::*;

The cfg(test) annotation says to only compile this if I'm building in "test" mode. In "test" mode, any function annotated with test will get run as a unit test. The use super::* part just pulls everything in the surrounding namespace into this module, so I can actually see the stuff I'm testing.

Let's write a test:

    #[test]
    fn test_current_frame() {
        let mut anim = OneShotAnimation::new(frames());
        // Starts at frame 0
        assert_eq!(anim.current_frame(), Some(frames()[0]));

        // Increments to new frames
        anim.timer += Duration::from_millis(80);
        assert_eq!(anim.current_frame(), Some(frames()[1]));

        // Finishes the frame list
        anim.timer += Duration::from_millis(160);
        assert_eq!(anim.current_frame(), None);
    }

This test is pretty straightforward, you can probably guess what assert_eq! does. A word about visibility: because it's not marked pub, nothing outside this file can mess with the timer member directly, but, since the tests module is inside this one, I can see it. Handy!

I realize that I'm likely to have to write out a list of frames a lot, so let's write a helper for that:

    fn frames() -> Vec<Sprite> {
        vec![
            Sprite::new((0, 0), (16, 16)),
            Sprite::new((16, 0), (16, 16)),
            Sprite::new((32, 0), (16, 16)),
        ]
    }

I can put whatever I want in this module helper-function-wise, as long as it's not annotated #[test].

I can run the tests with cargo test, and if I do, I see this one passes.


And there you have it. One fairly straightforward bit of Rust code, annotated.