The Mojo ManualXYZ Homework Edition

⇩ Download ▾

1.2 Get started with Mojo

Get started with Mojo by building Conway's Game of Life, a simulation in which cells live, die, and reproduce based on the state of their neighbors.

This tutorial walks you through the steps to build a simple version of the game. It should take you about 45-60 minutes to complete. Don't feel you need to rush it.

Whether you arrive from, say, C++ or Python, you'll encounter unfamiliar syntax like transfer operators and compile-time variables. Read the Takeaways items to map these new features to concepts you already know.

Run the code in your terminal and watch the grid evolve over time. As you build the game, you'll learn the Mojo syntax you need to start writing programs of your own.

Game state

Conway's Game of Life runs on a two-dimensional grid. Each cell is either alive or inactive. You'll use 1 for live cells and 0 for inactive cells. Count the neighbors around each cell by adding their values to a running sum.

Create life.mojo. Build an 8 x 8 grid containing a glider:

def main():
    var count: Int = 64
    var num_cols: Int = 8

    var glider_grid = List[Int](length=count, fill=0)

    # Convert (x, y) coordinates to a linear index in the grid
    var to_index = lambda (x: Int, y: Int) -> Int: y * num_cols + x

    # Set up the grid
    for coord in [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)]:
        glider_grid[to_index(coord[0], coord[1])] = 1

    # Print the grid
    for index in range(count):
        print("X" if glider_grid[index] else ".", end="")
        if index % num_cols == (num_cols - 1): print()
Show output ✓ Verified · Mojo 1.1.0 (8189361e)
..X.....
X.X.....
.XX.....
........
........
........
........
........

Run the program to see the initial glider configuration:

mojo life.mojo
Show expected output
..X.....
X.X.....
.XX.....
........
........
........
........
........

Output:

Takeaways

Add reusable printing

Move the display loop to a reusable function. Place this above main():

def print_grid(grid: List[Int], num_cols: Int):
    for index in range(len(grid)):
        print("X" if grid[index] else ".", end="")
        if index % num_cols == (num_cols - 1): print()

Replace the print loop with print_grid(glider_grid, num_cols) and run.

Improve performance by replacing individual prints with a single string. This solution uses comprehensions:

    def print_grid(self):
        var grid_str = "".join(
            [(("X" if value else ".") + 
              ("\n" if (index + 1) % Self.num_cols == 0 else ""))
              for index, value in enumerate(self.cells)
            ]
        )
        print(grid_str)

When running the program with larger grids and many generations, this approach is faster than printing each cell individually. You print once per frame instead of once per cell.

Call print_grid() from main() and confirm the output:

# Print the grid
print_grid(glider_grid, num_cols)

Takeaways

Add lookups

Make a few more changes in place in life.mojo:

comptime count: Int = 64
comptime num_cols: Int = 8

# Transform coordinates to a linear index
comptime to_index = lambda (x: Int, y: Int) -> Int: (
    y * num_cols + x
)

# Transform linear index to coordinates
comptime to_coord = lambda (i: Int) -> Tuple[Int, Int]: (
    (i % num_cols, i // num_cols)  # `//` is flooring divide
)

def print_grid(grid: List[Int]):
    for index in range(len(grid)):
        print("X" if grid[index] else ".", end="")
        if index % num_cols == (num_cols - 1): print()

def main():
    var glider_grid: List[Int] = List[Int](length=count, fill=0)

    # Set up the grid
    for coord in [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)]:
        glider_grid[to_index(coord[0], coord[1])] = 1

    # Print the grid
    print_grid(glider_grid)
Show output ✓ Verified · Mojo 1.1.0 (8189361e)
..X.....
X.X.....
.XX.....
........
........
........
........
........

Now you can convert coordinates to indices and indices to coordinates.

Takeaways

Try this

In a separate source file, declare the number of rows as a comptime constant and compute the count at compile time.

Remove the new source after verifying your solution is correct. You will always need two of these three items: the number of rows, the number of columns, and the total count of cells.

Define a Grid type

Revise your code again, into a new file called grid.mojo. You're creating a new type, called a struct:

struct Grid[num_cols: Int, num_rows: Int]:
    var cells: List[Int]
    var count: Int

    def __init__(out self):
        self.count = Self.num_cols * Self.num_rows
        self.cells = List[Int](length=self.count, fill=0)

