Conway's Game of Life
Conway’s Game of Life in Zig
Project Overview
Implementation of Conway’s Game of Life demonstrating key Zig concepts:
- Memory management with allocators
- Struct methods and organization
- Error handling
- Standard library usage
Code location: /home/wes/projects/game_of_zig/src/main.zig
Key Concepts Demonstrated
1. Struct with Methods
const Grid = struct {
cells: []bool,
width: usize,
height: usize,
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, width: usize, height: usize) !Grid {
const cells = try allocator.alloc(bool, width * height);
@memset(cells, false);
return Grid{
.cells = cells,
.width = width,
.height = height,
.allocator = allocator,
};
}
fn deinit(self: *Grid) void {
self.allocator.free(self.cells);
}
};
Key points:
initreturns!Grid- can fail with allocation error@memsetis a builtin for efficient memory initialization- Store the allocator so
deinitcan free memory - Struct literal syntax:
.{ .field = value }
2. Memory Management Pattern
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // Catches leaks!
const allocator = gpa.allocator();
var grid = try Grid.init(allocator, WIDTH, HEIGHT);
defer grid.deinit(); // Cleanup guaranteed
}
The defer pattern ensures cleanup even with early returns or errors.
3. 2D Array as 1D Slice
fn get(self: *const Grid, x: usize, y: usize) bool {
if (x >= self.width or y >= self.height) return false;
return self.cells[y * self.width + x]; // Row-major order
}
fn set(self: *Grid, x: usize, y: usize, value: bool) void {
if (x >= self.width or y >= self.height) return;
self.cells[y * self.width + x] = value;
}
2D coordinates → 1D index: index = y * width + x
4. Neighbor Counting
fn countNeighbors(self: *const Grid, x: usize, y: usize) u8 {
var count: u8 = 0;
const offsets = [_][2]i32{
.{ -1, -1 }, .{ 0, -1 }, .{ 1, -1 },
.{ -1, 0 }, .{ 1, 0 },
.{ -1, 1 }, .{ 0, 1 }, .{ 1, 1 },
};
for (offsets) |offset| {
const nx = @as(i32, @intCast(x)) + offset[0];
const ny = @as(i32, @intCast(y)) + offset[1];
if (nx >= 0 and ny >= 0 and nx < self.width and ny < self.height) {
if (self.get(@intCast(nx), @intCast(ny))) {
count += 1;
}
}
}
return count;
}
Type conversions:
@as(i32, @intCast(x))- Convert to i32 (for negative offsets)@intCast(nx)- Convert back to usize for array indexing- Explicit because Zig prevents silent integer overflows
5. Double Buffering
fn step(self: *Grid) !void {
var next = try Grid.init(self.allocator, self.width, self.height);
defer next.deinit();
// Apply Conway's rules to next generation
for (0..self.height) |y| {
for (0..self.width) |x| {
const neighbors = self.countNeighbors(x, y);
const alive = self.get(x, y);
const next_state = if (alive)
(neighbors == 2 or neighbors == 3)
else
(neighbors == 3);
next.set(x, y, next_state);
}
}
// Copy next generation to current
@memcpy(self.cells, next.cells);
}
Pattern:
- Create temporary grid
- Calculate next state
- Copy back to original
- Temporary cleaned up by defer
6. I/O with Buffered Writer (Zig 0.15.2)
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("Generation: {d}\n", .{generation});
try stdout.flush(); // Must flush buffered output!
7. Terminal Control
try writer.writeAll("\x1B[2J\x1B[H"); // Clear screen, move cursor
ANSI escape codes for terminal control.
8. Sleep
std.Thread.sleep(100 * std.time.ns_per_ms); // Sleep 100ms
In Zig 0.15.2, sleep moved from std.time to std.Thread.
Common Pitfalls Encountered
API Changes in Zig 0.15.2
-
stdout access:
// Old: std.io.getStdOut() // New: std.fs.File.stdout() -
Writer requires buffer:
var buffer: [4096]u8 = undefined; var writer = std.fs.File.stdout().writer(&buffer); -
Unicode characters:
const char: u8 = '█'; // ❌ Error: u8 can't hold Unicode const char: u8 = '#'; // ✅ ASCII only -
Sleep location:
std.time.sleep(ms); // ❌ Old API std.Thread.sleep(ms); // ✅ New API
Running the Code
cd /home/wes/projects/game_of_zig
zig build run
Lessons Learned
- Memory safety - Explicit allocation/deallocation prevents leaks
- Error handling -
trymakes error paths visible - Type safety - Explicit casts prevent bugs
- Zero cost - Nice patterns compile to efficient code
- Evolving language - Zig is pre-1.0, APIs change between versions
The final code is clean, fast, and the disassembly would show tight, predictable machine code with no hidden overhead.