Skip to main content
Log in

Get started with Mojo

Get ready to learn Mojo! This tutorial is designed to give you a tour of several features of Mojo by building a complete program that does much more than simply printing "Hello, world!"

In fact, we'll build a version of Conway's Game of Life, which is a simple simulation to explore self-replicating systems. If you haven't heard of it before, don't worry, it will make sense when you see it in action. Let's just get started so you can learn Mojo programming basics, including the following:

  • Using basic built-in types like Int and String
  • Using a List to manage a sequence of values
  • Creating custom types in the form of structs (data structures)
  • Creating and importing Mojo modules
  • Importing and using Python libraries

This tutorial might be a little long because there's a lot to learn, but we tried to keep the explanations simple, and we included links along the way for you to go learn more about each topic.

And if you just want to see the finished code, you can get it on GitHub.

1. Create a Mojo project with magic

We'll start by using the magic CLI to create a virtual environment and generate our initial project directory.

In your terminal, go to the directory in which you want to create the project and execute:

magic init life --format mojoproject
magic init life --format mojoproject

This creates a project directory named life. Let's go to the directory and list its contents:

cd life
cd life
ls -A
ls -A
.gitattributes
.gitignore
.magic
magic.lock
mojoproject.toml
.gitattributes
.gitignore
.magic
magic.lock
mojoproject.toml

You should see that the project directory contains:

  • An initial mojoproject.toml manifest file, which defines the project dependencies and other features

  • A lock file named magic.lock, which specifies the transitive dependencies and actual package versions installed in the project's virtual environment

  • A .magic subdirectory containing the conda virtual environment for the project

  • Initial .gitignore and .gitattributes files that you can optionally use if you plan to use git version control with the project

Because we used the --format mojoproject option when creating the project, magic automatically added the max package as a dependency, which includes Mojo. Let's verify that our project is configured correctly by checking the version of Mojo that's installed within our project's virtual environment. magic run executes a command in the project's virtual environment, so let's use it to execute mojo --version:

magic run mojo --version
magic run mojo --version

You should see a version string indicating the version of Mojo installed, which by default should be the latest released version.

Great! Now let's write our first Mojo program.

2. Create a "Hello, world" program

You can use any editor or IDE that you like. If you're using Visual Studio Code you can take advantage of the Mojo for Visual Studio Code extension, which provides features like syntax highlighting, code completion, and debugging support.

In the project directory, create a file named life.mojo containing the following lines of code:

life.mojo
# My first Mojo program!
def main():
print("Hello, World!")
# My first Mojo program!
def main():
print("Hello, World!")

If you've programmed before in Python, this should look familiar.

  • We're using the def keyword to define a function named main.
  • You can use any number of spaces or tabs for indentation as long as you use the same indentation for the entire code block. We'll follow the Python style guide and use 4 spaces.
  • This print() function a Mojo built-in so it doesn't require an import.

An executable Mojo program requires you to define a no-argument main() as its entry point. Running the program automatically invokes the main() function, and your program exits when the main() function returns.

To run the program, we first need to start a shell session in our project's virtual environment:

magic shell
magic shell

Later on, when you want to exit the virtual environment, just type exit.

Now we can use the mojo command to run our program.

mojo life.mojo
mojo life.mojo
Hello, World!
Hello, World!

Mojo is a compiled language, not an interpreted one like Python. So when we run our program like this, mojo performs just-in-time compilation (JIT) and then runs the result.

We can also compile our program into an executable file using mojo build like this:

mojo build life.mojo
mojo build life.mojo

By default, this saves an executable file to the current directory named life.

./life
./life
Hello, World!
Hello, World!

3. Create and use variables

Let's extend this basic program by prompting the user for their name and including that in the greeting printed. The built-in input() function accepts an optional String argument to use as a prompt, and returns a String consisting of the characters the user entered (with the newline character at the end stripped off).

So let's declare a variable, assign the return value from input() to it, and build a customized greeting.

life.mojo
def main():
var name: String = input("Who are you? ")
var greeting: String = "Hi, " + name + "!"
print(greeting)
def main():
var name: String = input("Who are you? ")
var greeting: String = "Hi, " + name + "!"
print(greeting)

Go ahead and run it:

mojo life.mojo
mojo life.mojo
Who are you? Edna
Hi, Edna!
Who are you? Edna
Hi, Edna!

Notice that this code uses a String type annotation indicating the type of value that the variable can contain. The Mojo compiler performs static type checking, which means that you'll encounter a compile-time error if your code tries to assign a value of one type to a variable of a different type.

Mojo also supports implicitly declared variables, where you simply assign a value to a new variable without using the var keyword or indicating its type. So we can replace the code we just entered with the following, and it works exactly the same.

