I don't think I've ever made Conway's Game of Life, so this will be a first for me. This game will be an interesting one for 2 reasons. The board is meant to be infinite. It's a 0-player game.
Either way, we have to make it in one day. I'll write out a full spec for us all to follow. If our application adheres to our concrete design list, we've made Conway's Game of Life!
Follow in your own programming language, but I'm using Golang.
From the User's Perspective
There's only one phase to this game. There should be a step over button, but also a play and pause. There is an infinite grid of on/off cells. A cell with less than 2 surrounding alive tiles dies. A cell with more than 3 surrounding alive tiles dies. A cell with 2 or 3 surrounding alive tiles stays alive. A cell with 3 neighbors that is dead becomes alive.
This is very short, let's make the list.
Shortlist
- The game will have a run mode where it will auto-step at a consistent pace
- This mode can be resumed and paused
- The game can be manually stepped
- The game is made up of an infinite grid of on/off cells
- The user should be able to move around the grid
- A cell with less than 2 surrounding alive tiles dies
- A cell with more than 3 surrounding alive tiles dies
- A cell with 2 or 3 surrounding alive tiles stays alive
- A cell with 3 neighbors that is dead becomes alive
- The cells for a new frame should update based on the last frame
This is a very short list, but there are a few parts that will make it difficult to get correct.
The Code
As per usual, we start off with our empty window.
We're probably going to need some UI elements, so let's put them in. We're going to need a pause and play button, a step button, and a reset button. The pause/play buttons will simply switch an auto-stepping bool.
Now we have the hard part, how do we make an infinite board? My idea is to have a list that only contains the currently "on" tiles. To create the next step, this list must first be fully copied. Then, it would be "bulked", which would go through each tile, and create dummy tiles. These dummy tiles are the tiles around activated tiles, which may come alive. Once all the rules are done, we can then strip out the off tiles, finishing the step. Sounds like a good plan, so let's start by making tiles.
type Tile struct {
row, col int
on bool
}
func (t *Tile) draw(g *Game) {
clr := tileOnColor
}
g.vis.DrawRect(
float64(t.col*TILE_SIZE-g.offsetX),
float64(t.row*TILE_SIZE-g.offsetY),
TILE_SIZE,
TILE_SIZE,
clr,
)
}
You may remember my bulking idea, where we generate surrouning tiles.
Let's add this method to the tile.
func (t *Tile) getSurrounds() [8]Tile {
return [8]Tile{
{t.row-1, t.col-1, false},
{t.row-1, t.col, false},
{t.row-1, t.col+1, false},
{t.row, t.col-1, false},
{t.row, t.col+1, false},
{t.row+1, t.col-1, false},
{t.row+1, t.col, false},
{t.row+1, t.col+1, false},
}
}
We have a finished tile, so now we need a virtual grid that can deal with them. Let's start with just adding a tile to the grid. When adding a tile, we don't want it to override a tile in the same position.
func (g *Grid) add(t Tile) {
for _, o := range g.tiles {
if o.row == t.row && o.col == t.col {
return
}
}
g.tiles = append(g.tiles, t)
}
Pretty simple, now let's do stripping.
func (g *Grid) strip() {
// Will give a little extra space,
// but will be used later when bulking
newTiles := make([]Tile, 0, len(g.tiles))
for _, o := range g.tiles {
if o.on {
newTiles = append(newTiles, o)
}
}
g.tiles = newTiles
}
Bulking should also be very simple, just generate surrounding tiles, and add them one by one.
func (g *Grid) bulk() {
// Only use current length, don't create new slice
curLength := len(g.tiles)
for i := range curLength {
// Paranoia, shouldn't be possible because of strip
if g.tiles[i].on {
surTiles := g.tiles[i].getSurrounds()
for n := range 8 {
g.add(surTiles[n])
}
}
}
}
The way we've done it allows us to use the same slice to read and write to. This makes can avoid a reallocation of our slice. We're really pushing through this well and quick, now let's do the copy method.
func (g *Grid) clone() Grid {
newGrid := Grid{}
// Allocate the whole slice
newGrid.tiles = make([]Tile, len(g.tiles))
// Copy over each tile
for i, t := range g.tiles {
newGrid.tiles[i] = t
}
return newGrid
}
func (g *Grid) step() {
lastFrame := g.clone()
g.bulk()
g.applyRules(lastFrame)
g.strip()
}
The step method we defined above is exactly how our plan should be executed. We'll write the applyRules
method later. There's no point in writing it if we can't, place and see it in action! So let's work on placing tiles and moving the camera.
func (g *Game) leftClick() {
// Get actual position
x := int(g.mousePos[0]) + g.offsetX
y := int(g.mousePos[1]) + g.offsetY
// Get row and col
row := y / TILE_SIZE
col := x / TILE_SIZE
// Place the tile
g.grid.add(Tile{row, col, true})
}
func (g *Game) cameraMove() {
if ebiten.IsKeyPressed(ebiten.KeyW) {
g.offsetY--
}
if ebiten.IsKeyPressed(ebiten.KeyS) {
g.offsetY++
}
if ebiten.IsKeyPressed(ebiten.KeyA) {
g.offsetX--
}
if ebiten.IsKeyPressed(ebiten.KeyD) {
g.offsetX++
}
}
Perfect, let's see how much we've done so far.
- [x] The game will have a run mode where it will auto-step at a consistent pace
- [x] This mode can be resumed and paused
- [x] The game can be manually stepped
- [x] The game is made up of an infinite grid of on/off cells
- [x] The user should be able to move around the grid
- [ ] A cell with less than 2 surrounding alive tiles dies
- [ ] A cell with more than 3 surrounding alive tiles dies
- [ ] A cell with 2 or 3 surrounding alive tiles stays alive
- [ ] A cell with 3 neighbours that is dead becomes alive
- [x] The cells for a new frame should update based on the last frame
We've done everything except the actual game logic! Let's implement that real quick. But first, we're going to need a few more methods. For example, it would useful to be able to count the number of alive surrounds a tile has.
func (g *Grid) isOn(row, col int) {
for i, o := range g.tiles {
if o.row == row && o.col == col {
return o.on
}
}
return false
}
func (t *Tile) countSurrounds(lastGrid Grid) int {
var n int
if lastGrid.isOn(t.row-1, t.col-1) {
n++
}
if lastGrid.isOn(t.row-1, t.col) {
n++
}
if lastGrid.isOn(t.row-1, t.col+1) {
n++
}
if lastGrid.isOn(t.row, t.col-1) {
n++
}
if lastGrid.isOn(t.row, t.col+1) {
n++
}
if lastGrid.isOn(t.row+1, t.col-1) {
n++
}
if lastGrid.isOn(t.row+1, t.col) {
n++
}
if lastGrid.isOn(t.row+1, t.col+1) {
n++
}
return n
}
Now let's write that logic.
func (t *Tile) step(lastGrid Grid) {
n := t.countSurrounds(lastGrid)
// Die (alone)
if n < 2 {
t.on = false
return
}
// Die (popular)
if n > 3 {
t.on = false
return
}
// Exist
if n == 2 {
return
}
// Born
if n == 3 {
t.on = true
return
}
}
How simple is that! And it works perfectly!
Done
Nicely done, that was a simple yet fun one. An infinite grid can be a daunting idea, but we managed to implement a working solution. You may want to look at other possible ways to describe an infinite grid, to see what works best for you. The code for this project as well as the others is here as always. The way we've implemented our code would make it relatively simple to alter the rules.
Your Next Steps
Now that you have Conway's Game of Life, here are some ways you could possibly extend it.
- Make hexagonal tiles instead
- Alter the game's rules
- Allow the change of animation speed
- Give cells health that can increase
- Make a playable character that can kill cells
- Create a live shared Game of Life instance between devices
- Colour the cells and have blending mechanics
If you have any more ideas for games I should explain and make, put them in the comments.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more