Sunday, 18 April 2021

Rust Game Development


 


Ever since I started learning Rust my intention has been to use it for writing games. In many ways it seems like the obvious choice but the restrictions around lifetimes and ownership can make it hard to apply usual game programming patterns. Rust is sufficiently different from all the other languages to require a different its own way to think about problems.

To learn more about how best to use Rust for game development I decided to write a Windows version of an Android game I had made a couple of years ago in Java. Doing a
rewrite meant that all my efforts were focussed on the programming problems rather than game design issues.

I wanted to make sure that this is a 'proper' game in the sense that it deals with all the ancillary issues such as saving progress, adjusting settings etc. and is built in a way that in a way that could be used as a starting point for other, more complex games. I also wanted to avoid, as much as possible, using Cell or RefCell which can be really useful but can sometimes chip away at some of the advantages of Rust.

Crates

There are a lot of game development libraries for Rust that provide a one stop solution for sound, graphics, UI, controls, etc. I think these can be great, especially in the early stages of development. My own preference is to assemble a set of set of smaller, more targeted crates. Rust's dependency management makes this particularly easy to do.

Most Rust game development libraries push some kind of Entity-Component solution. I don't think there is anything wrong with them but I wanted to to 'discover' the need for one rather than assuming that I needed to use one. ( Also, having spent a lot of time debugging Guice on Java I have become weary of code that automagically wire up things behind the scenes )

The downside with assembling your own set of crates is that sometimes they do not play nicely together and it can be tricky to find out why. I spent ages tracking down a problem caused by winit making drag and drop support the default which did not play well with cpal because they had incompatible Windows Thread Apartment models.

The crates I ended up using are;

  • glium. This is one of my favorite Rust crates. It is the almost perfect wrapper for OpenGL; it handles all the tedious and error prone state management but is close enough to the OpenGL API to keep my existing OpenGL knowledge relevant
  • rodio In my previous projects I used cpal which is an excellent library but this time I wanted something more high level that would let me just focus on what to play and when to play it. rodio definitely delivered.
  • glium-glyph This crate does a nice job of making glium play nicely with glyph-brush making text rendering very easy in glium
  • nalgebra-glm I really like what is is trying to do and it works but ergonomics aren't always great. It never felt intuitive like the other crates I used and because it uses deep multi-level traits it plays poorly with rust-analyzer
  • serde-json Another crate that 'just works'

High level architecture

Rust's ownership model puts strict limits on the what kind of architecture's are allowed. The biggest language restriction is that there can only be one mutable reference to a piece of data. Many design choices revolve around how to manage those mutable references. My experience is that the restrictions force you to think much harder about the design but this ultimately results in much cleaner designs.

Below is the very high level design that I ended up with;


 

  • Page This is trait that each page object implements. Examples of pages are; start menu, settings menu, game page. Only one page is active at any time and the implementation of each page keeps track of its own mutable data. The pages communicate with the rest of the system by sending PageAction messages to the PageManager.
  • PageManager Holds mutable references to all pages and keeps track of the active page. The page manager routes calls to the active page and is also responsible for coordinating access to shared objects, such as configuration data or glium.
  • PageAction Pages send PageAction messages to Page Manager to accomplish tasks that affect objects that are not owned by the Page. Examples of PageActions are; Save Settings, Change Page, Change screen mode.

When the game first starts up it first loads all the assets and then transfers the ownership to the PageManager.

This design works quite well and makes the ownership of any data very clear. There is a danger that PageManager could evolve into a god object as the project becomes more complex. I could foresee a situation where the number of PageActions could become hard to manage and tedious to maintain.

I don't think the design ended up being that different from the Java version. The main difference is that it is much cleaner in Rust, mainly because the ownership rules make it harder to break your own design rules. There were times where it would have been short-term convenient for multiple pages to control the same mutable object but would have introduced longer term code debt (In many ways Rust makes it harder to create code debt)

Inefficiencies

The glium-glyph crate sets glium's DrawParameters when a glyph_brush object is created. This means that if you need different DrawParameters you need to create a new glyph_brush object. Usually this would never happen but the game uses OpenGL scissor rectangles to constrain the draw area which is set on DrawPrameters. The scissor rectangle depends on the window dimensions so whenever the window is resized the scissor rectangle must be adjusted. This meant that the entire glyph_brush object had to be recreated just to change one value.

I had three ways to address this;

  • Fork my own version of the glium-glyph that let me override the DrawParameters. I didn't like this as the whole point of using crates is to outsource complexity.
  • Submit a PR for to allow DrawParam overrides. I liked this better but I wasn't convinced that solving my specific uses case would improve.
  • Live with the inefficiency Although recreating the entire object every time window size changes is horribly inefficient it doesn't really matter. It makes no perceptible difference to the user so there is little point in spending time changing it.

Rust Enums are awesome

Controlling all the different visual transitions can quickly turn the code into an unreadable mess. For example; when you enter a game page, it needs to control fading in, fly text in and out, drop the movable pieces and disable/enable the input.

Before using Rust I would typically end up with tons of little variables that would either be used only during a particular sequence or have variables whose meaning depended on the current state. Both made it very hard to reason about the code and had a tendency to introduce some very tricky bugs.

Rusts enums let me store the current state and the data relating to that state in the same enum. This stops polluting the code with state dependent variables. Combined with the match functionality and its requirement for completeness enums are very incredibly powerful. The more I used them, to more uses I find for them.

enum GameState{
    ShowingNewLevel( f64 ),
    Playing,
    ShowingSolution( f64 ),
    InGameMenu,
    ChangingPage( PageAction, f64 )
}

Code readability

It takes a little bit time to appreciate this. Initially all Rust code is a bit overwhelming because there are so many new concepts and the syntax is unfamiliar. Once I got used to the syntax I found that it was actually faster to read and understand Rust code. Because Rust is so strict about object ownership it is easier to build a mental model about how the code works. (This is not entirely true for code that has complex lifetime annotations which I still struggle with)

This project has been my pet project for some time with very infrequent opportunities to spend time on it. Usually when I don't spend time on pet project for a couple of weeks I find it tricky to get back into it. With this project I found it easier because Rust makes the state of any code more binary; if the code compiles I can be fairly confident that there are no unresolved design issues.

Conclusion

Rust changes the way you think. Working with other languages after spending time with Rust feels like entering a wild west where anything goes.

 Not having well defined ownership or rules on mutability feels irresponsible.

Code

You can get the source code for the project on github. My original original Android game is here.

No comments:

Post a comment

Rust Game Development

  Ever since I started learning Rust my intention has been to use it for writing games. In many ways it seems like the obvious choice but th...