# A small Bejeweled-like game in Pygame

I would really appreciate a review of my first game. It is a Bejeweled-like game written in Python with Pygame:

import pygame, random, time, sys
from pygame.locals import *

pygame.init()

NUM_SHAPES = 7         #7
PUZZLE_COLUMNS = 6     #6
PUZZLE_ROWS = 12       #12
SHAPE_WIDTH = 50       #50
SHAPE_HEIGHT = 50      #50

FPS = 15
WINDOW_WIDTH = PUZZLE_COLUMNS * SHAPE_WIDTH
WINDOW_HEIGHT = PUZZLE_ROWS * SHAPE_HEIGHT + 75

SHAPES_LIST = [CIRCLE, DIAMOND, HEXAGON, SQUARE, STAR, STAR2, TRIANGLE]
for x in xrange(len(SHAPES_LIST) - NUM_SHAPES):
del(SHAPES_LIST[0])

EXPLOSION_LIST = [EXPLOSION_1, EXPLOSION_2, EXPLOSION_3, EXPLOSION_4, EXPLOSION_5, EXPLOSION_6]

WHITE = (255, 255, 255)
BLACK = (0, 0, 0)

FONT_SIZE = 36
TEXT_OFFSET = 5

MINIMUM_MATCH = 3
SINGLE_POINTS = .9
DOUBLE_POINTS = 3
TRIPLE_POINTS = 9
EXTRA_LENGTH_POINTS = .1
RANDOM_POINTS = .3
DELAY_PENALTY_SECONDS = 10
DELAY_PENALTY_POINTS = .5

FPS_CLOCK = pygame.time.Clock()
DISPLAY_SURFACE = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT), DOUBLEBUF)
pygame.display.set_caption("Bilging Puzzle")

def main():
global score
global selector

bilgeBoard = generate_random_board()
selector = (0, 0)
score = 0.0
lastMoveTime = pygame.time.get_ticks()

blit_board(bilgeBoard)
draw_selector(selector)
remove_matches(bilgeBoard, selector)

blit_board(bilgeBoard)
blit_score(score)
blit_time(0)
draw_selector(selector)

while True:
for event in pygame.event.get():
if event.type == KEYUP:
if event.key == K_RIGHT and selector[0] < (PUZZLE_COLUMNS - 2):
selector = (selector[0] + 1, selector[1])
if event.key == K_LEFT and selector [0] > 0:
selector = (selector[0] - 1, selector[1])
if event.key == K_DOWN and selector[1] < (PUZZLE_ROWS - 1):
selector = (selector[0], selector[1] + 1)
if event.key == K_UP and selector[1] > 0:
selector = (selector[0], selector[1] - 1)
if event.key == K_SPACE:
lastMoveTime = pygame.time.get_ticks()

score -= 1
swap_pieces(selector, bilgeBoard)
remove_matches(bilgeBoard, selector)

if moveDelay / (DELAY_PENALTY_SECONDS * 1000) >= 1:
score -= DELAY_PENALTY_POINTS * (moveDelay / (DELAY_PENALTY_SECONDS * 1000))

if event.type == QUIT:
pygame.quit()
sys.exit()

moveDelay = pygame.time.get_ticks() - lastMoveTime

blit_board(bilgeBoard)
blit_score(score)
blit_time(moveDelay / 1000)
draw_selector(selector)

pygame.display.update()
FPS_CLOCK.tick(FPS)

def generate_random_board():
return[ [SHAPES_LIST[random.randrange(0, len(SHAPES_LIST))] for i in range(PUZZLE_COLUMNS)] for x in range(PUZZLE_ROWS) ]

#Time in seconds since last move
def blit_time(time):
font = pygame.font.Font(None, FONT_SIZE)
text = font.render("Move Timer: " + str(time / 60) + ":" + str(time % 60).zfill(2), True, BLACK)
textPosition = text.get_rect()
DISPLAY_SURFACE.blit(text, (TEXT_OFFSET, WINDOW_HEIGHT - (FONT_SIZE * 2)))

