Rust Life
I decided to learn a bit of Rust by recreating Conway's game of life. I haven't used SDL (Simple DirectMedia Layer) before, but I figured its the right tool for the job
⚠️ I'm still learning here.
A lot of the things I do in this project probably aren't the best
Starting with SDL
My first stop was checking out what crate
s were available for SDL. This page was for SDL3, and it linked to (this repo)[https://github.com/vhspace/sdl3-rs/blob/master/examples/demo.rs] with a good basic example.
Easy, right?
Nope. You've gotta get the SDL dll and lib files (at least for Windowws). I went here and grabbed the file name SDL3-devel-3.2.16-VC.zip
(or whatever the latest is). Inside SDL3-3.2.16\lib\x64
there's a SDL.lib
and SDL.dll
. Other guides said I can install them somewhere, but what worked for me was putting it in my project root (or the same folder as my built executable)
Then I could compile the code and then I could finally run it. Cool, a window.
The rules of the game
The rules of the game are;
- Any live cell with fewer than two live neighbours dies, as if by underpopulation.
- Any live cell with two or three live neighbours lives on to the next generation.
- Any live cell with more than three live neighbours dies, as if by overpopulation.
- Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.
We can sample the previous game state (or buffer) to write the next one. We can't read and write to the same one
Struct for the game state
Lately when I make games, I like to store the game state in an object with a type I like to call State
. For Conway's game of life I need
- an indicator of whether we're using the front or back buffer
- a front buffer
- a back buffer
Originally I tried to use arrays like front_buffer: [bool;400*300]
, but those lead to the error:
thread 'main' has overflowed its stack
I found this post, and it made sense, so I settled on using vectors.
struct State {
front: bool,
front_buffer: Vec<bool>,
back_buffer: Vec<bool>,
}
Inside the game, before the loop starts, I initialise it:
const WIDTH: usize = 512;
const HEIGHT: usize = 512;
const SIZE: usize = WIDTH * HEIGHT;
let mut state: State = create_state();
//... elsewhere.../
fn create_state() -> State {
let mut rng = rand::thread_rng();
State {
front: true,
front_buffer: (0..SIZE)
.map(|_| {
if rng.gen_range(0..9) == 0 { true } else { false }
})
.collect(),
back_buffer: (0..SIZE).map(|_| false).collect(),
}
}
- use the front buffer
- set the front buffer to false everywhere except when the random number generator picks 0, from a range of 0 to 9
- just set the back buffer to all falses
Mutability and Reference
Each frame I want to call my update
function, to take the state, update it, and draw to the canvas.
First, I want to pass in the state, but not copy it, so I pass it in by reference
fn update(state: &State, canvas: &Canvas<Window>)
but that's not enough, since I want to update the state, and to draw to the canvas
fn update(state: &mut State, canvas: &mut Canvas<Window>)
So when I call it, I call it like this
update(&mut state, &mut canvas);
It wasn't intuitive, but it makes sense in the end.
If you can't tell by now, I'm leanring by trying instead of reading the manual first.
Then, I want pick one buffer toread, and one to write to. I landed on this piece of code, which depending on state.front
returns the front buffer as read
and the back buffer as mutable write
or vice-versa.
let (read, write) = if state.front {
(&state.front_buffer, &mut state.back_buffer)
} else {
(&state.back_buffer, &mut state.front_buffer)
};
The update loop
All of the logic is based around counting neighbour "alive" cells. I wrote a function to safely sample the buffer (or return false if out of bounds) and then called it with offsets to cover all 8 neighbours
n = 0;
n += sample(x as i32 + 0, y as i32 - 1, &read);
n += sample(x as i32 + 1, y as i32 - 1, &read);
n += sample(x as i32 + 1, y as i32 + 0, &read);
n += sample(x as i32 + 1, y as i32 + 1, &read);
n += sample(x as i32 + 0, y as i32 + 1, &read);
n += sample(x as i32 - 1, y as i32 + 1, &read);
n += sample(x as i32 - 1, y as i32 + 0, &read);
n += sample(x as i32 - 1, y as i32 - 1, &read);
cur = sample(x as i32, y as i32, &read);
// ... much later ...
fn sample(x: i32, y: i32, buffer: &[bool]) -> i8 {
if x < 0 || y < 0 || x as usize >= WIDTH || y as usize >= HEIGHT {
0
} else if buffer[x as usize + y as usize * WIDTH] {
1
} else {
0
}
}
With the numbers, I can calculate the new state
if cur == 1 && (n < 2 || n > 3) { // Died
write[x + y * WIDTH] = false;
} else if cur == 0 && n == 3 { // Born
write[x + y * WIDTH] = true;
} else if cur == 1 { // Same (alive)
write[x + y * WIDTH] = true;
} else if cur == 0 { // Same (Dead)
write[x + y * WIDTH] = false;
}
Drawing and double buffering
Originally I thought I would just have to draw the cells that have changed, making it faster to render. However, SDL uses double buffering, so you just end up with a flickery mess.
Instead, I clear the screen to black, then only update it for the alive ones (and for cells going from alive to dead, for a colourful effect)
Framerate and the Release build
One thing I noticed early on is that higher resolutions lead to lower speeds. To better understand the impact I wanted to add a framerate value to the window's title.
Outside of the loop I define a time and a frame counter
use std::time::{Duration, Instant};
// ...
const FPS: u32 = 60;
let mut time = Instant::now();
let mut i: u8 = 0;
Then inside the loop I increment that counter. When it reaches the value of the framerate (which has to fit in my u8
) I take the elapsed time and divide it by the number of frames, then reset the counter
// Calculate and update frame rate
i = i + 1;
if i >= FPS as u8 {
let frame_rate = FPS as f32 / time.elapsed().as_secs_f32();
time = Instant::now();
i = 0;
window
.set_title(format!("Rust Life {:.2}", frame_rate).as_str())
.expect("Failed to set window title");
}
Framerate Results
Using the command cargo run
and a target FPS of 60, as 512 × 512 pixels, I was getting closer to 16fps.
Once I started doing release builds using cargo run --release
I was getting 46fps.
I think there's more optimization that could be done, but I'm pretty happy to put this one to bed.