Skip to main content

Lesson 25: Difficulty & DDA

  • Module 13: Balance & Testing
  • Lesson 25 of 27
  • โฑ๏ธ About 1 h 30 min (instruction + lab)

The same game can bore one player and crush another in the same minute. In this lesson you give a game one difficulty dial that safely drives every tuning knob, add assists that players control, and build a gentle automatic adjuster that keeps players in the zone without ever making the game unwinnable.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Build a compute_parameters(level, assists) function in which one level drives many knobs through separate curves, none of which can reach zero.
  • Explain the flow channel and label its sides correctly: skill far above challenge is boredom, challenge far above skill is anxiety.
  • Implement dynamic difficulty adjustment (DDA) as a rate in levels per second, with a deadband, a rate limit and a clamp.
  • Compare static, DDA and flow-channel modes on the same player, and debug why assists or DDA "don't stick".
  • Distinguish real rubber banding from other adjustments, and describe difficulty choices honestly.

Project: Adaptive Arena, a small shooter with three difficulty modes, two assists and a live graph of its difficulty level.

In This Lesson

๐Ÿ“ˆ What Difficulty Really Is

Think about learning to ride a bike. Training wheels first, then a parent jogging alongside, then a gentle slope, then a real hill. At every stage the challenge is a little ahead of the rider's skill, never so far ahead that they give up, never so far behind that they get bored. Good difficulty design does the same thing on purpose.