def blit_score(score):
font = pygame.font.Font(None, FONT_SIZE)
text = font.render("Score: " + str(score), True, BLACK)
textPosition = text.get_rect()
DISPLAY_SURFACE.blit(text, (TEXT_OFFSET, WINDOW_HEIGHT - FONT_SIZE))

def blit_board(board):
DISPLAY_SURFACE.blit(BACKGROUND, (0, 0))
rowNum = 0
for row in board:
columnNum = 0
for shape in row:
DISPLAY_SURFACE.blit(shape, (SHAPE_WIDTH * columnNum, SHAPE_HEIGHT * rowNum))
columnNum += 1
rowNum += 1

#Accepts a tuple indicating the position of the left shape in the selector relative to the board (as an array) (row, column)
def draw_selector(position):
topLeft = (position[0] * SHAPE_WIDTH, position[1] * SHAPE_HEIGHT)
topRight = (topLeft[0] + SHAPE_WIDTH * 2, topLeft[1])
bottomLeft = (topLeft[0], topLeft[1] + SHAPE_HEIGHT)
bottomRight = (topRight[0], topRight[1] + SHAPE_HEIGHT)
pygame.draw.lines(DISPLAY_SURFACE, WHITE, True, [topLeft, topRight, bottomRight, bottomLeft], 3)

#Accepts a tuple indicating the position of the selector
def swap_pieces(position, board):
x, y = position
board[y][x + 1], board[y][x] = board[y][x], board[y][x + 1]

def remove_matches(board, selector):
matches = find_matches(board)

while matches:
explosion_animation(board, matches)
score_matches(board, selector, matches)
clear_matches(board, matches)
refill_columns(board)
matches = find_matches(board)
selector = (0, 0) #So subsequent matches won't be counted as player matches

def score_matches(board, selector, matches):
global score
playerMatches = []

selector = (selector[1], selector[0])

for match in matches:
for position in match:
if (position == selector or position == (selector[0], selector[1] + 1)) and (not match in playerMatches):
playerMatches.append(match)

if len(playerMatches) == 1:
score += SINGLE_POINTS
elif len(playerMatches) == 2:
score += DOUBLE_POINTS
elif len(playerMatches) == 3:
score += TRIPLE_POINTS

for match in playerMatches:
score += (len(match) - MINIMUM_MATCH) * EXTRA_LENGTH_POINTS

for match in matches:
if not match in playerMatches:
score += RANDOM_POINTS

def find_matches(board):
clearList = []

#First scan the columns for matches
for column in xrange(PUZZLE_COLUMNS):
length = 1
for row in xrange(1, PUZZLE_ROWS):
if board[row][column] == board[row - 1][column]:
length += 1

if not board[row][column] == board[row - 1][column]:
if length >= MINIMUM_MATCH:
match = []
for clearRow in xrange(row - length, row):
match.append((clearRow, column))
clearList.append(match)
length = 1

if row == PUZZLE_ROWS - 1:
if length >= MINIMUM_MATCH:
match = []
for clearRow in xrange(row - (length - 1), row + 1):
match.append((clearRow, column))
clearList.append(match)

#Next scan the rows for matches
for row in xrange(PUZZLE_ROWS):
length = 1
for column in xrange(1, PUZZLE_COLUMNS):
if board[row][column] == board[row][column - 1]:
length += 1

if not board[row][column] == board[row][column - 1]:
if length >= MINIMUM_MATCH:
match = []
for clearColumn in xrange(column - length, column):
match.append((row, clearColumn))
clearList.append(match)
length = 1

if column == PUZZLE_COLUMNS - 1:
if length >= MINIMUM_MATCH:
match = []
for clearColumn in xrange(column - (length - 1), column + 1):
match.append((row, clearColumn))
clearList.append(match)

return clearList

#Accepts list of positions to clear
def clear_matches(board, matches):
for match in matches:
for position in match:
row, column = position
board[row][column] = BLANK

def refill_columns(board):
for column in xrange(PUZZLE_COLUMNS):
for row in xrange(PUZZLE_ROWS):
if board[row][column] == BLANK:
test = 0
length = 0

