Notes about writing a Z-Day clone

  2015-04-11


One of my recreational software projects is writing a Go version of Radomir ‘The Sheep’ Dopieralski’s 2005 7DRL Challenge entry Z-Day. This article is about the why and how.

My version is a hybrid application. It can be compiled to a native terminal app running on GNU/Linux, Windows or OS X but it is also playable as a browser game. It is Free and Open Software covered by the terms of the GPLv3+.

Z-Day on a Raspberry Pi played with a tablet

Okay, I admit it: It’s a bit lame to copy-cat an existing project. But in this particular case I feel different about it.

First of all I like playing Z-Day because it is more casual then the most rogue-likes I know. The hack ‘n’ slay part is quiet dominant. Maybe as a consequence of being a 7DRL project the original version is a bit rough about the edges both on the technical and the presentation side.

The Python source code is messy. I guess Radomir would not deny this ;-) and finally the license does not qualify it as Free Software. This alone should justify a re-write.

Writing a rogue-like from scratch without having a clear a-priori plan about the story or the needed feature set leads very likely in writing a soulless “engine only” kind of game. I resisted to take this doomed path. Instead I’ve tried to stay close to the original Z-Day. I’ve kept the over-all game mechanics like the probabilities of the appearing items. The damage system and the level generation are nearly identical and so on. But I’ve changed a few things:

  • I removed the annoying blinking of the already selected zombie.
  • Switching weapons does not eat up a turn.
    This makes the game a little easier but also a bit more usable.
  • I’ve added a panel showing which floor you’re on.
  • The intro, win and lose screens are improved.
  • There is an in-game help.
  • It handles terminal resizing.
  • The overall performance is much better.
  • The memory consumption dropped significantly (to below 12).

As a result I’m able to run a game smoothly on a Raspberry Pi with a memory footprint around 2.4MB. The Python version takes around 5.5MB with some serious screen flickering issues and notable thinking pauses.

The web version performs pretty well, too.

On the implementation side it’s not a simple Python to Go translation. It aims to be idiomatic Go and leverage e.g. the advantages of static typing. To enable both user front ends the game logic is separated from the virtual terminal with an interface type. There are two implementations distinguish from each other by Go’s build tags.

For the native terminals Termbox Go as a simple pure Go ncurses alternative is used. Having experimented with it before and found some “surprises” with syncing state to the real terminal it is nevertheless a sufficient and solid library to work with. As it is a low-level library you have to implement windowing support yourself but this is not too hard.

The web version is compiled with GopherJS which is a Go to JavaScript compiler. All I can say about it, that it works pretty well. To address a big concern of one of my colleagues (Hello, Raimund!): Even if it’s not native JavaScript with some degree of bloat the generated code is comprehensible and debug-able.

The terminal implementation itself uses the js/dom library to create a table/tr/td based text grid inside a div. I’ve considered using the canvas API but the text based approach works fast enough and offers good screen scaling properties.

There are many ways to write a turn-based rogue-like. In this case I’ve decided to model the game logic as a state machine. A turn is simply a transition from one state to another. Such a transition is triggered by an event in-coming from the terminal applied to the game.
Here’s how it looks in the Go source code:

type gameState func(g *Game, e *TEvent) gameState

This defines a new type gameState which is a function when called with pointers to a game and an event returns a gameState function. In other words it’s a recursive type definition.

type Game struct {
  state gameState
  // ...
}

The game structure has a field holding the current gameState. Driving the machine becomes simple with this idiom:

func (g *Game) HandleEvent(event *TEvent) {
  g.state = g.state(g, event)
}

This is the complete event handler called from the terminal. The current state function is invoked with the in-coming event and the game. The result is stored back into the game struct.

game := &Game{
  state: splashState,
  // ...
}

The system is initialized with the state that displays the ZDAY splash screen. After pressing space two times (splash -> story ->) you enter the default state which is the effectively a turn handling in the game. This state returns itself.

func splashState(g *Game, event *TEvent) gameState {
  if event.Key != TSpc {
    return splashState
  }
  // ...
  return storyState
}

func storyState(g *Game, event *TEvent) gameState {
  if event.Key != TSpc {
    return storyState
  }
  // ...
  return defaultState
}

func defaultState(g *Game, event *TEvent) gameState {
  // ...
  return defaultState
}

The cool thing about this technique is that you can insert new game states with ease. Defeats and wins are simply modeled as gameState functions. Even things like quit dialogs or help screens, where you optionally want to return to the previous states are easy to build: You can capture the current states in closures which them self are gameState functions. On execution they can return the saved states.

More complex visual feedbacks like hit zombies or players having a time constraint bound with them fit well into this framework, too. Consult the source for the concrete implementations.

The recursive function trick is borrowed from the text/template engine of the Go standard library. In its internals the lexer of the engine (besides the different domain) works quiet similar. See Rob Pike’s interesting talk “Lexical Scanning in Go” from 2011 for details.

The rest of the code is a pretty straight forward implementation of the game rules. Look at the mentioned defaultState first.

If you find bugs or have questions please open a bug report or contact me otherwise.