def main():
    var glider_grid = Grid[8, 8]()

__init__(out self) initializes a new Grid. Every field must receive a value before the initializer returns.

Here, count comes from the grid dimensions, and cells starts as a list of zeros.

Takeaways

Add grid operations

Put the following content into your Grid struct:

    comptime to_index = lambda (x: Int, y: Int) -> Int: (
        y * Self.num_cols + x
    )

    comptime to_coord = lambda (i: Int) -> Tuple[Int, Int]: (
        (i % Self.num_cols, i // Self.num_cols)
    )

    def print_grid(self):
        var grid_str = ""
        for index in range(len(self.cells)):
            grid_str += "X" if self.cells[index] else "."
            if (index % Self.num_cols == (Self.num_cols - 1) and
                index != len(self.cells) - 1):
                grid_str += "\n"
        print(grid_str)

    def __setitem__(mut self, coord: Tuple[Int, Int], value: Int):
        self.cells[Self.to_index(coord[0], coord[1])] = value

    def __getitem__(self, coord: Tuple[Int, Int]) -> Int:
        return self.cells[Self.to_index(coord[0], coord[1])]

Takeaways

Don't add this anywhere. It's just a preview of how this all works from the call site:

# Set up the grid
for coord in [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)]:
    glider_grid[coord] = 1  # Uses indexing with `__setitem__()`

Import Grid

Remove main() from grid.mojo, save it, and import your new type into life.mojo:

from grid import Grid  # Separate concerns into separate files

def main():
    var glider_grid = Grid[8, 8]()

    # Set up the grid
    for coord in [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)]:
        glider_grid[coord] = 1

    # Print the grid
    glider_grid.print_grid()

Run it and confirm everything works as expected.

Be random

Gliders are terrific for validating code, but random values produce great animations. Set up your seed in main():

from std.random import seed
from grid import Grid

def main():
    seed()

    # ...

Mojo won't allow runtime statements at global scope, so you can't call seed() there. Instead, seed the RNG from executable code, such as main() or another function. You only need to seed once: seeding sets the state of a single PRNG shared across threads.

Add protection

Filling every cell independently gives you random static. It works, but it doesn't produce especially interesting Game of Life patterns. Instead, build a few random clumps of live cells.

To start, add checks to your setter in grid.mojo. A coordinate check function helps:

    def is_valid_coord(self, coord: Tuple[Int, Int]) -> Bool:
        return not (
            coord[0] < 0 or coord[0] >= Self.num_cols or
            coord[1] < 0 or coord[1] >= Self.num_rows
        )

    def __setitem__(mut self, coord: Tuple[Int, Int], value: Int):
        if not self.is_valid_coord(coord): return # no op
        self.cells[Self.to_index(coord[0], coord[1])] = value

You can add a check to the getter, too. Instead of returning a made-up value, raise an error or abort the process. The best approach tests the coordinates before indexing (if self.is_valid_coord(coord):) to avoid indexing errors instead of adding no-op workarounds.

Introducing errors

This version of __getitem__() raises an error when coordinates aren't valid. Add raises to the signature before the arrow, and call raise:

    def __getitem__(self, coord: Tuple[Int, Int]) raises -> Int:
        if not self.is_valid_coord(coord):
            raise String(t"Invalid coordinate: ({coord[0]}, {coord[1]})")

        return self.cells[Self.to_index(coord[0], coord[1])]

A TString starts with t" and creates a template format. print() statements automatically convert to strings, but everywhere else explicitly call String().

Once __getitem__() raises, every caller must either raise or handle the error using Mojo's try/except error handling.

For now, revert your changes and let __getitem__() handle invalid coordinates as it did before.

Construct the random grid

Add a static method that constructs a random Grid:

from std.random import random_si64  # Add this import to grid.mojo

# and inside Grid:

    @staticmethod
    def random_grid(clumps: Int = 2) -> Self:
        var grid = Self()
        for _ in range(clumps):
            var idx = Int(random_si64(0, Int64(grid.count) - 1))
            var x, y = Self.to_coord(idx)
            grid[(x, y)] = 1  # Fill index cell

            for dx in range(-1, 2):  # -1, 0, or 1
                for dy in range(-1, 2):
                    if not grid.is_valid_coord((x + dx, y + dy)): continue
                    if random_si64(0, 3) > 0: continue  # 75% skip
                    grid[(x + dx, y + dy)] = 1
        return grid^