#Determine how long the clear is
while row + test < PUZZLE_ROWS and board[row + test][column] == BLANK:
length += 1
test += 1

for blankRow in xrange(row, PUZZLE_ROWS):
try:
board[blankRow][column] = board[blankRow + length][column]
except:
board[blankRow][column] = SHAPES_LIST[random.randrange(0, len(SHAPES_LIST))]

def explosion_animation(board, matches):
for frame in EXPLOSION_LIST:
for match in matches:
for position in match:
row, column = position
board[row][column] = frame
blit_board(board)
pygame.display.update()
FPS_CLOCK.tick(FPS)

if __name__ == '__main__':
main()


Here is a zip with the code and assets: http://dl.dropbox.com/u/1541103/game.zip

• Link is broken now... – Malady Oct 22 '18 at 23:07

### 1. Introduction

The code seems pretty good to me overall. I had no difficulty reading it or understanding what it did, and it seems to work well enough. So take all the points below (especially those in section 3) with a pinch of salt. I would have written it differently, but that doesn't mean that every change I have made is an improvement.

### 2. Major points

1. You implement your explosions by writing a little loop that updates the board to put each animation frame in place, and then waits for a frame to pass:

def explosion_animation(board, matches):
for frame in EXPLOSION_LIST:
# ...
blit_board(board)
pygame.display.update()
FPS_CLOCK.tick(FPS)


The problem with this approach is that no other events can happen during this loop: the score can't update, the player can't move the cursor, and nothing else can animate. For this particular game you only have one thing happening at a time, so you can get away with this approach. But what if you wanted to have several different things happening at the same time?

The usual way to handle many objects which all need to move or animate at the same time is to give each object a tick function that is responsible for updating its internal state based on the amount of time that has passed since the last frame. This means, of course, that you will have to re-organize your code so that you record the set of matches, and keep track of time from when the matches occurred so that you can select the correct frame from the explosion animation.

I've put some revised code at the end of this answer showing one way in which you might go about this. Note that a benefit of this approach is that you can decouple the game's framerate from your animation framerate, so that you can change one without having to change the other.

2. The scoring system is both hard to understand (there's no clear relationship between what I do and the score I get) and very harsh. From reading the code, it seems that I lose a point every time I move (and a further half point for every 10 seconds I take) but only gain 0.9 points when I make a match. No wonder I quickly racked up loads of negative points!

The usual way to indicate the relationship between what the player does and the score they get is to draw the score on the board in a position close to the action happened. If there's a bonus, there needs to be some text of the form "BONUS" or "+100" or whatever.

The harshness of the scoring system is really up to you as game designer. But look at it like this: it costs you nothing to hand out points by the millions, and there are people out there who will enjoy the game more if you do.

3. The game never ends! It just carries on forever, with no possibility of winning or losing.

### 3. Minor points

1. Code that maintains some kind of persistent state and manipulates it is often most clearly written in the form of classes and methods. This is particularly so for games, which typically implement some kind of persistent world. It would seem sensible here to have a Game class to represent the global game state, a Board class to represent the playing area, and a Cell class to represent a square on the board.

2. The thing you call a selector is more commonly known as a cursor.

3. The term blit is short for block transfer and means roughly "to copy blocks of pixels from one part of memory to another." Unless you're specifically writing about blitting, I'd use a more general term like draw.

4. If you made selector a (mutable) list rather than an immutable tuple, you'd be able to update its x and y component independently:

selector = [0, 0]
# ...
selector[0] += 1  # Move right

5. You can use the function random.choice to simplify

SHAPES_LIST[random.randrange(0, len(SHAPES_LIST))]


to random.choice(SHAPES_LIST).

6. It's conventional to use _ for a loop variable that you don't actually use:

[[random.choice(SHAPES_LIST) for _ in range(PUZZLE_COLUMNS)] for _ in range(PUZZLE_ROWS)]

7. The code could be simplified if you kept the board in a single list of length width × height (rather than a list of lists). You could replace each pair of nested loops with a single loop, and the matches test would also be simplified.

8. When iterating over the elements of a sequence, if you also want the index of each element, use the function enumerate. So instead of:

rowNum = 0
for row in board:
columnNum = 0
for shape in row:
DISPLAY_SURFACE.blit(shape, (SHAPE_WIDTH * columnNum, SHAPE_HEIGHT * rowNum))
columnNum += 1
rowNum += 1


write:

for j, row in enumerate(board):
for i, shape = enumerate(row):
DISPLAY_SURFACE.blit(shape, (SHAPE_WIDTH * i, SHAPE_HEIGHT * j))

9. The line

selector = (0, 0) #So subsequent matches won't be counted as player matches


seems wrong: if there are subsequent matches at (0,0) then these would be incorrectly counted as player matches. It would be more reliable to add a simple flag instead of a special case:

def remove_matches(board, selector):
matches = find_matches(board)
score_player_matches = True

while matches:
explosion_animation(board, matches)
score_matches(board, selector, matches, score_player_matches)
clear_matches(board, matches)
refill_columns(board)
matches = find_matches(board)
score_player_matches = False

10. You can simplify your code for finding matches by making use of the function itertools.groupby.

11. The title "Bilging Puzzle" is unlikely to mean anything to someone who has not played Yohoho! Puzzle Pirates. (Also, the new shapes pushing in from the bottom make sense in YPP where they represent water coming into the ship from below, but when the shapes are jewels as in Bejeweled it makes more sense for them to enter from the top.)

12. The black text on the dark background is pretty much impossible to read.

SHAPES = 'circle diamond hexagon square star star2 triangle'
for shape in SHAPES.split()]

