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:
- players (the things running around dropping bombs)
- bombs
- fire (from the bombs)
- 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).