Rewriting my mobile game in Rust targeting WASM
TL;DR: I rewrote my C++ mobile game in Rust as a hobby project to learn Rust. The results were pretty satisfying to me: I'm quite excited with what I learnt and the new version of the game runs pretty smoothly in the browser, practically with the same look and feel as the old native version did (at least in the mobile devices I have at home). If you're curious, you can play the new version here. I’ve also open sourced the code here.
In 2012, a friend and I wrote a mobile game called Panda Doodle. It was a puzzle game in which the player had to draw connections between doodles by matching/mixing the correct colors while looking for the shortest paths that required the least amount of paint. We were two programmers, but we also got help from an artist who contributed with very nice drawings which made the game look great!
The game was originally written in C++ using the Marmalade SDK. Since then this SDK has been discontinued and because of that we lost the ability to compile the game for newer platforms. In consequence of lacking those updates the game got kicked out of both the App Store and Google Play.
Panda Doodle wasn't a big hit or anything, but it was quite well polished and we were kinda proud of it. The fact that we couldn't play the game anymore made me feel a bit sad. It was funny: every time I got a new smartphone or tablet, the first thing I tried was installing Panda Doodle on it and checking how it ran. And now I couldn't find the game in the app stores anymore. My son was about to be born and I thought "Damn, at this pace he probably won't be able to play my game!". Sometimes I can get obsessed with fixing things, and I had to fix this.
What about Rust?
Well, in the beginning of 2020 I was starting to get curious about Rust. What's up with all this hype around it? I read the Rust Book which was a nice introduction, but in my experience I usually come to grasp something much better by trying to use it. So I started to look for opportunities to do that.
For those not familiar with Rust, if I had to describe what is unique about it in a few words I would say it's a programming language without garbage collection that can still guarantee memory safety (all other languages that I know that can guarantee memory safety come with garbage collection and its performance drawbacks). To add a few more words, it also provides thread safety.
All of that means Rust is a language in the same league as C/C++ in terms of performance, but also one that takes away a bunch of foot cannons that usually come with that. The thread safety guarantees are also a big deal (very few languages have these), which make it even safer than languages like Java or Go. So fast and safe, a pretty interesting combination.
Ok, this all sounds very promising, so what are the drawbacks? I asked myself. Again some first hand experience could help me answer this. Check here what I found, in case you want to skip ahead.
WASM and the idea
That was when I first had the idea. Maybe... I could rewrite my game in Rust to run in the browser?
This idea had a few interesting points going for it:
- I get to learn Rust. This is a mid-sized project that will probably push me enough to explore a good part of the language and its features.
- Translating C++ into Rust would probably give me insights on what exactly is different between these languages. By understanding changes forced by Rust, I would probably even become a better C++ programmer by getting a clearer picture of some of its pitfalls.
- I get my game back!
- And by targeting browsers this time the game will last... forever? I bet someone thought this when they were building Flash games back in the day! haha. Still the HTML5 APIs seem pretty solid and in much better shape than Flash ever was, so betting that they're here to stay for a long while seems reasonable.
- I also get to bypass the app stores and don't have to deal with their annoyances. They can't kick me out anymore! 😉 (to be fair, it wasn't their fault that the SDK I used disappeared)
All of these combined seemed good enough to motivate me through this project. I get my game back from learning Rust and fixing my game is not just some big dull task of porting it to a different C++ SDK, I get to explore a new language instead. WASM also sounds like an interesting technology, so dipping my toe in the water there is a nice extra.
What I wanted/expected from my Rust code
Before starting this project, I already had in mind a few things that I wanted to explore about Rust with it:
- No unsafe code allowed, unless absolutely necessary (i.e. if I had to use some other library function marked as unsafe, which didn't happen). I wanted to understand what could be done within the safety boundaries and what would need to change because of them.
- Minimize as much as possible changes to the API of my ad-hoc game engine and to the structure of the game code as well. This would probably help me make the code translation faster, but the main reason to do it was actually to stress the limits of what can be done with Rust. For example, Rust is not an OOP language (it doesn't have the concepts of classes or inheritance), so I wanted to know how much trouble I would get into by trying to replicate a framework that relies a lot on OOP (like the UI framework of my ad-hoc engine).
- Be ergonomic and expressive. This meant that even if I had already solved a specific problem, I might come back to refactor it to try to push a bit to make the code less verbose, or to use a different design pattern that would make that code simpler to read/write (even if that meant sacrificing a bit of performance).
The main question I had to answer at the start was whether the game would run well enough in the browser of a mobile device. This was the biggest risk threatening the project. So my initial goal was to build a prototype to exercise the scene of the game that had most animations and rendering calls.
I looked into a few existing Rust game engines, but I decided to move forward without using any of them. My game had its own ad-hoc engine for everything: game entities, animations, rendering and UI. The Marmalade SDK provided low level drawing/input/audio APIs that I thought I could probably replace directly by HTML5 APIs, as opposed to adding another engine as a layer of abstraction in the between, which would probably make things harder, or it would force me to make big changes to the game's ad-hoc engine.
For rendering sprites I started trying to use the Canvas2d API. It's pretty simple, and if it worked well enough I wouldn't need to delve into the much more complex WebGL API. I created the prototype that contained the scene that did the heaviest rendering in the game, and sure enough it was already getting slow on my Galaxy Tab A 8" 2017 (not the fastest tablet around, but still...). I tried to replace the Canvas2d calls by naive WebGL ones and the results got even worse?! That's when I realized there was a lot going on with rendering sprites and that Canvas2d was probably already dealing with a bunch of optimizations for me. So yeah, I'm not an expert in rendering sprites and I don't want to spend too much time on this. Back to the Canvas2d API and let's explore if there are some tricks I could use to boost performance (even with the Marmalade SDK I remember applying some tricks to enable the game in slower devices).
After exploring a few different tricks in the way I was rendering sprites, things started to look much better (I plan to write another post in the future going into these tricks). The performance got to a level that felt like it was on par with the old native version! Green light, now all we need is to re-implement the whole thing 😅.
Writing some Rust code
As I started writing some real code, my lack of experience with Rust started to get in the way. My original C++ code used many patterns which aren't straightforward for a beginner to replicate in Rust. Even if these patterns would look more straightforward for a C++ beginner programmer, I've got to admit a bunch of them were risky and very fragile. A C++ beginner can probably get a lot more done faster than a Rust beginner, but they are also more likely to create a lot more crap, bugs, crashes, etc. Rust was pushing me towards more robust software.
I needed to keep multiple references to the same struct instances in different places. And I also needed to be able to modify these instances through them. Given the borrow checker restriction that only one mutable borrow is allowed at a time, I naively started looking into the
Rc<RefCell<T>> pattern, so that I could hold multiple references to the single instance and mutably borrow them at runtime. I even created a
Shared<T> type to avoid having to write
Rc::new(RefCell::new(...)) everywhere haha.
Rc<RefCell<T>> pattern started to give me trouble in some situations in which I recursively ended up asking for a second mutable borrow of the same instance before returning the first one (which was still open above somewhere in the call stack) resulting in a panic. At this point I took a step back to try to understand things better. I started asking myself questions like "What mutable in Rust means exactly?" and "Why Rust doesn't allow multiple mutable borrows at the same time?". This was totally worthwhile and also prompted me to look further into Interior Mutability, which I realized I didn't understand very well. The solution to the specific problem I was having turned out to be using just
Rc<T>, stop requiring the whole T structs to be mutable and instead use Cell/RefCell for their fields that needed to be modified. More importantly after this I became much more proficient in solving borrow checker or mutability issues. I was finally becoming a bit more productive.
The next bump was translating the parts of the game engine that relied on inheritance. At first I played a bit with static polymorphism, but in the end I had to resort to dynamic dispatching. I started exploring Rust features around Traits and started looking for creative ways to emulate inheritance using them (including data inheritance). Some of these cases gave me a substantial amount of trouble, but there were also other cases in which the use of inheritance was more casual that were easier to replace.
As I was getting more fluent, I noticed some aspects of Rust were making me go faster. In particular, Rust Enums are awesome and when I realized how much I like them I had already started using them everywhere. The ease with which one can put data inside them and then access that data through match statements is very convenient. The compiler checks to verify that all enum values are covered by match statements are incredibly helpful. Enums are first class citizens in Rust and these features are probably some of the things I'll start missing the most in other languages now.
Relative to other languages, Rust is a hard language to learn (if not the hardest I've ever learnt). It took me quite some time to become productive. When I was learning Go about 4 years ago, I remember being productive almost off the bat. Comparing to Go is not a fair comparison though: if your application can afford garbage collection, you should probably pick a language with garbage collection in the first place anyway (it's too much overhead otherwise, which explains a lot of why Rust is more complex).
The Rust compiler will shout at you more often than you're used to with other languages. The borrow checker, lifetimes, mutability checks among others can often be frustrating to newcomers. That's not necessarily bad though, as long as you understand what you're getting from these checks: it's definitely much better to have the compiler shouting at you than to have bugs, exploits or crashes in production shouting at you. A programmer learning C++ (which is a fairer language to compare against than Go) might suggest they've gotten more productive faster than if they were learning Rust, but I think the truth is their C++ beginner code is probably worse and more fragile than they realize. So my guess is that to actually get to write C++ that's as robust and safe as Rust, one would need much more time of experience with C++ than they would need with Rust (because a lot of that is a given with Rust, assuming you refrain from using the unsafe keyword).
Lack of usual OOP concepts (such as classes and inheritance) can bring a few obstacles, but not a big deal (I remember being much much more frustrated with the lack of Generics in Go for instance). I was able to go a long way by using Traits and Generics to replace the C++ code that relied on inheritance. My impression is that Traits might end up producing a little extra verbosity when compared to inheritance, but are also more flexible and probably easier to manage in the long run.
Ah iOS Safari...
I need to pause here to add a rant about what actually was the most frustrating part of this project. I think we need to ask ourselves "Why is iOS Safari... so bad?". Many times I reached a project milestone and tried running a test on an iPhone I found something was broken. Sure, no fullscreen API support, no vibration API support, it's tricky to perform pixel perfect rendering on canvas2d... each of these individually might not look so bad, but they start stacking up.
It was when I got to implementing the game audio that it got totally out of hand. I had two working implementations (one with HTMLAudioElement and one Web Audio API) that worked pretty well with both Chrome and Firefox for Android, but failed in weird and inconsistent ways on iOS Safari. Safari wants an interaction for the first play() call of every audio element (not just one interaction for the page and then you're allowed to play sound), or so it seems. The play call has to happen in the event listener of the interaction itself (it cannot happen sometime later in the game loop, depending on your game animations or whatever), it doesn't allow you to pre-fetch the audio resources... and even after playing the sound once it always lags whenever the audio is played again in the future. You can find people on the internet suggesting all sorts of hacks to manage these problems, and the hacks change depending on the iOS version. I tried using the Web Audio API instead, got to a point that it was working for Chrome Android but failing mysteriously on iOS Safari, then I decided it's not worth losing much more time on this. I reverted to the previous HTMLAudioElement implementation which was simpler. Now on iOS the game is a bit laggy when a sound is played and I put a hack to play every sound of the game on the first interaction in the loading screen for the shortest amount of time possible so that they get fetched and their audio element is allowed to play later.
I also enabled the game to work as a Progressive Web App (PWA), which is how a web app behaves when the user selects "Add to Home Screen" on the iOS Safari share options. This allows the web app to run fullscreen for instance. Initially it seemed to be working fine until I noticed that the game would freeze if I minimized it and tried to resume afterwards. I spent some time trying to debug without success. Then I found other people having the same problem online. Apparently it's a problem in iOS 13 that got fixed in iOS 14, I was testing in a non updated device.
This situation seems embarrassing for a company that has the resources Apple has. Or maybe it's not. I know, I know, "Don't attribute to malice that which can be adequately explained by incompetence". Still, it's hard to picture Apple as incompetent, specially on something like iOS Safari which has been behind on features for a while already. I read somewhere a few years ago a theory that Apple actually leaves iOS Safari lacking functionality and lagging on mobile APIs on purpose because this pushes developers to build and publish native apps through Apple's App Store instead (for which Apple gets their cut). To make things look uglier, Apple doesn't allow other developers to publish their full browsers on the App Store. Sure there are other browsers on the App Store, but they're just some makeup on top of the same crappy browser engine used by iOS Safari (other browsers are not allowed to use their own engines per App Store's terms). Maybe I'm lacking some context, but from where I stand this all does look like a jerk move from Apple.
15000 lines of Rust later, many of them written between my newborn son's naps while babysitting him overnight, the reimplementation of the game is done! If you want to check it out, you can play it at https://pandadoodle.lucamoller.com/ (the experience is better on touch screens). You can even install it as a PWA on Android which is pretty neat and then it can run offline. And if you’d like to take a look at the resulting code, I’ve shared it on github.
As for Rust, after trying it out, I think the hype is justified. I'm probably part of the hype now haha. It's an interesting language that solves a real problem: high performance with safety. I hope to see it being used in more places like Operating Systems and Browsers. And I'm definitely looking forward to my next project in which I'll be able to use it again.