Design Patterns and Video Games

Discover Python and Patterns (27): Music and Sounds

In this post, I add music and sounds to our tank game and make use of the Observer pattern to implement it efficiently.

This post is part of the Discover Python and Patterns series

Objective

The objectives are the following:

The pieces of music from https://www.freesfx.co.uk, and are all royalty-free (https://www.freesfx.co.uk/Music.aspx). I keep the original file names to better credit the authors:

The sounds come from https://freesound.org, and their license is Attribution-NonCommercial 3.0 Unported (https://creativecommons.org/licenses/by-nc/3.0/). I also keep the original file names to better credit the authors:

Music with Pygame

It is easy to play a piece of music with Pygame. Firstly, load the music file using the pygame.mixer.music.load() function:

pygame.mixer.music.load("music.mp3")

Note that it stops any music already playing. Also, note that you can only have one piece at a time.

Once the music file is loaded, you can start it using the pygame.mixer.music.play() function. This function has several optional arguments. In our case, we set loops to -1 to repeat indefinitely:

pygame.mixer.music.play(loops=-1)   

You can find more details here: https://www.pygame.org/docs/ref/music.html.

In the constructor of the UserInterface class, we add to two following lines to get a piece of music playing at the beginning of the game:

pygame.mixer.music.load("17718_1462204250.mp3")
pygame.mixer.music.play(loops=-1)

Copy and paste these lines at the beginning of a Pygame program, update the music file, run, and listen!

Play a sound with Pygame

On the contrary to the music, you can load and store several sounds in Python variables using the pygame.mixer.Sound class constructor. For instance:

fireSound = pygame.mixer.Sound("fire.wav")
explosionSound = pygame.mixer.Sound("explosion.wav")

Then, use the play() method of the pygame.mixer.Sound class to play the sound:

fireSound.play()

You can play several sounds at the same time. There are many other methods, like the set_volume() method that sets the volume of the sound. You can find more details here: https://www.pygame.org/docs/ref/mixer.html#pygame.mixer.Sound.

A naive approach

A naive way to trigger music changes or sounds playing is to repeat the two previous lines when something is modified. For instance, at the end of the update() method of the PlayGameMode class, when we evaluate if the game is over, we could immediately change the music:

if self.playerUnit.status != "alive":
    self.gameOver = True
    pygame.mixer.music.load("17675_1462199580.mp3")
    pygame.mixer.music.play(loops=-1) 
else:
    oneEnemyStillLives = False
    for unit in self.gameState.units:
        if unit == self.playerUnit:
            continue
        if unit.status == "alive":
            oneEnemyStillLives = True
            break
    if not oneEnemyStillLives:
        self.gameOver = True
        pygame.mixer.music.load("17382_1461858477.mp3")
        pygame.mixer.music.play(loops=-1) 

It works fine, but we introduce a dependency between the game updates and the user interface. As you should know, in software design, we don't like dependencies and try to avoid them as much as possible.

Imagine that we want to create a multiplayer version of our game. In this case, there is a game server that runs the updates. This server has no screen and no user interface. With the naive approach, if we do nothing, the server plays music! Even if it works fine, it uses CPU and disk I/O unnecessarily.

We could patch the code to add a condition to play music, like if self.playMusic: .... It is a small example, but in real cases, there are thousands of such triggers. We would lose a lot of time and risk to forget to patch some lines.

They are also many other examples like this one, and as usual, I invite you to trust the many developers that faced them. They finally created solutions like the one I present in the next section.

Sounds with the Observer pattern

The Observer pattern allows us to remove the dependency between modules while creating links between them. These links are dynamics, and the observed one does not have to worry about who observes it.

We already used it with the game state:

Game state observer

The game state can notify any observer (or listener) that a unit is destroyed. Currently, the ExplosionLayer class observes the GameState class, and when it receives this notification, it starts an explosion.

We can add a new bulletFired() method to our observer scheme to handle bullet shots:

Game state observer

Then, in the ShootCommand class, we call the notifyBulletFired() method of the GameState class to indicate that a bullet is fired.

At this point, anyone observing the game state can know when a unit is destroyed, and when a bullet is fired.

Sound layer

For the sound case, I propose to create a new SoundLayer class, child of the Layer class:

class SoundLayer(Layer):
    def __init__(self,fireFile,explosionFile):
        self.fireSound = pygame.mixer.Sound(fireFile)
        self.fireSound.set_volume(0.2)
        self.explosionSound = pygame.mixer.Sound(explosionFile)
        self.explosionSound.set_volume(0.2)

    def unitDestroyed(self,unit):
        self.explosionSound.play()

    def bulletFired(self,unit):
        self.fireSound.play()

    def render(self,surface):
        pass

Even if this layer does not render anything, we can also consider it as one of the blocks that create the multimedia user experience. If you don't like that, you can create two branches, for instance, a VisualLayer base class for visual layers, and an AudioLayer base class for audio layers.

The implementation of this class is straightforward:

It seems that Pygame handles well the playing of many sounds. If it was not the case, we could handle it easily in this class, for instance, using a delay or a maximum number of simultaneous sounds. It would be easy because we use the Observer pattern. Otherwise, we would have to carry these values (delay or maximum) in many places in the program! A true nightmare, trust me!

Observe the game state

If we add the sound layer to the list of layers in the constructor of the PlayGameMode class, then it will observe the game state thanks to the lines right after:

self.layers = [
    ArrayLayer(self.cellSize,"ground.png",self.gameState,self.gameState.ground,0),
    ArrayLayer(self.cellSize,"walls.png",self.gameState,self.gameState.walls),
    UnitsLayer(self.cellSize,"units.png",self.gameState,self.gameState.units),
    BulletsLayer(self.cellSize,"explosions.png",self.gameState,self.gameState.bullets),
    ExplosionsLayer(self.cellSize,"explosions.png"),
    SoundLayer("170274__knova__rifle-fire-synthetic.wav","110115__ryansnook__small-explosion.wav")
]

# All layers listen to game state events
for layer in self.layers:
    self.gameState.addObserver(layer)

Run the game with these changes, and the game now has sounds :)

Music with the Observer pattern

Music changes are on a higher level than the game updates. For instance, it can happen between game creation and destruction. As a result, I propose to apply the Observer pattern on the game modes:

Game mode observer

There are many cases to handle and as many methods. Please don't be afraid of the number of these methods. There is software that automatically creates these methods and their implementation. If you don't want to use them or can't afford them, you can implement processes of your own. It especially easy with Python because the standard library already contains a code parser and updater that works very fine for this kind of refactoring.

Factual and request events

There are two kinds of events: the factual ones and the request ones. The factual ones indicate that we changed something, like worldSizeChanged().

The request ones are queries: maybe we didn't change anything, but we would like something to happen, and we can't do it ourselves. For instance, the showMenuRequested() event means that we would like that the menu pops up, but we can't do it. For instance, the game is running, and the player hits the escape key.

Since the game is running, it means that the PlayGameMode is active and captures the key events. The player hits the escape key, meaning that (s)he wants to get the menu. The PlayGameMode class must not handle that, because it involves a higher level in the user interface since it destroys and creates modes, including the play game mode. In the previous program, we already delegated this to the UserInterface class thanks to its showMenu() method.

Calling a method of the UserInterface in the PlayGameMode is creating a circular dependency. Instead, the PlayGameMode calls the notifyShowMenuRequested() method. If we let the UserInterface listen to the PlayGameMode, then it can show the menu. Since it is a request, there is no warranty that someone will fulfill it.

Implementation

In many cases, the implementation is straightforward: we replace the call to a method of the UserInterface by a notification. For instance, in the processInput() method of the MessageGameMode class, we replace self.ui.quitGame() by self.notifyQuitRequested() and self.ui.showMenu() by self.notifyShowMenuRequested()

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        self.notifyQuitRequested()
        break
    elif event.type == pygame.KEYDOWN:
        if event.key == pygame.K_ESCAPE \
        or event.key == pygame.K_SPACE \
        or event.key == pygame.K_RETURN:
            self.notifyShowMenuRequested() 

GameMode child classes no more have a ui attribute that points to the UserInterface class. We removed the dependency.

For the other cases, where something happens, we update the user interface and change the music when necessary. For instance, when the game is lost or won:

def gameWon(self):
    self.showMessage("Victory !")
    pygame.mixer.music.load("17382_1461858477.mp3")
    pygame.mixer.music.play(loops=-1)

def gameLost(self):
    self.showMessage("GAME OVER")
    pygame.mixer.music.load("17675_1462199580.mp3")
    pygame.mixer.music.play(loops=-1)

Final code

Download code and assets

In the next post, we'll see how to package our game so that anyone can run it!