Game of Life implemented in Rust-sdl2


Conway’s Game of life

Programs which are easy to write and give interesting outputs are always good learning exercises for beginners. This is especially so in the case of Rust which has a reputation of having a steep learning curve. It is very easy to implement a version of Conway’s game of Life in Rust using the rust-sdl2 library; you will not face any difficult issues associated with ownership/move semantics/lifetimes etc and you get to see some fun graphics output!

Using the rust-sdl2 library

A simple window

Let’s create a project first:

cargo init --bin life

The dependencies section of Cargo.toml should contain the following lines:

sdl2 = "0.23.0"
rand = "0.3.14"

(we will be using the rand crate some time later in our code)

Here is our main.rs; it simply creates a window (filled with red color), and terminates after two seconds:

extern crate sdl2;

use std::{thread, time};
use sdl2::pixels::Color;


fn  main() {
    let sdl_context = sdl2::init().unwrap();
    let video_subsystem = sdl_context.video().unwrap();

    let window = video_subsystem.window("demo", 400, 400)
        .position_centered()
        .opengl()
        .build()
        .unwrap();

    let mut renderer = window.renderer().build().unwrap();
    renderer.set_draw_color(Color::RGB(255, 0, 0));
    renderer.clear();
    renderer.present();
    
    thread::sleep(time::Duration::from_millis(2000));
}

Run the code by executing:

cargo run

The variable renderer is a handle using which we can draw on the screen. The set_draw_color method sets the current drawing color, the screen is then cleared using that color. All drawing operations are done on an invisible buffer; the output will be copied to the visible frame only when we invoke the present method.

Keyboard input and the event loop

The next program will terminate when you hit the Esc key. Let’s see the code first:


extern crate sdl2;

use sdl2::pixels::Color;
use std::{thread, time};

use sdl2::event::Event;
use sdl2::keyboard::Keycode;

fn  main() {
    let sdl_context = sdl2::init().unwrap();
    let video_subsystem = sdl_context.video().unwrap();

    let window = video_subsystem.window("demo", 400, 400)
        .position_centered()
        .opengl()
        .build()
        .unwrap();

    let mut renderer = window.renderer().build().unwrap();
    let mut event_pump = sdl_context.event_pump().unwrap();

    renderer.set_draw_color(Color::RGB(255, 255, 255));
    renderer.clear();
    renderer.present();


    'running:loop {
	 for event in event_pump.poll_iter() {
            match event {
                Event::KeyDown {
		  keycode: Some(Keycode::Escape), .. 
                } => 
                      { break 'running },
                
		_ =>  {}
            }
        }
        thread::sleep(time::Duration::from_millis(10));
    }
}

The important addition in this program is the variable event_pump:

 let mut event_pump = sdl_context.event_pump().unwrap();

event_pump is a handle which helps us access the event queue of our application; anything like a key press/release, mouse click/movement etc is an event - the events can be polled by iterating through the value returned by:

event_pump.poll_iter()

Each event has a type and some values associated with it which gives more information regarding the event; for example, when you press a key, a KeyDown event is generated and this event will contain the exact key which was pressed. We can use pattern matching to match for the event and to extract the values associated with the event. In the above case, we pattern match for a KeyDown and when we get such an event, we extract the actual key code, if the key is an Esc key, we simply exit the loop.

Drawing a rectangle

Here is a program which draws a filled rectangle (red colour):


extern crate sdl2;
extern crate rand;

use sdl2::pixels::Color;
use sdl2::rect::{Rect};

use std::{thread, time};

use sdl2::event::Event;
use sdl2::keyboard::Keycode;
use sdl2::render::Renderer;
use sdl2::EventPump;

fn  init<'a>()-> (Renderer<'a>, EventPump) {
    let sdl_context = sdl2::init().unwrap();
    let video_subsystem = sdl_context.video().unwrap();

    let window = video_subsystem.window("demo", 400, 400)
        .position_centered()
        .opengl()
        .build()
        .unwrap();

    let mut renderer = window.renderer().build().unwrap();

    let event_pump = sdl_context.event_pump().unwrap();

    renderer.set_draw_color(Color::RGB(255, 255, 255));
    renderer.clear();
    renderer.present();

    (renderer, event_pump)
}

