└── 2020-02-14-golang-roguelike.md /2020-02-14-golang-roguelike.md: -------------------------------------------------------------------------------- 1 | # A Go Roguelike 2 | 3 | I'm building a [roguelike](https://en.wikipedia.org/wiki/Roguelike) game in Go called ["Jumpdrive."](https://imgur.com/kYE6CvR) 4 | 5 | It's a 70's space sci-fi about a pilot who's crashed her scout craft onto an island on an alien planet. 6 | Why? 7 | For me, drinking a bourbon while building a silly videogame in a pleasant language evokes the same sort of relaxed-but-rewarding state of mind that playing videogames does. 8 | 9 | ![screenshot](https://i.imgur.com/9Z7PpsO.png) 10 | 11 | These are a few thoughts I've had about programming while hacking it together. 12 | 13 | ## Generic Grids 14 | 15 | Roguelikes are all about grids. They're rendered into ASCII or ASCII-like grids of tiles. 16 | Movement and action is all oriented to the north, south, east, or west. 17 | The procedural generation of levels - cellular automata, fractal terrain, etc - uses grid-based algorithms. 18 | 19 | Ideally, we could define a generic grid: 20 | 21 | ```go 22 | type Grid (type E) struct { 23 | cells []E 24 | width int 25 | height int 26 | } 27 | 28 | func New (type E) (w, h int) Grid(E) { 29 | return Grid(E){ 30 | width: w, 31 | height: h, 32 | cells: make([]E, w * h), 33 | } 34 | } 35 | ``` 36 | 37 | Which could be used in a variety of grid types: 38 | 39 | ```go 40 | type Tile struct { 41 | Terrain int 42 | Prop int 43 | Light color.RGBA 44 | } 45 | 46 | tileGrid := grid.New(Tile)(128, 128) 47 | ``` 48 | 49 | And each of those grid types would support all the handy features of `Grid`, like: 50 | 51 | ```go 52 | neighbors := tileGrid.Neighbors4(16, 24) // []Tile 53 | 54 | tile, wrapped := tileGrid.At(192, 100) // Tile, bool 55 | 56 | continent := tileGrid.Contiguous(10, 20, func(t Tile) bool { // []Tile 57 | return t.Terrain == terrain.Rock 58 | }) 59 | ``` 60 | 61 | ### Reality 62 | 63 | Unfortunately, that isn't possible in Go ([yet!](https://blog.golang.org/why-generics)). 64 | I spun my wheels for a while finding a workaround that I was satisfied with before 65 | landing on a composable grid indexing system. 66 | 67 | First, you pair a generic slice with a grid: 68 | 69 | ```go 70 | type TileGrid struct { 71 | grid.Grid 72 | V []Tile 73 | } 74 | 75 | func NewTileGrid(w, h int) TileGrid { 76 | return TileGrid{ 77 | Grid: grid.Grid{Width: w, Height: h}, 78 | V: make([]Tile, w * h), 79 | } 80 | } 81 | 82 | tileGrid := NewTileGrid(128, 128) 83 | ``` 84 | 85 | Then, all of the grid's methods return indices for the attached slice: 86 | 87 | ```go 88 | nn := tileGrid.Neighbors4(16, 24) // []int 89 | 90 | i, wrapped := tileGrid.Index(192, 100) // int, bool 91 | 92 | cc := tileGrid.Contiguous(i, func(i int) bool { // []int 93 | return tileGrid.V[i].Terrain == terrain.Rock 94 | }) 95 | ``` 96 | 97 | For many scenarios, just the index, or even the length, is sufficient. 98 | If not, this solution grows more awkward: 99 | 100 | ```go 101 | nn := tileGrid.Neighbors4(16, 24) // []int 102 | 103 | neighbors := make([]Tile, len(nn)) 104 | for i, j := range nn { 105 | neighbors[i] = tileGrid.V[j] 106 | } 107 | ``` 108 | 109 | While I'm proud of what I believe is a simple and pragmatic workaround, 110 | I look forward to ripping this code out and replacing it with generic containers. 111 | If you can recommend a better approach, please do - I'm not a Go expert and I would be happy to improve it. 112 | 113 | In Jumpdrive, this grid system is used in the procedural generation pipeline that 114 | starts with a fractal noise, discretizes it into types of contiguous terrain on an island, 115 | tunnels rooms and mazes through it, builds structures and props, and finally adds items and creatures: 116 | 117 | ![fractal noise](https://i.imgur.com/i2f99w4.png) 118 | 119 | ![island terrain](https://i.imgur.com/mIQgH0S.png) 120 | 121 | ![structures](https://i.imgur.com/Uk8wXUA.png) 122 | 123 | ![final map](https://i.imgur.com/9i29mt4.png) 124 | 125 | The grid package is also used in Jumpdrive's path-traced renderer, which is designed to evoke the feeling of ASCII 126 | with extra color, light, and [drama](https://i.imgur.com/kYE6CvR.mp4): 127 | 128 | ![lighting](https://i.imgur.com/6V13Hx7.png) 129 | ![more lighting](https://i.imgur.com/E5BQ72i.png) 130 | 131 | ## Packages, Seams, and Structure 132 | 133 | When learning new codebases, I appreciate when the code itself acts as a sort of guide: 134 | `package main` as the table of contents, wiring up the program's structure at the highest level of abstraction 135 | and describing the system's overall shape. `main` shouldn't drop down into detailed work, 136 | but neither should it obfuscate what the program does. 137 | 138 | Here is `cmd/jumpdrive/jumpdrive.go`: 139 | 140 | ```go 141 | // imports omitted 142 | 143 | const ( 144 | title = "Jumpdrive" 145 | pixelScale = 3.0 146 | worldSize = 513 147 | ) 148 | 149 | var ( 150 | winSize = image.Point{600, 400} 151 | fullscreen = flag.Bool("full", false, "run in fullscreen") 152 | seed = flag.Int64("seed", 1, "random seed (1)") 153 | profCPU = flag.Bool("cpu", false, "record a CPU profile") 154 | ) 155 | 156 | func main() { 157 | flag.Parse() 158 | 159 | if err := play(); err != nil { 160 | log.Fatalf("error: %v", err) 161 | } 162 | } 163 | 164 | func play() error { 165 | rng := rand.New(rand.NewSource(*seed)) 166 | 167 | gm, err := game.New(rng, worldSize) 168 | if err != nil { 169 | return fmt.Errorf("creating game: %w", err) 170 | } 171 | 172 | win, err := newWindow(gm, *fullscreen) 173 | if err != nil { 174 | return fmt.Errorf("creating window: %w", err) 175 | } 176 | 177 | if *profCPU { 178 | defer profile.Start().Stop() 179 | } 180 | 181 | return win.OpenAndRun() 182 | } 183 | 184 | func newWindow(gm game.Instance, full bool) (win *ui.Window, err error) { 185 | if full { 186 | return ui.NewFullWindow(gm, title, pixelScale) 187 | } 188 | return ui.NewWindow(gm, title, pixelScale, winSize) 189 | } 190 | ``` 191 | 192 | I've been very happy with how naturally seams have emerged between packages due to Go's `package.Export` organization. 193 | The game's logic knows nothing about the UI that's rendering it, 194 | the terrain generator knows nothing about the level it's populating, and so forth. 195 | Dependencies point from the UI (less stable) towards game logic (more stable) to small underlying systems like the ECS (most stable). 196 | 197 | Go forces me to sit and think about names and groupings that will provide a nice API, which in turn 198 | leads me to better choices than I would make otherwise (*"plop the Foo class into utils/Foo.lang and then import ../../utils/Foo"*). 199 | 200 | Several packages have become completely decoupled; I plan to split them into their own modules for publishing: 201 | 202 | - grid 203 | - ecs (Entity-Component System) 204 | - sprites (with Ebiten) 205 | - diamondsquare (terrain generation) 206 | 207 | For the first time, I'm using an `internal` package. 208 | I don't know if I'm using it correctly, but there were several packages that all relied on agreeing 209 | on certain measurements with each other, and ensuring that these constants made it down dependency chains 210 | was a brittle solution: 211 | 212 | ```go 213 | package internal 214 | 215 | const ( 216 | TileWidth = 16 217 | TileHeight = 16 218 | SpriteWidth = 16 219 | SpriteHeight = 24 220 | ) 221 | ``` 222 | 223 | ## Entity-Component Systems 224 | 225 | If you haven't done much game development, [entity-component systems](https://en.wikipedia.org/wiki/Entity_component_system) might be new to you. 226 | On the other hand, if you've ever worked in Unity, then whether you know it or not you've built on top of an ECS. 227 | 228 | Games tend to have many more object instances, as well as many more types of objects, than business-oriented applications. 229 | Additionally, the number of other types a given object type should interact with tends to be higher. 230 | For example, a "bullet" should interact with a "monster." But it should also interact with a "player." 231 | As you add more things to a game, you'll find that many of them should also interact with "bullet:" 232 | "wall," "barrel," "vehicle," etc. 233 | 234 | The exponential increase of these interactions as you linearly increase the types of things in your game 235 | tends to lead game code into a complexity wall pretty quickly. 236 | That punishes creativity, since every new idea comes with exponentially more pain buried in its implementation. 237 | 238 | ### A naive solution 239 | 240 | To flatten out that complexity into something more linear, game developers turn to orthogonal behaviors via components. 241 | In a language like Go, you might first try to implement this pattern via broad compositional structures (that's what I did, anyway): 242 | 243 | ```go 244 | type Player struct { 245 | HealthComponent 246 | PerceptionComponent 247 | PositionComponent 248 | WalkComponent 249 | } 250 | 251 | type Bullet struct { 252 | DamageComponent 253 | PositionComponent 254 | FlyComponent 255 | } 256 | ``` 257 | 258 | This actually works okay, but brings several tradeoffs. 259 | 260 | In an entity-component system, order is important. All entities that can move, should move, and then 261 | all entities that can be damaged by weapons, should be damaged by weapons touching them. 262 | So you can't just loop through each object and apply all of its components, then do the same for the next object, etc. 263 | 264 | Enforcing order with embedded structs over a large set of non-homogenous objects gets awkward: 265 | 266 | ```go 267 | type Entity interface{} 268 | 269 | func Update(entities []Entity) { 270 | for _, e := range entities { 271 | if h, ok := e.(*HealthComponent); ok { 272 | h.UpdateHealth() 273 | } 274 | } 275 | for _, e := range entities { 276 | if p, ok := e.(*PerceptionComponent); ok { 277 | p.UpdatePerception() 278 | } 279 | } 280 | // ...etc 281 | } 282 | ``` 283 | 284 | That kind of pattern is slow, and made even slower by encouraging cache misses - the data is stored per-entity but our pattern of access is per-component. 285 | 286 | Game state also needs to be saved and loaded; serializing these embedded components is going to be a pain. Similar concerns exist for sharing game state over a network. 287 | 288 | Additionally, these statically-typed entities are inflexible at runtime; any component that might ever be available on an entity must be included in its type - so if your player could ever fly, `Player` needs the `FlyComponent`, 289 | plus a mechanism for marking components as disabled. It's a lot of book-keeping and the proliferation of "it could happen" 290 | components hurts readability. 291 | 292 | ### package ecs 293 | 294 | Instead, Jumpdrive now uses a minimal Entity-Component System package that I modeled after the most useful parts of Unity's 295 | behaviors. Each `Entity` in the `System` has a name, an ID, and zero or more tags; beyond that, all behavior is controlled by Components: 296 | 297 | ```go 298 | sys.Create("player", 299 | &Player{}, 300 | &Position{Point: pt}, 301 | &Perceptive{Range: 48}, 302 | &Harmable{Health: 3}, 303 | &Inventory{}, 304 | ).Tag("creature") 305 | ``` 306 | 307 | A tag is essentially a no-op component that simplifies searching for and building lists of entities. 308 | 309 | Notice that a "crab" isn't so different from a "player:" 310 | 311 | ```go 312 | sys.Create("crab", 313 | &Wander{Towards: gen.Water, Rng: rng}, 314 | &Position{Point: pt}, 315 | &Perceptive{Range: 32}, 316 | &Harmable{Health: 2}, 317 | ).Tag("creature") 318 | ``` 319 | 320 | A ray gun can interact with both a player and a crab. 321 | A player can pick it up, due to its `Inventory` component interacting with the gun's `Collectable` component. 322 | A crab can be damaged by the gun via the interactions between the `Shoot` component and the `Harmable` component: 323 | 324 | ```go 325 | sys.Create("ray-gun", 326 | &Shoot{Ammo: "ray", Damage: 1, Range: 100}, 327 | &Position{Point: pt}, 328 | &Collectable{}, 329 | ) 330 | ``` 331 | 332 | ![shooting](https://i.imgur.com/R4v05S8.png) 333 | 334 | Interestingly, without any extra code - or even meaning to do it - players can also shoot themselves since they, 335 | too, include `Harmable`. 336 | 337 | Rather than suppress creativity, an ECS tends to encourage and inspire creativity with emergent behaviors. 338 | Combining existing components in new ways creates new interactions without any new code! 339 | 340 | What about order? One solution would be to require each `Component` to specify its priority in the queue. 341 | However, I prefer explicitness, so each `System` has its own stack of components. 342 | They bear some similarity to the stacks of middleware that web developers may be more familiar with: 343 | 344 | ```go 345 | sys := ecs.NewSystem( 346 | InputComponent, 347 | WorldComponent, 348 | WeatherComponent, 349 | ShootComponent, 350 | HarmableComponent, 351 | PlayerComponent, 352 | WanderComponent, 353 | PositionComponent, 354 | InventoryComponent, 355 | CollectableComponent, 356 | PerceptiveComponent, 357 | BlockingComponent, 358 | ) 359 | ``` 360 | 361 | New components are easy to add to the system, which then triggers their behaviors with events. 362 | For example, whenever `system.Trigger("Step")` is called, all `Step` methods on all components 363 | are executed, in system-wide component order: 364 | 365 | ```go 366 | const PlayerComponent = "Player" 367 | 368 | type Player struct{} 369 | 370 | func (p *Player) Name() ecs.ComponentName { 371 | return PlayerComponent 372 | } 373 | 374 | func (p *Player) Step(s *ecs.System, e *ecs.Entity) { 375 | input := s.ComponentNamed(InputComponent).(*Input) 376 | 377 | h := e.Component(HarmableComponent).(*Harmable) 378 | if h.Health <= 0 { 379 | return 380 | } 381 | 382 | switch cmd := input.lastCommand.(type) { 383 | case MoveCommand: 384 | p.move(s, e, cmd) 385 | case ShootCommand: 386 | p.shoot(s, e, cmd) 387 | } 388 | } 389 | ``` 390 | 391 | The ecs package also provides several high-performance methods to query the state of the system, 392 | which is stored as maps of components rather than slices of entities: 393 | 394 | ```go 395 | player := sys.EntityNamed("player") 396 | 397 | pos := player.Component(game.PositionComponent).(*game.Position) 398 | 399 | world := sys.ComponentNamed(WorldComponent).(*World) 400 | 401 | items := sys.EntitiesWith(game.CollectableComponent) 402 | 403 | creatures := sys.EntitiesTagged("creature") 404 | ``` 405 | 406 | So, that's the hack project that I've been inching closer and closer towards a usable state over the past couple of months. I learned today that [Diablo started as a roguelike with custom lighting](https://www.youtube.com/watch?v=VscdPA6sUkc) 24 years ago, so while this may not be cutting-edge, at least I'm in good company :) 407 | 408 | ## Next steps 409 | 410 | - Some cleanup inspired by [Clean Code](https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882), especially in the very-messy rendering system. 411 | - Leveraging the path-traced lighting system (animated ray-gun rays, foliage that catches on fire, bioluminescence...) 412 | - Leveraging the ECS (map, teleport, drone items; enemy aliens, combat tactics; saving and restoring state...) 413 | - Leveraging the grid (procgen terrain, prop variety; hiding valuable items in hard-to-reach cells...) 414 | 415 | If you have ideas, corrections, or resources, please share them: [@hunterloftis](https://twitter.com/hunterloftis) 416 | --------------------------------------------------------------------------------