Video Coming Soon...

Created by Zed A. Shaw Updated 2025-10-07 14:32:54

28: Rogue Part 1: Mazes and Enemies

WARNING This exercise is in DRAFT status, so there may be errors. If you find any, please email me at help@learncodethehardway.com so I can fix them.

You'll now add a maze generation algorithm named Hunt-and-Kill to create randomized maps. This algorithm is a lot of fun, but it also has a few interesting properties that other algorithms don't have. At the end of this module I'll have you exploit some of these features when you extend this game further.

ALERT You should delete the temp.go file now as you'll be replacing it with the maze.go code.

About diff

I'm going to use something called a "diff" to show you how I'm changing the code to add the new features. A diff is a text representation of the differences (thus "diff") between two files. Programmers use them to show changes they've made, see what's different between a working vs. broken version of some code, and to share their changes with others.

Learning to read a .diff file is very useful, but I don't want you to get hung up on this. The output of diff can be kind of weird depending on what algorithm the tool uses. With that in mind, I'll give a brief explanation of how to read a .diff, but I'll also make these changes in the associated video so you can see them live.

Video is probably better for watching these interactive actions like making a change to existing code, installing software, and debugging.

Reading a .diff

If you look at the first diff below for main.go you'll see the following sections:

  1. --- and +++ tell you information about the files being diffed. In this case I have two directories from my curse-you-go-rogue project that has the previous 01_the_screen version and the current 02_mazes_and_enemies version. In those two directories we're editing the main.go file.
  2. Next are @@ that I mostly ignore.
  3. Then there's a few lines of context. You know it's a context line because it does not start with a +, !, or - character.
  4. Next there's a line with + followed by some code. This is new code that is added at that location after the context line. You'll also see - and ! for removed lines of code.
  5. After that will be a few more lines of trailing context so you can see where the new/removed lines are located.

If you ever get stuck trying to decipher these .diff files just go to my curse-you-go-rogue repository and get the file yourself.

main.go Changes

You only need to add two lines to main.go that generates a game.NewMaze() and game.PlaceEnemies() in the map:

View Source file curse-you-go-rogue/02_mazes_and_enemies/main.go.diff Only

--- 01_the_screen/main.go   2025-10-06 00:33:27.349824400 -0400
+++ 02_mazes_and_enemies/main.go    2025-10-06 11:53:40.243831800 -0400
@@ -8,4 +8,6 @@
 
   game.NewMap()
+  dead_ends := game.NewMaze()
+  game.PlaceEnemies(dead_ends)
   game.Render()

Both of these functions are found in the game.go and maze.go files you'll create soon. As with our main.go from Exercise 27, this is not going to compile and works as a goal for your later changes.

game.go Changes

In game.go you'll add the "math/rand" package and then create the PlaceEnemies function.

View Source file curse-you-go-rogue/02_mazes_and_enemies/game.go.diff Only

--- 01_the_screen/game.go   2025-10-06 10:40:36.384247800 -0400
+++ 02_mazes_and_enemies/game.go    2025-10-06 10:40:37.923662000 -0400
@@ -3,4 +3,5 @@
 import (
   "os"
+  "math/rand"
 )
 
@@ -24,2 +25,10 @@
   os.Exit(0)
 }
+
+func (game *Game) PlaceEnemies(places []Position) {
+  for _, pos := range places {
+    if rand.Int() % 2 == 0 {
+      game.Enemies[pos] = &Enemy{10, pos, 4}
+    }
+  }
+}

PlaceEnemies() takes a list of possible positions, and then randomly half of them. You should confirm you know what's in Enemy from data.go when you make this function.

map.go Changes

WARNING You deleted temp.go right?

In map.go you are adding enemy detection to Occupied(), a Neighbors function for the maze and pathing algorithms later, and a NewMap() function that replaces the one we previously had in temp.go.

View Source file curse-you-go-rogue/02_mazes_and_enemies/map.go.diff Only

--- 01_the_screen/map.go    2025-10-06 23:32:48.943928000 -0400
+++ 02_mazes_and_enemies/map.go 2025-10-06 23:33:02.887088700 -0400
@@ -22,4 +22,5 @@
 
 func (game *Game) Occupied(pos Position) bool {
+  _, is_enemy := game.Enemies[pos]
   is_player := pos == game.Player.Pos
 
@@ -27,4 +28,5 @@
   return !game.Inbounds(pos, 1) ||
       game.Level[pos.Y][pos.X] == WALL ||
+      is_enemy ||
       is_player
 }
@@ -35,2 +37,19 @@
   }
 }
+
+func (game *Game) Neighbors(near Position) []Position {
+  result := make([]Position, 0, 4)
+  points := compass(near, 2)
+
+  for _, pos := range points {
+    if game.Inbounds(pos, 0) {
+      result = append(result, pos)
+    }
+  }
+
+  return result
+}
+
+func (game *Game) NewMap() {
+  game.FillMap(game.Level, '#')
+}