fn main() {
    let (mut r,mut e) = init();

    r.set_draw_color(Color::RGB(255, 0, 0));
    r.fill_rect(Rect::new(10, 10, 100, 100));   
    r.present();

    'running:loop {
        for event in e.poll_iter() {
            match event {
                Event::KeyDown {
                  keycode: Some(Keycode::Escape), .. 
                } => { break 'running },
                _ => {}
            }
        }
        thread::sleep(time::Duration::from_millis(10));
    }
}

This is the function which draws the filled rectangle:

r.fill_rect(Rect::new(10, 10, 100, 100));   

It takes a Rect as argument; a Rect holds the x,y co-ordinates of the top left corner of the rectangle, and the width and height of the rectangle.

(There is one explicit lifetime parameter here … but we will not have to worry about it anywhere else in the code)

Animating a rectangle

We will now move a rectangle across the screen:

fn main() {
    let (mut r,mut e) = init();

    let mut x = 0;
    let y = 20;
    let white = Color::RGB(255, 255, 255);
    let red = Color::RGB(255, 0, 0);

    'running:loop {
        for event in e.poll_iter() {
            match event {
                Event::KeyDown {
                  keycode: Some(Keycode::Escape), .. 
                } => { break 'running },
                _ => {}
            }
        }
        r.set_draw_color(white);
        r.clear();
        r.set_draw_color(red);
        r.fill_rect(Rect::new(x, y, 10, 10));
        r.present();
        x = (x + 5) % 400;
        thread::sleep(time::Duration::from_millis(50));
    }
}

We now have enough rust-sdl2 skills to implement the game of life!

Implementing the Game of Life

Please go through the descriptions given in the wikipedia article as well as this page.

We will follow the same logic in our code.

First, some constants

Here are the key constants we will be using in our code:

const MAX_X: i32 = 199;
const MAX_Y: i32 = MAX_X;
const CELL_WIDTH: i32 = 5;
const CELL_HEIGHT: i32 = CELL_WIDTH;
const NCELLS: i32 = (MAX_X+1)/CELL_WIDTH;

MAX_X and MAX_Y represent the maximum X and Y co-ordinate values. We will be creating our window like this:

let window = video_subsystem.window(
              "demo", MAX_X as u32 + 1 , MAX_Y as u32 + 1
             )
             .position_centered()
             .opengl()
             .build()
             .unwrap();

CELL_WIDTH and CELL_HEIGHT refers to the width and height of each cell in our grid, in pixels.

Generating random numbers

We will use the rand crate to generate random numbers.

Here is an example:

extern crate rand;
use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();   
    let a:bool = rng.gen();
    let b:u32 = rng.gen();

    println!("{:?}, {:?}", a, b);

}

Representing the grid

We will use a:

Vec<Vec<bool>>

to represent an NxN grid. Each element (cell) of the grid will be either true (a live cell) or false (dead).

If v is the variable which represents the grid, then v[i][j] represents the cell at row i and column j.

Creating an initial grid

Here is a function which creates an (ncells)x(ncells) grid initialized randomly with true/false values:

fn life_random(ncells: i32) -> Vec<Vec<bool>> {
    let mut rng = rand::thread_rng();

    let mut v:Vec<Vec<bool>> = Vec::new();

    for i in  0..ncells {
        v.push(Vec::new());
        for j in  0..ncells {
            v[i as usize].push(rng.gen());
        }
    }

    v
}

Displaying a cell

Here is the function which displays a cell on the grid:

fn display_cell(r: &mut Renderer, row: i32, col: i32) {

    let mut x = CELL_WIDTH * col;
    let mut y = CELL_WIDTH * row;

    let cell_color = Color::RGB(255, 0, 0);
    r.set_draw_color(cell_color);
    r.fill_rect(Rect::new(x, y, 
			  CELL_WIDTH as u32, 
                          CELL_HEIGHT as u32));

}

This is a very simple function which converts row/column values into x-y pixel co-ordinates and then draws the rectangle in color red at that point (note that CELL_WIDTH and CELL_HEIGHT are equal).

Displaying the whole grid

We can now write the function which will display the whole grid by repeatedly calling display_cell on those cells which are live:

