Thursday, June 14, 2012

Bite-sized Twisted: A simple, working program

There's an asynchronous, single-player Bomberman clone at the end of this post.

Quick overview

Very quickly, these are the components of the game:

  1. players (the things running around dropping bombs)
  2. bombs
  3. fire (from the bombs)
  4. the game board
This post will build them in the reverse order: the board, fire, bombs then players. I'm going to skim through the normal Python, only emphasizing the points where the Twisted library does something neat.

Coordinates throughout the program are x,y tuples (e.g. (3,2)).

Big fat disclaimer

I'm not a game programmer. I've previously written simple games like Tetris. I know there are cool optimizations that game makers use (for dealing with lag and minimizing network traffic and such). I'm not going to prematurely optimize. I will accept good patches though :)

Da board

The game board will be divided into layers. At the bottom is the bottom layer... which, for now, consists of identical background tiles. One layer up is the layer of destructable and indestructable tiles. We'll call these the foreground tiles. Here's how we'll implement the foreground tiles and a method to generate a basic board:

EMPTY = 0
HARD = 1
SOFT = 2

class Board:

def __init__(self):
self.fg_tiles = {}

def generate(self, width, height):
for x in xrange(width):
for y in xrange(height):
coord = (x,y)
if (x % 2) and (y % 2):
self.fg_tiles[coord] = HARD
else:
self.fg_tiles[coord] = SOFT
board1.py

EMPTY means there's no foreground tile, SOFT tiles are destructable and HARD tiles are indestructable. A basic 5 x 5 board looks like this:

+-----+
|:::::|
|:#:#:|
|:::::|
|:#:#:|
|:::::|
+-----+

: = SOFT
# = HARD

The keys for fg_tiles are coordinate tuples (e.g. (4,5)). There's nothing asynchronous or Twisted-y about that code. Let's move along.

Start the fire!

Generally, an exploding bomb will make fire appear on the board. Once a bomb makes the fire, however, the bomb has nothing more to do with it. Let's implement the post-bomb portion of the fire.

We'll keep track of fires in a dictionary on Board. The keys into the fires dictionary are x,y coordinate tuples:

    def __init__(self):
self.fg_tiles = {}
self.fires = {}
fire1.py

Fires have an asynchronous component: they ignite, burn for some amount of time, then die. We'll use reactor.callLater to do the "burn for some amount time" bit and use a Deferred to signal to the fire-starter when the fire is extinguished:

from twisted.internet import reactor
from twisted.internet.defer import Deferred

...

def startFire(self, coord, burntime):
defer = Deferred()

# Put the fire out after a bit.
reactor.callLater(burntime, self.stopFire, coord)

# Record that there's a fire on the board.
self.fires[coord] = defer

# Destroy whatever tile is here
self.fg_tiles[coord] = EMPTY
return defer


def stopFire(self, coord):
defer = self.fires[coord]

# Remove the fire from the board
del self.fires[coord]

# notify people who care that the fire is out
defer.callback(None)
fire2.py

The Deferred is created, stored in the fires dictionary, then returned to the caller (they can add callbacks to it if they want). Also, the tile at that coordinate is "destroyed" (made EMPTY).

The callLater calls stopFire, which remove the entry in the fires dictionary and calls back the stored Deferred. That the Deferred is called back with None simply means that any attached callbacks will be passed None as the first argument.

Let's talk about threading. If you've done threaded programming, you may be cringing over the blatant disregard with which global variables are accessed (self.fires and self.fg_tiles) without proper locking mechanisms to guarantee atomicity. This code, though, isn't threaded. startFire will run from beginning to end, sequentially, without any other code executing. Breathe well! This is Twisted!

Restart the fire!

It's possible that an exploding bomb will ignite a tile that is already burning. With fire2.py, that scenario is a problem because the second fire will overwrite the first fire's entry in the fires dict. Then the first fire's stopFire call will delete the entry from fires dict so that when the second fire's stopFire happens, a KeyError is raised. From the players' perspective, the second fire will be prematurely extinguished.

