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:

  • init returns !Grid - can fail with allocation error
  • @memset is a builtin for efficient memory initialization
  • Store the allocator so deinit can 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:

  1. Create temporary grid
  2. Calculate next state
  3. Copy back to original
  4. 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

  1. stdout access:

    // Old: std.io.getStdOut()
    // New: std.fs.File.stdout()
  2. Writer requires buffer:

    var buffer: [4096]u8 = undefined;
    var writer = std.fs.File.stdout().writer(&buffer);
  3. Unicode characters:

    const char: u8 = '█';  // ❌ Error: u8 can't hold Unicode
    const char: u8 = '#';  // ✅ ASCII only
  4. 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

  1. Memory safety - Explicit allocation/deallocation prevents leaks
  2. Error handling - try makes error paths visible
  3. Type safety - Explicit casts prevent bugs
  4. Zero cost - Nice patterns compile to efficient code
  5. 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.

Wesley Ray · blog · git · resume