fn display_frame(r: &mut Renderer, v: &Vec<Vec<bool>>) {
    r.set_draw_color(Color::RGB(200, 200, 200));
    r.clear();
    for i in 0..NCELLS {
        for j in 0..NCELLS {
            if v[i as usize][j as usize] {
                display_cell(r, i, j);
            }
        }
    }
    r.present();
}

Note that we are first clearing the frame and displaying the live cells. We will call the present method at the end to map the drawn areas on to the visible frame.

Generating the grid in the next iteration

Here is where we implement the actual logic of the game of life.

We need to look at the current configuration of live/dead cells and decide whether each of these cells would be live or dead in the next iteration.


fn alive(r: i32, c: i32,
         v: &Vec<Vec<bool>>) -> bool {

    let n = count_surrounding(r, c, v);
    let curr = v[r as usize][c as usize] as i32;
    
    match (curr,  n) {
        (1, 0...1) => false,
        (1, 4...8) => false,
        (1, 2...3) => true,
        (0, 3)     => true,
        (0, 0...2) => false,
        (0, 4...8) => false,
        _ => panic!("alive: error in match"),
    }
}

This function takes a row/column value and the vector which represents the grid as argument. It then decides whether the cell at that row/column position would be alive or dead in the next generation/iteration.

The key function here is count_surroundings; it will count how many live neighbours the current cell has (in eight directions):


fn count_surrounding(r: i32, c: i32,  
                     v: &Vec<Vec<bool>>) -> i32{
    let r = r as usize;
    let c = c as usize;
    
    v[dec(r)][c] as i32 +
    v[inc(r)][c] as i32 +
    v[r][dec(c)] as i32 +
    v[r][inc(c)] as i32 +
    v[dec(r)][dec(c)] as i32 +
    v[dec(r)][inc(c)] as i32 +
    v[inc(r)][inc(c)] as i32 +
    v[inc(r)][dec(c)] as i32
}

(note: a boolean value can be cast to an i32; you will get 1 or 0 as the result)

The only functions of interest here are dec and inc.

The game of life theoretically evolves on an infinite grid; because we can’t have such a grid in practice, we assume that the left and right sides as well as the top and bottom sides wrap over.

That is, you can move from column 0 to 1, 2, … upto 39, and once you reach column 39, you wrap around to column 0. Also, you can move from column 39 to 38, 37, … upto 0 and once again reach back to column 39.

Similar is the case with the rows.

fn inc(n: usize) ->  usize {
    (n + 1) % (NCELLS as usize)
}

fn dec(n: usize) -> usize {
    if n == 0 {
        (NCELLS - 1) as usize
    } else {
        (n - 1) as usize
    }
}

Using these functions, it becomes easy to implement a function which will generate the grid for the next iteration:

fn life_next(v: Vec<Vec<bool>>) -> Vec<Vec<bool>> {
    let mut v2:Vec<Vec<bool>> = Vec::new();
    
    for i in 0..NCELLS {
            v2.push(Vec::new());
        for j in 0..NCELLS {
            if alive(i,  j, &v) {
                v2[i as usize].push(true);
            } else {
                v2[i as usize].push(false);
            }
        }
    }

    v2
}

Our main function is short and sweet:


fn main() {
    let (mut r,mut e) = init();
    let mut v = life_random(NCELLS);


    'running:loop {
        for event in e.poll_iter() {
            match event {
                Event::KeyDown {
		  keycode: Some(Keycode::Escape), .. 
                } => { break 'running },
                _ => {}
            }
        }
        display_frame(&mut r, &v);
        v = life_next(v);
        thread::sleep(time::Duration::from_millis(50));
    }
}

Experimenting with various initial configurations

The game of life exhibits fascinating behaviours when it is seeded with specific initial values.

For example, here is the description of a glider pattern.

You can replace life_random with the following function and watch the fun!

fn glider(ncells: i32) -> Vec<Vec<bool>> {
    let mut v:Vec<Vec<bool>> = Vec::new();
    
    for i in 0..ncells {
        v.push(Vec::new());
        for j in 0..ncells {
            v[i as usize].push(false);
        }
    }
    
    v[10][11] = true;
    v[11][12] = true;
    v[12][10] = true;
    v[12][11] = true;
    v[12][12] = true;
    
    v
}

Have fun experimenting with spaceships, Blinkers, Beacons, Pulsar’s and such other weird things …

You can get the source code of this program from here.

Discuss this post on reddit