There's nothing too interesting here. Neighbors() uses compass() to get four directions, and then filters only the ones that are Inbounds(). The only thing different about Neighbors() is it use the offset parameter to compass() for the maze.go later.

NOTE It's debatable whether Neighbors() belongs here or in maze.go. You decide.

ui.go Changes

The only thing you add to ui.go is drawing enemies:

View Source file curse-you-go-rogue/02_mazes_and_enemies/ui.go.diff Only

--- 01_the_screen/ui.go 2025-10-06 10:55:34.357039400 -0400
+++ 02_mazes_and_enemies/ui.go  2025-10-06 11:18:20.691714300 -0400
@@ -69,4 +69,9 @@
   game.DrawMap()
   game.DrawEntity('@', game.Player.Pos, tcell.ColorYellow)
+
+  for pos, _ := range game.Enemies {
+    game.DrawEntity('G', pos, tcell.ColorRed)
+  }
+
   game.DrawStatus()
   game.Screen.Show()

This is a simple for-loop that goes through every position in Game.Enemies and draws a red G for the enemy.

WARNING You should carefully look at the Game.Enemies field in data.go to confirm you understand that it is a map that uses Position as the key. This is important!

New maze.go

Now we have the core of this exercise, the maze.go implementation. This code implements the fun Hunt-and-Kill to generate out maps:

View Source file curse-you-go-rogue/02_mazes_and_enemies/maze.go Only

package main

import (
  "math/rand"
  "time"
)

func (game *Game) NeighborWalls(pos Position) []Position {
  neighbors := game.Neighbors(pos)
  result := make([]Position, 0)

  for _, at := range neighbors {
    cell := game.Level[at.Y][at.X]

    if cell == WALL {
      result = append(result, at)
    }
  }

  return result
}

func (game *Game) HuntNext(on *Position, found *Position) bool {
  for y := 1; y < game.Height ; y += 2 {
    for x := 1; x < game.Width ; x += 2 {
      if game.Level[y][x] != WALL {
        continue
      }

      neighbors := game.Neighbors(Position{x, y})

      for _, pos := range neighbors {
        if game.Level[pos.Y][pos.X] == SPACE {
          *on = Position{x, y}
          *found = pos
          return true
        }
      }
    }
  }

  return false
}

func (game *Game) HAKStep(from Position, to Position) {
  game.Level[from.Y][from.X] = SPACE
  row := (from.Y + to.Y) / 2
  col := (from.X + to.X) / 2
  game.Level[row][col] = SPACE
}

func (game *Game) NewMaze() []Position {
  on := Position{1, 1}
  found := Position{1,1}

  dead_ends := make([]Position, 0)

  for {
    neighbors := game.NeighborWalls(on)

    if len(neighbors) == 0 {
      dead_ends = append(dead_ends, on)

      if !game.HuntNext(&on, &found) {
        break
      }

      game.HAKStep(on, found)
    } else {
      rand_neighbor := rand.Int() % len(neighbors)
      nb := neighbors[rand_neighbor]
      game.HAKStep(nb, on)
      on = nb
    }

    if SHOW_RENDER {
      game.Render()
      time.Sleep(50 * time.Millisecond)
    }
  }

  return dead_ends
}

In the data.go file there's a setting SHOW_RENDER which will let you see this algorithm work. Set that to true and there will be a slow delay between each step.

How this algorithm works in a simple way is:

  1. Start at {1,1} and use NeighborWalls() to get all of the walls near that position. This algorithm "carves" through walls so it wants to find walls, not space.
  2. It randomly picks one of these walls and turns it into a SPACE then "moves' there for the next step.
  3. It continues to randomly walk through the walls, carving them away, until it gets to a "dead end." A dead end is any step where there are no viable walls near the current position to carve.
  4. When it hits a dead end it uses HuntNext() to find a new WALL to start carving again. This is a simple brute force start at the first cell and scan across until you find one.
  5. Once it finds a new viable location it begins at #2 again, randomly carving until a new dead end.
  6. Finally, if it hits a dead end and HuntNext() can't find anything then the algorithm is done.

There's two small details that make this code work in this algorithm:

  1. The Neighbors() function is actually looking at every other cell because it calls compass(near, 2). This jumps over cells and leaves them as walls.
  2. The HuntNext() function does the same with the for loops searching every other cell for a new wall to start carving.

The result of this is that it carves only every other cell most of the time, leaving behind walls. This turns out to create interesting effects you'll explore at the end.

Going Further

I highly recommend visualizing this algorithm any way you can. One great way to visualize something like this is to replicate the algorithm on graph/grid paper with a pencil.

Previous Lesson Next Lesson

Register for Learn Go the Hard Way

Register today for the course and get the all currently available videos and lessons, plus all future modules for no extra charge.