By default, random_grid() builds two clumps when called without arguments (clumps: Int = 2). The default value follows the equal sign.

A @staticmethod belongs to the type and not an individual instance. Call it through the Grid type:

print("\nRandom grid:\n")
var random_grid = Grid[8, 8].random_grid()
random_grid.print_grid()

You don't have to add this code except to test it. You can remove it afterwards.

Takeaways

Evolve

In life.mojo, cut out the glider grid and remove the print statements. Next, you'll start making the grid change over time. Start by adding a separate next_cells list to Grid and initialize it:

struct Grid[num_cols: Int, num_rows: Int]:
    var cells: List[Int]       # Holds the current generation
    var next_cells: List[Int]  # Holds the next generation
    var count: Int

    def __init__(out self):
        self.count = Self.num_cols * Self.num_rows
        self.cells = List[Int](length=self.count, fill=0)
        self.next_cells = List[Int](length=self.count, fill=0)

Conway's Game of Life applies three rules to every cell:

The updated cells are stored in the next_cells list. They won't affect math for the previous generation.

Each cell has eight neighbors: the cells in the 3 x 3 square around it, excluding the cell itself.

Add these methods to Grid.

evolve_cell() determines the state for the next generation of a single cell based on its neighbors:

    def evolve_cell(mut self, i: Int):
        var is_live = Bool(self.cells[i])
        self.next_cells[i] = 0

        # Count the neighbors
        var ncount = -1 if is_live else 0  # Exclude self from the count
        var x, y = Self.to_coord(i)
        for dx in range(-1, 2):
            for dy in range(-1, 2):
                var nx = x + dx
                var ny = y + dy
                ncount += self.cells[Self.to_index(nx, ny)]

        # Live cell stays alive with two or three live neighbors
        if is_live and (ncount == 2 or ncount == 3):
            self.next_cells[i] = 1
        # Inactive cell becomes alive with exactly three live neighbors
        elif not is_live and ncount == 3:
            self.next_cells[i] = 1

evolve() updates the entire grid to the next generation by calling evolve_cell() for each index:

    def evolve(mut self):
        for i in range(self.count):
            var x, y = Self.to_coord(i)
            # Edges are excluded from evolution and will always go inactive.
            if (x == 0 or y == 0 or
                x == Self.num_cols - 1 or y == Self.num_rows - 1):
                self.next_cells[i] = 0  # Edges go inactive
                continue
            self.evolve_cell(i)

        # Swap the current and next cell states
        var tmp = self.cells^  # Transfer
        self.cells = self.next_cells^  # Transfer
        self.next_cells = tmp^  # Every cell is written, so this is safe

Takeaways

Run the simulation

Add a loop so you can watch the grid evolve in the terminal. Here's the final life.mojo:

from std.random import seed
from grid import Grid
from std.time import sleep

def main():
    comptime gridw: Int = 80
    comptime gridh: Int = 20
    comptime grid_count: Int = 400

    seed()
    var grid = Grid[gridw, gridh].random_grid(grid_count)

    while True:
        for gen in range(100):
            print(t"\033[H\033[J\nGeneration: {gen}{' ' * 4}")
            grid.evolve(); grid.print_grid()
            sleep(0.1)
        grid = Grid[gridw, gridh].random_grid(grid_count)

Takeaways

Your first day? Try these

Final code

Adapted from the Mojo Manual and the MAX GPU programming guides, part of the Modular Platform repository (github.com/modular/modular), Copyright (c) Modular Inc., licensed under the Apache License v2.0 with LLVM Exceptions. Changes were made: reformatted as an accessible XYZ web edition with build-verified example outputs. Mojo and MAX are trademarks of Modular Inc.; this edition is not affiliated with or endorsed by Modular. License: Apache-2.0 WITH LLVM-exception.

These eBooks are a prerelease and are not yet certified conformant with WCAG 2.1 AA or any other accessibility standard. Every page is built against an automated accessibility gate, and our target for the published editions is WCAG 2.2 Level AA. If something is unusable, please tell us.