Making a Game in 48 hours with Rust and WebAssembly
Ludum Dare has two tracks: the 'Compo' and the 'Jam'.
The rules of the Compo require you to make a game (other than source code) within 48 hours. At the end your fellow entrants will rate your game and your game will be ranked against your peers.
With everyone quarantined at home due to Coronavirus this April's Ludum Dare had vastly more entrants than usual. A total of 1383 people entered the Compo and there were 3576 entries to the Jam. That's an absurd amount of games!
I've entered the Compo a few times using Unity, but this time around I wanted to write a game purely with the relatively new programming language Rust.
This post started out focused on the experience of using Rust, but turned into a general overview of the technical and design process for the game.
Rust is a 'systems programming language' focused on performance and safety. 'Safety' means that Rust helps you avoid certain classes of vulnerabilities and crashes. I have been using Rust for my personal work and decided now was the time to give it a shot for a gamejam.
Why not Unity?
I've entered at least five past Ludum Dares with Unity, and Unity with C# is by far the most common set of tools used for Ludum Dare. But I just simply haven't been enjoying Unity as much recently.
Unity is a giant game engine, but I find it hard to get into a flow state with. There are too many knobs, features, and ways to do things. It's easy to flip a bunch of switches and have something decent, but that just doesn't mesh with my personal design flow. I prefer to work in a clean mental environment, not a complex editor.
Additionally Unity's WebGL build takes around 20 minutes on my laptop, which is absolutely miserable.
My Setup: Computer, Base Code, and Tools
I've been coding exclusively on a 2016 Macbook Pro for a while now. It's not the best, but not the worst.
I could have used an existing Rust game engine, but I'm not familiar with the popular ones. Instead I cobbled together a mix of Rust libraries and various personal Rust scripts.
Rust makes it trivially easy to use other libraries (which it calls crates) through its package manager crates.io.
For this project I used the following crates:
- kApp My personal windowing and input library designed to build super fast.
- wasm-bindgen The Rust ecosystem's standard way to call Javascript from Rust
- glow "GL on whatever" A wrapper around OpenGL and WebGL.
The recommended way to work with Rust and WebAssembly (Wasm) is through a command line tool called wasm-pack
. I skipped using wasm-pack
and instead just used a two line bash script that would run a Rust build and then call wasm-bindgen
directly to generate the web bindings.
Quick iteration times are absolutely critical for a gamejam. The following tools were instrumental in attaining quick iteration times:
cargo watch
ran the build script automatically whenever the code was saved.devserver
hosted the local web page and automatically reloaded it when a file changed- Visual Studio Code was configured to automatically save
The combination of cargo watch
, devserver
, and the Visual Studio Code settings meant that I could edit a value, like a color, in my Rust code and watch it change on the web page nearly instantly.
Typical Rust build times were around 1-3 seconds while working on this project, but sometimes they'd inexplicably go up to around 10 seconds. Rust is known for slow build times, and these quick iteration times were only possible by carefully choosing tiny crates. My goal was to always have the new build ready on the web page by the time I changed to the browser window.
Code Structure
With only 48 hours best practices go out the window, but even still I made some early choices to help make writing new code as easy as possible. One of the key things I wanted to ensure was that if I were to declare a new variable, or load a new asset, that it would only have to be declared once. Many Rust frameworks follow a pattern a bit like the following:
struct Game {
player: Player,
/* Other stuff */
}
impl Game {
fn setup(context: &Context) -> Game {
Game {
player: Player::new(),
}
}
fn update(context: &Context) {
/* Respond to user input and redraw here*/
}
}
The above works fine in regular scenarios, but when you add a new variable like 'player' it needs to be declared in multiple places. Instead I used a structure a bit like the following:
fn main() {
let player = Player::new();
/* Other setup code here */
loop {
/* Respond to user input and redraw here forever*/
}
}
This let me declare a variable and use it immediately, no extra fuss.
Async
Unfortunately the above structure doesn't work on web. On Web the main loop must return control to the browser.
The way to get around this is to pass a closure that the browser calls when an event occurs:
fn main() {
let player = Player::new();
/* Other setup code here */
run(move |context| {
/* Respond to events and draw here */
});
}
Rust windowing and input frameworks like Winit use the above approach.
Another issue on web is loading assets. On non-web platforms it's simple to just wait for an asset to be done loading:
let wind_sound_handle = std::fs::read("wind.wav").unwrap();
On web the above isn't possible because it would prevent returning control to the browser. It's not appropriate to lock up while waiting for an asset to return from a server, so all loads are asynchronous.
An approach like the following could be used in Rust:
let wind_sound_handle = fetch_asset("wind.wav");
run(move |context| {
let wind_sound = None;
/* Respond to events and draw requests here */
if let Some(loaded_asset) = wind_sound_handle.get_asset() {
window_sound = loaded_asset;
}
/* Respond to events and draw here */
});
But that approach can lead to tedious bookkeeping, which is the opposite of what you want for a gamejam.
Instead I decided to use Rust's relatively new feature: async
. Fortunately I had recently added asyc
support to kApp
.
Rust's async
feature generates a state machine for a function that allows it to pause and later resume when ready.
An async
function can look very similar to a traditional infinite game loop:
async fn run(app: Application, events: Events) {
let wind_sound = audio::load_audio("wind.wav").await.unwrap();
loop {
match events.next_event().await {
/* Respond to events and draw here */
}
}
}
This let me load assets with one line and not have to worry about any bookkeeping. Perfect!
Game Design
The theme for this Ludum Dare was "Keep It Alive" which immediately struck me as overly morbid given the rapid spread of coronavirus. I couldn't motivate myself to create a game about death, and I nearly decided to quit the jam entirely.
Instead I looked for alternative ways to interpret the theme. As I stared out my apartment window towards San Francisco hills I thought about the way people droop their heads as they walk to and from work. It's a beautiful world, but it's tough to notice the beauty every day when the routine of life weighs heavy.
What if you played as a spirit to lift people up? You could keep their "wonder" alive. You'd play as some sort of spirit and perhaps you'd lift a commuter bicyclist up into the sky where you'd whisk them around and show them the stars.
I imagined you'd guide the bicyclist through the sky to collect stars and people on the ground would notice and point up in awe.
It was difficult to figure out how to pair that idea to gameplay, but what I settled upon was a game inspired by the old school flash game Line Rider.
You'd draw lines for the bicyclist and they'd roll along those lines and bump into collectible stars.
Implementation
The plan was to implement the physics of the character as a rolling ball and then substitute in character art for the bicyclist.
The first thing I needed was line rendering. I ripped some code out of a prior Rust project that would generate mesh line segments by creating rectangles with circles at the ends. I wasn't sure if this heavy-handed approach to line joins would cause problems. Each individual line segment was made up of a bunch of triangles so I felt that perhaps the game would lag as too many lines appeared.
I stress tested this by scribbling the entire screen full of lines over and over, and I was shocked when the framerate didn't drop at all.
For the physics I took some math for finding the closest point on a line to the ball. If the point is inside the ball then check if the ball's velocity is moving towards the point. If the ball is moving towards the point then "bounce" the ball by pushing the opposite direction on the ball.
The heart of the collision code wasn't very long at all:
fn check_lines(&mut self, points: &[Vector3]) {
let len = points.len();
for i in (1..len).step_by(2) {
let (distance, p) = point_with_line_segment(self.position, points[i - 1], points[i]);
if distance
< (self.radius + LINE_RADIUS - 0.001/* Allow ball to sink slightly into surface*/)
{
let normal_of_collision = (self.position - p).normal();
let velocity_along_collision = Vector3::dot(normal_of_collision, self.velocity);
if velocity_along_collision < 0.0 {
self.velocity -= normal_of_collision * velocity_along_collision * 1.4;
}
self.position += normal_of_collision * 0.0001;
}
}
}
(The code could have been a little cleaner had I known about the chunks method on iterators.)
I was pretty surprised at how great the ball physics felt without much tuning. When I started with this design the physics felt like a big unknown for me. I felt like I might not be able to get them right within the 48 hour window, but coding the physics was actually one of the shortest features in the project.
I was pretty concerned that the ball physics would be laggy, after all the ball code was naively testing for collision against every single line segment. Once again I scribbled the screen full of lines to make the framerate drop. But it didn't drop. It took scribbling the screen full multiple times over before lag showed up. So I decided to do no optimizations at all. Another unexpected victory! Thanks Wasm and Rust!
Reality Strikes
It was becoming clear that I wasn't going to have time to draw a bicyclist, or a kid looking up in awe at the sky. This was concerning because it would be very difficult to convey the theme I was going for with more abstract imagery.
I started brainstorming. The ball reminded me of the moon from hanafuda cards, and I started to think about aesthetics drawing from that minimalism.
I hopped over to Figma to mock up aesthetics drawing from that inspiration:
The new aesthetic jumped out to me as something that looked great and could be accomplished. It felt like it'd be a beautiful little moment to click the moon and watch it wobble and roll out of the sky. The moon could roll around and collect little circles that could represent other stars or drops of dew. The player would draw lines to guide the moon to the circles.
I was reminded of the Japanese concept of Yūgen:
[Yūgen] describe[s] the subtle profundity of things that are only vaguely suggested by the poems
Unfortunately it felt like it'd be exceptionally hard to convey with this aesthetic how the game connected to the theme "Keep it Alive".
At this point it was 1 am and I decided to get some sleep. I fell asleep thinking about ways to convey the theme with this new minimalist aesthetic.
Discovering Hidden Powers
As I was drifting into sleep my thoughts found a hidden power in the game's design. One of my favorite things in game design is finding simple techniques that provide disproportionate value. Techniques that make people go "Huh, that's really cool", but which require little effort.
My sleepy thought process was this:
The game needs levels with obstacles for the player.
But how will I design the levels?
I can use the design tool I already have working: Using the mouse to draw lines.
My inputs will be recorded to play back drawing lines at the start of the level.
The next morning I quickly set out to coding the idea. A key press would begin recording inputs, another key press would rewind, and another would save the data as a bunch of numbers in a file.
When playing back the inputs I tried to preserve the pauses I made with the mouse. This was to preserve some of the human quality of the drawings and not make playback feel too digital and inhuman. If I paused for too long I'd trim the pause to a maximum length, but that code was finicky and would sometimes pause too long even in the final version.
Narrative
Because I was making an effort to capture the human quality of drawing and writing I began to think about how to convey a short narrative through a series of handwritten notes.
I started to think about how people write touching letters to others, and I began to imagine an abstract narrative of someone writing a letter to someone else who is not well. I thought about writing a letter to someone in a hospital to keep their "wonder" alive, to give them some pleasant moments. There was a duality there that by keeping someone's 'wonder' alive you may help keep their physical being alive as well. Perhaps the game could evoke both feelings of keeping humanity's wonder alive in these difficult times, but also the wonder of an individual?
I was hesitant to touch on such heavy themes but I decided to abstractly weave these ideas into the levels.
A level would have handwritten notes that would say things "Remember starry nights?" accompanied by a drawing. Each note would be intended as a message to humanity and perhaps to another person. By playing as the rolling ball, sometimes moon, you'd be the force that is a reminder of these pleasant memories.
There unfortunately wouldn't be time to do art other than lines so I decided to weave more types of memories into the game. Later I added a few silly memories because I felt the game's tone needed some moments of levity.
Empathy
The unfortunate flaw in this entire plan is that my handwriting is absolutely awful. I connected a drawing tablet, but even with the tablet I found myself redrawing levels over and over trying to get the writing to be legible. The game would have been notably better if I simply had better handwriting.
But as I rewrote these messages over and over I started to genuinely feel like I was writing messages to the player. At the time many people were confused and afraid because of coronavirus.
With each little memory I wrote I started to think, "Hey, maybe this will actually make someone feel better?" That tone started to carry through the game.
Loosely I thought of the text as a message from an imaginary person to another person, a message from nature to humanity, and it became a message from myself to the player.
It was sappy, but I was rather sleepy and I found myself drawn into it.
Keep it Alive
Did the game succeed at conveying this unusual and extremely abstract interpretation of the theme "Keep it Alive"?
Well, let's see what the reviewers thought:
No.
The game did not manage to convey the theme to most players. The theme was connected aesthetically through a unique interpretation, but was not connected mechanically. Despite not succeeding in this category I'm very glad I took design risks in my interpretation of the theme.
Level Design
Because I spent so long on the level design tools the process of making them was fun and fluid. The game was designed to be long enough to be satisfying, but not so long people that people would stop playing partway through.
Because of the theming of the game I tried to design the game to be pretty easy. It would be bad if the player were challenged to the point of frustration and left annoyed instead of a little happier. In retrospect I overdid it a bit with the easy difficulty. My hope is that players would find fun through creatively using the mechanics, but I could have designed levels to push them to be more creative.
Pots and Pans
For the sounds I did what I always do: Panic in the last hour or two.
I borrowed a microphone and made wind sounds with my mouth to serve as background ambience. For the star collection sound I clinked together every object and glass in the kitchen until I found the sound that was most like a star twinkling. Then I pitch shifted the sound up a bit to feel a little more magical.
The collection sounds felt far too sterile when they were all the same note. I tried randomly shifting the pitch but then a series of quick collections felt like discordant chaos. So what I did is pitch shift the sounds up or down based on the vertical position of the collectible in the level. This had the nice effect of playing pleasant scales as the ball collects a series of notes. A tiny bit of randomness was left in so that a quick series of ball collections wouldn't sound too similar.
Those sort of tweaks were only possible because of the effort spent at the very beginning to setup a process capable of rapid iteration.
For the rolling moon/ball a wind sound was used again, but the speed of the ball modulated the volume of the sound. The sounds helped lend a floaty feeling to the ball.
Results and Closing Thoughts
The game placed 71st overall and 16th in Fun. Which is quite good!
The full ratings and reviews are here.
I'm pleased with the ratings, Rust was great, and I didn't crunch. All in all I'm very happy with the results.
For future gamejams I hope to have more Rust helper code ready to go. Hacking together WebAudio Javascript calls was one of the low points and easily could be avoided with more preparation.
I'd encourage others to pickup Rust and WebAssembly. It's a great combo if you're not afraid of inventing some things yourself.
The code for Wonder is incredibly messy, but you can view it here.
Play Wonder on itch.io!