life.mojo
def main():
name = input("Who are you? ")
greeting = "Hi, " + name + "!"
print(greeting)
def main():
name = input("Who are you? ")
greeting = "Hi, " + name + "!"
print(greeting)

However, implicitly declared variables still have a fixed type, which Mojo automatically infers from the initial value assignment. So in this example both name and greeting are inferred as String type variables. If you then try to assign an integer value like 42 to the name variable, you'll get a compile-time error because of the type mismatch. You can learn more about Mojo variables in the Variables section of the Mojo manual.

4. Use Mojo Int and List types to represent the game state

As originally envisioned by John Conway, the game's "world" is an infinite, two-dimensional grid of square cells, but for our implementation we'll constrain the grid to a finite size. A drawback to making the edges of the grid a hard boundary is that there are fewer neighboring cells around the edges compared to the interior, which tends to cause die offs. Therefore, we'll model the world as a toroid (a donut shape), where the top row is considered adjacent to the bottom row, and the left column is considered adjacent to the right column. This will come into play later when we implement the algorithm for calculating each subsequent generation.

To keep track of the height and width of our grid we'll use Int, which represents a signed integer of the word size of the CPU, typically 32 or 64 bits.

To represent the state of an individual cell, we'll represent the cell state with an Int value of 1 (populated) or 0 (unpopulated). Later, when we need to determine the number of populated neighbors surrounding a cell, we can simply add the values of the neighboring cells.

To represent the state of the entire grid, we need a collection type. The most appropriate for this use case is List, which is a dynamically-sized sequence of values.

All of the values in a Mojo List must be the same type so that the Mojo compiler can ensure type safety. (For example, when we retrieve a value from a List[Int], the compiler knows that the value is an Int and can verify that we then use it correctly). Mojo collections are implemented as generic types, so that we can indicate the type of values the specific collection will hold by specifying a type parameter in square brackets like this:

# The List in row can contain only Int values
row = List[Int]()

# The List in names can contain only String values
names = List[String]()
# The List in row can contain only Int values
row = List[Int]()

# The List in names can contain only String values
names = List[String]()

We can also create a List with an initial set of values and let the compiler infer the type.

nums = List(12, -7, 64)  # A List[Int] containing 3 Int values
nums = List(12, -7, 64)  # A List[Int] containing 3 Int values

The Mojo List type includes the ability to append to the list, pop values out of the list, and access list items using subscript notation. Here's a taste of those operations:

nums = List(12, -7, 64)
nums.append(-937)
print("Number of elements in the list:", len(nums))
print("Popping last element off the list:", nums.pop())
print("First element of the list:", nums[0])
print("Second element of the list:", nums[1])
print("Last element of the list:", nums[-1])
nums = List(12, -7, 64)
nums.append(-937)
print("Number of elements in the list:", len(nums))
print("Popping last element off the list:", nums.pop())
print("First element of the list:", nums[0])
print("Second element of the list:", nums[1])
print("Last element of the list:", nums[-1])
Number of elements in the list: 4
Popping last element off the list: -937
First element of the list: 12
Second element of the list: -7
Last element of the list: 64
Number of elements in the list: 4
Popping last element off the list: -937
First element of the list: 12
Second element of the list: -7
Last element of the list: 64

We can also nest Lists:

grid = List(
List(11, 22),
List(33, 44)
)
print("Row 0, Column 0:", grid[0][0])
print("Row 0, Column 1:", grid[0][1])
print("Row 1, Column 0:", grid[1][0])
print("Row 1, Column 1:", grid[1][1])
grid = List(
List(11, 22),
List(33, 44)
)
print("Row 0, Column 0:", grid[0][0])
print("Row 0, Column 1:", grid[0][1])
print("Row 1, Column 0:", grid[1][0])
print("Row 1, Column 1:", grid[1][1])
Row 0, Column 0: 11
Row 0, Column 1: 22
Row 1, Column 0: 33
Row 1, Column 1: 44
Row 0, Column 0: 11
Row 0, Column 1: 22
Row 1, Column 0: 33
Row 1, Column 1: 44

This looks like a good way to represent the state of the grid for our program. So let's update the main() function with the following code that defines an 8x8 grid containing the initial state of a "glider" pattern.