Games challenge players in different ways, and each kind can be tuned separately:

  • Mechanical: speed, precision and timing (enemy speed, spawn rate, how many hits you can take).
  • Strategic: planning and resource decisions (how much gold, how clever the AI is).
  • Knowledge: learning the rules and the map (hints, tutorials, how obvious a puzzle's clue is).

Over a whole game, difficulty follows a curve. The three classic shapes are a steady ramp, a staircase of rises with rest periods, and a curve that adapts to the player as they go:

Three small charts of difficulty over time. Linear ramp: a straight rising line. Plateau: rises separated by flat rest periods. Dynamic difficulty adjustment: a line that weaves around a dashed player-skill curve.
Linear ramps are predictable, plateaus give players time to consolidate, and dynamic difficulty adjustment (DDA) follows the player's measured performance.

The flow channel

Psychologist Mihaly Csikszentmihalyi described flow as the absorbed state people reach when a task's challenge matches their skill. His diagram has two axes, skill and challenge, and a diagonal "channel" where they are balanced. Off the channel, the two sides are easy to mix up, so say them slowly:

SituationRatio skill รท challengeWhat the player feelsWhat the game should do
Skill far above challengewell above 1Boredom: "this is too easy"Raise the challenge
Balancedclose to 1FlowLeave it alone
Challenge far above skillwell below 1Anxiety: "this is too hard"Lower the challenge
def flow_state(skill, challenge, band=0.25):
    """The flow channel: skill well above challenge is boredom, challenge well above skill is anxiety."""
    ratio = skill / max(challenge, 1e-6)
    if ratio > 1 + band:
        return "boredom"
    if ratio < 1 - band:
        return "anxiety"
    return "flow"

Notice that the state depends on the ratio, not on the difficulty setting alone: the same "Hard" mode is boring for an expert and terrifying for a newcomer.

๐ŸŽ›๏ธ One Level, Many Knobs

A game has dozens of numbers that affect difficulty. Letting a designer (or an algorithm) turn them all separately is chaos. Instead, keep one difficulty level, here from 1 to 10 with 5 as the baseline, and derive every knob from it through its own curve:

def compute_parameters(level, assists):
    """One level, many knobs, each with its own curve. Assists are applied LAST, every time,
    so recomputing the parameters can never wipe them out."""
    mult = level / NORMAL                              # 0.2 at level 1, 2.0 at level 10
    p = {
        "enemy_speed": 90 * mult ** 0.8,               # px/s: grows a bit slower than the level
        "enemy_hp": 20 * mult ** 1.2,                  # grows a bit faster than the level
        "spawn_interval": 1.6 / mult ** 0.7,           # seconds between spawns
        "player_damage": 10 * mult ** -0.5,            # shrinks, but never reaches 0
        "pickup_chance": 0.3 * mult ** -0.3,           # shrinks, but never reaches 0
        "damage_taken": 1.0,
    }
    if assists.slow_enemies:
        p["enemy_speed"] *= 0.75
    if assists.shield:
        p["damage_taken"] *= 0.5
    return p

An exponent below 1 makes a knob grow more slowly than the level; above 1, faster; a negative exponent makes it shrink. These exponents are design choices to tune by playtesting, not laws. Here is what they produce (printed by the lab's own function):

LevelEnemy speed (px/s)Enemy hpSpawn every (s)Your damagePickup chance
124.82.94.9422.40.49
590.020.01.6010.00.30
10156.746.00.987.10.24

Never let a knob reach zero

A tempting way to make the player weaker is 10 * (2 - mult) ** 0.5. It looks fine at level 5 (10.0) and level 7 (7.75), but at level 10, mult is exactly 2 and the player's damage is 0. Enemies become immortal and the top difficulty is literally unwinnable. The same trap catches resources, ammo drops and healing. A negative exponent (mult ** -0.5) shrinks smoothly and can never hit zero; an explicit floor (max(2.0, ...)) works too. Check the ends of every curve, not just the middle.

๐Ÿ’ก Why this matters

With one level driving everything, a difficulty setting is one number, a DDA system only has to steer one number, and a playtest note like "level 7 feels right" is precise and repeatable.

๐Ÿงฉ Modes, Presets and Assists

Presets are just named levels: Story = 2, Normal = 5, Hard = 7, Expert = 9. Assists are different: they are player-chosen changes layered on top of whatever the level says, such as a shield that halves damage or slower enemies. Many modern games present assists as accessibility options with no penalty, which respects that players have different needs.

Because the level can change every frame (DDA), the parameters are rebuilt every frame. That creates a classic bug: if turning on an assist edits params directly, the very next rebuild wipes it out, and the assist lasts one frame.

# BUG: the assist is gone on the next frame, because update() rebuilds params
def toggle(self, name):
    self.params["damage_taken"] *= 0.5

The fix is to store the assist choices (a small frozen dataclass) and apply them inside compute_parameters, last, on every call. Then no recomputation can lose them.

@dataclass(frozen=True)
class Assists:
    shield: bool = False           # take half damage
    slow_enemies: bool = False     # enemies move 25% slower

def toggle(self, name):
    a = self.assists
    self.assists = Assists(shield=not a.shield if name == "shield" else a.shield,
                           slow_enemies=not a.slow_enemies if name == "slow" else a.slow_enemies)
    self.params = compute_parameters(self.level, self.assists)

โœ… Growth Mindset: "Too Easy" and "Too Hard" Are Both Data

When a playtester breezes through your game or rage-quits it, that isn't a verdict on you as a designer. It's a measurement, and you now have the tools to act on it: change one curve, run it again, compare. Tuning difficulty is iterative for everyone; the curves in this lesson are starting points that you will adjust after watching real players.

๐Ÿ“Š Measuring Performance

To adjust difficulty automatically, the game needs a number that says how the player is doing right now. Lifetime totals are useless for that (a great first hour hides a terrible last minute), so keep only the events from the last few seconds:

class PerformanceWindow:
    """Counts events (shot, hit, kill, hurt) from the last `window` seconds."""
    def __init__(self, window=WINDOW):
        self.window = window
        self.events = deque()                          # (time in seconds, kind)

    def add(self, now, kind):
        self.events.append((now, kind))

    def trim(self, now):
        while self.events and now - self.events[0][0] > self.window:
            self.events.popleft()

    def count(self, kind):
        return sum(1 for _, k in self.events if k == kind)


def performance(window):
    """A 0..1 score from recent accuracy, kills and hits taken. Safe with no data (returns 0.5)."""
    shots, hits = window.count("shot"), window.count("hit")
    accuracy = hits / shots if shots else 0.5
    score = 0.5 + 0.4 * (accuracy - 0.5) + 0.04 * window.count("kill") - 0.12 * window.count("hurt")
    return clamp(score, 0.0, 1.0)

Three habits make this trustworthy: the time stamps come from a clock that adds up dt (so pausing the game pauses the window); a player who hasn't fired yet gets a neutral accuracy instead of a ZeroDivisionError; and the result is clamped to 0..1. The weights (0.4, 0.04, 0.12) are, again, tuning values for this game.

๐Ÿ” DDA as a Control Loop

A thermostat compares the room to a target temperature and turns the heat up or down a little at a time. Dynamic difficulty adjustment is the same idea: compare the player's performance to a target (here 0.6, "winning a bit more than losing") and nudge the level.

if self.mode == "dda":
    error = perf - self.target
    if abs(error) > self.deadband:
        rate = clamp(self.gain * error, -self.max_rate, self.max_rate)   # levels per second
self.level = clamp(self.level + rate * dt, LEVEL_MIN, LEVEL_MAX)

Every part of that loop has a job:

  • A rate in levels per second, times dt. If you add 0.01 * error every frame instead, a 120 FPS player's game adapts twice as fast as a 60 FPS player's. The lab's test runs two seconds at 30 and at 240 FPS and checks the level ends up the same.
  • A deadband. Small errors are noise. Ignoring them stops the level from twitching constantly.
  • A rate limit. Even a huge error changes the level by at most 0.5 per second, so the change is gradual rather than a sudden jolt.
  • A clamp. The level can never leave 1..10, however long the player dominates or struggles.

Watch it on a simulated player who improves over two minutes and has a tired patch in the middle. Compare how long each mode keeps them in the flow channel (the green band):

Mode:

If you raise the gain or the rate limit, the level starts to overshoot and swing back and forth, just as a badly tuned thermostat does. Slower is usually better: players notice sudden changes much more than slow drift.

Be honest about what it is

You will see systems like this marketed as "AI" or "machine learning". A control loop is neither; neither is extrapolating the recent trend of a performance score, however useful that can be. Call things what they are in your code and design documents. Honesty applies to players too: some players dislike discovering a hidden adjustment, so consider telling them, letting them switch it off, or only ever adjusting in their favor.

๐ŸŒŠ Flow Mode and Rubber Banding

DDA pushes toward a target all the time. A gentler alternative only acts when the player leaves the flow channel, and does nothing while they are in it:

if self.mode == "flow":                  # the flow branch of Director.update()
    if self.state == "boredom":
        rate = self.max_rate / 2         # too easy: slowly raise the level
    elif self.state == "anxiety":
        rate = -self.max_rate / 2        # too hard: slowly lower it

The state comes from flow_state(skill_estimate, level), where the skill estimate is level ร— performance รท target: a player hitting the target exactly is estimated at the current level, one doing better is estimated above it.

What rubber banding actually means

In racing games, rubber banding means computer rivals speed up when they fall far behind the player and ease off when they get far ahead, as if tied to the player by an elastic band. It is about the gap between racers:

gap = player_progress - rival_progress            # track distance in px (positive: rival behind)
rival_speed_mult = clamp(1.0 + gap / 4000, 0.9, 1.1)

Slowly pulling the difficulty level back toward a default is a different thing (you could call it "reverting to baseline"). Keeping the names straight matters when you discuss tuning with a team. Rubber banding can also frustrate skilled players, who may feel their hard-earned lead is being taken away, so it is worth offering a setting to turn it down.

๐Ÿ‹๏ธ Practice Exercise: Adaptive Arena

Objective: fix a small arena shooter so its difficulty level safely drives every knob, its assists stick, and its static, DDA and flow modes all behave correctly at any frame rate.

Time: about 45 minutes. Starter file: difficulty_starter.py (your instructor has it). The arena, drawing and input work. Your damage hits 0 at level 10, assists vanish after one frame, DDA ignores dt, the flow labels point the wrong way, flow mode does nothing, and slow enemies freeze. The numbered to-do comments in it match these steps.

  1. Play the starter in mode 2 for a minute and watch the level graph and the flow state. (โ‰ˆ 3 min)
  2. To-do 1: replace the player-damage curve with one that can't reach zero. (โ‰ˆ 4 min)
  3. To-do 2: apply the assists inside compute_parameters and delete the one-frame version in toggle. Press 8 and check the shield stays on. (โ‰ˆ 8 min)
  4. To-do 3: turn DDA into a rate in levels per second with a deadband and a rate limit. (โ‰ˆ 10 min)
  5. To-do 4: fix the flow labels. (โ‰ˆ 3 min)
  6. To-do 5: make flow mode (key 3) raise the level when bored and lower it when anxious. (โ‰ˆ 7 min)
  7. To-do 6: move enemies by the float step, not an int(). (โ‰ˆ 3 min)
  8. Play each mode for a minute: first well, then badly (stand still). Describe how the graph differs. (โ‰ˆ 7 min)

You are done when:

  • at level 10 your shots still hurt enemies (about 7 damage);
  • assists stay on while the level changes, and the HUD shows them;
  • in mode 2 the level climbs when you play well and falls when you stand still, never faster than 0.5 per second;
  • in mode 3 the level only moves while the flow state is boredom or anxiety;
  • in mode 1 the level never moves;
  • enemies glide smoothly toward you even at level 1.
๐Ÿ’ก Hint

For to-do 3, compute rate and let the existing last line do level + rate * dt; don't touch self.level inside the if. For to-do 2, remember that Director.update() calls compute_parameters every frame: anything that isn't inside that function won't survive. If you're unsure whether DDA is frame-rate independent, change clock.tick(60) to clock.tick(30) and time how long the level takes to climb one step.

โœ… Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.

"""Adaptive Arena: Advanced Lesson 25 practice exercise (solution).

A small top-down arena shooter driven by one difficulty level (1 to 10). The
level feeds every tuning knob through its own curve, player-chosen assists
are applied on top, and three modes decide how the level changes:
1 = static, 2 = DDA (a gentle control loop, in levels per second),
3 = flow (only moves when you leave the flow channel).

Keys: WASD or arrows move, SPACE fires (hold it), 1/2/3 pick the mode,
8 toggles the shield assist, 9 toggles the slow-enemies assist.
Close the window to quit.
"""
import math
import random
from collections import deque
from dataclasses import dataclass

import pygame


WIDTH, HEIGHT = 900, 600
LEVEL_MIN, LEVEL_MAX, NORMAL = 1.0, 10.0, 5.0
PLAYER_SPEED = 240          # px/s
BULLET_SPEED = 520          # px/s
FIRE_COOLDOWN = 0.22        # seconds
CONTACT_DAMAGE = 20         # player hp lost when an enemy reaches you (before assists)
WINDOW = 15.0               # seconds of recent play that count as "performance"
TEXT = (235, 235, 235)
FLOW_COLORS = {"flow": (90, 220, 120), "boredom": (240, 210, 80), "anxiety": (240, 100, 90)}


@dataclass(frozen=True)
class Assists:
    shield: bool = False           # take half damage
    slow_enemies: bool = False     # enemies move 25% slower


def clamp(value, low, high):
    return max(low, min(high, value))


def compute_parameters(level, assists):
    """One level, many knobs, each with its own curve. Assists are applied LAST, every time,
    so recomputing the parameters can never wipe them out."""
    mult = level / NORMAL                              # 0.2 at level 1, 2.0 at level 10
    p = {
        "enemy_speed": 90 * mult ** 0.8,               # px/s: grows a bit slower than the level
        "enemy_hp": 20 * mult ** 1.2,                  # grows a bit faster than the level
        "spawn_interval": 1.6 / mult ** 0.7,           # seconds between spawns
        "player_damage": 10 * mult ** -0.5,            # shrinks, but never reaches 0
        "pickup_chance": 0.3 * mult ** -0.3,           # shrinks, but never reaches 0
        "damage_taken": 1.0,
    }
    if assists.slow_enemies:
        p["enemy_speed"] *= 0.75
    if assists.shield:
        p["damage_taken"] *= 0.5
    return p


class PerformanceWindow:
    """Counts events (shot, hit, kill, hurt) from the last `window` seconds."""
    def __init__(self, window=WINDOW):
        self.window = window
        self.events = deque()                          # (time in seconds, kind)

    def add(self, now, kind):
        self.events.append((now, kind))

    def trim(self, now):
        while self.events and now - self.events[0][0] > self.window:
            self.events.popleft()

    def count(self, kind):
        return sum(1 for _, k in self.events if k == kind)


def performance(window):
    """A 0..1 score from recent accuracy, kills and hits taken. Safe with no data (returns 0.5)."""
    shots, hits = window.count("shot"), window.count("hit")
    accuracy = hits / shots if shots else 0.5
    score = 0.5 + 0.4 * (accuracy - 0.5) + 0.04 * window.count("kill") - 0.12 * window.count("hurt")
    return clamp(score, 0.0, 1.0)


def flow_state(skill, challenge, band=0.25):
    """The flow channel: skill well above challenge is boredom, challenge well above skill is anxiety."""
    ratio = skill / max(challenge, 1e-6)
    if ratio > 1 + band:
        return "boredom"
    if ratio < 1 - band:
        return "anxiety"
    return "flow"


class Director:
    """Owns the difficulty level and changes it according to the mode."""
    def __init__(self, mode="dda", level=NORMAL, target=0.6, gain=2.0, deadband=0.08, max_rate=0.5):
        self.mode = mode
        self.level = level
        self.assists = Assists()
        self.target = target          # the performance we aim for (0..1)
        self.gain = gain              # levels per second per unit of error
        self.deadband = deadband      # errors smaller than this are ignored
        self.max_rate = max_rate      # never change faster than this many levels per second
        self.state = "flow"
        self.params = compute_parameters(level, self.assists)

    def skill_estimate(self, perf):
        return self.level * perf / self.target

    def update(self, perf, dt):
        self.state = flow_state(self.skill_estimate(perf), self.level)
        rate = 0.0
        if self.mode == "dda":
            error = perf - self.target
            if abs(error) > self.deadband:
                rate = clamp(self.gain * error, -self.max_rate, self.max_rate)
        elif self.mode == "flow":
            if self.state == "boredom":
                rate = self.max_rate / 2
            elif self.state == "anxiety":
                rate = -self.max_rate / 2
        self.level = clamp(self.level + rate * dt, LEVEL_MIN, LEVEL_MAX)
        self.params = compute_parameters(self.level, self.assists)

    def toggle(self, name):
        a = self.assists
        self.assists = Assists(shield=not a.shield if name == "shield" else a.shield,
                               slow_enemies=not a.slow_enemies if name == "slow" else a.slow_enemies)
        self.params = compute_parameters(self.level, self.assists)


class Enemy:
    def __init__(self, pos, hp):
        self.pos = pygame.Vector2(pos)     # float position: slow enemies still move
        self.hp = hp
        self.radius = 14

    def update(self, target, speed, dt):
        to_target = target - self.pos
        if to_target.length_squared() > 0:
            self.pos += to_target.normalize() * speed * dt


def spawn_point(rng):
    side = rng.randrange(4)
    if side == 0:
        return (rng.uniform(0, WIDTH), -20)
    if side == 1:
        return (rng.uniform(0, WIDTH), HEIGHT + 20)
    if side == 2:
        return (-20, rng.uniform(0, HEIGHT))
    return (WIDTH + 20, rng.uniform(0, HEIGHT))


class Arena:
    def __init__(self, seed=None):
        self.rng = random.Random(seed)
        self.director = Director()
        self.window = PerformanceWindow()
        self.player = pygame.Vector2(WIDTH / 2, HEIGHT / 2)
        self.facing = pygame.Vector2(0, -1)
        self.hp = 100.0
        self.enemies = []
        self.bullets = []                  # [pos, vel] pairs of Vector2
        self.spawn_timer = 0.0
        self.fire_timer = 0.0
        self.clock = 0.0                   # seconds since start
        self.score = 0
        self.deaths = 0
        self.history = deque(maxlen=300)   # (time, level) for the graph

    def update(self, move, firing, dt):
        self.clock += dt
        p = self.director.params
        if move.length_squared() > 0:
            self.facing = move.normalize()
            self.player += self.facing * PLAYER_SPEED * dt
            self.player.x = clamp(self.player.x, 15, WIDTH - 15)
            self.player.y = clamp(self.player.y, 15, HEIGHT - 15)

        self.fire_timer = max(0.0, self.fire_timer - dt)
        if firing and self.fire_timer == 0:
            self.bullets.append([pygame.Vector2(self.player), self.facing * BULLET_SPEED])
            self.fire_timer = FIRE_COOLDOWN
            self.window.add(self.clock, "shot")

        self.spawn_timer -= dt
        if self.spawn_timer <= 0:
            self.spawn_timer += p["spawn_interval"]
            self.enemies.append(Enemy(spawn_point(self.rng), p["enemy_hp"]))

        for b in self.bullets:
            b[0] += b[1] * dt
        self.bullets = [b for b in self.bullets if -20 < b[0].x < WIDTH + 20 and -20 < b[0].y < HEIGHT + 20]

        for e in self.enemies:
            e.update(self.player, p["enemy_speed"], dt)
            for b in self.bullets:
                if b[0].distance_to(e.pos) < e.radius + 4:
                    self.bullets.remove(b)
                    e.hp -= p["player_damage"]
                    self.window.add(self.clock, "hit")
                    break
            if e.hp <= 0:
                self.score += 1
                self.window.add(self.clock, "kill")
            elif e.pos.distance_to(self.player) < e.radius + 12:
                e.hp = 0                                   # it hits you and is used up
                self.hp -= CONTACT_DAMAGE * p["damage_taken"]
                self.window.add(self.clock, "hurt")
        self.enemies = [e for e in self.enemies if e.hp > 0]
        if self.hp <= 0:
            self.deaths += 1
            self.hp = 100.0
            self.enemies.clear()

        self.window.trim(self.clock)
        self.director.update(performance(self.window), dt)
        self.history.append((self.clock, self.director.level))

    def draw(self, screen, font):
        screen.fill((22, 24, 32))
        for e in self.enemies:
            pygame.draw.circle(screen, (230, 90, 90), e.pos, e.radius)
        for b in self.bullets:
            pygame.draw.circle(screen, (255, 230, 90), b[0], 4)
        pygame.draw.circle(screen, (90, 200, 250), self.player, 12)
        pygame.draw.line(screen, (250, 250, 250), self.player, self.player + self.facing * 18, 3)

        d, p = self.director, self.director.params
        assists = [n for n, on in (("shield", d.assists.shield), ("slow", d.assists.slow_enemies)) if on]
        lines = [f"Mode: {d.mode}   (1 static  2 DDA  3 flow)   Assists: {', '.join(assists) or 'none'}  (8, 9)",
                 f"Level {d.level:4.2f}   performance {performance(self.window):.2f}   target {d.target:.2f}",
                 f"Enemy speed {p['enemy_speed']:5.1f} px/s   hp {p['enemy_hp']:4.1f}   "
                 f"spawn every {p['spawn_interval']:.2f} s   your damage {p['player_damage']:.1f}",
                 f"HP {self.hp:5.1f}   score {self.score}   deaths {self.deaths}"]
        for i, text in enumerate(lines):
            screen.blit(font.render(text, True, TEXT), (10, 8 + i * 20))
        state = font.render(f"Flow state: {d.state}", True, FLOW_COLORS[d.state])
        screen.blit(state, (10, 90))

        # Level over the last 30 seconds (bottom-right graph).
        gx, gy, gw, gh = WIDTH - 250, HEIGHT - 110, 240, 100
        pygame.draw.rect(screen, (40, 44, 56), (gx, gy, gw, gh))
        pts = [(gx + gw - (self.clock - t) / 30 * gw, gy + gh - (lvl - 1) / 9 * gh)
               for t, lvl in self.history if self.clock - t <= 30]
        if len(pts) > 1:
            pygame.draw.lines(screen, (120, 200, 255), False, pts, 2)


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Adaptive Arena")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 22)             # created once
    arena = Arena(seed=25)
    held = set()
    modes = {pygame.K_1: "static", pygame.K_2: "dda", pygame.K_3: "flow"}

    running = True
    while running:
        dt = min(clock.tick(60) / 1000, 0.05)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                held.add(event.key)
                if event.key in modes:
                    arena.director.mode = modes[event.key]
                elif event.key == pygame.K_8:
                    arena.director.toggle("shield")
                elif event.key == pygame.K_9:
                    arena.director.toggle("slow")
            elif event.type == pygame.KEYUP:
                held.discard(event.key)

        move = pygame.Vector2(
            (pygame.K_RIGHT in held or pygame.K_d in held) - (pygame.K_LEFT in held or pygame.K_a in held),
            (pygame.K_DOWN in held or pygame.K_s in held) - (pygame.K_UP in held or pygame.K_w in held))
        arena.update(move, pygame.K_SPACE in held, dt)
        arena.draw(screen, font)
        pygame.display.flip()

    pygame.quit()
    d = arena.director
    print(f"Mode: {d.mode}  level: {d.level:.2f}  score: {arena.score}  deaths: {arena.deaths}")
    print("Arena closed cleanly.")


if __name__ == "__main__":
    main()

๐Ÿ““ Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

โœ๏ธ This lesson's prompts:

  1. Think of a game that felt unfair to you. Was it too hard, or was the difficulty changing in a way you could feel? What would you have wanted instead?
  2. Would you tell players about a DDA system in your own game? Argue both sides in two sentences each.
  3. Which knobs in your capstone idea should grow with difficulty, which should shrink, and what floor keeps each one fair?

๐Ÿ“ Summary

Difficulty is the gap between challenge and skill, and the flow channel names its sides: boredom when skill runs ahead, anxiety when challenge does. You drove every tuning knob from one level through separate curves, checked the ends of each curve so nothing reaches zero, and made assists a separate layer applied on every recompute. You measured performance over a window of seconds, then steered the level with a DDA control loop that uses dt, a deadband, a rate limit and a clamp, and compared it with a flow mode that only acts outside the channel.

๐ŸŽ“ Key Takeaways

  • One difficulty level, many knobs, each with its own curve; check both ends of every curve.
  • Negative exponents or explicit floors keep player damage and resources above zero.
  • Store assist choices and apply them inside the parameter function, last, every time.
  • Measure recent performance over seconds, with guards for empty data.
  • DDA is a rate (levels per second ร— dt) with a deadband, a rate limit and a clamp.
  • Rubber banding is about the gap between racers; name adjustments honestly.

๐Ÿ”ญ Looking Ahead

Curves and targets are guesses until real people play. Next, Playtesting & Telemetry shows how to run sessions, log the right events, analyze them safely and compare two versions of a design without fooling yourself.

โ“ Common Questions

What should the performance target be?

There is no universal number. 0.6 here means "succeeding a bit more often than failing", which suits an arcade shooter. A horror game might aim lower, a relaxing game much higher. Choose it, then check it against real playtests.

Should enemies spawned before a level change be updated?

In the lab, an enemy's hit points are fixed when it spawns, while speed is read from the current parameters every frame. That is a design choice: sudden hit-point changes on enemies already on screen would feel strange, but speed changes are gradual anyway.

Isn't DDA just cheating?

It can feel that way if it is hidden and punishes skill. Many designers limit it to helping struggling players, keep it off in competitive modes, or make it an opt-in assist. The code is the same; the ethics are in how you use it.

Why use a deque for the performance window?

Events arrive in time order, so old ones are always at the left end. popleft() removes them in constant time, which a list's pop(0) does not.

Can I use exponents with presets instead of DDA?

Yes, and many games only do that: the player picks Story, Normal or Hard, which sets the level once, and compute_parameters turns it into all the knobs. DDA is optional on top.

๐ŸŽฏ Quick Quiz

Question 1: Why does the lab use 10 * mult ** -0.5 for player damage instead of 10 * (2 - mult) ** 0.5?

Question 2: A player's estimated skill is 8 and the challenge level is 4. Which flow state is that, with a band of 0.25?

Question 3: Why does DDA compute a rate in levels per second and multiply it by dt?

Question 4: Why are assists applied inside compute_parameters instead of by editing params when the player toggles them?

Question 5: What does "rubber banding" mean in a racing game?

๐ŸŒŸ Going Further

  • Pickups: use the pickup_chance knob: when an enemy dies, drop a health pickup with that probability (from a seeded random.Random).
  • Hysteresis: in flow mode, only start raising the level after 3 seconds of continuous boredom, so a lucky streak doesn't trigger it.
  • Presets menu: add Story / Normal / Hard buttons that set the level once and switch DDA off, then compare how testers feel about each.
  • Racing rivals: add the rubber-band multiplier to the AI rival in your Lap Racer and try it with and without.
  • Read more: the Game Accessibility Guidelines list many assist ideas and why they matter.
  • Coming up in Game Dev III: Advanced: Playtesting & Telemetry, where you log exactly the events this lesson's performance window counts.