Muncher
These instructions will take you through the steps of creating a game that uses the 1980 game Pac-Man as its inspiration. In this version, the ghosts are not constrained by walls so can float through them.
You will control Muncher, moving him around the screen to collect all the pellets whilst avoiding being caught by the ghosts.
Eat a power pellet to super charge Muncher and allow him to eat the ghosts.
When fruit appears, munch them for a big bonus.
Get a new life for each 10,000 points scored.
Learning points
These instructions will take you through the process of creating a game which uses the fundamental PyGame Zero classes with fundamental Python code concepts including:
- variables
- lists
- functions
- if/else conditions
- loops
Some techniques are introduced that demonstrate introspection of Python objects as well as how to attach additional functionality to existing Actors.
These instructions are suitable for you if you are comfortable with basic Python coding.
Step 0: Create the project in Replit
Navigate to Replit and login.
Create a new project using the PyGame template and give it the title "Muncher" as illustrated by the screenshot below.
In the main.py
file, replace the code provided with the code below and run the
program to make sure it can download the packages and run. You should be presented
with a black screen with a red zero for the score at the top of the screen.
After clearing the screen, the draw()
function loops over the list draw_funcs
.
In later steps we will be adding functions to this list to draw the elements on
the screen. The advantage of this technique is that the draw()
function will not need
to modified in those later steps. We do a similar technique for the update()
function.
import time
import pgzrun
from pgzero.clock import Clock
from pgzero.keyboard import Keyboard
from pgzero.screen import Screen
WIDTH = 600
HEIGHT = 700
screen: Screen
keyboard: Keyboard
clock: Clock
score = 0
lives = 3
paused = False
draw_funcs = []
def draw():
screen.clear()
screen.draw.text(f"{score}", (WIDTH / 2, 15), color="red", fontsize=24)
for draw_func in draw_funcs:
draw_func()
update_funcs = []
def update(dt):
if paused:
return
for update_func in update_funcs:
update_func(dt)
pgzrun.go()
Step 1: Introducing Muncher
The completed code for this step is available here.
Our protagonist is called Muncher and is a yellow round ball that likes to eat little yellow pellets. Muncher will be controlled by the player and can be moved around the screen using the cursor keys. Muncher will be 32 x 32 pixels in size and will be animated. Therefore you will need at least 2 images for Muncher. You can create your own images using a paint program that supports transparency (all the images provided here were created using PixilArt) or you can use my images that are provided below.
The two images should be called muncher.png
and muncher2.png
. Create a images
folder in
your project and place your files in there as illustrated in the screenshot below.
muncher.png
muncher2.png
Then add the following code before call to pgzrun.go()
which will setup and draw Muncher:
MUNCHER_START_X = WIDTH / 2
MUNCHER_START_Y = (HEIGHT / 4) * 3
muncher = Actor('muncher', (MUNCHER_START_X, MUNCHER_START_Y))
muncher.images = ['muncher', 'muncher2']
muncher.vx = 150
muncher.vy = 150
draw_funcs.append(muncher.draw)
To add movement for Muncher, add a move_muncher()
function before call to pgzrun.go()
. The
move_muncher()
function will move Muncher up, down, left or right at the correct speed (using
Munchers horizontal and vertical velocity properties in pixels per second multiplied by the
proportion of seconds that have passed since the last update):
def move_muncher(dt):
if keyboard.left:
muncher.x -= muncher.vx * dt
if keyboard.right:
muncher.x += muncher.vx * dt
if keyboard.up:
muncher.y -= muncher.vy * dt
if keyboard.down:
muncher.y += muncher.vy * dt
update_funcs.append(move_muncher)
Try out your game. You should be able to move Muncher around the screen.
Experiment: Changing the speed of Muncher
The speed of Muncher is controlled by the vx
and vy
properties that represent the horizontal
and vertical speed that Muncher can move in pixels per second. These are currently set to 150
each in your code. Try experimenting with different values to see what effect this has. Example
values to try are:
- 300
- 50
- 0
- -150
Step 2: Keeping Actors within the screen
The completed code for this step is available here.
What happens when Muncher gets close to the edges of the screen? Does he stop or keep going so he goes outside the bounds of the screen?
During the game, we want Muncher to stay within the play area. We are therefore going to need to
to set some boundaries that we want to keep all Actors (ghosts and Muncher) within. This boundary
will be set to a border of 50 pixels. We will add a keep_in_bounds()
method to the Actor
class which we can call in each update()
call. Add the following code before the call to
pgzrun.go()
:
BOUNDS_X1 = 50
BOUNDS_Y1 = 50
BOUNDS_X2 = WIDTH - BOUNDS_X1
BOUNDS_Y2 = HEIGHT - BOUNDS_Y1
def keep_in_bounds(actor, dt):
if actor.x < BOUNDS_X1:
actor.x = BOUNDS_X1
elif actor.x > BOUNDS_X2:
actor.x = BOUNDS_X2
if actor.y < BOUNDS_Y1:
actor.y = BOUNDS_Y1
elif actor.y > BOUNDS_Y2:
actor.y = BOUNDS_Y2
Actor.keep_in_bounds = keep_in_bounds
update_funcs.append(muncher.keep_in_bounds)
Try out your game. You should be able to move Muncher around the screen but cannot go outside the bounds.
Experiment: Changing the bounds
The variables BOUNDS_X1
and BOUNDS_Y1
set the width of the border around the screen.
Try using different values and see how it changes the playing area. Example values to try are:
- 0
- 250
- -50
Explanation: Why is Muncher faster when moving diagonally?
Muncher has two properties that are used to determine his velocity: vx
and vy
. These represent the
horizontal and vertical velocity that Muncher moves. If moving just left or right, the speed of
Muncher is the value of the vx
parameter, in this case 150 pixels per second. If moving just up or
down, the speed of Muncher is the value of the vx
parameter, again 150 pixels per second. However,
if Muncher moves diagonally, he Moves 150 pixels left or right AND 150 pixels up or down. This is
a right angled triangle and pythagoras theorem can be used to determine the distance actually moved
which is about 212 pixels. A move consistent movement speed could be implemented by testing whether
Muncher is moving diagonally or not but that would complicate the move calculation code. Besides,
having a slightly faster diagonal move speed adds an additional strategy to the game and is a fun
side effect.
Step 3: Animating Actors
The completed code for this step is available here.
We want all of our Actors to be animated. At present, as Muncher runs around the screen he looks a
little boring. In the code that creates the Muncher Actor
, we set an images
property that
contained the two images that we are going to use to animate Muncher.
Before the call to pgzrun.go()
, add the following code which adds an animate()
method to the
Actor
class. This method first checks for the presence of some properties on the Actor
instance
and if not present, provides some default values.
def animate(actor, draw):
if not hasattr(actor, "images"):
return
if not hasattr(actor, "fps"):
actor.fps = 5
if not hasattr(actor, "next_frame"):
actor.next_frame = time.time_ns()
if not hasattr(actor, "frame"):
actor.frame = 0
now = time.time_ns()
if now > actor.next_frame:
actor.frame = (actor.frame + 1) % len(actor.images)
actor.image = actor.images[actor.frame]
while actor.next_frame < now:
actor.next_frame += (1_000_000_000 / actor.fps)
Actor.animate = animate
draw_funcs.append(muncher.animate)
Extension: More complex animations
If you want to make more elaborate or complex animations, you can add more images to
the images
property of an Actor. For example, if you have an animation that spans
4 frames, you can change the images
property of Muncher to read as follows:
muncher.images = ['muncher', 'muncher2', 'muncher3', 'muncher4']
Experiment: Animation speed
You can also adjust the speed with which Muncher is animated by providing a value
for the fps
property on Muncher. By default, this will be set to 5 fps (Frames
Per Second). Try experimenting with different values by adding the following code
immediately after you set the vx
and vy
properties of Muncher:
muncher.fps = 15
Try experimenting with different values. Examples to try are:
- 1
- 2
- 15
Step 4: Drawing lives
The completed code for this step is available here.
In this game, Muncher will start with 3 lives. We should add an indicator to the top of
the screen to show how many lives Muncher has. Place the following code before the
call to pgzrun.go()
:
def draw_lives():
for i in range(lives):
screen.blit('muncher', (5 + (37 * i), 5))
draw_funcs.append(draw_lives)
Step 5: Introducing the ghosts
The completed code for this step is available here.
Muncher is antagonised by four ghosts, Blue, Orange, Red and Pink. Each of the ghosts will be animated so will require at least 2 images. Just like Muncher, all of the ghost images should be 32 x 32 pixels in size. You can create your own images using a paint program that supports transparency (all the images provided here were created using PixilArt) or you can use my images that are provided below.
The images for each ghost should be placed in the images
folder you already created and be called:
ghost-blue.png
ghost-blue2.png
ghost-orange.png
ghost-orange2.png
ghost-red.png
ghost-red2.png
ghost-pink.png
ghost-pink2.png
Place the following code before the call to pgzrun.go()
which will create the ghosts
and give each one an individual speed and direction to start with but all will have
the same starting position:
GHOST_START_X = WIDTH / 2
GHOST_START_Y = (HEIGHT / 3)
GHOST_START = (GHOST_START_X, GHOST_START_Y)
blue = Actor('ghost-blue', GHOST_START)
blue.images = ['ghost-blue', 'ghost-blue2']
blue.vx = -80
blue.vy = -80
orange = Actor('ghost-orange', GHOST_START)
orange.images = ['ghost-orange', 'ghost-orange2']
orange.vx = 260
orange.vy = 60
red = Actor('ghost-red', GHOST_START)
red.images = ['ghost-red', 'ghost-red2']
red.vx = 40
red.vy = 280
pink = Actor('ghost-pink', GHOST_START)
pink.images = ['ghost-pink', 'ghost-pink2']
pink.vx = 60
pink.vy = 60
ghosts = [blue, orange, red, pink]
def draw_ghosts():
for ghost in ghosts:
ghost.animate()
ghost.draw()
draw_funcs.append(draw_ghosts)
Run your program and see what happens? do the ghosts move? Why do you think this is?
Extension: More complex ghost animations
Just as you can for Muncher, you can make more elaborate or complex animations for the
ghosts through using more animation frames by changing the images
property of an Actor.
For example, if you have an animation that spans 5 frames that you want to use for the
blue ghost, you can change the images
property of blue to read as follows:
blue.images = ['ghost-blue', 'ghost-blue2', 'ghost-blue3', 'ghost-blue4', 'ghost-blue5']
Step 6: Moving the ghosts
The completed code for this step is available here.
It looks like there is only a single ghost on the screen because they are all on top
of each other and there is no code that does movement for the ghosts yet. The technique
used for moving the ghosts is exactly the same as that for Muncher. We modify the ghosts
position by its vx
and vy
properties based on the elapsed time since the ghost
was last moved.
Place the following code before the call to pgzrun.go()
to call the move()
method for
each of the ghosts in turn.
def ghost_move(ghost, dt):
ghost.x += ghost.vx * dt
ghost.y += ghost.vy * dt
for ghost in ghosts:
import types
ghost.move = types.MethodType(ghost_move, ghost)
def move_ghosts(dt):
for ghost in ghosts:
ghost.move(dt)
update_funcs.append(move_ghosts)
Run your program and see what happens. Do the ghosts stay in the play area like Muncher does? Why do you think this is? Can you fix it?
Experiment: Changing the ghosts speed and direction
Just as it is for Muncher, the speed of the ghosts is controlled by the vx
and vy
properties
that represent the horizontal and vertical speed each ghost can move in pixels per second.
Each ghost has a different set of values for the vx
and vy
properties. Experiment with changing
the values to see what effect it has.
Step 7: Keeping the ghosts in bounds
The completed code for this step is available here.
The reason the ghosts all fly off the screen is because we do not perform the check to keep them
within the play area bounds like we do for Muncher. The code in the update()
function is missing
a call to ghost.keep_in_bounds()
.
Modify your move_ghosts()
function so that is is as follows:
def move_ghosts(dt):
for ghost in ghosts:
ghost.move(dt)
ghost.keep_in_bounds(dt)
What happens why you run your game now? Why do you think this is? How do you think you could go about fixing it?
Experiment: Adding more ghosts
The game uses an Python list to store all of the ghosts in the game. This means that we do
not need to do the movement and bounds checking for each ghost individually by their
variables names pink
, blue
, orange
and red
but can instead iterate over all of the
ghosts in a list and perform those operations. This is done in the for ghost in ghosts:
statement in the update()
function. The advantage of this method is that we can add more
ghosts to the ghosts
lists and they will automatically appear and move on the screen.
Try it. Create another ghost called blue2
(which will use the same images as blue
) and
add it to the ghosts
lists. Run your game and check that it gets displayed and moves:
blue2 = Actor('ghost-blue', GHOST_START)
blue2.images = ['ghost-blue', 'ghost-blue2']
blue2.vx = -10
blue2.vy = -60
ghosts = [blue, orange, red, pink, blue2]
Create some more ghosts called yellow
, mango
, barry
and dingo
. Create some super cool
images and animations for them.
Step 8: Bouncing the ghosts
The completed code for this step is available here.
When the game runs, the ghosts all whizz away and stay within the play area but they eventually all end up stuck in one of the corners. This is because the ghosts never change their direction. What we want to do is make the ghosts bounce at the edges of the play area.
Change the ghost_move()
function so that it is as follows:
def ghost_move(ghost, dt):
ghost.x += ghost.vx * dt
ghost.y += ghost.vy * dt
if ghost.x >= BOUNDS_X2:
ghost.x = BOUNDS_X2
ghost.vx *= -1
elif ghost.x <= BOUNDS_X1:
ghost.x = BOUNDS_X1
ghost.vx *= -1
if ghost.y >= BOUNDS_Y2:
ghost.y = BOUNDS_Y2
ghost.vy *= -1
elif ghost.y <= BOUNDS_Y1:
ghost.y = BOUNDS_Y1
ghost.vy *= -1
Run your game and you should now see your ghosts bouncing furiously around the screen.
Step 9: Muncher and ghost collisions
The completed code for this step is available here.
At present, when a ghost collides with Muncher, nothing happens. What we want is for the
collision to result in Muncher losing a life. Once a life is lost, we want a short pause,
then all of the actors to return to their starting positions, another short pause and
then the game to start again. The pauses will be controlled by chaining some function
calls using the PyGame clock
class. When all lives are lost, the game will exit()
.
Place the following code before the call to pgzrun.go()
which will check for muncher
colliding with the ghosts:
def unpause():
global paused
paused = False
def reset_actors():
for ghost in ghosts:
ghost.x = GHOST_START_X
ghost.y = GHOST_START_Y
muncher.x = MUNCHER_START_X
muncher.y = MUNCHER_START_Y
if lives <= 0:
exit()
clock.schedule(unpause, 2)
def check_for_ghost_collision(dt):
global lives, paused
for ghost in ghosts:
if ghost.colliderect(muncher):
lives -= 1
paused = True
clock.schedule(reset_actors, 2)
update_funcs.append(check_for_ghost_collision)
Explanation: Why is a collision detected between Muncher and the ghosts when they don't touch?
The collision detection algorithm that is used in this game is a relatively simple one.
The code is checking whether the images of the Actors
overlap each other. Those images
are square 32 x 32 pixels but the image of Muncher does not use the entire square because
Muncher is a circle. This means there are "blank" areas that surround Muncher in each corner.
Our collision detection algorithm does not distinguish between the coloured in parts of the
image and the blank areas, considering them all as Muncher. Therefore, it a ghost overlaps
with one of these blank areas, it still registers as a collision. Later projects will look
at implementing more advanced collision detection algorithms that offer higher fidelity.
Step 10: Walls to negotiate
The completed code for this step is available here.
The play area needs some walls to make it more difficult for Muncher to avoid the ghosts. Two different images will be required for the walls. One is 128 x 32 pixels (representing a horizontal wall) and the other is 32 x 128 pixels (representing a vertical wall). You can create your own images using a paint program that supports transparency (all the images provided here were created using PixilArt) or you can use my images that are provided below.
The two images should be called wall.png
and wall2.png
and placed in the images
folder.
wall.png
should be 128 x 32 pixelswall2.png
should be 32 x 128 pixels
All wall elements will be created as Actors
. Add the following code before before the call to
pgzrun.go()
which will create the walls and draw them:
walls = [
Actor('wall', (WIDTH / 4, HEIGHT / 2)),
Actor('wall', ((WIDTH / 4) * 3, HEIGHT / 2)),
Actor('wall', (WIDTH / 2, HEIGHT / 5)),
Actor('wall', (WIDTH / 2, (HEIGHT / 5) * 4)),
Actor('wall2', (WIDTH / 5, HEIGHT / 3)),
Actor('wall2', ((WIDTH / 5) * 4, HEIGHT / 3)),
Actor('wall2', (WIDTH / 5, (HEIGHT / 3) * 2)),
Actor('wall2', ((WIDTH / 5) * 4, (HEIGHT / 3) * 2)),
]
def draw_walls():
for wall in walls:
wall.draw()
draw_funcs.append(draw_walls)
Run your game. What happens when Muncher collides with a wall? Why do you think this is?
Extension: Adding more walls
Try extending your game to add more wall elements to the play area. Experiment with new
images of different sizes to create interesting mazes and shapes. Just make sure to add
all of your new wall elements to the wall
list so that they will be displayed.
Step 11: Colliding with walls
The completed code for this step is available here.
We want the ghosts to be able to go through the walls, they are ghosts after all. However, we do not want Muncher to be able to go through the walls. We therefore need to check for collisions between Muncher and wall elements. The collision detection algorithm that we will be using is a relatively simple one that checks for overlaps of the edges of Muncher and the walls but is effective enough for this type of game.
Add the following code before before the call to pgzrun.go()
to add the wall collision
detection for Muncher:
def check_for_wall_collisions(dt):
for wall in walls:
if wall.colliderect(muncher):
# Try each of the edges of the wall
if muncher.top < wall.top and muncher.bottom > wall.top:
muncher.bottom = wall.top
if muncher.bottom > wall.bottom and muncher.top < wall.bottom:
muncher.top = wall.bottom
if muncher.left < wall.left and muncher.right > wall.left:
muncher.right = wall.left
if muncher.right > wall.right and muncher.left < wall.right:
muncher.left = wall.right
update_funcs.append(check_for_wall_collisions)
Run your game and check that Muncher cannot now pass through walls but the ghosts can.
Step 12: Pellets to eat
The completed code for this step is available here.
The pellets that Muncher likes to eat are small 8 x 8 pixel dots that are spread across
the play area but not where there are walls. Either create your own image or use mine
below, ensuring you copy it into the images
folder.
pellet.png
Add the following pellet creation and drawing code before the call to pgzrun.go()
:
def create_pellets():
global pellets
pellets = []
for y in range(12):
for x in range(11):
pos = (50 + (50 * x), 100 + (50 * y))
collide = False
for wall in walls:
if wall.collidepoint(pos):
collide = True
if not collide:
pellets.append(Actor('pellet', pos))
pellets = []
create_pellets()
def draw_pellets():
for pellet in pellets:
pellet.draw()
draw_funcs.append(draw_pellets)
Run your game and try to eat the pellets? What happens? Why do you think this is?
Step 13: Eating the pellets
The completed code for this step is available here.
Muncher cannot eat the pellets because we have not done the collision detection code
for it yet. Add the new code before the call to the pgzrun.go()
function to perform
the collision detection and allow all of the pellets to be eaten:
def check_for_eaten_pellets(dt):
global score, pellets
before = len(pellets)
pellets = [pellet for pellet in pellets if not pellet.colliderect(muncher)]
after = len(pellets)
score += (before - after) * 100
update_funcs.append(check_for_eaten_pellets)
What happens when you eat all of the pellets? How do you think you should fix this?
Step 14: Eating all of the pellets
The completed code for this step is available here.
When Muncher eats all of the pellets the game should start another level. Instead it
just carries on. This is because we do not check that all pellets have been eaten.
Add the new code before the call to the pgzrun.go()
function to perform the check
on how many pellets are left and start a new level if all have been eaten.
def check_for_all_pellets_eaten(dt):
global paused
if len(pellets) <= 0:
paused = True
create_pellets()
reset_actors()
update_funcs.append(check_for_all_pellets_eaten)
Step 15: Ghosts with personality
The completed code for this step is available here.
Currently, the ghosts race around the screen in a fixed way which can make the game just a little bit predictable and easy. We are now going to program one of ghosts ghosts with some additional intelligence to make them chase after Muncher, rather than just race around the screen randomly.
Now we will add in the specific code for the more advanced movement for the blue
ghost. This code should be added before the call to pgzrun.go()
. This will make
blue ghost move directly towards Muncher. This gives a simple chase type behaviour
to the blue ghost, rather than just bouncing around the screen.
import types
def chase(ghost, dt):
if ghost.vx < 0:
ghost.vx *= -1
if ghost.vy < 0:
ghost.vy *= -1
if ghost.x < muncher.x:
ghost.x += ghost.vx * dt
else:
ghost.x -= ghost.vx * dt
if ghost.y < muncher.y:
ghost.y += ghost.vy * dt
else:
ghost.y -= ghost.vy * dt
blue.move = types.MethodType(chase, blue)
Run your game to make sure the ghost exhibits the new behaviour. Does the blue ghost
now catch you really fast and is impossible to evade? Why do you think this is? Try
changing the values that blue.vx
and blue.vy
are initialised to? What are good
values?
Extension: Create your own AI
Try to give some of your ghosts unique movement behaviours; some ghosts can keep using the
default ghost_move()
function. You can add the chase()
move behaviour to other slower
moving ghosts too.
Create your own special move function for that can be attached to some of your ghosts. If you are stuck for ideas as to what to write as your algorithm, try this:
- Pick random
vx
andvy
values for -40 to 40. - Pick move in that direction for a random amount of time, ensuring to bounce off walls.
- Repeat
Step 16: Bonus fruit
The completed code for this step is available here.
A diet of just chomping on pellets can get a little boring so Muncher like to also eat fruit when it is available. In our game, we want to occasionally make fruit available on the screen so that Muncher can get a big bonus for eating it. Only one fruit will be available at a time.
We are going to start with three different types of fruit, each with a 32 x 32 pixel
image. The three images should be placed in the images
folder you already created
and be called:
apple.png
lemon.png
strawberry.png
Then add the following code before the call to pgzrun.go()
function which creates a
list that contains an Actor
for each of the possible fruits we will display:
fruits = [
Actor('apple', (WIDTH / 2, HEIGHT / 2)),
Actor('lemon', (WIDTH / 2, HEIGHT / 2)),
Actor('strawberry', (WIDTH / 2, HEIGHT / 2)),
]
fruit = None
The fruit
variable which is currently set to the special value None
is used to
indicate which fruit we are currently displaying. When fruit
is None
it means
that no fruit is to be displayed. When fruit
is an Actor
then it is to be
displayed. Add the following code before the call to pgzrun.go()
to ensure fruit
gets drawn each frame when it is a value other than None
:
def draw_fruits():
if fruit is not None:
fruit.draw()
draw_funcs.append(draw_fruits)
Now run your game to check it out. Does the fruit get displayed? Why do you think this is?
Extension: More fruits please
Having 3 different fruits adds some variety but Muncher likes a whole lot of different types
of fruit. Use your artistic talent to create more fruits. Don't forget to add them to the
fruits
list. Some examples of fruits to draw are:
- Banana
- Pineapple
- Cherries
- Kiwi
- Peach
- Grapes
Step 17: Showing and hiding the fruit
The completed code for this step is available here.
Presently, when the game is running, the fruits do not appear. This is because there is no
code that currently changes the fruit
variable from the value None
. What we want is for
a fruit to appear every five second and be displayed for 3 seconds. This should make it
challenging for Muncher to get the fruit whilst also avoiding the ghosts. To achieve this we
are going to use the clock.schedule()
method that we used earlier. The method
clock.schedule()
is used to schedule a function to be called a number of seconds in the
future. We will schedule a call pick a random fruit from the list to show and then schedule
another different call to hide that fruit.
Add the following code before the call to pgzrun.go()
:
def show_fruit():
import random
global fruit
fruit = fruits[random.randint(0, len(fruits) - 1)]
clock.schedule(hide_fruit, 3)
def hide_fruit():
global fruit
fruit = None
clock.schedule(show_fruit, 5)
clock.schedule(show_fruit, 5)
Run your program. Now your fruits will show and hide. What happens when Muncher touches the fruit? Why do you think that is?
Experiment: Changing how often the fruits are visible
Try adjusting the specified number of seconds that the three calls to clock.schedule()
to see how
this affects the game. The three values represent:
- The time to wait for the first fruit being shown.
- The time that each fruit is displayed for.
- The time between each fruit being displayed.
Which value is which? Select some values that you feel are best for your game.
Step 18: Eating the fruits
The completed code for this step is available here.
At present, when Muncher overlaps with the fruit, nothing happens. This is because we have not added the collision detection code like has been done for the pellets, ghosts and walls.
Add the following code before the call to pgzrun.go()
so that it checks for the collision
between Muncher and the fruit and awards points based on the index of the fruit in the list.
The first item will be worth 1,000 points, the second item 2,000, the third item 3,000 points
and so on.
def check_if_fruit_eaten(dt):
global fruit, score
if fruit is not None:
if fruit.colliderect(muncher):
index = fruits.index(fruit) + 1
score += (1000 * index)
fruit = None
update_funcs.append(check_if_fruit_eaten)
Run your game and try it out.
Experiment: Fruits in different places
All of the fruits currently appear in the same location no the screen. Why do you think this is? Try changing your code so that different fruits appear in different locations.
Step 19: Getting additional lives
The completed code for this step is available here.
An additional life should be awarded for each 10,000 points the player scores.
Add the following code before the call to pgzrun.go()
:
next_life = 10000
def check_for_new_life(dt):
global score, lives, next_life
if score >= next_life:
lives += 1
next_life += 10000
update_funcs.append(check_for_new_life)
Experiment: Changing the way extra lives are awarded
The code currently awards a new life every 10,000 points. Try changing it so that a life is awarded after the first 1,000 points and then every 2,000 points thereafter.
What happens when you get lots and lots of lives?
Try modifying the code so that you award lives in the following pattern:
- 1st new life awarded after 10,000 points (10,000)
- 2nd new life awarded after 30,000 points (10,000 + 20,000)
- 3rd new life awarded after 60,000 points (10,000 + 20,000 + 30,000)
- 4th new life awarded after 100,000 points (10,000 + 20,000 + 30,000 + 40,000)
- ... and so on ...
Step 20: Power pellets
The completed code for this step is available here.
Each time Muncher eats a power pellet, it supercharges Muncher and allows him to eat the ghosts for a few seconds. The power pellet is a larger 16 x 16 pixel pellet. You can create your own using whatever colour you like, or use mine.
The image for the power pellet should be placed in the images
folder you already
created and be called:
power-pellet.png
We will use the same pattern for creating the power pellets, drawing them and performing collision detection as has been used for the smaller pellets. You should be familiar with these patterns by now.
Add the following code before the call to pgzrun.go()
which will create the list to
hold the power pellets and add a property to Muncher to indicate how long Muncher
has left (in seconds) in power mode. We also need to draw the power pellets:
power_pellets = []
muncher.power = 0
create_pellets()
def draw_power_pellets():
for pellet in power_pellets:
pellet.draw()
draw_funcs.append(draw_power_pellets)
Add the following code before the call to pgzrun.go()
. This code first decreases how
much time Muncher has left in power mode and then detects for collision between Muncher
and the power pellets, granting Muncher a 500 point bonus and more time in power mode if
one or more power pellets were eaten:
def check_if_power_pellet_eaten(dt):
global score, power_pellets
muncher.power -= dt
if muncher.power < 0:
muncher.power = 0
before = len(power_pellets)
power_pellets = [pellet for pellet in power_pellets if not pellet.colliderect(muncher)]
after = len(power_pellets)
score += (before - after) * 500
if after < before:
muncher.power = 5
update_funcs.append(check_if_power_pellet_eaten)
Finally, we need to add code to create the power pellets. We are going to do this by
adding the following code to our existing create_pellets()
function which will create
the power pellets for each new level:
global power_pellets
power_pellets = [
Actor('power-pellet', (WIDTH / 5, HEIGHT / 6)),
Actor('power-pellet', ((WIDTH / 5) * 4, HEIGHT / 6)),
]
Your create_pellets()
function should now look like this:
def create_pellets():
global pellets
pellets = []
for y in range(12):
for x in range(11):
pos = (50 + (50 * x), 100 + (50 * y))
collide = False
for wall in walls:
if wall.collidepoint(pos):
collide = True
if not collide:
pellets.append(Actor('pellet', pos))
global power_pellets
power_pellets = [
Actor('power-pellet', (WIDTH / 5, HEIGHT / 6)),
Actor('power-pellet', ((WIDTH / 5) * 4, HEIGHT / 6)),
]
Experiment: Changing the number of power pellets
Currently the game has two power pellets. Why not add some more power pellets to the game. You could go for four with one in each corner or perhaps place then extra ones in random positions. Experiment with different placings to get something you like.
Step 21: Power mode and frightened ghosts
The completed code for this step is available here.
When you run your game, there is no indication that Muncher is in power mode. In fact even in power mode, Muncher will still lose a life if he touches the ghosts. We need to do the following things:
- Make the ghosts change their look so it is clear that power mode is activated
- Stop Muncher losing a life when colliding with a ghost in power mode.
- Award Muncher 500 bonus points when colliding with a ghost in power mode and move the ghost back to its starting position.
Either draw your own 32 x 32 pixel scared ghost images using your favourite paint
program or use my images below. The two scared ghost images should be placed in
the images
folder you already created and be called:
ghost-scared.png
ghost-scared2.png
Add the following code before the call to pgzrun.go()
which will create a list for
the scared ghost images and swap the images used for each ghost Actor
based on whether
Muncher is in power mode or not:
scared_images = ['ghost-scared', 'ghost-scared2']
def check_for_scared_ghosts(dt):
global scared_images
if muncher.power > 0:
for ghost in ghosts:
if ghost.images != scared_images:
ghost.original_images = ghost.images
ghost.images = scared_images
ghost.frame = 0
else:
for ghost in ghosts:
if ghost.images == scared_images:
ghost.images = ghost.original_images
ghost.frame = 0
update_funcs.append(check_for_scared_ghosts)
If you run your program now you will find the ghosts change their look when you eat a power pellet. The only item remaining is to modify the collision detection code so Muncher does not lose a life when colliding with the ghosts when in power mode.
Locate the check_for_ghost_collision()
function and change it to look like this:
def check_for_ghost_collision(dt):
global lives, paused
if muncher.power <= 0:
for ghost in ghosts:
if ghost.colliderect(muncher):
lives -= 1
paused = True
clock.schedule(reset_actors, 2)
The final step is to now add the code that allows Muncher to each the ghosts when in
power mode. Add this code before the call to pgzrun.go()
:
def check_if_eaten_ghost(dt):
global score
if muncher.power > 0:
for ghost in ghosts:
if ghost.colliderect(muncher):
score += 500
ghost.pos = GHOST_START
update_funcs.append(check_if_eaten_ghost)
Extension: Frightened ghosts should run away
Presently, when Muncher has eaten a power pellet, the ghosts do not change their behaviour. The ghosts moving randomly continue to do so and the ghosts chasing also continue to do so. Change your chase code so that you rather than chase towards Muncher a chasing ghost runs away when Muncher is in power mode.
Step 22: Adding sounds
The completed code for this step is available here.
You game is fun, but it is lacking something. Sound! You can create your own sound effects or use some free online resources. All of the sound effects listed here came from MixKit. Other sites are available such as pixabay.
Create a sounds
folder in your project to place your sound files. You will need
6 sounds:
Playing a sound is super easy using the sounds
object. To play the lose_life.wav
sound, just use this code: sounds.lose_life.play()
. The other sounds are played in
exactly the same way. Each of the six sounds will be added to the function that
performs the relevant checks. Those six functions are:
check_for_ghost_collision()
check_for_eaten_pellets()
check_for_all_pellets_eaten()
check_if_fruit_eaten()
check_for_new_life()
check_if_eaten_ghost()
Each of those functions is provided below with the sounds added. Modify your functions to look the same by adding in the sounds:
def check_for_ghost_collision(dt):
global lives, paused
if muncher.power <= 0:
for ghost in ghosts:
if ghost.colliderect(muncher):
lives -= 1
paused = True
clock.schedule(reset_actors, 2)
sounds.lose_life.play()
def check_for_eaten_pellets(dt):
global score, pellets
before = len(pellets)
pellets = [pellet for pellet in pellets if not pellet.colliderect(muncher)]
after = len(pellets)
score += (before - after) * 100
if after < before:
sounds.eat_pellet.play()
def check_for_all_pellets_eaten(dt):
global paused
if len(pellets) <= 0:
paused = True
create_pellets()
reset_actors()
sounds.new_level.play()
def check_if_fruit_eaten(dt):
global fruit, score
if fruit is not None:
if fruit.colliderect(muncher):
index = fruits.index(fruit) + 1
score += (1000 * index)
fruit = None
sounds.eat_fruit.play()
def check_for_new_life(dt):
global score, lives, next_life
if score >= next_life:
lives += 1
next_life += 10000
sounds.new_life.play()
def check_if_eaten_ghost(dt):
global score
if muncher.power > 0:
for ghost in ghosts:
if ghost.colliderect(muncher):
score += 500
ghost.pos = GHOST_START
sounds.eat_ghost.play()
Extension: Add a special sound when a power pellet is eaten
There is currently no special sound played when a power pellet is eaten. Create or
choose a sound, add it to your sounds
directory, then add the code to play the
sound when a power pellet is eaten.
Extension: Frightened ghosts sound
Extend your game to play a background melody when the ghosts are frightened.
Appendix A: Finished code
Here is a version of the finished code: main.py.
You can see the completed game on Replit.