14. The cursor can be cut off at the top and the sides by the edge of the window. You need to add a bit of margin around the board.

15. You can use Python's string.format method to replace

"Move Timer: " + str(time / 60) + ":" + str(time % 60).zfill(2)


with

"Move Timer: {}:{:02}".format(time // 60, time % 60)

16. You could cache the font instead of recomputing it every time you want to draw some text.

17. The two lines textPosition = text.get_rect() seem to be useless.

18. The player gets no score for a move that makes four matches (which is possible).

19. There are several copies of the drawing code: two at the start of the function main and one at the end of the main loop. With a bit of reorganization you could arrange to have only one copy.

20. You can avoid having to store the previous frame's value of pygame.time.get_ticks and subtract, if you use the return value of Clock.tick method. Note that it's a good idea, when computing the frame time, to clamp the result to 1/FPS, because you can get long frames from time to time, and you don't want these long frames to lead to wild movement. (Long frames can occur when loading assets, as in the first frame of the game here, or when another process on the system happens to hog the CPU.)

### 4. Revised code

Here's a revision of your code that implements most of the suggestions above, and includes some bonus improvements for you to discover and reverse engineer.

import pygame, random, time, sys
from pygame.locals import *
import itertools
import os

WHITE = (255, 255, 255)
BLACK = (0, 0, 0)

SHAPE_WIDTH = 50                # Width of each shape (pixels).
SHAPE_HEIGHT = 50               # Height of each shape (pixels).
PUZZLE_COLUMNS = 6              # Number of columns on the board.
PUZZLE_ROWS = 12                # Number of rows on the board.
MARGIN = 2                      # Margin around the board (pixels).
WINDOW_WIDTH = PUZZLE_COLUMNS * SHAPE_WIDTH + 2 * MARGIN
WINDOW_HEIGHT = PUZZLE_ROWS * SHAPE_HEIGHT + 2 * MARGIN + 75
FONT_SIZE = 36
TEXT_OFFSET = MARGIN + 5

# Map from number of matches to points scored.
SCORE_TABLE = {0: 0, 1: .9, 2: 3, 3: 9, 4: 27}
MINIMUM_MATCH = 3
EXTRA_LENGTH_POINTS = .1
RANDOM_POINTS = .3
DELAY_PENALTY_SECONDS = 10
DELAY_PENALTY_POINTS = .5

FPS = 30
EXPLOSION_SPEED = 15            # In frames per second.
REFILL_SPEED = 10               # In cells per second.

class Cell(object):
"""
A cell on the board, with properties:
image -- a Surface object containing the sprite to draw here.
offset -- vertical offset in pixels for drawing this cell.
"""
def __init__(self, image):
self.offset = 0.0
self.image = image

def tick(self, dt):
self.offset = max(0.0, self.offset - dt * REFILL_SPEED)

class Board(object):
"""
A rectangular board of cells, with properties:
w -- width in cells.
h -- height in cells.
size -- total number of cells.
board -- list of cells.
matches -- list of matches, each being a list of exploding cells.
refill -- list of cells that are moving up to refill the board.
score -- score due to chain reactions.
"""
def __init__(self, width, height):
for i in range(1, 7)]
shapes = 'circle diamond hexagon square star star2 triangle'
for shape in shapes.split()]
self.w = width
self.h = height
self.size = width * height
self.board = [Cell(self.blank) for _ in range(self.size)]
self.matches = []
self.refill = []
self.score = 0.0

