Conway's Game of Life in Rust
On this page
Conway's Game of Life stands as a canonical example of a cellular automaton. The state of each cell, either **ALIVE** or **DEAD**, evolves synchronously across a grid based on four simple, local rules. Drawing inspiration from a video by Salvatore Sanfilippo, I built a simple implementation of Conway's Game of Life in Rust, featuring an infinite toroidal grid.
<figure class="video">
<iframe width="560" height="315" src="https://www.youtube.com/embed/Mao0AyWuIq8?si=iMYxoIi7AVyWMR27" title="RustLandia - COnways Game of Life" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</figure>
Toroidal Grid Geometry and Coordinate Mapping#
A key idea of this implementation is the handling of grid boundaries. Rather than imposing constraints that terminate the simulation, or adding infinite space to the grid, i chooce a toroidal topology. This configuration seamlessly connects opposing edges—the left with the right and the top with the bottom—simulating a continuous, donut-shaped universe.
fn get_unbound_coords(x: isize, y: isize) -> Coords {
let cols = GRID_COLS as isize;
let rows = GRID_ROWS as isize;
let corrected_x = ((x % cols) + cols) % cols;
let corrected_y = ((y % rows) + rows) % rows;
Coords { x: corrected_x as usize, y: corrected_y as usize }
}The double modulo operation, ((x % cols) + cols) % cols provides a robust and branchless solution for wrapping coordinates, correctly handling both positive and negative values.
Double Buffering#
To prevent race conditions and ensure that cell updates are based on the state of the previous generation, the simulation employs a double buffering strategy. Two grids are maintained: one representing the current state and a second for the next state.
update_grid(&grid1, &mut grid2);
std::mem::swap(&mut grid1, &mut grid2);After each generation's new state is computed, the roles of the two grids are exchanged using std::mem::swap. This operation performs a pointer-level exchange, which is a near-zero-cost operation, effectively avoiding the need for deep, memory-intensive copies.
Pattern Instantiation#
Classic patterns such as gliders and pulsars are defined declaratively as arrays of coordinate offsets. This design enables a single, reusable helper function to instantiate any pattern.
fn draw_pattern(
grid: &mut [[Status; GRID_COLS]; GRID_ROWS],
x: isize, y: isize,
offsets: &[(isize, isize)]
) {
for (dx, dy) in offsets {
let c = get_unbound_coords(x + *dx, y + *dy);
set_cell(grid, Cell { coords: c, status: Status::ALIVE });
}
}You can find this implementation on GitHub:
gslf/RustLandia: Things in Rust