Purely Functional Retrogames, Part 2

(Read Part 1 if you missed it.)

The difficult, or at least different, part of writing a game in a purely functional style is living without global, destructive updates. But before getting into how to deal with that, anything that can be done to reduce the need for destructive updates is going to make things easier later on.

Back when I actually wrote 8-bit games, much of my code involved updating timers and counters used for animation and special effects and so on. At the time it made a lot of sense, given the limited math capabilities of a 6502. In the modern world you can achieve the same by using a single clock counter that gets incremented each frame.

Ever notice how the power pills in Pac-Man blink on and off? Let's say the game clock is incremented every 1/60th of a second, and the pills flop from visible to invisible--or the other way around--twice per second (or every 30 ticks of the clock). The state of the pills can be computed directly from the clock value:

pills_are_visible(Clock) -> is_even(Clock div 30).

No special counters, no destructive updates of any kind. Similarly, the current frame of the animation of a Pac-Man ghost can be computed given the same clock:

current_ghost_frame(Clock) -> Offset = Clock rem TOTAL_GHOST_ANIMATION_LENGTH, Offset div TIME_PER_ANIMATION_FRAME.

Again, no special counters and no per frame updates. The clock can also be used for general event timers. Let's say the bonus fruit appears 30 seconds after a level starts. All we need is one value: the value of the clock when the level started plus 30*60. Each frame we check to see if the clock matches that value.

None of this is specific to functional programming. It's common in C and other languages. (The reason it was ugly on the 6502 was because of the lack of division and remainder instructions, and managing a single global clock involved verbose 24-bit math.)

There are limits to how much a single clock value can be exploited. You can't make every enemy in Robotron operate entirely as a function of time, because they react to other stimuli in the world, such as the position of the player. If you think about this trick a bit, what's actually going on is that some data is entirely dependent on other data. One value can be used to compute others. This makes a dynamic world be a whole lot more static than it may first seem.

Getting away from clocks and timing, there are other hidden dependencies in the typical retro-style game. In a procedural implementation of Pac-Man, when Pac-Man collides with a blue ghost, a global score is incremented. This is exactly the kind of hidden update that gets ugly with a purely functional approach. Sure, you could return some special data indicating that the score should change, but there's no need.

Let's say that each ghost has a state that looks like this: {State_name, Starting_time}. When a ghost has been eaten and is attempting to return to the box in the center of the maze, the state might be {return_to_box, 56700}. (56700 was the value of the master clock when the ghost was eaten.) Or it might be more fine-grained than that, but you get the idea. The important part is that there's enough information here to realize that a ghost was eaten during the current frame: if the state name is "return_to_box" and the starting time is the same as the current game clock. A separate function can scan through the ghost states and look for events that would cause a score increase.

The same technique also applies to when sounds are played. It's not something that has to be a side effect of the ghost behavior handling code. There's enough implicit information, given the state of the rest of the world, to make decisions about when sounds should be played. Using the example from the preceding paragraph, the same criteria for indicating a score increase can also be used to trigger the "ghost eaten" sound.

Part 3

permalink April 19, 2008

previously