def randomize(self):
"""
Replace the entire board with fresh shapes.
"""
for i in range(self.size):
self.board[i] = Cell(random.choice(self.shapes))

def pos(self, i, j):
"""
Return the index of the cell at position (i, j).
"""
assert(0 <= i < self.w)
assert(0 <= j < self.h)
return j * self.w + i

def busy(self):
"""
Return True if the board is busy animating an explosion or a
refill and so no further swaps should be permitted.
"""
return self.refill or self.matches

def tick(self, dt):
"""
Advance the board by dt seconds: move rising blocks (if
any); otherwise animate explosions for the matches (if any);
otherwise check for matches.
"""
if self.refill:
for c in self.refill:
c.tick(dt)
self.refill = [c for c in self.refill if c.offset > 0]
if self.refill:
return
elif self.matches:
self.explosion_time += dt
f = int(self.explosion_time * EXPLOSION_SPEED)
if f < len(self.explosion):
self.update_matches(self.explosion[f])
return
self.update_matches(self.blank)
self.refill = list(self.refill_columns())
self.explosion_time = 0
self.matches = self.find_matches()
self.score += len(self.matches) * RANDOM_POINTS

def draw(self, display):
"""
Draw the board on the pygame surface display.
"""
display.blit(self.background, (0, 0))
for i, c in enumerate(self.board):
display.blit(c.image,
(MARGIN + SHAPE_WIDTH * (i % self.w),
MARGIN + SHAPE_HEIGHT * (i // self.w - c.offset)))

def swap(self, cursor):
"""
Swap the two board cells covered by cursor and update the
matches.
"""
i = self.pos(*cursor)
b = self.board
b[i], b[i+1] = b[i+1], b[i]
self.matches = self.find_matches()

def find_matches(self):
"""
Search for matches (lines of cells with identical images) and
return a list of them, each match being represented as a list
of board positions.
"""
def lines():
for j in range(self.h):
yield range(j * self.w, (j + 1) * self.w)
for i in range(self.w):
yield range(i, self.size, self.w)
def key(i):
return self.board[i].image
def matches():
for line in lines():
for _, group in itertools.groupby(line, key):
match = list(group)
if len(match) >= MINIMUM_MATCH:
yield match
return list(matches())

def update_matches(self, image):
"""
Replace all the cells in any of the matches with image.
"""
for match in self.matches:
for position in match:
self.board[position].image = image

def refill_columns(self):
"""
Move cells downwards in columns to fill blank cells, and
create new cells as necessary so that each column is full. Set
appropriate offsets for the cells to animate into place.
"""
for i in range(self.w):
target = self.size - i - 1
for pos in range(target, -1, -self.w):
if self.board[pos].image != self.blank:
c = self.board[target]
c.image = self.board[pos].image
c.offset = (target - pos) // self.w
target -= self.w
yield c
offset = 1 + (target - pos) // self.w
for pos in range(target, -1, -self.w):
c = self.board[pos]
c.image = random.choice(self.shapes)
c.offset = offset
yield c

class Game(object):
"""
The state of the game, with properties:
clock -- the pygame clock.
display -- the window to draw into.
font -- a font for drawing the score.
board -- the board of cells.
cursor -- the current position of the (left half of) the cursor.
score -- the player's score.
last_swap_ticks --
swap_time -- time since last swap (in seconds).
"""
def __init__(self):
pygame.init()
pygame.display.set_caption("Bejewelled Clone")
self.clock = pygame.time.Clock()
self.display = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT),
DOUBLEBUF)
self.board = Board(PUZZLE_COLUMNS, PUZZLE_ROWS)
self.font = pygame.font.Font(None, FONT_SIZE)

def start(self):
"""
Start a new game with a random board.
"""
self.board.randomize()
self.cursor = [0, 0]
self.score = 0.0
self.swap_time = 0.0

def quit(self):
"""
Quit the game and exit the program.
"""
pygame.quit()
sys.exit()

def play(self):
"""
Play a game: repeatedly tick, draw and respond to input until
"""
self.start()
while True:
self.draw()
dt = min(self.clock.tick(FPS) / 1000.0, 1.0 / FPS)
self.swap_time += dt
for event in pygame.event.get():
if event.type == KEYUP:
self.input(event.key)
elif event.type == QUIT:
self.quit()
self.board.tick(dt)

def input(self, key):
"""
Respond to the player pressing key.
"""
if key == K_q:
self.quit()
elif key == K_RIGHT and self.cursor[0] < self.board.w - 2:
self.cursor[0] += 1
elif key == K_LEFT and self.cursor[0] > 0:
self.cursor[0] -= 1
elif key == K_DOWN and self.cursor[1] < self.board.h - 1:
self.cursor[1] += 1
elif key == K_UP and self.cursor[1] > 0:
self.cursor[1] -= 1
elif key == K_SPACE and not self.board.busy():
self.swap()

def swap(self):
"""
Swap the two cells under the cursor and update the player's score.
"""
swap_penalties = int(self.swap_time / DELAY_PENALTY_SECONDS)
self.swap_time = 0.0
self.board.swap(self.cursor)
self.score -= 1 + DELAY_PENALTY_POINTS * swap_penalties
self.score += SCORE_TABLE[len(self.board.matches)]
for match in self.board.matches:
self.score += (len(match) - MINIMUM_MATCH) * EXTRA_LENGTH_POINTS

def draw(self):
self.board.draw(self.display)
self.draw_score()
self.draw_time()
self.draw_cursor()
pygame.display.update()

def draw_time(self):
s = int(self.swap_time)
text = self.font.render('Move Timer: {}:{:02}'.format(s / 60, s % 60),
True, WHITE)
self.display.blit(text, (TEXT_OFFSET, WINDOW_HEIGHT - (FONT_SIZE * 2)))

def draw_score(self):
total_score = self.score + self.board.score
text = self.font.render('Score: {}'.format(total_score), True, WHITE)
self.display.blit(text, (TEXT_OFFSET, WINDOW_HEIGHT - FONT_SIZE))

def draw_cursor(self):
topLeft = (MARGIN + self.cursor[0] * SHAPE_WIDTH,
MARGIN + self.cursor[1] * SHAPE_HEIGHT)
topRight = (topLeft[0] + SHAPE_WIDTH * 2, topLeft[1])
bottomLeft = (topLeft[0], topLeft[1] + SHAPE_HEIGHT)
bottomRight = (topRight[0], topRight[1] + SHAPE_HEIGHT)
pygame.draw.lines(self.display, WHITE, True,
[topLeft, topRight, bottomRight, bottomLeft], 3)

if __name__ == '__main__':
Game().play()

• Dude, this is an awesome review. You really, really have a triple gift: an extraordinarily pleasant prose, clarity of thought and, of course, programming skills! It is amazing that you took the time to do this, I am profoundly impressed! – Robottinosino Sep 25 '12 at 1:20
• Thanks a lot for the very thorough review. I will make sure to study this code in depth, thanks again :) – cactusbin Sep 25 '12 at 22:29
• Splendid review, unbiased, honest and thorough. Well done! – oodavid Dec 12 '13 at 14:23
• How do you make it match vertical? – johnny Feb 7 '15 at 20:22
• @johnny: That might make a good question for Stack Overflow. – Gareth Rees Feb 9 '15 at 9:58