Code of the Day
IntermediatePolish and Persistence

Lab: Polish and persistence

Extend the tile platformer with jump and coin sound effects, looping background music, save-on-quit / load-on-start, and a settings scene for volume control.

Lab · optionalGame DevIntermediate35 min
By the end of this lesson you will be able to:
  • Add jump and coin sound effects triggered at the correct game events
  • Play looping background music with volume control
  • Save score and player position on quit and load them on start
  • Add a settings scene with a volume slider that writes back to config.json

This lab extends the tile platformer from the Sprites and Levels module with audio, persistence, and a settings screen. The game structure stays the same — three-scene state machine, tile map, animated player — but it will now remember the player's high score between sessions and respond with sound to every meaningful action.

Pyodide (the in-browser Python runner) does not support pygame's display or mixer system. Work through this lab locally: pip install pygame numpy then run python polished_platformer.py after each checkpoint.

Starting point

Copy your platformer.py from the tile platformer lab to polished_platformer.py. You will add features incrementally. The core game loop, tile map, and player sprite stay unchanged.

Checkpoint 1 — Sound effects

Add the mixer setup and sound generation near the top of the file, before the main loop:

import numpy as np

pygame.mixer.init(frequency=44100, size=-16, channels=1, buffer=512)

def make_tone(freq=440, dur=0.12, vol=0.5, sr=44100):
    n  = int(sr * dur)
    t  = np.linspace(0, dur, n, endpoint=False)
    w  = (np.sin(2 * np.pi * freq * t) * 32767 * vol).astype(np.int16)
    return pygame.sndarray.make_sound(w)

jump_sfx = make_tone(freq=520, dur=0.12, vol=0.5)
coin_sfx = make_tone(freq=880, dur=0.08, vol=0.6)
land_sfx = make_tone(freq=300, dur=0.10, vol=0.4)

Trigger them at the right state transitions in Player.update(). Add a was_on_ground flag to detect the landing moment:

# In Player.update(), just before computing on_ground:
was_on_ground = self.on_ground

# ... collision resolution sets self.on_ground ...

# After collision resolution:
if not was_on_ground and self.on_ground:
    land_sfx.play()   # first frame back on ground = land

Trigger the jump sound in the jump block:

if keys[pygame.K_SPACE] and self.on_ground:
    self.vy = self.JUMP_VEL
    jump_sfx.play()

Trigger the coin sound where coin collection is detected in the main loop:

if collected:
    for r in collected:
        row_idx, col_idx = coin_rects.pop(r)
        TILEMAP[row_idx][col_idx] = AIR
        score += 1
    coin_sfx.play()

Run the game. Jump, land, and collect coins. Each action should now have a distinct tone.

Checkpoint 2 — Background music

Generate a longer low-frequency tone as a background drone (placeholder for a real music file). Because pygame.mixer.music streams from a file, the simplest cross-platform workaround is to save a generated wave to a temporary WAV file:

import tempfile, wave, struct, os, math

def write_temp_wav(freq=110, duration=4.0, sample_rate=44100):
    """Write a looping bass tone to a temp WAV file and return the path."""
    n       = int(sample_rate * duration)
    samples = [int(math.sin(2*math.pi*freq*i/sample_rate) * 8000)
               for i in range(n)]
    path    = os.path.join(tempfile.gettempdir(), "bg_music.wav")
    with wave.open(path, "w") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        wf.writeframes(struct.pack(f"<{n}h", *samples))
    return path

music_path = write_temp_wav()
pygame.mixer.music.load(music_path)
pygame.mixer.music.set_volume(0.3)
pygame.mixer.music.play(-1)   # loop forever

In a real project replace write_temp_wav() with a direct path to your OGG file. The pygame.mixer.music calls are identical.

Checkpoint 3 — Save and load

Add save/load at the top of the file using the pattern from the save-load-config lesson:

import json
from pathlib import Path

SAVE_DIR  = Path.home() / ".mygame_platformer"
SAVE_FILE = SAVE_DIR / "save.json"
SAVE_DIR.mkdir(parents=True, exist_ok=True)

DEFAULT_SAVE = {"high_score": 0}

