How Rust made me Roll

I’ve seen a number of blog and forum posts about difficulties with Rust and the things it forces you learn about, so I wanted to share a happy accident, where the systems of Rust pushed me into creating something that I really like in hindsight.

So, What Was It?

I was prototyping a fighting game[1] in Bevy with my (current) massive amount of free time[2], so I copied my template and started working. A big concept in this type of game is input buffering: inputs are stored over a number of frames, and at fixed times read to determine the game actions to execute.

I wrote the input buffer code so that I can tweak and adjust the amount of frames stored in the buffer without changing the code[3]:

#[derive(Component, Debug)]
pub struct InputBuffer<const N: usize, T> {
    /// The *accumlating* input. Invisible to the rest of game state.
    pub(crate) current: T,
    pub(crate) buffer: [T; N],
}

then I added code to handle the buffering operations: accumulating input for the “next” frame, and updating the buffer, resetting the accumulator in the process:

impl<const N: usize, T: Copy + Default> InputBuffer<N, T> {
  /// Get the next state of input (for accumulation)
  pub fn next(&mut self) -> &mut T {
      &mut self.current
  }

  /// Update the input buffer
  pub fn update(&mut self) {
      // shift up previous inputs
      self.buffer.copy_within(1..N, 0);
      // copy the accumulating input into the last position
      self.buffer[N - 1] = self.current;
      // clear accumulating input
      self.current = default();
  }

  // more c*de goes here...
}

Writing the buffer this way allows me to use one of Rust’s main strengths: the ability to write code that is agnostic to future use. The plan is to eventually just add this to my template, and never have to write another input buffer again[4]~

Then came the problem: how do I read the buffer?

Reading the Buffer

Having all the inputs in one place is all fine and dandy, but to do the game part of the game, you have to be able to read those inputs.

A naïve solution would be to expose the buffer for outside code to read and understand the inputs themselves, but that would lead to maintenance hell, where wanting to change how an input is read would require going to every place where the buffer is being read and changing it, not to mention the effort it would take to add a new input after-the-fact.

This is a good place for reification[5]: give the buffer a bunch of things we want to see in some sequence, and the buffer tells you if it found it or not.

// A type that can be matched to an input
pub trait InputMatcher<T> {
    fn matches(
        &self,
        input: &T,
    ) -> bool;
}

#[derive(Debug)]
pub enum SequencedAction<'a, C> {
  // ...borrows of input matchers with variants about where to
  // search in the buffer for the input matcher
}

impl<const N: usize, T: std::fmt::Debug> InputBuffer<N, T> {
    /// Check if this buffer represents an execution of a sequence of inputs
    pub fn matches<'a, C: InputMatcher<T> + 'a>(
        &self,
        first_action: &'a C,
        sequence: &[SequencedAction<'a, C>],
    ) -> bool
    {
      // code not reproduced here...
    }
}

Wait, since C is always behind a borrow, do we need to know its size?

Why Ask?

Rust provides lots of interfaces to reason about assumptions. Sized is one of them and is put on everything that the Rust compiler can tell has a fixed size at compile time[6].

  • Rust loves putting things on the stack.
  • To put something on the stack, you need to know its size at compile time.

So to simplify the language, type parameters have an implicit + Sized bound. However, a parameter can opt-out of this by explicitly adding a + ?Sized bound, which enables trait objects (dyn Trait) to be used as an argument.

We always works with input matchers through a reference[7], which are always Sized even if the type they are reference is not, so I added the + ?Sized bound, which allows C to be dyn InputMatcher<T>:

// earlier code...

#[derive(Debug)]
pub enum SequencedAction<'a, C: ?Sized> {
  // ...borrows of input matchers with variants about where to
  // search in the buffer for the input matcher
}

impl<const N: usize, T: std::fmt::Debug> InputBuffer<N, T> {
    /// Check if this buffer represents an execution of a sequence of inputs
    pub fn matches<'a, C: InputMatcher<T> + ?Sized + 'a>(
        &self,
        first_action: &'a C,
        sequence: &[SequencedAction<'a, C>],
    ) -> bool
    {
      // code not reproduced here either...
    }
}

So I wrote up some tests[8], and was about move on with my day until I remembered: wait, how do I specify my inputs?

What are you on about?

This reified matcher system is good on the buffer side: when I want to create a new input that can be read from the buffer, how the buffer matches that input doesn’t change and it automatically works with all ways to sequence buffer checks that already exist.

But how do I make the inputs to this function in the first place?

The most straightforward, common, right-in-your-face solution is to stick them all into an enum and call it a day:

// b/c fighting game, assume "Numpad" and "Button" are inputs that read the joystick and button state
// respectively
enum FrameInput {
  Button(Button),
  Numpad(Numpad),
  // more as necessary...
}

impl InputMatcher<InputFrame> for FrameInput {
  // ..use a match, and delegate to the wrapped input
}

I didn’t want to do that because:

  1. the problem is just shifted up one layer: how do I make FrameInputs?
  2. I don’t want to solve this using serde b/c that seems verbose for an input system
  3. if I were to use serde, how do I represent SequencedAction?
  4. in a fighting game, the input’s relation to a frame’s data changes as a result of game state (a player is player 2, after all). How do I track that in this system?[9]

but I support trait objects for C! There has to be a better way, there has to be…

S T R I N G

Lexing a String

At the end of the day, serde is just a parser for a string[10].

A parser is just a way to convert an input format into an output format.

unscanny exists.

Why don’t I make the string parser?

I’ve done it so many times before for fun, this won’t be too bad, and to my surprise, it wasn’t.

The interface would look like this:

pub fn buffer_matches_input<const N: usize>(
    buffer: &input::InputBuffer<N, InputFrame>,
    input: impl AsRef<str>,
) -> Option<bool> {
  // magic code that's for me, not for you...
}

bumpalo exists, so I can create an arena to allocate Numpad and Button and get a bunch of &dyn InputMatcher<InputFrame>, then use those references as arguments to the matcher system, with other characters representing the way that the next input should be sequenced.

  • None means the input string’s bad, either:
    • an input isn’t seen where it should be (after a sequencer character)
    • there is no initial input, which is required
  • Some(found) means the input string was successfully understood, and returns the buffer’s answer

A bit of lexer writing later, and I had to take a moment when I tested the first input string 2>3>6[11].

Taking the arena as a parameter (to enable reuse) also had me adding a flag to flip inputs, and with no change on the lexing side, all of a sudden, the string format worked for both player 1 and player 2 as expected.

The Upshot

In combination with loading assets in Bevy with hot-reload enabled, I felt like crying tears of joy when I could experiment with different inputs without even restarting the game[12].

Here I was, feeling that I’ve been struggling against a language and engine when this system just randomly fell into my lap, making me realize: oh, when it works, it works.

I would eventually:

  • add new inputs
  • add a couple new sequences
  • add a way to request only matching on a subslice of the buffer

and the rest of the code didn’t change much. No need to add and handle new variants, figure out how to recombined data. It just wa really easy to update[13].

This was a story about me taking a reified input system, and somehow rolling myself into simplicity.

The naive input frame solution would have looked something like:

{
  first_input: Numpad(Num2),
  sequence: [
    Next {
      input: Numpad(Num3),
      range: [0, 3],
    },
    Next {
      input: Numpad(Num6),
    },
  ]
}

where with this system, I can just write 2>(..3)3>6.

This isn’t impossible in other languagesby far, I just think it kinda funny that I just kinda fell onto this solution because it was “easy”[14].

My favorite thing about this situation is that now that I’ve figured out how to do this, I can just copy-paste it, tweak it a bit, switch out the input frame type, and it’s ready to go for a different game. And this all happened in one day 🤣.

  1. as one does… ↩

  2. if job have, pls contact me here ↩

  3. there’s code to ensure that N isn’t 0 in the constructor function ↩

  4. the code that I use for controlling the camera is a result of this process: I isolated a pattern I liked and stuck it in a module to never have to write the initial code for it again. ↩

  5. Here is a good explanation. I know of the book, have the book, and have read bits and pieces of the book except for the bytecode chapter. I have read that one chapter back to front, and think about it a lot. ↩

  6. generally, see the docs for more info ↩

  7. following the “do I need to own it? no. do I need to mutate it? no.” flow of logic when selecting parameter types in Rust ↩

  8. having a unit testing solution just built into language packaging is still really cool, helps me with choice paralysis by not having to think about it, and provides me with one less reason to not test things when I can. ↩

  9. I thought it was a Good Idea™️ at one point to use an atomic bool to store the current facing state in… ↩

  10. we are using the definition of “a fixed size array of UTF-8” for string because we snarf it from Rust ↩

  11. “QCF” or quarter circle forward. Very common motion input. The syntax I used was based off numpad notation. ↩

  12. ig this is why hot reloading is such a big deal, b/c it is such a great feeling ↩

  13. the most effort was rewriting the code for matching numpad inputs from a bunch of separate cases into c @ ('1'..='9'), and indexing a const for the actual input to use, but it wasn’t even bad, like at all. ↩

  14. a combination of “easy-for-present-me-to-make” and “easy-for-future-me-to-maintain-and-fix” ↩