life.mojo
def main():
num_rows = 8
num_cols = 8
glider = List(
List(0, 1, 0, 0, 0, 0, 0, 0),
List(0, 0, 1, 0, 0, 0, 0, 0),
List(1, 1, 1, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
)
def main():
num_rows = 8
num_cols = 8
glider = List(
List(0, 1, 0, 0, 0, 0, 0, 0),
List(0, 0, 1, 0, 0, 0, 0, 0),
List(1, 1, 1, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
)

5. Create and use a function to print the grid

Now let's create a function to generate a string representation of the game grid that we can print it to the terminal.

There are actually two different keywords that we can use to define functions in Mojo: def and fn. Using fn gives us finer level control over the function definition, whereas def provides a good set of default behaviors for most use cases.

Let's add the following definition of a function named grid_str() to our program. The Mojo compiler doesn't care whether we add our function before or after main(), but the convention is to put main() at the end.

life.mojo
def grid_str(rows: Int, cols: Int, grid: List[List[Int]]) -> String:
# Create an empty String
str = String()

# Iterate through rows 0 through rows-1
for row in range(rows):
# Iterate through columns 0 through cols-1
for col in range(cols):
if grid[row][col] == 1:
str += "*" # If cell is populated, append an asterisk
else:
str += " " # If cell is not populated, append a space
if row != rows-1:
str += "\n" # Add a newline between rows, but not at the end
return str
def grid_str(rows: Int, cols: Int, grid: List[List[Int]]) -> String:
# Create an empty String
str = String()

# Iterate through rows 0 through rows-1
for row in range(rows):
# Iterate through columns 0 through cols-1
for col in range(cols):
if grid[row][col] == 1:
str += "*" # If cell is populated, append an asterisk
else:
str += " " # If cell is not populated, append a space
if row != rows-1:
str += "\n" # Add a newline between rows, but not at the end
return str

When we pass a value to a Mojo function, the default behavior for def is that an argument is treated as a read-only reference to the value. However, if the Mojo compiler determines that there is code in the function that can change the value, then the argument gets a copy of the original value assigned to it. As we'll see later, we can specify a different behavior by including an explicit argument convention. In contrast, when you define a function with fn Mojo simply treats each argument as a read-only reference by default unless you provide an explicit argument convention.

Each argument name is followed by a type annotation indicating the type of value you can pass to the argument. Just like when you're assigning a value to a variable, you'll encounter a compile-time error if your code tries to pass a value of one type to an argument of a different type. Finally, the -> String following the argument list indicates that this function has a String type return value.

In the body of the function, we generate a String by appending an asterisk for each populated cell and a space for each unpopulated cell, separating each row of the grid with a newline character. We use nested for loops to iterate through each row and column of the grid, using range() to generate a sequence of integers from 0 up to but not including the given end value. Then we append the correct characters to the String representation. See Control flow for more information on if, for, and other control flow structures in Mojo.

Now that we've defined our grid_str() function, let's invoke it from main().

life.mojo
def main():
...
result = grid_str(num_rows, num_cols, glider)
print(result)
def main():
...
result = grid_str(num_rows, num_cols, glider)
print(result)

Then run the program to see the result:

mojo life.mojo
mojo life.mojo
 *
*
***





 *
*
***





We can see that the position of the asterisks matches the location of the 1s in the glider grid.

6. Create a module and define a custom type

We're currently passing three arguments to grid_str() to describe the size and state of the grid to print. A better approach would be to define our own custom type that encapsulates all information about the grid. Then any function that needs to manipulate a grid can accept just a single argument. We can do this by defining a Mojo struct, which is a custom data structure.

A Mojo struct is a custom type consisting of:

  • Fields, which are variables containing the data associated with the structure
  • Methods, which are functions that we can optionally define to manipulate instances of the struct that we create

We could define the struct in our existing life.mojo source file, but let's create a separate module for the struct. A module is simply a Mojo source file containing struct and function definitions that can be imported into other Mojo source files. To learn more about creating and importing modules, see the Modules and packages section of the Mojo manual .

So create a new source file named gridv1.mojo in the project directory containing the following definition of a struct named Grid consisting of three fields:

gridv1.mojo
@value
struct Grid():
var rows: Int
var cols: Int
var data: List[List[Int]]
@value
struct Grid():
var rows: Int
var cols: Int
var data: List[List[Int]]

Mojo requires you to declare all of the fields in the struct definition. You can't add fields dynamically at run-time. You must declare the type for each field, but you cannot assign a value as part of the field declaration. Instead, the constructor is responsible for initializing the value of all fields.

Mojo structs support several different lifecycle methods defining the behavior when an instance of the struct is created, moved, copied, and destroyed. For structs that are basic aggregations of other types and don't require custom resource management or lifecycle behaviors, you can simply add the @value decorator to your struct definition to have the Mojo compiler automatically generate lifecycle methods for you.

Because we used the @value decorator, Grid includes a "member-wise" constructor . The constructor's arguments are the same names and types as the struct's fields and appear in the same order. So this means that we can create an instance of Grid like this:

my_grid = Grid(2, 2, List(List(0, 1), List(1, 1)))
my_grid = Grid(2, 2, List(List(0, 1), List(1, 1)))

We can then access the field values with "dot" syntax like this:

print("Rows:", my_grid.rows)
print("Rows:", my_grid.rows)
Rows: 2
Rows: 2

7. Import a module and use our custom Grid type

Now let's edit life.mojo to import Grid from our new module and update our code to use it.

life.mojo
from gridv1 import Grid

def grid_str(grid: Grid) -> String:
# Create an empty String
str = String()

# Iterate through rows 0 through rows-1
for row in range(grid.rows):
# Iterate through columns 0 through cols-1
for col in range(grid.cols):
if grid.data[row][col] == 1:
str += "*" # If cell is populated, append an asterisk
else:
str += " " # If cell is not populated, append a space
if row != grid.rows - 1:
str += "\n" # Add a newline between rows, but not at the end
return str

def main():
glider = List(
List(0, 1, 0, 0, 0, 0, 0, 0),
List(0, 0, 1, 0, 0, 0, 0, 0),
List(1, 1, 1, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
)
start = Grid(8, 8, glider)
result = grid_str(start)
print(result)
from gridv1 import Grid

def grid_str(grid: Grid) -> String:
# Create an empty String
str = String()

# Iterate through rows 0 through rows-1
for row in range(grid.rows):
# Iterate through columns 0 through cols-1
for col in range(grid.cols):
if grid.data[row][col] == 1:
str += "*" # If cell is populated, append an asterisk
else:
str += " " # If cell is not populated, append a space
if row != grid.rows - 1:
str += "\n" # Add a newline between rows, but not at the end
return str

def main():
glider = List(
List(0, 1, 0, 0, 0, 0, 0, 0),
List(0, 0, 1, 0, 0, 0, 0, 0),
List(1, 1, 1, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
List(0, 0, 0, 0, 0, 0, 0, 0),
)
start = Grid(8, 8, glider)
result = grid_str(start)
print(result)

At this point we've made several changes to improve the structure of our program, but the output should remain the same.

mojo life.mojo
mojo life.mojo
 *
*
***





 *
*
***





8. Implement grid_str() as a method

Our grid_str() function is really a utility function unique to the Grid type. So rather than defining it as a standalone function, it makes more sense to define it as part of the Grid type as a method.

To do so, move the function into gridv1.mojo and edit it to look like this (or simply copy the code below into gridv1.mojo):

gridv1.mojo
@value
struct Grid():
var rows: Int
var cols: Int
var data: List[List[Int]]

def grid_str(self) -> String:
# Create an empty String
str = String()

# Iterate through rows 0 through rows-1
for row in range(self.rows):
# Iterate through columns 0 through cols-1
for col in range(self.cols):
if self.data[row][col] == 1:
str += "*" # If cell is populated, append an asterisk
else:
str += " " # If cell is not populated, append a space
if row != self.rows - 1:
str += "\n" # Add a newline between rows, but not at the end
return str
@value
struct Grid():
var rows: Int
var cols: Int
var data: List[List[Int]]

def grid_str(self) -> String:
# Create an empty String
str = String()

# Iterate through rows 0 through rows-1
for row in range(self.rows):
# Iterate through columns 0 through cols-1
for col in range(self.cols):
if self.data[row][col] == 1:
str += "*" # If cell is populated, append an asterisk
else:
str += " " # If cell is not populated, append a space
if row != self.rows - 1:
str += "\n" # Add a newline between rows, but not at the end
return str

So aside from moving the code from one source file to another, there are a few other changes that we made.

  • The function definition is indented to indicate that it's a method defined by the Grid struct. This also changes the way that we invoke the function. Instead of grid_str(my_grid) we now write my_grid.grid_str().
  • We've changed the argument name to self. When you invoke an instance method, Mojo automatically passes the instance as the first argument, followed by any explicit arguments that you provide. Although we could use any name we like for this argument, the convention is to call it self.
  • We've deleted the argument's type annotation. The compiler knows that the first argument of the method is an instance of the struct, so it doesn't require an explicit type annotation.

Now that we've refactored the function into an instance method, we also need to update the code in life.mojo where we invoke it from main():

life.mojo
def main():
...
start = Grid(8, 8, glider)
print(start.grid_str())
def main():
...
start = Grid(8, 8, glider)
print(start.grid_str())

Once again, our refactoring has improved the structure of our code, but it still produces the same output. You can verify that by running the program again.

9. Implement support for the StringableRaising trait

You can pass most Mojo types to the built-in str() function to produce a String representation of that instance. But you'll get an error if you try to do that with our current implementation of Grid. So let's fix that.

Because the Mojo compiler performs static type checking, a function like str() can accept a value only if its type implements some required behavior—in this case, it only accepts types that can generate a String representation.

To enable that, Mojo supports traits. A trait is a set of requirements in the form of one or more method signatures. A type can conform to that trait by implementing all of the method signatures declared in the trait. Then we can have a function that indicates that it accepts values of any type that conform to a specified trait. (This type of function is sometimes referred to as a generic function.)

In the case of str(), it requires a type to conform to either the Stringable or StringableRaising trait. Each trait requires a conforming type to implement a __str__() method that returns a String representation. The only difference between the two traits is that Stringable requires that the method cannot raise and error, whereas StringableRaising indicates that the method might raise an error. (To learn more, read The Stringable, Representable, and Writable traits.)

Our grid_str() method already returns a String representation, so it looks like we just have to rename it to __str__(). But we also need to indicate which trait Grid conforms to. In our case, it's StringableRaising because we used def to define the method. If you define a function or method with def, the compiler always assumes that the function can raise an error. In contrast, if you define a function or method with fn you must explicitly indicate with a raises keyword if it can raise an error.

So in gridv1.mojo we need to update the Grid declaration to indicate that the type conforms to StringableRaising and rename the grid_str() method to __str__():

gridv1.mojo
@value
struct Grid(StringableRaising):
...
def __str__(self) -> String:
...
@value
struct Grid(StringableRaising):
...
def __str__(self) -> String:
...

Now let's verify that str() works with an instance of Grid.

life.mojo
def main():
...
start = Grid(8, 8, glider)
print(str(start))
def main():
...
start = Grid(8, 8, glider)
print(str(start))

If you run the program again, you should still see the same glider pattern as before.

mojo life.mojo
mojo life.mojo
 *
*
***





 *
*
***





10. Implement methods to support indexing

Looking at the implementation of __str__() you'll notice that we use self.data[row][col] to retrieve the value of a cell in the grid. And if my_grid is an instance of Grid, we would use my_grid.data[row][col] to refer to a cell in the grid. This breaks a fundamental principle of encapsulation in that we need to know that Grid stores the game state in a field called data, and that field is a List[List[Int]]. If we later decide to change the internal implementation of Grid, then there could be a lot of code that would need to be changed.

A cleaner approach is to provide "getter" and "setter" methods to access cell values. We could simply define methods like get_cell() and set_cell(), but this is a good opportunity to show how we can define the behavior of built-in operators for custom Mojo types. Specifically, we'll implement support for indexing, so that we can refer to a cell with syntax like my_grid[row, col]. This will be useful when we implement support for evolving the state of the grid.

As described in Operators, expressions, and dunder methods, Mojo allows us to define the behavior of many of the built-in operators for a custom type by implementing special dunder (double underscore) methods. In the case of indexing, the two methods are __getitem__() and __setitem__(). So let's add the following methods to the Grid struct in gridv1.mojo:

gridv1.mojo
@value
struct Grid(StringableRaising):
...
def __getitem__(self, row: Int, col: Int) -> Int:
return self.data[row][col]

def __setitem__(mut self, row: Int, col: Int, value: Int) -> None:
self.data[row][col] = value
@value
struct Grid(StringableRaising):
...
def __getitem__(self, row: Int, col: Int) -> Int:
return self.data[row][col]

def __setitem__(mut self, row: Int, col: Int, value: Int) -> None:
self.data[row][col] = value

The implementation of __getitem__() is easy. For the given values of row and col we just need to retrieve and return the corresponding value from the nested List[List[Int]] stored in the data field of the instance.

The body of __setitem__() is similarly straightforward. We just take the given value and store it in the corresponding row and col in data. One thing new in the declaration is that we set the return type to None to indicate that the method doesn't have a return value. But more notable is that we've added the mut argument convention to the self argument to explicitly tell the Mojo compiler that we want to mutate the state of the current instance. If we were to omit mut, we would get an error because the compiler would default to read-only access for the argument.

Now that we've implemented these methods, we can update __str__() to use indexing syntax to access the cell value.

gridv1.mojo
@value
struct Grid(StringableRaising):
...
def __str__(self) -> String:
...
# Iterate through columns 0 through cols-1
for col in range(self.cols):
if self[row, col] == 1:
...
@value
struct Grid(StringableRaising):
...
def __str__(self) -> String:
...
# Iterate through columns 0 through cols-1
for col in range(self.cols):
if self[row, col] == 1:
...

Our refactoring hasn't changed our program's behavior, but it's still a good idea to run it to be sure that we don't have any errors in our code.

11. Define a static method to generate random grids

So far, we've used the glider to build the basic functionality of our Grid type. But what's much more interesting is to start with a grid in a random state and see how it evolves over time.

Let's add a static method named random() to the Grid struct to generate and return an instance of Grid with a random state. A static method doesn't operate on specific instances of the type, so it can be invoked as a utility function. We indicate that a method is a static method by using the @staticmethod decorator.

gridv1.mojo
import random

@value
struct Grid(StringableRaising):
...
@staticmethod
def random(rows: Int, cols: Int) -> Self:
# Seed the random number generator using the current time.
random.seed()

data = List[List[Int]]()

for row in range(rows):
row_data = List[Int]()
for col in range(cols):
# Generate a random 0 or 1 and append it to the row.
row_data.append(int(random.random_si64(0, 1)))
data.append(row_data)

return Self(rows, cols, data)
import random

@value
struct Grid(StringableRaising):
...
@staticmethod
def random(rows: Int, cols: Int) -> Self:
# Seed the random number generator using the current time.
random.seed()

data = List[List[Int]]()

for row in range(rows):
row_data = List[Int]()
for col in range(cols):
# Generate a random 0 or 1 and append it to the row.
row_data.append(int(random.random_si64(0, 1)))
data.append(row_data)

return Self(rows, cols, data)

At the top of the file we're importing the random package from the Mojo standard library. It includes several functions related to random number generation.

By default, the pseudorandom number generator used by the Mojo standard library currently uses a fixed seed. This means that it generates the same sequence of numbers unless you provide a different seed, which is useful for testing purposes. But for this application we want to call random.seed() to set a seed value based on the current time, which gives us a unique value every time.

Then we create an empty List[List[Int]] that we populate with a random initial state. For each cell, we call random.random_si64(), which returns a random integer value from the provided minimum and maximum values of 0 and 1, respectively. This function actually returns a value of type Int64, which is a signed 64-bit integer value. As described in Numeric types, this is not the same as the Int type whose precision is dependent on the native word size of the system. Therefore we're passing this value to the built-in int() function, which explicitly converts a numeric value to an Int.

The return type of the method is Self, which is an alias for the type of the struct. This is a convenient shortcut if the actual name of the struct is long or includes parameters.

The last line uses Self() to invoke the struct's constructor and return a newly created instance with random data.

Now we can update the main() function in life.mojo to create a random Grid and print it.

life.mojo
...

def main():
start = Grid.random(8, 16)
print(str(start))
...

def main():
start = Grid.random(8, 16)
print(str(start))

Run the program a few times to verify that it generates a different grid each time.

mojo life.mojo
mojo life.mojo
*** *      ****
* **** ******
* * *****
* * ** **
* * ** ****
* ** * * * ***
* * ** ** **
* ***** **
*** *      ****
* **** ******
* * *****
* * ** **
* * ** ****
* ** * * * ***
* * ** ** **
* ***** **

12. Implement a method to evolve the grid

It's finally time to let our world evolve. We'll implement an evolve() method to calculate the state of the grid for the next generation. One option would be to do an in-place modification of the existing Grid instance. But instead we'll have evolve() return a new instance of Grid for the next generation.

gridv1.mojo
...
struct Grid(StringableRaising):
...
def evolve(self) -> Self:
next_generation = List[List[Int]]()

for row in range(self.rows):
row_data = List[Int]()

# Calculate neighboring row indices, handling "wrap-around"
row_above = (row - 1) % self.rows
row_below = (row + 1) % self.rows

for col in range(self.cols):
# Calculate neighboring column indices, handling "wrap-around"
col_left = (col - 1) % self.cols
col_right = (col + 1) % self.cols

# Determine number of populated cells around the current cell
num_neighbors = (
self[row_above, col_left]
+ self[row_above, col]
+ self[row_above, col_right]
+ self[row, col_left]
+ self[row, col_right]
+ self[row_below, col_left]
+ self[row_below, col]
+ self[row_below, col_right]
)

# Determine the state of the current cell for the next generation
new_state = 0
if self[row, col] == 1 and (num_neighbors == 2 or num_neighbors == 3):
new_state = 1
elif self[row, col] == 0 and num_neighbors == 3:
new_state = 1
row_data.append(new_state)

next_generation.append(row_data)

return Self(self.rows, self.cols, next_generation)
...
struct Grid(StringableRaising):
...
def evolve(self) -> Self:
next_generation = List[List[Int]]()

for row in range(self.rows):
row_data = List[Int]()

# Calculate neighboring row indices, handling "wrap-around"
row_above = (row - 1) % self.rows
row_below = (row + 1) % self.rows

for col in range(self.cols):
# Calculate neighboring column indices, handling "wrap-around"
col_left = (col - 1) % self.cols
col_right = (col + 1) % self.cols

# Determine number of populated cells around the current cell
num_neighbors = (
self[row_above, col_left]
+ self[row_above, col]
+ self[row_above, col_right]
+ self[row, col_left]
+ self[row, col_right]
+ self[row_below, col_left]
+ self[row_below, col]
+ self[row_below, col_right]
)

# Determine the state of the current cell for the next generation
new_state = 0
if self[row, col] == 1 and (num_neighbors == 2 or num_neighbors == 3):
new_state = 1
elif self[row, col] == 0 and num_neighbors == 3:
new_state = 1
row_data.append(new_state)

next_generation.append(row_data)

return Self(self.rows, self.cols, next_generation)

We start out with an empty List[List[Int]] to represent the state of the next generation. Then we use nested for loops to iterate over each row and each column of the existing Grid to determine the state of each cell in the next generation.

For each cell in the grid we need to count the number of populated neighboring cells. Because we're modeling the world as a toroid, we need to consider the top and bottom rows as adjacent and the left-most and right-most columns as adjacent. So as we iterate through each row and column, we're using the modulo operator, %, to handle "wrap-around" when we calculate the indices of the rows above and below and the columns to the left and right of the current cell. (For example, if there are 8 rows, then -1 % 8 is 7.)

Then we apply the Game of Life rules that determines if the current cell is populated (1) or unpopulated (0) for the next generation:

  • A populated cell with either 2 or 3 populated neighbors remains populated in the next generation
  • An unpopulated cell with exactly 3 populated neighbors becomes populated in the next generation
  • All other cells become unpopulated in the next generation

After calculating the state of the next generation, we use Self() to create an new instance of Grid, and return the newly created instance.

Now that we can evolve the grid, let's use it in life.mojo. We'll add a run_display() function to control the game's main loop:

  • Display the current Grid
  • Prompt the user to continue or quit
  • Break out of the loop if the user enters q
  • Otherwise, calculate the next generation and loop again

Then we'll update main() to create a random initial Grid and pass it to run_display(). Here is the updated version of life.mojo:

life.mojo
from gridv1 import Grid

def run_display(owned grid: Grid) -> None:
while True:
print(str(grid))
print()
if input("Enter 'q' to quit or press <Enter> to continue: ") == "q":
break
grid = grid.evolve()

def main():
start = Grid.random(16, 16)
run_display(start)
from gridv1 import Grid

def run_display(owned grid: Grid) -> None:
while True:
print(str(grid))
print()
if input("Enter 'q' to quit or press <Enter> to continue: ") == "q":
break
grid = grid.evolve()

def main():
start = Grid.random(16, 16)
run_display(start)

Run the program and verify that each call to evolve() successfully produces a new generation.

So now we have a working version of the Game of Life, but the terminal interface is not very pretty. Let's spice things up with a nicer graphical user interface, using a Python library.

13. Import and use a Python package

Mojo lets you import Python modules, call Python functions, and interact with Python objects from Mojo code. To demonstrate this capability, we're going to use a Python package called pygame to create and manage a graphical user interface for our Game of Life program.

First, we need to update our mojoproject.toml file to add a dependency on Python and the pygame package. So in the project directory, execute the following command from the terminal:

magic add "python>=3.11,<3.13" "pygame>=2.6.1,<3"
magic add "python>=3.11,<3.13" "pygame>=2.6.1,<3"

You can import a Python module in Mojo using Python.import_module(). This returns a reference to the module in the form of a PythonObject wrapper. You must store the reference in a variable so that you can then access the functions and objects in the module. For example:

from python import Python

def run_display():
# This is roughly equivalent to Python's `import pygame`
pygame = Python.import_module("pygame")
pygame.init()
from python import Python

def run_display():
# This is roughly equivalent to Python's `import pygame`
pygame = Python.import_module("pygame")
pygame.init()

You can learn more about importing and using Python modules in Mojo by reading Python integration.

Once we import pygame, we can call its APIs as if we were writing Python code. For this project, we'll use pygame to create a new window and draw the whole game UI. This requires a complete rewrite of the run_display() function. Take a look at the updated code for life.mojo and we'll explain more of it below:

life.mojo
from gridv1 import Grid
from python import Python
import time

def run_display(
owned grid: Grid,
window_height: Int = 600,
window_width: Int = 600,
background_color: String = "black",
cell_color: String = "green",
pause: Float64 = 0.1,
) -> None:
# Import the pygame Python package
pygame = Python.import_module("pygame")

# Initialize pygame modules
pygame.init()

# Create a window and set its title
window = pygame.display.set_mode((window_height, window_width))
pygame.display.set_caption("Conway's Game of Life")

cell_height = window_height / grid.rows
cell_width = window_width / grid.cols
border_size = 1
cell_fill_color = pygame.Color(cell_color)
background_fill_color = pygame.Color(background_color)

running = True
while running:
# Poll for events
event = pygame.event.poll()
if event.type == pygame.QUIT:
# Quit if the window is closed
running = False
elif event.type == pygame.KEYDOWN:
# Also quit if the user presses <Escape> or 'q'
if event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
running = False

# Clear the window by painting with the background color
window.fill(background_fill_color)

# Draw each live cell in the grid
for row in range(grid.rows):
for col in range(grid.cols):
if grid[row, col]:
x = col * cell_width + border_size
y = row * cell_height + border_size
width = cell_width - border_size
height = cell_height - border_size
pygame.draw.rect(
window, cell_fill_color, (x, y, width, height)
)

# Update the display
pygame.display.flip()

# Pause to let the user appreciate the scene
time.sleep(pause)

# Next generation
grid = grid.evolve()

# Shut down pygame cleanly
pygame.quit()

def main():
start = Grid.random(128, 128)
run_display(start)
from gridv1 import Grid
from python import Python
import time

def run_display(
owned grid: Grid,
window_height: Int = 600,
window_width: Int = 600,
background_color: String = "black",
cell_color: String = "green",
pause: Float64 = 0.1,
) -> None:
# Import the pygame Python package
pygame = Python.import_module("pygame")

# Initialize pygame modules
pygame.init()

# Create a window and set its title
window = pygame.display.set_mode((window_height, window_width))
pygame.display.set_caption("Conway's Game of Life")

cell_height = window_height / grid.rows
cell_width = window_width / grid.cols
border_size = 1
cell_fill_color = pygame.Color(cell_color)
background_fill_color = pygame.Color(background_color)

running = True
while running:
# Poll for events
event = pygame.event.poll()
if event.type == pygame.QUIT:
# Quit if the window is closed
running = False
elif event.type == pygame.KEYDOWN:
# Also quit if the user presses <Escape> or 'q'
if event.key == pygame.K_ESCAPE or event.key == pygame.K_q:
running = False

# Clear the window by painting with the background color
window.fill(background_fill_color)

# Draw each live cell in the grid
for row in range(grid.rows):
for col in range(grid.cols):
if grid[row, col]:
x = col * cell_width + border_size
y = row * cell_height + border_size
width = cell_width - border_size
height = cell_height - border_size
pygame.draw.rect(
window, cell_fill_color, (x, y, width, height)
)

# Update the display
pygame.display.flip()

# Pause to let the user appreciate the scene
time.sleep(pause)

# Next generation
grid = grid.evolve()

# Shut down pygame cleanly
pygame.quit()

def main():
start = Grid.random(128, 128)
run_display(start)

Each argument for run_display() other than grid has a default value associated with it (for example, the default window_height is 600 pixels). If you don't explicitly pass a value for an argument when you invoke run_display(), Mojo uses the default value specified in the function definition.

After importing the pygame module, we call pygame.init() to initialize all the pygame subsystems.

The set_mode() function creates and initializes a window, with the height and width passed as a Mojo tuple of two values. This returns a PythonObject wrapper for the window, which we can then use to call functions and set attributes to manipulate the window. (For more information about interacting with Python objects from Mojo, see Python types.)

The bulk of the run_display() function is a loop that uses pygame to poll for events like key presses and mouse clicks. If it detects that the user presses q or the <Escape> key or closes the display window, it ends the program with pygame.quit(). Otherwise, it clears the window and then iterates through all cells in the grid to display the populated cells. After sleeping for pause seconds, it evolves the grid to the next generation and loops again.

So it's finally time to try it out.

mojo life.mojo
mojo life.mojo

Now when you run the program you should see a new window appear on screen displaying your evolving grid. We now have a fully functional implementation of the Game of Life with a nice interface. We've come quite a way from just displaying a few asterisks on the terminal!

game_of_life_screen.png

To quit the program press the q or <Escape> key, or close the window.

And now that we're done with the tutorial, exit our project's virtual environment:

exit
exit

Summary

Congratulations on writing a complete Mojo application from scratch! Along the way, you got a taste of:

  • Using Magic to create, build, and run a Mojo program
  • Using Mojo built-in types like Int, String, and List
  • Creating and using variables and functions
  • Using control structures like if, while, and for
  • Defining and using a custom Mojo struct
  • Creating and importing a Mojo module
  • Using modules from the Mojo standard library
  • Importing and using a Python module

Next steps

The Mojo manual and the Mojo standard library API reference contains much more information about these and other features of Mojo programming. These resources will help prepare you to take full advantage of the MAX platform to build and deploy high-performance AI pipelines.

Was this page helpful?