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.
- 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 = landTrigger 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 foreverIn 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 = scoreIn the pygame.QUIT handler:
if event.type == pygame.QUIT:
write_save(high_score)
running = FalseUpdate 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.
Save, load, and config
Write game state to a JSON save file with pathlib, load it back on startup, and read a config.json for tunable game values.
Vectors and math
A 2D vector is a direction and a magnitude. Master addition, scalar multiplication, normalisation, and the dot product — the four operations that underpin game physics.