def load_save():
    if not SAVE_FILE.exists():
        return dict(DEFAULT_SAVE)
    try:
        d = json.loads(SAVE_FILE.read_text())
        for k, v in DEFAULT_SAVE.items():
            d.setdefault(k, v)
        return d
    except (json.JSONDecodeError, KeyError):
        return dict(DEFAULT_SAVE)

def write_save(high_score):
    SAVE_FILE.write_text(json.dumps({"high_score": high_score}, indent=2))

At startup, before the main loop:

save_data  = load_save()
high_score = save_data["high_score"]

Update high_score whenever the current session score exceeds it:

if score > high_score:
    high_score = score

In the pygame.QUIT handler:

if event.type == pygame.QUIT:
    write_save(high_score)
    running = False

Update the HUD to show both values:

def draw_hud(surface):
    surf = font.render(
        f"Score: {score}   Best: {high_score}   Lives: {lives}",
        True, (255, 255, 255))
    surface.blit(surf, (10, 10))

Quit and restart the game. Your high score should persist.

Checkpoint 4 — Settings scene

Add a simple settings scene reachable with the Escape key from the title. It shows the current music volume and lets the player raise or lower it with the arrow keys.

CONFIG_FILE = Path("platformer_config.json")
DEFAULT_CFG = {"music_volume": 0.3}

def load_config():
    if not CONFIG_FILE.exists():
        return dict(DEFAULT_CFG)
    try:
        c = json.loads(CONFIG_FILE.read_text())
        for k, v in DEFAULT_CFG.items():
            c.setdefault(k, v)
        return c
    except Exception:
        return dict(DEFAULT_CFG)

def save_config(cfg):
    CONFIG_FILE.write_text(json.dumps(cfg, indent=2))


class SettingsScene(Scene):
    STEP = 0.05

    def __init__(self, manager, config):
        super().__init__(manager)
        self.config = config
        self.font   = pygame.font.SysFont(None, 36)

    def update(self, events):
        for e in events:
            if e.type == pygame.KEYDOWN:
                if e.key == pygame.K_ESCAPE:
                    save_config(self.config)
                    self.manager.set_scene(TitleScene(self.manager))
                if e.key == pygame.K_RIGHT:
                    self.config["music_volume"] = min(
                        1.0, self.config["music_volume"] + self.STEP)
                    pygame.mixer.music.set_volume(self.config["music_volume"])
                if e.key == pygame.K_LEFT:
                    self.config["music_volume"] = max(
                        0.0, self.config["music_volume"] - self.STEP)
                    pygame.mixer.music.set_volume(self.config["music_volume"])

    def draw(self, screen):
        screen.fill((25, 25, 45))
        vol = self.config["music_volume"]
        t   = self.font.render("SETTINGS", True, (255, 255, 255))
        v   = self.font.render(
            f"Music volume: {vol:.0%}   LEFT / RIGHT", True, (200, 200, 200))
        b   = self.font.render(
            "ESC to save and return", True, (160, 160, 160))
        cx  = SCREEN_W // 2
        screen.blit(t, t.get_rect(center=(cx, 170)))
        screen.blit(v, v.get_rect(center=(cx, 240)))
        screen.blit(b, b.get_rect(center=(cx, 310)))

In TitleScene.update(), add:

if e.key == pygame.K_s:
    self.manager.set_scene(SettingsScene(self.manager, load_config()))

And update the title draw to show the hint:

hint = pygame.font.SysFont(None, 26).render(
    "Any key = start   S = settings", True, (140, 140, 140))
screen.blit(hint, hint.get_rect(center=(SCREEN_W // 2, 310)))

What you have now

The platformer now has a complete polish and persistence stack:

  • Jump, land, and coin sounds triggered at the right game events.
  • Looping background music with volume that persists across sessions.
  • A high score that survives quitting and restarting.
  • A settings scene that writes configuration back to disk on exit.

This pattern — scene-based settings, event-triggered SFX, JSON save/config — scales directly to larger projects. Add difficulty settings, key bindings, or a level-select screen by following the same structure.

Finished reading? Mark it complete to track your progress.

On this page