Video Coming Soon...
27: Rogue Part 0: The Screen
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 are now going to create a version of a game from the 80s called Rogue. Rogue is one of the most influential games ever, yet it was quite simple and easy to play. It didn't have any graphics, but instead used regular text characters and your Terminal to play the game.
An example screen from go might be like this:
------ ------
| @ | | |
| +####+ u |
------ ------
This would be two rooms, connected by a hallway (#
for hallways), with the player being the @
and an enemy or some loot being the u
.
Despite this very simple graphics quality, the game ends up being fun and challenging because of its design. Roguelike games feature the idea of starting over when you die, but retaining some aspect of your previous skill or equipment. This makes each attempt through the dungeon easier up to where you usually died. The fun in these kinds of games is seeing how far you can get before dying, and the luck of finding the right loot to extend your life farther.
Rogue makes an excellent game for beginners because it is all code. There's no graphics, sound, music, collision, physics, or anything else you find in modern games. Even the world is text, which is adjacent to code. This allows you to tinker and try things to make your game using only basic programming knowledge.
For the next 4 exercises we'll be building a tiny starter Rogue. It'll have all the main features:
- Randomly generated mazes.
- A player and enemies.
- A simple combat system.
- Pathing for the enemies.
- Running the terminal.
Building Mine First
The best way to get started with this project is run mine so you know what you're making. Do this to get it and run it:
git clone https://lcthw.dev/go/curse-you-go-rogue.git
cd curse-you-go-rogue/04_combat
go build .
./gorogue
You can then walk around (you are @
) and kill the red G
enemies by moving into them. If you die then it starts over, making a new maze. To quit you type q
.
That will show you what you'll eventually make at the end of Exercise 30, but you should also look at the other directories:
01_the_screen
-- This is the code for Exercise 27, this exercise and your goal is to get some basic screen operations working so you can display a fixed map and let the player walk around.02_mazes_and_enemies
-- The Exercise 28 code where you use the Hunt and Kill maze generation algorithm to make random mazes.03_pathing_enemies
-- For Exercise 29 you implement Dijkstra's Algorithm for pathing enemies, and then make the enemies path.04_combat
-- Finally, in Exercise 30 you add some simple combat and then I give you a bunch of new things to make your game do.
The goal with these four exercises is to leave you with a working little game you can push farther to push your knowledge of Go, but also so you can have some fun with Go.
NOTE You should keep my code around just in case you get stuck. You may run into problems that require you to compare your code to mine just to confirm you're doing it right.
The Setup
You'll first need to configure a new Go project named gorogue
and add a couple of libraries:
mkdir gorogue
cd gorogue
go mod init MY/gorogue
Then you create a simple main.go
and confirm it all builds before you continue. Later we'll be adding the tcell module to your project, but we'll let go mod tidy
handle that for us.
The Target main.go
Your target for this phase of the project is this main.go
file:
View Source file curse-you-go-rogue/01_the_screen/main.go Only
package main
func main() {
DebugInit()
game := NewGame(17, 11)
game.InitScreen()
game.NewMap()
game.Render()
for game.HandleEvents() {
game.Render()
}
game.Exit()
}
You should type this in and get it ready, but keep in mind that it won't actually compile because you haven't created all of the functions. You have two choices when working with this:
- Leave the missing function errors as a kind of "TODO list" and keep working until all of the errors are gone...
- Or, comment out the parts that aren't working and then enable them as you implement that part.
There's nothing special in this main.go
other than having the other functions live in separate files. All of the files will still be in the main
package, but it'll be easier to change them and work on them when they're separated.
The Data
We're going to need some data for our project, and the data.go
file is where we'll put it:
View Source file curse-you-go-rogue/01_the_screen/data.go Only
package main
import (
"github.com/gdamore/tcell/v2"
)
const (
WALL = '#'
SPACE = '.'
PATH_LIMIT = 1000
RENDER = true
SHOW_RENDER = false
SHOW_PATHS = false
HEARING_DISTANCE = 6
)
type Map [][]rune
type Position struct {
X int
Y int
}
type Enemy struct {
HP int
Pos Position
Damage int
}
type Game struct {
Screen tcell.Screen
Level Map
Player Enemy
Status string
Width int
Height int
Enemies map[Position]*Enemy
}
The data.go
file contains five important things:
- A set of constants used to configure the rest of the game. Constants are similar to variables but you can change them and they are compiled directly into the resulting code rather than referenced every time you use one.
- A
Map
type for our little text grid, and in this case we only need to say it's a[][]rune
. So far we've worked only with one level ofarray
(slice
) which would be[]rune
in this case. The additional[]
means that thisMap
has two dimensions, and we'll be using they
dimension first. That means to get the cell at coordinatex=6
,y=8
you would accessmap[8][6]
ormap[y][x]
. - A
Position
struct to indicate where things are in theMap
grid. This is passed to every function when we talk about something that has a position. - An
Enemy
struct that stores information about "enemies". This is the enemy's HP, position, and damage they can do. We also use this for the Player in the game since, technically, they are an enemy as well. - A
Game
struct that stores the rest of the information we'll be using withing the game. This will also be the mainstruct
for our methods.
Get this working and confirm you know how everything compiles as much as possible so far.
The Game
The game.go
currently only has two functions needed for the game to work. One to make a NewGame
and another to Exit
the game:
View Source file curse-you-go-rogue/01_the_screen/game.go Only
package main
import (
"os"
)
func NewGame(width int, height int) (*Game) {
var game Game
game.Width = width
game.Height = height
game.Enemies = make(map[Position]*Enemy)
game.Level = make(Map, height, height)
game.Player = Enemy{20, Position{1,1}, 4}
return &game
}
func (game *Game) Exit() {
if RENDER {
game.Screen.Fini()
}
os.Exit(0)
}
The important part of Exit
is how it closes the Screen
. If you find that your Terminal is "stuck" or has garbage on it, then it's probably because this isn't being called. Just close the terminal and fix the code to call game.Exit()
when its done.
The Map
These are functions that you don't quite use yet, but are going to be essential going forward:
View Source file curse-you-go-rogue/01_the_screen/map.go Only
package main
import (
"slices"
)
func compass(near Position, offset int) []Position {
return []Position{
Position{near.X, near.Y - offset},
Position{near.X, near.Y + offset},
Position{near.X + offset, near.Y},
Position{near.X - offset, near.Y},
}
}
func (game *Game) Inbounds(pos Position, offset int) bool {
return pos.X >= offset &&
pos.X < game.Width - offset &&
pos.Y >= offset &&
pos.Y < game.Height - offset
}
func (game *Game) Occupied(pos Position) bool {
is_player := pos == game.Player.Pos
// Inbounds comes first to prevent accessing level with bad x,y
return !game.Inbounds(pos, 1) ||
game.Level[pos.Y][pos.X] == WALL ||
is_player
}
func (game *Game) FillMap(target Map, setting rune) {
for y := 0 ; y < game.Height; y++ {
target[y] = slices.Repeat([]rune{setting}, game.Width)
}
}
compass()
-- There's only 4 directions of movement, and this function just returns an array with those 4 directions calculated given a current position.Occupied()
-- Determines if a cell in thegame.Level
is occupied by something. It also checks that the given coordinate is within the bounds of the grid usingInbounds
.Inbounds()
-- Simple check that anx
,y
coordinate is within bounds, but can also take an additional offset. The offset is used later when we get to the maze generation.FillMap()
-- Used to reset or initialize aMap
to a fixed value. This is needed for the later maze generation and the pathing algorithms.
I also have you place the game.NewMap()
in a temp.go
file because you're going to get rid of this later. It's easier to just delete this temp.go
file and add the real game.NewMap()
to map.go
later.
YOU SHOULD COPY-PASTE THIS Something like this has no value as a code writing exercise so go ahead and copy-paste it.
View Source file curse-you-go-rogue/01_the_screen/temp.go Only
package main
func (game *Game) NewMap() {
game.Level = Map{
[]rune("#################"),
[]rune("#.#...#.........#"),
[]rune("#.#.###.#.###.#.#"),
[]rune("#.#.....#...#.#.#"),
[]rune("#.#.#######.#.###"),
[]rune("#.#...#...#.#...#"),
[]rune("#.###.###...###.#"),
[]rune("#...#.......#...#"),
[]rune("#.#.#########...#"),
[]rune("#.#.............#"),
[]rune("#################"),
}
}
You'll delete this in the next phases, but for now we just need a fixed map to play with while we get things working.
The Movement
At this stage in the development you only need a simple MovePlayer
function that moves the game.Player
by a certain x
,y
delta.
View Source file curse-you-go-rogue/01_the_screen/movement.go Only
package main
func (game *Game) MovePlayer(x_delta int, y_delta int) {
target := Position{
game.Player.Pos.X + x_delta,
game.Player.Pos.Y + y_delta,
}
if !game.Occupied(target) {
game.Player.Pos = target
}
}
A "delta" just means a "change." This function also has to confirm that where the player wants to move is not Occupied
. You should also go look at Occupied
again to confirm that it's also using Inbounds
to make sure the player isn't going out of bounds of the map.
The UI
This is the largest part of this first version of the game, but we won't need to change this much as we move on. I suggest you take this slow and get each function working rather than type it all in at once. I'v e said that many, many, many, time before but it's always worth repeating.
View Source file curse-you-go-rogue/01_the_screen/ui.go Only
package main
import (
"log"
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/encoding"
)
//// DRAWING
func (game *Game) DrawText(x int, y int, text string) {
for i, cell := range text {
game.Screen.SetContent(x+i, y, cell, nil, tcell.StyleDefault)
}
}
func (game *Game) DrawStatus() {
game.DrawText(0, game.Height, game.Status)
hp := fmt.Sprintf("HP: %d", game.Player.HP)
game.DrawText(game.Width - len(hp), game.Height, hp)
}
func (game *Game) SetStatus(msg string) {
game.Status = msg
}
func (game *Game) DrawEntity(symbol rune, pos Position, color tcell.Color) {
style := tcell.StyleDefault.Bold(true).Foreground(color)
game.Screen.SetContent(pos.X, pos.Y, symbol, nil, style)
}
func (game *Game) DrawMap() {
gray := tcell.StyleDefault.Foreground(tcell.ColorGray)
for y, line := range game.Level {
for x, cell := range line {
if cell == SPACE {
game.Screen.SetContent(x, y, cell, nil, gray)
} else {
game.Screen.SetContent(x, y, cell, nil, tcell.StyleDefault)
}
}
}
}
///// RENDERING
func (game *Game) InitScreen() {
var err error
encoding.Register()
game.Screen, err = tcell.NewScreen()
// using log.Fatal instead of dbg.Fatal
// because the screen isn't setup yet
if err != nil { log.Fatal(err) }
err = game.Screen.Init()
if err != nil { log.Fatal(err) }
}
func (game *Game) Render() {
if !RENDER { return }
game.Screen.Clear()
game.DrawMap()
game.DrawEntity('@', game.Player.Pos, tcell.ColorYellow)
game.DrawStatus()
game.Screen.Show()
}
//// EVENTS
func (game *Game) HandleKeys(ev *tcell.EventKey) bool {
switch ev.Key() {
case tcell.KeyEscape:
return false
case tcell.KeyUp:
game.MovePlayer(0, -1)
case tcell.KeyDown:
game.MovePlayer(0, 1)
case tcell.KeyRight:
game.MovePlayer(1, 0)
case tcell.KeyLeft:
game.MovePlayer(-1, 0)
}
switch ev.Rune() {
case 'q':
return false
}
return true
}
func (game *Game) HandleEvents() bool {
if !RENDER { return false }
switch ev := game.Screen.PollEvent().(type) {
case *tcell.EventResize:
game.Screen.Sync()
case *tcell.EventKey:
return game.HandleKeys(ev)
}
return true
}
The functions in ui.go
handle all of the User Interface (UI) operations:
DrawText()
-- This wraps the weirdly convoluted and strangetcell.SetContent
function.DrawStatus()
-- Every game needs some kind of status line, and this does that. It usese theGame.Status
field. The status is drawn directly under the map on the far left.SetStatus()
-- How your program will set status messages for the user to see.DrawEntity()
-- Draws an "entity," which is anything you can interact with in the game. Player, Enemy, healing items, loot, etc. are all "entities." At first you only have the player, but later you'll add enemies and this will draw them too.DrawMap()
-- Does the grunt work of drawing thegame.Map
and altering the color of eachrune
depending on whether its aWALL
orSPACE
. Look indata.go
to find theconst
for those.InitScreen()
-- This is called once to setup the game.Screen so you can work with it.Render()
-- The meat of the game. This is called every step through thefor
inmain.go
to render the state of the game to the player. You'll be adding to this as you add new features, but mostly this is how it works for a while. The order is important here because later things are drawn over previous things. It's generally easier and faster to simply draw over something than to carve out a hole for it. For example, you can draw the wholegame.Map
and then draw the player wherever they are and that will replace the.
correctly.HandleKeys()
-- This handles the keyboard arrow keys and theq
key to quit.HandleEvents()
-- This deals with the different type of events thattcell
produces. You only need to work with the keyboard and resize events, but you could add mouse later if you want. Look at tcell's documentation.
Some Debugging
Finally, some debugging is also useful, and normally you'd just use fmt
or log
, but you're using tcell
. The tcell
module takes over the whole terminal screen so you're not able to log debug messages anymore. The simple solution is to make a new log and make it available at dbg
.
View Source file curse-you-go-rogue/01_the_screen/debug.go Only
package main
import (
"log"
"os"
)
var dbg *log.Logger
func DebugInit() {
out, err := os.Create("debug.log")
if err != nil { log.Fatal(err) }
dbg = log.New(out, "", log.LstdFlags)
}
Now you can write dbg.Fatal()
instead of log.Fatal()
in your code, or any other function in the log package.
Additional Tips
When you're working on this code you may feel like it's "totally a mess" because it's in several separated pieces. This is usually how the creative process works, and you have to trust that when you are done it'll come together. In everything you create "from scratch" there's an initial enthusiastic stage where you think things are going great. Then there's the ugly middle stage where it looks like a failure and it's never going to work. Then you enter the finishing stage and it you can see how it'll look when you're done.
Never judge your work during the ugly middle stage. You don't know how it will really turn out if you keep working, but more importantly you won't learn anything if you keep giving up before you've really finished it. It's better to power through that stage, get it working, clean it up a bit, and then critique it honestly so you can learn from your mistakes and try again.
The next thing to consider as you go through the next 3 exercises is the feeling of, "I could never do this on my own!" You're right, you probably can't. But, do you know how you learn to do things on your own?
Copy every damn thing you can. The way you learn how to make things you admire is to copy those things until you can make them yourself, then you'll be able to make your own things other people might desire. Copying is not cheating, or pathetic. It's how nearly every single creative discipline learns their craft. In music you copy the songs of other musicians. In painting you do "Master Copies" of other great paintings. In poetry you memorize poems of the greats...or maybe...I don't know sometimes I think they just flap around with a thesaurus until they get famous on Instagram.
Anyway! The point is in the beginning your job is to copy as many things as you can, and I don't mean only copying the code. You should also be trying to recreate things you admire without the code. Try to keep your copies small and give yourself a time limit, but if you want to get good, copy other work that's better than yours.
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.