So instead of having two fires burning in the same tile, if a second fire ignites, let's extend the existing fire's time. We can do this with the reset method of the thing returned by callLater (it's called a DelayedCall and you can read the docs here):

    def startFire(self, coord, burntime):
# Is there already a fire on this tile?
if coord in self.fires:
defer, call = self.fires[coord]
# Stoke the fire
call.reset(burntime)
else:
defer = Deferred()
# Put the fire out after a bit.
call = reactor.callLater(burntime, self.stopFire, coord)

# Record that there's a fire on the board.
self.fires[coord] = (defer, call)

# Destroy whatever tile is here
self.fg_tiles[coord] = EMPTY
return defer


def stopFire(self, coord):
d, call = self.fires[coord]

# Remove the fire from the board
del self.fires[coord]

# notify people who care that the fire is out
d.callback(None)
fire3.py

Now a second call to start a fire on a tile will prolong the burning without glitches. Anyone waiting on the Deferred returned by the first call will just wait a little longer.

Bomb, bomb, bomb

Now that we can start fires at will, let's add bombs to the fray. We'll track bombs similarly to fires:

    def __init__(self):
self.fg_tiles = {}
self.bombs = {}
self.fires = {}
bomb1.py

And the code for dropping and detonating bombs is very similar to that for igniting and extinguishing fires (with some extra in detonateBomb to account for the different kinds of tiles and board boundaries, etc...):

    dft_burn = 1


def dropBomb(self, coord, fuse, size):
# Set the bomb up to explode later
defer = Deferred()
call = self._reactor.callLater(fuse, self.detonateBomb, coord)

# Place the bomb on the board
self.bombs[coord] = (defer, call, size)
return defer


def detonateBomb(self, coord):
defer, call, size = self.bombs[coord]

# Remove the bomb from the board
del self.bombs[coord]

# Let people who care know that the bomb has exploded
defer.callback(None)

# Start the fires up, down, left and right
self.startFire(coord, self.dft_burn)
directions = [
(0, -1),
(0, 1),
(-1, 0),
(1, 0),
]
for i in xrange(1, size+1):
for d in list(directions):
target = (coord[0]+(d[0]*i), coord[1]+(d[1]*i))
try:
tile = self.fg_tiles[target]
except KeyError:
continue
if tile == HARD:
directions.remove(d)
continue
if target in self.bombs:
directions.remove(d)
if tile == SOFT:
directions.remove(d)
self.startFire(target, self.dft_burn)
bomb2.py

In Bomberman, exploding bombs cause other bombs to explode. We can incorporate that by changing startFire (changed lines are highlighted):

    def startFire(self, coord, burntime):
# Is there already a fire on this tile?
if coord in self.fires:
defer, call = self.fires[coord]
# Stoke the fire
call.reset(burntime)
else:
defer = Deferred()
# Put the fire out after a bit.
call = reactor.callLater(burntime, self.stopFire, coord)

# Record that there's a fire on the board.
self.fires[coord] = (defer, call)

# Destroy whatever tile is here
self.fg_tiles[coord] = EMPTY

# Is there a bomb to detonate here?
if coord in self.bombs:
self.detonateBomb(coord)
return defer
bomb3.py

But now what happens when the bomb's original fuse expires? It will cause problems trying to re-explode an exploded bomb. So, let's cancel the original fuse using the cancel() method of the DelayedCall:

    def detonateBomb(self, coord):
defer, call, size = self.bombs[coord]

# Remove the bomb from the board
del self.bombs[coord]

# Let people who care know that the bomb has exploded
defer.callback(None)

# Premature explosion?
if call.active():
call.cancel()

# Start the fires up, down, left and right
self.startFire(coord, self.dft_burn)
...
bomb4.py

Now the fuse is canceled on prematurely ignited bombs.

Merely a pawn

I'm using the term pawn to represent the thing that moves around on the board. We'll keep track of Pawns in a set rather than a dictionary because multiple Pawns can be at the same location at once.

class Board:

def __init__(self):
self.fg_tiles = {}
self.bombs = {}
self.fires = {}
self.pawns = set()
pawn1.py

Pawns have various attributes, as shown:

class Pawn:

bombs = 1
flame_size = 1
fuse = 2.0
alive = True

def __init__(self, name=None):
self.name = name
pawn2.py

First things first: Pawns need to know how to die.

    def kill(self):
self.alive = False
pawn3.py

We need to be able to put Pawns on the Board:

class Board:
...
def insertPawn(self, coord, pawn):
# Let the Board and Pawn know about each other
pawn.board = self
pawn.loc = coord
self.pawns.add(pawn)

# Clear a space for the Pawn
self.fg_tiles[pawn.loc] = EMPTY
directions = [
(1,0),
(-1,0),
(0,1),
(0,-1),
]
for d in directions:
target = (pawn.loc[0]+d[0], pawn.loc[1]+d[1])
if target in self.fg_tiles:
self.fg_tiles[target] = EMPTY


class Pawn:
...
board = None
loc = None
pawn4.py

A fire + a Pawn = death for the Pawn:

    def startFire(self, coord, burntime):
# Is there already a fire on this tile?
if coord in self.fires:
defer, call = self.fires[coord]
# Stoke the fire
call.reset(burntime)
else:
defer = Deferred()
# Put the fire out after a bit.
call = reactor.callLater(burntime, self.stopFire, coord)

# Record that there's a fire on the board.
self.fires[coord] = (defer, call)

# Destroy whatever tile is here
self.fg_tiles[coord] = EMPTY

# Is there a bomb to detonate here?
if coord in self.bombs:
self.detonateBomb(coord)

# Any pawns that should diaf?
for pawn in [x for x in self.pawns if x.loc==coord]:
pawn.kill()
return defer
pawn5.py

Pawns can move (very quickly in this version). And when they move, the Board is notified:

class YoureDead(Exception): pass
class IllegalMove(Exception): pass


class Board:
...
def pawnMoved(self, pawn, new_loc):
pawn.loc = new_loc

# Did the Pawn unwittingly move into a fire?
if new_loc in self.fires:
pawn.kill()


class Pawn:
...
def move(self, direction):
if not self.alive:
raise YoureDead("Dead people can't move")

# Which way?
delta = {
'u': (0,-1),
'd': (0,1),
'l': (-1,0),
'r': (1,0),
}[direction]
target = (self.loc[0]+delta[0], self.loc[1]+delta[1])

# Is it okay to move there?
try:
tile = self.board.fg_tiles[target]
except KeyError:
raise IllegalMove("That would be off the board")
if tile != EMPTY:
raise IllegalMove("There's a brick there")
if target in self.board.bombs:
raise IllegalMove("There's a bomb there")

# Go ahead and move
self.board.pawnMoved(self, target)
pawn6.py

Merely an asynchronous pawn

Nothing in the above Pawn snippets makes use of Twisted. But the act of dropping a bomb on the board does:

class Pawn:
...
def dropBomb(self):
if not self.alive:
raise YoureDead("Dead people can't drop bombs")
if self.bombs <= 0:
raise IllegalMove("You're out of bombs")

# Use a bomb from the Pawn's stash
self.bombs -= 1
d = self.board.dropBomb(self.loc, self.fuse, self.flame_size)

# Get the bomb back after it explodes
def bombExploded(result, pawn):
pawn.bombs += 1
d.addCallback(bombExploded, self)
pawn7.py

To keep track of the bombs allotted to the Pawn, we subtract when it's put down, then add when the bomb's Deferred is called back.

Wanna play?

Do this:

git clone -b tx-single-player git://github.com/iffy/boom boom.git
cd boom.git
PYTHONPATH=. python run.py

Or if you don't have Git:

wget https://github.com/iffy/boom/tarball/tx-single-player
tar xf tx-single-player
cd iffy-boom-58c1582/
PYTHONPATH=. python run.py

The controls are W, A, S, D for up, left, down, right respectively. E lays a bomb. AND you have to press enter after each key press. I know, I know! "What a dumb game! Twisted is terrible!" I just haven't covered it yet. We'll get there. Remember, it's bite-sized Twisted.

All the code for this post is in the tx-single-player branch on GitHub. Unlike the snippets shown here, the code there is fully tested and has class and method documentation. Take a look at game.py

Next time

Next time, I'll likely highlight some asynchronous testing tools, then move on to making the game multiplayer and playable (not having to press enter every move).

No comments:

Post a Comment