The concept of Pac-Man is quite simple. Pac-Man eats dots in a maze to score points. Avoid the ghosts unless you have just eaten a power-up, in which case ghosts are tasty. In this series we have gradually introduced new elements of Pygame Zero and also concepts around writing games. This is the first instalment in a two-part tutorial which will show you some more tricks to writing arcade games with Pygame Zero. We will also use some more advanced programming concepts to make our games even better. In this first part, we will put together the basics of the Pac-Man game and introduce the concept of adding extra Python modules to our program.
See also:
- Pygame Zero Invaders
- Pygame Zero: Space Invaders II
- Rediscover Retro Computing in The MagPi #67
- Easy retro gaming on a Raspberry Pi with Lakka and NOOBS
- The best Python websites and resources
- Learn Python with a Raspberry Pi
This article was written by Mark Vanstone and first appeared in The MagPi magazine issue #76. Sign up to our newsletter to get a free digital PDF of The MagPi every month, or click here to subscribe to our print magazine.
Code your own Pac-Man in Python
As with the more recent episodes of this series, let’s jump straight in, assuming that we have our basic Pygame Zero setup done. Let’s set our window size to WIDTH = 600 and HEIGHT = 660. This will give us room for a roughly square maze and a header area for some game information. We can get our gameplay area set up straight away by blitting two graphics – ‘header’ and ‘colourmap’ – to 0,0 and 0,80 respectively in the draw() function. You can make these graphics yourself or you can use ours, which can be found on this GitHub repository.
Pac-Man is a-mazing
The original game had a very specific layout to the maze, but many different ones have appeared in later versions. The one we will be using is very similar to the original, but you can make your own design if you want. If you make your own, you’ll also have to make two more maps (we’ll come to those in a bit) which help with the running of the game. The main things about the map is that it has a central area where the ghosts start from and it doesn’t have any other closed-in areas that the ghosts are likely to get trapped in (they can be a bit stupid sometimes).
Hmmm, pizza
Our next challenge is to get a player actor moving around the maze. For some unknown reason, the game’s creator, Toru Iwatani, decided to make the main character a pizza that ate dots. Well, the eighties were a bit strange and that seemed perfectly reasonable at the time. We’ll need two frames for our character: one with the mouth open and one with it closed. We can create our player actor near the top of the code using player = Actor("pacman_o"). This will create the actor with the mouth-open graphic. We will then set the actor’s location in an init() function, as in previous programs.
Modulify to simplify
We can get our player onto the play area by setting player.x = 290 and player.y = 570 in the init() function and then call player.draw() in the draw() function, but to move the player character we’ll need to get some input from the player. Previously we have used keyboard and mouse input, but this time we are going to have the option of joystick or gamepad input. Pygame Zero doesn’t currently directly support gamepads, but we are going to borrow a bit of the Pygame module to get this working. We are also going to make a separate Python module for our input.
It’s a joystick.init
Setting up a new module is easy. All we need to do is make a new file, in this case gameinput.py, and in our main program at the top, write import gameinput. In this new file we can import the Pygame functions we need with from pygame import joystick, key and from pygame.locals import *. We can then initialise the Pygame joystick object (this also includes gamepads) by typing joystick.init(). We can find out how many joysticks or gamepads are connected by using joystickcount = joystick.getcount(). If we find any joysticks connected, we need to initialise them individually – see figure1.py.
# gameinput Module from pygame import joystick, key from pygame.locals import * joystick.init() joystick_count = joystick.get_count() if(joystick_count > 0): joyin = joystick.Joystick(0) joyin.init() # For the purposes of this tutorial # we are only going to use the first # joystick that is connected.
Checking the input
We can now write a function in our gameinput module to check input from the player. If we define the function with def checkInput(p): we can get the x axis of a joystick using joyin.getaxis(0) and the y axis by using joyin.getaxis(1). The numbers that are returned from these calls will be between -1 and +1, with 0 being the central position. We can check to see if the values are over 0.8 or under -0.8, as, depending on the device, we may not actually see -1 or 1 being returned. You may like to test this with your gamepad or joystick to see what range of values are returned.
Up, down, left, or right
The variable p that we are passing into our checkInput() function will be the player actor. We can test each of the directions of the joystick at the same time as the keyboard and then set the player angle (so that it points in the correct direction for movement) and also how much it needs to move. We’ll set these by saying (for example, if the left arrow is pressed or the joystick is moved to the left) if key.getpressed()[KLEFT] or xaxis < -0.8: and then p.angle = 180 and p.movex = -20. See figure2.py for the full checkInput() function.
def checkInput(p): global joyin, joystick_count xaxis = yaxis = 0 if joystick_count > 0: xaxis = joyin.get_axis(0) yaxis = joyin.get_axis(1) if key.get_pressed()[K_LEFT] or xaxis < -0.8: p.angle = 180 p.movex = -20 if key.get_pressed()[K_RIGHT] or xaxis > 0.8: p.angle = 0 p.movex = 20 if key.get_pressed()[K_UP] or yaxis < -0.8: p.angle = 90 p.movey = -20 if key.get_pressed()[K_DOWN] or yaxis > 0.8: p.angle = 270 p.movey = 20
Get a move on!
Now we have our input function set up, we can call it from the update() function. Because this function is in a different module, we need to prefix it with the module name. In the update() function we write gameinput.checkInput(player). After this function has been called, if there has been any input, we should have some variables set in the player actor that we can use to move. We can say if player.movex or player.movey: and then use the animate() function to move by the amount specified in player.movex and player.movey.
Hold your horses
The way we have the code at the moment means that any time there is some input, we fire off a new animation. This will soon mean that layers of animation get called over the top of each other, but what we want is for the animation to run and then start looking for new input. To do this we need an input locking system. We can call an input lock function before the move and then wait for the animation to finish before unlocking to look for more input. Look at figure3.py to see how we can make this locking system.
# inside update() function if player.movex or player.movey: inputLock() animate(player, pos=(player.x + player.movex, player.y + player.movey), duration=1/SPEED, tween='linear', on_finished=inputUnLock) # outside update() function def inputLock(): global player player.inputActive = False def inputUnLock(): global player player.movex = player.movey = 0 player.inputActive = True
You can’t just move anywhere
Now, here comes the interesting bit. We want our player actor to move around the maze, but at the moment it will go though the walls and even off the screen. We need to restrict the movement only to the corridors of the maze. There are several different ways we could do this, but for this game we’re going to have an image map marking the areas that the player actor can move within. The map will be a black and white one, showing just the corridors as black and the walls as white. We will then look at the map in the direction we want to move and see if it is black; if it is, we can move.Testing the map
To be able to test the colour of a part of an image, we need to borrow a few functions from Pygame again. We’ll also put our map functions in a separate module. So make a new Python file and call it gamemaps.py and in it we’ll write from pygame import image, Color.# gamemaps module from pygame import image, Color moveimage = image.load('images/pacmanmovemap.png') dotimage = image.load('images/pacmandotmap.png') def checkMovePoint(p): global moveimage if p.x+p.movex < 0: p.x = p.x+600 if p.x+p.movex > 600: p.x = p.x-600 if moveimage.get_at((int(p.x+p.movex), int(p.y+p.movey-80))) != Color('black'): p.movex = p.movey = 0 def checkDotPoint(x,y): global dotimage if dotimage.get_at((int(x), int(y))) == Color('black'): return True return False def getPossibleDirection(g): global moveimage if g.x-20 < 0: g.x = g.x+600 if g.x+20 > 600: g.x = g.x-600 directions = [0,0,0,0] if g.x+20 < 600: if moveimage.get_at((int(g.x+20), int(g.y-80))) == Color('black'): directions[0] = 1 if g.x < 600 and g.x >= 0: if moveimage.get_at((int(g.x), int(g.y-60))) == Color('black'): directions[1] = 1 if g.x-20 >= 0: if moveimage.get_at((int(g.x-20), int(g.y-80))) == Color('black'): directions[2] = 1 if g.x < 600 and g.x >= 0: if moveimage.get_at((int(g.x), int(g.y-100))) == Color('black'): directions[3] = 1 return directionsWe must also load in our movement map, which we need to do in the Pygame way: moveimage = image.load('images/pacmanmovemap.png'). Then all we need to do is write a function to check that the direction of the player is valid. See figure4.py for this function.
# gamemaps module from pygame import image, Color moveimage = image.load('images/pacmanmovemap.png') def checkMovePoint(p): global moveimage if p.x+p.movex < 0: p.x = p.x+600 if p.x+p.movex > 600: p.x = p.x-600 if moveimage.get_at((int(p.x+p.movex), int(p.y+p.movey-80))) != Color('black'): p.movex = p.movey = 0
Using the movemap
To use this new module, we need to import gamemaps at the top of our main code file and then, before we animate the player (but after we have checked for input), we can call gamemaps.checkMovePoint(player),which will zero the movex and movey variables of the player if the move is not possible. So now we should find that the player actor can only move inside the corridors. We do have one special case that you may have noticed in figure4.py, and that is because there is one corridor where the player can move from one side of the screen to the other.You spin me round
There is one more aspect to the movement of the player actor, and that is the animation. As Pac-Man moves, the mouth opens and shuts and points in the direction of the movement. The mouth opening and closing is easy enough: we have an image for open and one for closed and alternate between the two. For pointing in the correct direction, we can rotate the player actor. Unfortunately, this has a slight problem that Pac‑Man will be upside-down when moving left. So we just need to have one version that is switched the other way round. See figure5.py for a function that sorts out all of this.def getPlayerImage(): global player # we need to import datetime at the top of our code dt = datetime.now() a = player.angle # this next line will give us a number between # 0 and 5 depending on the time and SPEED tc = dt.microsecond%(500000/SPEED)/(100000/SPEED) if tc > 2.5 and (player.movex != 0 or player.movey !=0): # this is for the closed mouth images if a != 180: player.image = "pacman_c" else: # reverse image if facing left player.image = "pacman_cr" else: # this is for the open mouth images if a != 180: player.image = "pacman_o" else: player.image = "pacman_or" # set the angle on the player actor player.angle = a
Spot on
So when we have put in a call to getPlayerImage() just before we draw the player actor, we should have Pac-Man moving around, chomping and pointing in the correct direction. Now we need something to chomp. We are going to create a set of dots at even spacings along most of the corridors. An easy way to do this is to use a similar technique that we’re using for testing where the corridors are. If we make an image map of the places the dots need to go and loop over the whole map, only placing dots where it is black, we can get the desired effect.Tasty, tasty dots
To get our dots doing their thing, we’ll need to code a few things. We need to initialise actors for each dot, we need to draw each dot, and if the player eats the dot, we need to stop drawing it; figure6.py shows how we can do each of these jobs. We need initDots(), we need to add another function to gamemaps.py to work out where to position the dots, and we need to add some drawing code to the draw() function. In addition to the code in figure6.py, we need to add a call to initDots() in our init() function.# This goes in the main code file. def initDots(): global pacDots pacDots = [] a = x = 0 while x < 30: y = 0 while y < 29: if gamemaps.checkDotPoint(10+x*20, 10+y*20): pacDots.append(Actor("dot",(10+x*20, 90+y*20))) pacDots[a].status = 0 a += 1 y += 1 x += 1 # This goes in the gamemaps module file. dotimage = image.load('images/pacmandotmap.png') def checkDotPoint(x,y): global dotimage if dotimage.get_at((int(x), int(y))) == Color('black'): return True return False # This bit goes in the draw() function. pacDotsLeft = 0 for a in range(len(pacDots)): if pacDots[a].status == 0: pacDots[a].draw() pacDotsLeft += 1 if pacDots[a].collidepoint((player.x, player.y)): pacDots[a].status = 1 # if there are no dots left, the player has won if pacDotsLeft == 0: player.status = 2
I ain’t afraid of no ghosts
Now that we have our Pac-Man happily munching dots, we must introduce our villains to the mix. In the original game, the ghosts had names; in the English version they were known as Blinky, Pinky, Inky, and Clyde. They roam the maze looking for Pac-Man, starting from an enclosure in the centre of the map. We can initialise each ghost as an actor to appear at the centre of the maze and keep them in a list called ghosts[]. To start off with, we’ll just make them move around randomly. The way we can do this is to set a random direction (ghosts[g].dir) for each and then keep them moving until they hit a wall.
Random motion
We can use the same system that we used to check player movement for the ghosts. Each time we move a ghost – moveGhosts() – we can get a list of which directions are available to it. If the current direction (ghosts[g].dir) is not available, then we randomly pick another direction until we find one that we can move in. We can also have a random occurrence of changing direction, just to make it a bit less predictable – and if the ghosts collide with each other, we could do the same. When we have moved the ghosts with the animate() function, we get it to count how many ghosts have finished moving. When they are all done, we can call the moveGhosts() function again.
Look like a ghost
The last thing to do with our ghosts is to actually draw them to the screen. We can create a function called drawGhosts() where we loop through the four ghosts and draw them to the screen. One of the details of the original game was that the eyes of the ghosts would follow the player; we can do this by setting the ghost image to reverse if the player is to the left of the ghost. We have numbered images so that ghost one is ghost1.png and ghost two is ghost2.png, etc. Have a look at the full pacman1.py program listing to see all the functions that make the ghosts work.
Game over
Of course, we need to deal with the end-of-the-game conditions and, as before, we can use a status variable. In this case we have previously set player.status = 2 if the player wins. We can check to see if a ghost collides with the player and set player.status = 1. Then we just need to display some text in the draw() function based on this variable. And that’s it for part one. In the next part we’ll be giving the ghosts more brains, adding levels, lives, and power-ups – and adding some sweet, soothing music and sound effects.
import pgzrun import gameinput import gamemaps from random import randint from datetime import datetime WIDTH = 600 HEIGHT = 660 player = Actor("pacman_o") # Load in the player Actor image SPEED = 3 def draw(): # Pygame Zero draw function global pacDots, player screen.blit('header', (0, 0)) screen.blit('colourmap', (0, 80)) pacDotsLeft = 0 for a in range(len(pacDots)): if pacDots[a].status == 0: pacDots[a].draw() pacDotsLeft += 1 if pacDots[a].collidepoint((player.x, player.y)): pacDots[a].status = 1 if pacDotsLeft == 0: player.status = 2 drawGhosts() getPlayerImage() player.draw() if player.status == 1: screen.draw.text("GAME OVER" , center=(300, 434), owidth=0.5, ocolor=(255,255,255), color=(255,64,0) , fontsize=40) if player.status == 2: screen.draw.text("YOU WIN!" , center=(300, 434), owidth=0.5, ocolor=(255,255,255), color=(255,64,0) , fontsize=40) def update(): # Pygame Zero update function global player, moveGhostsFlag, ghosts if player.status == 0: if moveGhostsFlag == 4: moveGhosts() for g in range(len(ghosts)): if ghosts[g].collidepoint((player.x, player.y)): player.status = 1 pass if player.inputActive: gameinput.checkInput(player) gamemaps.checkMovePoint(player) if player.movex or player.movey: inputLock() animate(player, pos=(player.x + player.movex, player.y + player.movey), duration=1/SPEED, tween='linear', on_finished=inputUnLock) def init(): global player initDots() initGhosts() player.x = 290 player.y = 570 player.status = 0 inputUnLock() def getPlayerImage(): global player dt = datetime.now() a = player.angle tc = dt.microsecond%(500000/SPEED)/(100000/SPEED) if tc > 2.5 and (player.movex != 0 or player.movey !=0): if a != 180: player.image = "pacman_c" else: player.image = "pacman_cr" else: if a != 180: player.image = "pacman_o" else: player.image = "pacman_or" player.angle = a def drawGhosts(): for g in range(len(ghosts)): if ghosts[g].x > player.x: ghosts[g].image = "ghost"+str(g+1)+"r" else: ghosts[g].image = "ghost"+str(g+1) ghosts[g].draw() def moveGhosts(): global moveGhostsFlag dmoves = [(1,0),(0,1),(-1,0),(0,-1)] moveGhostsFlag = 0 for g in range(len(ghosts)): dirs = gamemaps.getPossibleDirection(ghosts[g]) if ghostCollided(ghosts[g],g) and randint(0,3) == 0: ghosts[g].dir = 3 if dirs[ghosts[g].dir] == 0 or randint(0,50) == 0: d = -1 while d == -1: rd = randint(0,3) if dirs[rd] == 1: d = rd ghosts[g].dir = d animate(ghosts[g], pos=(ghosts[g].x + dmoves[ghosts[g].dir][0]*20, ghosts[g].y + dmoves[ghosts[g].dir][1]*20), duration=1/SPEED, tween='linear', on_finished=flagMoveGhosts) def flagMoveGhosts(): global moveGhostsFlag moveGhostsFlag += 1 def ghostCollided(ga,gn): for g in range(len(ghosts)): if ghosts[g].colliderect(ga) and g != gn: return True return False def initDots(): global pacDots pacDots = [] a = x = 0 while x < 30: y = 0 while y < 29: if gamemaps.checkDotPoint(10+x*20, 10+y*20): pacDots.append(Actor("dot",(10+x*20, 90+y*20))) pacDots[a].status = 0 a += 1 y += 1 x += 1 def initGhosts(): global ghosts, moveGhostsFlag moveGhostsFlag = 4 ghosts = [] g = 0 while g < 4: ghosts.append(Actor("ghost"+str(g+1) ,(270+(g*20), 370))) ghosts[g].dir = randint(0, 3) g += 1 def inputLock(): global player player.inputActive = False def inputUnLock(): global player player.movex = player.movey = 0 player.inputActive = True init() pgzrun.go()