From Four Rules to Infinity: Implementing Conway’s Game of Life in Go
I don’t remember when I first encountered Conway’s Game of Life, but watching four simple rules give rise to complex, evolving patterns felt almost magical.
Conway’s Game of Life is a cellular automaton created by John Conway. It is a zero-player game - once you set the initial conditions, the game evolves entirely on its own without any further input.
It consists of an infinite 2D grid called the universe. Each point in the universe is called a cell and it can be in one of these two states - dead or alive.
At the start we provide an initial condition for the universe (often called seed) and then start the game by applying the following set of rules simultaneously to each cell -
Any live cell with fewer than 2 live neighbours dies - Underpopulation
Any live cell with 2 or 3 live neighbours lives - Next Generation
Any live cell with more than 3 live neighbours dies - Overpopulation
Any dead cell with exactly 3 live neighbours becomes a live cell - Reproduction
Each time we apply these rules to the universe, we produce a new generation. Each step is often called a tick, producing the next generation.
In this post, we’ll look at how I implemented Conway’s Game of Life in the terminal using Go. Here’s how the final result looks like:
The Universe
The universe is an infinite 2D grid of cells but our computers only have finite memory, so we need a compact way to represent it.
In Go, the universe can be represented with a simple struct:
main.go
-------------
...
type Universe struct {
grid Grid
generation int
paused bool
}
...Here the grid holds the state of the cells of the universe, generation tracks the current tick and paused tells us whether the simulation is running or not.
The Grid struct will only store live cells of the universe - any cell which is not part of the grid is considered dead. The Grid struct is a boolean map of Cell struct represented as follows:
main.go
-------------
...
type Cell struct {
X, Y int
}
type Grid map[Cell]bool
type Universe struct {
...As the live cells are typically sparse, this representation scales well for large simulations. Next, we’ll add some helper functions for Universe and Grid structs. We’ll explore the Universe struct’s draw and tick methods in a while
main.go
-------------
// Grid
type Cell struct {
X, Y int
}
type Grid map[Cell]bool
func (grid Grid) addCell(cell Cell) {
grid[cell] = true
}
func (grid Grid) removeCell(cell Cell) {
delete(grid, cell)
}
func (grid Grid) getNeighbourCells(cell Cell) (dead []Cell, live []Cell) {
directions := []Cell{
{-1, 1}, // TOP LEFT
{0, 1}, // TOP
{1, 1}, // TOP RIGHT
{1, 0}, // RIGHT
{1, -1}, // BOTTOM RIGHT
{0, -1}, // BOTTOM
{-1, -1}, // BOTTOM LEFT
{-1, 0}, // LEFT
}
for _, direction := range directions {
if (grid[Cell{X: cell.X + direction.X, Y: cell.Y + direction.Y}]) {
live = append(live, Cell{X: cell.X + direction.X, Y: cell.Y + direction.Y})
} else {
dead = append(dead, Cell{X: cell.X + direction.X, Y: cell.Y + direction.Y})
}
}
return dead, live
}
// Universe
type Universe struct {
grid Grid
paused bool
generation int
}
func (universe *Universe) play() {
universe.paused = false
}
func (universe *Universe) pause() {
universe.paused = true
}
func (universe Universe) draw() {
/ * Draw the universe on the terminal * /
}
func (universe *Universe) tick() {
/ * Calculate the next generation * /
}Raw Mode
So far, we have looked at how the universe is represented in code. Now, let’s see how we can display it on the screen.
So what is Raw Mode ?
Whenever you open any terminal it opens in cooked (or canonical) mode. In this mode, the standard input is preprocessed before you send it to the program - in simple terms, you can type, edit, and view commands before executing them on pressing Enter. Let’s see what this looks like in action.
In this demo, you can see that every key you press is processed by the terminal before it reaches the program - typing characters appends them to the input line, pressing Backspace deletes them, and control combinations like Ctrl+C interrupt the running program (the shell in this case).
Raw mode is the opposite of canonical mode. In this mode the terminal does not preprocess input - each keypress is sent directly to the running program and now it’s up to the program how it wants to handle. You can learn more about terminal modes here
When the terminal is in raw mode, we gain full control over what’s displayed on it and how it’s displayed. This is what we need for drawing the universe. Let’s look at how we can achieve this in Go
We are going to use the term package to enable and disable raw mode.
We use term.MakeRaw() method with the standard input (keyboard) to enable raw mode. Once we are in raw mode, we continuously read key presses directly using an infinite loop and exit the program when CTRL+C is pressed. Before we exit, we use term.Restore() method to return the terminal to its original state.
main.go
-------------
import (
“fmt”
“os”
“golang.org/x/term”
)
...
func main() {
// Enabling Raw Mode
fd := int(os.Stdin.Fd())
oldState, err := term.MakeRaw(int(fd))
if err != nil {
fmt.Println(”Error enabling raw mode:”, err)
panic(err)
}
if err != nil {
panic(err)
}
// Disable Raw mode on return
defer term.Restore(fd, oldState)
// Reading Standard Input and exiting on CTRL+C
for {
keyBuffer := make([]byte, 32)
_, err := os.Stdin.Read(keyBuffer)
if err != nil {
return
}
key := keyBuffer[0]
if key == 3 {
// Code for CTRL+C -> 3
return
}
}
}In this demo, you can see once the program is in raw mode, nothing you type is being displayed on the terminal. The input is captured directly by the program, and when you press Ctrl+C, it exits.
Drawing on the Terminal
Now that we know raw mode gives us full control over what goes in and out of the terminal, let’s turn it into our drawing canvas.
The terminal itself is a 2D grid, and we’ll use it to display a small section of the universe’s infinite grid. The only caveat is that the terminal coordinates start from (1,1) and is at the top-left corner. The terminal grid consists of rows and columns - the row number increases as you move down and the column number increases as you move right. To move around on it, we send a special sequence with row and column number.
These special sequences are called ANSI Escape Sequences. They’re sent to standard output (the terminal) and let us control the cursor position, colors, and other screen operations.
The ANSI Escape Sequence to move the cursor is -
ESC[row;columnH]
ESC is a special control character represented by \x1B in hexadecimal. It’s followed by the row number, column number, and the letter H. When this sequence is sent to the terminal, it moves the cursor to the specified row and column. We can then print any string to standard output, and it will appear exactly at that position.
Let’s try to display "Hello from Raw Mode" at (Row 1, Column 1) and (Row 20, Column 30). To do this, we’ll print the escape sequence along with the string using fmt.Printf() -
main.go
-------------
...
func main() {
// Handling Raw Mode
...
// Printing "Hello from Raw Mode" at arbitrary position
fmt.Printf(”\x1B[%d;%dH%s”, 1, 1, “Hello from Raw Mode”)
fmt.Printf(”\x1B[%d;%dH%s”, 20, 30, “Hello from Raw Mode”)
// Reading Standard Input and exiting on CTRL+C
...
}We can see that “Hello from Raw Mode” is printed on the specified row and column on the terminal -
Similarly there are other escape sequences for various terminal operations - clearing screen, hiding cursor, changing colors etc. If you’re curious, this gist is a great reference for many of them.
Let’s add some helper methods to make it easier to treat the terminal as a 2D grid canvas. We’ll also store the total number of rows and columns of the terminal in Universe struct :
main.go
-------------
...
type Universe struct {
grid Grid
rows int
cols int
paused bool
generation int
}
...
// Raw mode helpers
func drawOnTerminal(row int, col int, element string) {
fmt.Printf(”\x1B[%d;%dH%s”, row, col, element)
}
func clearScreen() {
fmt.Print(”\x1B[2J”)
}
func moveToHome() {
fmt.Print(”\x1B[H”)
}
func showCursor() {
fmt.Print(”\x1b[?25h”)
}
func hideCursor() {
fmt.Print(”\x1b[?25l”)
}
func enableMouseEvents() {
fmt.Print(”\x1B[?1000h”)
fmt.Print(”\x1B[?1006h”)
}
func disableMouseEvents() {
fmt.Print(”\x1B[?1000l”)
fmt.Print(”\x1B[?1006l”)
}
func main(){
...
}tick() and draw()
We’ve defined how the universe is represented in Go and set up how to display it in the terminal - it’s time to draw!
Let’s look at the tick() function - it’s the core of our program, responsible for evolving the universe one generation at a time. It loops through all the live cells in the universe and determines which cells will live, die, or be born in the next generation based on the four rules we discussed earlier -
main.go
-------------
...
func (universe *Universe) tick() {
newGrid := make(Grid)
for cell := range universe.grid {
deadNeighbours, aliveNeighbours := universe.grid.getNeighbourCells(cell)
if len(aliveNeighbours) == 2 || len(aliveNeighbours) == 3 {
newGrid.addCell(Cell{cell.X, cell.Y})
}
for _, deadNeighbour := range deadNeighbours {
_, aliveNeighboursAroundDead := universe.grid.getNeighbourCells(deadNeighbour)
if len(aliveNeighboursAroundDead) == 3 {
newGrid.addCell(Cell{deadNeighbour.X, deadNeighbour.Y})
}
}
}
universe.grid = newGrid
universe.generation++
}
...
func main(){
...
}Let’s look at the draw() function - it is responsible for rendering the universe on the terminal. It uses the raw mode helper methods we defined earlier to draw on the screen -
main.go
-------------
...
func (universe Universe) draw() {
clearScreen()
offsetX := universe.cols / 2
offsetY := universe.rows / 2
for cell := range universe.grid {
row := offsetY - cell.Y + 1
col := offsetX + cell.X + 1
if row >= 1 && col >= 1 && row <= universe.rows && col <= universe.cols {
drawOnTerminal(row, col, “\u25A3”)
}
}
}
...
func main(){
...
}This function maps our universe grid coordinate (X, Y) into terminal coordinate (Row, Column) with origin centered on the screen and uses drawOnTerminal() to display each live cell.
As we saw earlier, the rows increases as we move downward and columns increases as we move to right. The column is treated as the X-axis and the row is treated as the Y-axis in the universe grid.
We apply the following mapping to each (X,Y) coordinate of the universe grid -
R = Rn / 2 - Y + 1 // Row: invert Y because terminal rows increase downward
C = Cn / 2 + X + 1 // Column: move right for positive X
Rn - total rows, Cn - total columns
The drawOnTerminal() function takes in the mapped Y-coordinate as the row and mapped X-coordinate as column -
drawOnTerminal( Mapped Y, Mapped X, Content)
We could have used drawOnTerminal(Mapped Y, Mapped X, Content) but I chose to follow the escape sequence order - Row first, then Column.
Running the Game
Let’s pull this all together in the main() function and render our universe the with a seed called Acorn -
main.go
-------------
...
func main() {
// Handle Input
inputChannel := make(chan []byte)
go func() {
buf := make([]byte, 32)
for {
_, err := os.Stdin.Read(buf)
if err != nil {
close(inputChannel)
fmt.Println(”Error Reading input”)
return
}
inputChannel <- buf
}
}()
// Enabling Raw Mode
fd := int(os.Stdin.Fd())
oldState, err := term.MakeRaw(int(fd))
if err != nil {
fmt.Println(”Error enabling raw mode:”, err)
panic(err)
}
cols, rows, err := term.GetSize(fd)
if err != nil {
panic(err)
}
hideCursor()
clearScreen()
moveToHome()
// Disable Raw mode on return
defer clearScreen()
defer moveToHome()
defer showCursor()
defer term.Restore(fd, oldState)
acornGrid := Grid{
Cell{X: -2, Y: 1}: true,
Cell{X: -3, Y: -1}: true,
Cell{X: -2, Y: -1}: true,
Cell{X: 3, Y: -1}: true,
Cell{X: 0, Y: 0}: true,
Cell{X: 1, Y: -1}: true,
Cell{X: 2, Y: -1}: true,
}
universe := Universe{
grid: acornGrid,
rows: rows,
cols: cols,
paused: true,
generation: 0,
currentSeedIdx: 0,
}
// Render Loop
for {
universe.draw()
if !universe.paused {
universe.tick()
}
select {
case keyBuffer := <-inputChannel:
// Reading the Escape Code - ESC[{code};{string};{...}p
key := keyBuffer[0]
// Code for CTRL+C -> 3
// Code for Space -> 32
if key == 3 {
return
} else if key == 32 {
if universe.paused {
universe.play()
} else {
universe.pause()
}
}
default:
time.Sleep(100 * time.Millisecond)
}
}
}
There’s quite a lot happening here, so let’s break it down step by step:
Handling Input Asynchronously -
In the Raw mode section, we used an infinite for loop to read the keypresses that blocks executions until a key is pressed. Since this would block our render loop (where we call universe.tick() and universe.draw()), we use a Go channel - inputChannel - and a goroutine to read keypresses asynchronously and send them to the render loop.Enabling/Disabling Raw Mode
We clear the screen when entering and exiting raw mode and hide the cursor while in raw mode. We also use the term.GetSize() method to get total number of rows and columns.Defining the Universe Object
We define the acornGrid - a Grid object - as an initial seed, and create a Universe object.Render Loop
We continuously call the universe.draw() and universe.tick() method in render loop to evolve and display the simulation when it’s not paused. Keypresses from the inputChannel are used to toggle pause/play (Space) or exit (Ctrl+C) the simulation.
Let’s see this in action. When we run the program we see the acorn seed displayed on the center of the screen. We can play/pause simulation using Space and exit using Ctrl+C -
Wrapping up
Finally, I added some popular seeds, a status bar, and mouse support to edit and enter our own seeds!
I hope you enjoyed reading through this post! You can check out the full source code here.
Here are some of the seeds in action -




Very cool!