Skip to main content

Lesson 13: Character Controllers

  • Module 7: Platformer Feel
  • Lesson 13 of 27
  • ⏱️ About 2 h (instruction + lab)

A platformer lives or dies by how its hero moves: a jump that ignores a press made a split second too early feels broken, even when the code is "correct." In this lesson you build a complete character controller with weight, air control and two small forgiveness windows, then let the player's state pick the animation.

🎯 Learning Objectives

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

  • Tune how a character runs with acceleration, friction and air control, in pixels per second and pixels per second squared.
  • Build coyote time and a jump buffer that switch on and off on the right frames.
  • Combine a variable-height jump with a rule of one jump per ground contact, so there are no free mid-air jumps.
  • Drive animation from a State enum, including a one-shot landing clip and events fired on chosen frames.
  • Debug controller bugs such as a timer that never runs, a flickering ground flag or a jump that fires twice, by watching the timers on screen.

Project: Forgiving Jump, a small platformer level where keys 1, 2 and 3 switch coyote time, the jump buffer and the jump cut on and off, so you can feel what each one adds.

In This Lesson

🏃 What a Controller Does

Watch a sprinter. They don't reach full speed on the first step; they build up to it. When they stop, they slide a little. Once they leave the ground, they can twist and lean, but they can't change direction the way they could with their feet planted. A character controller is the code that gives your hero those same habits: how fast it speeds up, how quickly it stops, how much it can steer in the air, and how high and how long it jumps.

You already have most of the pieces. Gravity & Jumping gave you gravity, terminal velocity and a variable-height jump. Tile Maps gave you float positions, per-axis collision and a 1 px ground probe. This lesson puts them into one controller and adds the parts that make it feel good.

Every frame, the controller runs the same steps in the same order:

graph TD A["Read input: left, right, jump pressed, jump released"] --> B["Update timers: coyote, buffer"] B --> C["Horizontal speed: accelerate or brake"] C --> D["Jump? press or buffer, while grounded or in coyote time"] D --> E["Gravity, capped at a terminal speed"] E --> F["Move x, resolve x"] F --> G["Move y, resolve y"] G --> H["Ground probe: 1 px under the feet"] H --> I["Pick the state, then the animation"]
ParameterUnitWhat it changes
RUN_SPEEDpx/sTop running speed
GROUND_ACCELpx/s²How fast you reach top speed: low feels heavy, high feels snappy
GROUND_FRICTIONpx/s²How fast you stop when you let go: low feels icy
AIR_ACCELpx/s²Steering in the air; zero means you are locked into your jump
JUMP_SPEED, GRAVITYpx/s, px/s²Together they set the jump height: JUMP_SPEED² / (2 × GRAVITY)
COYOTE_TIME, BUFFER_TIMEsHow forgiving the jump is about timing

💡 Why this matters

Players never see your code, but they feel it within seconds. Keeping every feel value as a named constant in real units means you can tune the game by changing numbers, not by rewriting logic, and a value that feels right at 60 FPS stays right at 144 FPS.

↔️ Running: Acceleration, Friction and Air Control

The simplest controller sets the speed straight from the keys: right arrow held, vel.x = 240; nothing held, vel.x = 0. It works, but the hero starts and stops like a switch. To give it weight, move the speed toward a target a little each frame. One small helper does it:

def approach(value, target, max_step):
    """Move value toward target by at most max_step, without overshooting."""
    if value < target:
        return min(value + max_step, target)
    return max(value - max_step, target)

# Each frame, with move_x = -1, 0 or +1 from the keys:
if move_x != 0:
    accel = GROUND_ACCEL if self.on_ground else AIR_ACCEL
    self.vel.x = approach(self.vel.x, move_x * RUN_SPEED, accel * dt)
elif self.on_ground:
    self.vel.x = approach(self.vel.x, 0, GROUND_FRICTION * dt)

Three details matter here:

  • accel * dt is the most the speed may change this frame. With GROUND_ACCEL = 1800, the hero reaches 240 px/s in 240 ÷ 1800 ≈ 0.13 s, whatever the frame rate.
  • approach never overshoots. Plain vel.x -= FRICTION * dt would push a nearly stopped hero backward and make it jitter around zero.
  • No friction in the air. With no key held in mid-air, the speed stays as it was, so a running jump carries forward. Pressing a key in the air uses the smaller AIR_ACCEL, so you can steer, but less than on the ground.

🔮 Predict, then run

When the skeleton in the next section is running, predict what happens if you set GROUND_FRICTION = 200. Then try it. Now set AIR_ACCEL = 0 and try to land a jump on a ledge you misjudged. Which change would you keep for an ice level, and which for a game about precise jumps?

🧱 A Controller Skeleton

Here is a complete, runnable controller with running, jumping and collisions, and no forgiveness yet. Save it as controller_skeleton.py and play it for a minute. Try to run off the left floor and jump at the very last moment before the gap.

import pygame

WIDTH, HEIGHT = 800, 450
RUN_SPEED = 240          # px/s
GROUND_ACCEL = 1800      # px/s²
AIR_ACCEL = 900          # px/s²: less grip in the air
GROUND_FRICTION = 2200   # px/s²
GRAVITY = 980            # px/s²
JUMP_SPEED = 470         # px/s


def approach(value, target, max_step):
    """Move value toward target by at most max_step, without overshooting."""
    if value < target:
        return min(value + max_step, target)
    return max(value - max_step, target)


class Player:
    def __init__(self, x, y):
        self.rect = pygame.FRect(x, y, 24, 36)     # float position
        self.vel = pygame.Vector2(0, 0)
        self.on_ground = False

    def update(self, move_x, jump_pressed, dt, platforms):
        # Horizontal: accelerate toward the target speed, or brake on the ground.
        if move_x != 0:
            accel = GROUND_ACCEL if self.on_ground else AIR_ACCEL
            self.vel.x = approach(self.vel.x, move_x * RUN_SPEED, accel * dt)
        elif self.on_ground:
            self.vel.x = approach(self.vel.x, 0, GROUND_FRICTION * dt)

        if jump_pressed and self.on_ground:            # strict: ground only, for now
            self.vel.y = -JUMP_SPEED
        self.vel.y += GRAVITY * dt

        # Move and resolve x, then move and resolve y.
        self.rect.x += self.vel.x * dt
        for p in platforms:
            if self.rect.colliderect(p):
                if self.vel.x > 0:
                    self.rect.right = p.left
                elif self.vel.x < 0:
                    self.rect.left = p.right
                self.vel.x = 0
        self.rect.y += self.vel.y * dt
        for p in platforms:
            if self.rect.colliderect(p):
                if self.vel.y > 0:
                    self.rect.bottom = p.top
                elif self.vel.y < 0:
                    self.rect.top = p.bottom
                self.vel.y = 0

        # A 1 px probe under the feet decides "on the ground".
        probe = pygame.FRect(self.rect.left, self.rect.bottom, self.rect.width, 1)
        self.on_ground = self.vel.y >= 0 and probe.collidelist(platforms) != -1


pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Controller Skeleton")
clock = pygame.time.Clock()
platforms = [pygame.FRect(0, 400, 330, 50), pygame.FRect(430, 400, 370, 50),
             pygame.FRect(110, 300, 150, 16), pygame.FRect(470, 270, 170, 16)]
player = Player(40, 300)

running = True
while running:
    dt = clock.tick(60) / 1000
    jump_pressed = False
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key in (pygame.K_SPACE, pygame.K_UP):
            jump_pressed = True                        # the moment of the press
    keys = pygame.key.get_pressed()
    move_x = int(keys[pygame.K_RIGHT]) - int(keys[pygame.K_LEFT])

    player.update(move_x, jump_pressed, dt, platforms)
    if player.rect.top > HEIGHT:                       # fell in the gap: respawn
        player.rect.topleft = (40, 300)
        player.vel.update(0, 0)

    screen.fill((22, 27, 44))
    for p in platforms:
        pygame.draw.rect(screen, (92, 112, 140), p)
    pygame.draw.rect(screen, (250, 200, 90), player.rect, border_radius=5)
    pygame.display.flip()

pygame.quit()

It follows the rules from Tile Maps:

  • The position is an FRect, so a step of 3.7 pixels is not rounded away. With a plain Rect, slow speeds stall and the hero drifts.
  • One axis at a time. Move x and push out along x; then move y and push out along y. If you move both at once and then resolve, the code can't tell whether you hit a wall or a floor, and the hero teleports to the top of walls it runs into.
  • A 1 px ground probe. After resolving, the hero's feet sit exactly on the platform, touching but not overlapping it, so colliderect with the hero itself would say "not on the ground" on every frame. A thin strip just under the feet does overlap the platform, so on_ground stays steady.

Notice how the jump reads the event (KEYDOWN), not get_pressed(). The controller needs to know the moment the key went down, because that is what the buffer will remember.

Play it again and count how often you run off the edge, press jump a fraction too late and fall into the gap. Nothing is wrong with the code. That is the problem the next section solves.

⏱️ Coyote Time and the Jump Buffer

People don't press buttons on an exact frame. At 60 FPS a frame lasts about 17 ms, and a press can easily land a few frames early or late. Two tiny windows absorb that:

  • Coyote time (named after the cartoon coyote who runs off a cliff and hangs in the air before falling): for a short time after you leave a ledge, you can still jump as if you were on it.
  • Jump buffer: if you press jump just before landing, the press is remembered and the jump fires the moment you touch down.
Two stacked timelines. The top, Coyote time, shows a player stepping off a ledge; a short amber grace window of about 0.1 seconds follows, and a jump pressed inside it still launches while a press after it is too late and the player just falls. The bottom, Jump buffer, shows a falling player; a jump pressed in mid-air is remembered for about 0.12 seconds, so a landing within that window fires the jump the instant the player touches down.
Coyote time forgives a press that is a little late; the jump buffer forgives a press that is a little early. This lesson uses 0.1 s and 0.12 s.

Try both in the demo. Click the canvas, then use the arrow keys (or A/D) and Space. Switch each feature off and try the same jump again. The strip at the bottom marks every press: green if it jumped, red if it was ignored.

Two stacked timelines showing the coyote-time grace window after leaving a ledge and the jump-buffer window before landing.
The interactive demo needs a keyboard. On a larger screen you can play it; here are the two forgiveness windows it lets you switch on and off.

Coding the two timers

Both are just numbers of seconds that count down by dt. The trick is when you fill them and when you empty them:

# Start of update(): self.on_ground still holds LAST frame's probe result.
if self.on_ground:
    self.coyote = COYOTE_TIME            # refilled on every grounded frame...
else:
    self.coyote = max(0.0, self.coyote - dt)   # ...and drains once you are in the air

# A press sets the buffer (press_jump() is called from the KEYDOWN event).
def press_jump(self):
    self.pressed = True
    self.buffer = BUFFER_TIME

# Later in update(): jump on a fresh OR a remembered press, from the ground OR coyote time.
if (self.pressed or self.buffer > 0) and (self.on_ground or self.coyote > 0):
    self.vel.y = -JUMP_SPEED
    self.coyote = 0.0                    # used up: no second jump from this ledge
    self.buffer = 0.0                    # used up: the press can't fire twice
self.pressed = False
self.buffer = max(0.0, self.buffer - dt)

Refilling the coyote timer on every grounded frame means you never have to catch the exact frame the hero left the ledge; the timer is simply full at that moment and starts draining. And because the jump check runs every frame, a buffered press fires on the first frame the probe finds the ground, with no special "just landed" code.

Both "used up" lines matter. Forget self.coyote = 0.0 and the hero can jump, then jump again in mid-air while the coyote timer is still running. Forget self.buffer = 0.0 and one press can fire a second jump on the next landing.

✅ Growth Mindset: Make the Invisible Visible

Timing bugs are hard to see because they happen in a tenth of a second. If your coyote time "doesn't work", you haven't failed; you just can't see the timers yet. Draw them on screen in milliseconds (the exercise does), or print on_ground, coyote and buffer every frame for a few seconds. A timer stuck at 0, or one that refills in mid-air, jumps out at you in the numbers, and each bug you find this way trains your eye for the next one.

🪂 Variable Jumps, One Jump per Landing

In Gravity & Jumping you let the player control the jump height by how long they hold the button. The controller version is the same idea: when jump is released while the hero is still rising from its own jump, cut the upward speed.

def release_jump(self):                  # called from the KEYUP event
    if self.jumping and self.vel.y < 0:  # still rising from our own jump
        self.vel.y *= JUMP_CUT           # 0.5 keeps half the upward speed
    self.jumping = False

The jumping flag is set when the jump starts and cleared on landing or when the hero bumps a ceiling. Without it, releasing jump while something else launches the hero upward, such as a spring, would cut that launch too.

The last rule is the simplest and the one most often broken: a jump needs the ground or coyote time. There is no "else, jump anyway" branch. If you want a double jump later, give the hero a counter such as air_jumps = 1 that is refilled only when the ground probe finds a platform, and spend it on a mid-air press. Because it refills only on the ground, the hero can never gain extra jumps in the air.

🎭 Let the State Pick the Animation

Once the physics has run, you know everything about the hero this frame: rising, falling, running, standing, or just landed. Name those situations with an Enum (you met Enum in Game States & Scenes) and pick one from the physics:

from enum import Enum


class State(Enum):
    IDLE = "idle"
    RUN = "run"
    JUMP = "jump"
    FALL = "fall"
    LAND = "land"


def pick_state(self):
    if not self.on_ground:
        return State.JUMP if self.vel.y < 0 else State.FALL
    if self.land_timer > 0:              # set to LAND_TIME on the frame we touch down
        return State.LAND
    if abs(self.vel.x) > 10:
        return State.RUN
    return State.IDLE

This time each member gets a value you choose, a short string, instead of auto(). Both work the same way; State.RUN.value gives back "run", which is handy for on-screen labels and, later, for saving a state to a file.

The physics decides the state, and the state decides the animation, never the other way round. That keeps one source of truth: the hero can't be drawn "running" while it is actually falling.

An animator holds one clip per state: a list of frames, the seconds each frame lasts, and whether it loops. It uses the subtract-the-remainder timing from the Intro course's Sprite Animation lesson, in seconds:

def play(self, state):
    if state == self.state:
        return                           # already playing: don't restart the clip
    self.state, self.index, self.timer, self.finished = state, 0, 0.0, False
    self._fire()                         # frame 0 may have an event too

def update(self, dt):
    frames, frame_time, loops = self.clips[self.state]
    self.timer += dt
    while self.timer >= frame_time and not self.finished:
        self.timer -= frame_time         # keep the leftover time
        if self.index + 1 < len(frames):
            self.index += 1
        elif loops:
            self.index = 0
        else:
            self.finished = True         # one-shot clips hold their last frame
            break
        self._fire()                     # run any event attached to the new frame

Two ideas from this code are worth keeping:

  • One-shot clips. The landing squash plays once and holds its last frame (loops = False). The controller's land_timer keeps the hero in LAND just long enough for it.
  • Animation events. animator.on_frame(State.RUN, 1, footstep) attaches a function to frame 1 of the run clip. Each time the clip enters that frame, the function runs: a dust puff, a footstep sound, or the moment a sword swing can hit. Because update steps through frames one at a time in a while loop, an event still fires on a slow frame that skips past it.
Two horizontal timelines compare uniform and variable frame timing. The top row is a six-frame walk cycle, each frame held one hundred milliseconds for six hundred total milliseconds, then looping back to frame zero. The bottom row is a four-phase attack: wind-up eighty milliseconds, hit forty milliseconds, impact-hold two hundred milliseconds, and recovery one hundred twenty milliseconds, totaling four hundred forty milliseconds before returning to idle. The impact-hold phase is outlined in amber.
A looping clip (top) and a one-shot clip (bottom). The figure labels times in milliseconds; in code they are seconds (100 ms is 0.1). The "hit" frame of a one-shot attack is exactly where you would attach an event.

🏋️ Practice Exercise: Forgiving Jump

Objective: finish a platformer controller so late and early jump presses are forgiven, short taps make short hops, and the hero's animation follows its state.

Time: about 45 minutes. Starter file: forgiving_jump_starter.py (your instructor has it). It runs, jumps and animates already, but only jumps on the exact frame the hero stands on a platform. Its numbered comments match the steps below.

  1. Run the starter. Run off the left floor and try a late jump over the gap; watch the HUD. (≈ 3 min)
  2. Coyote time: at the top of update(), refill self.coyote to COYOTE_TIME on grounded frames (when self.coyote_on) and count it down by dt otherwise. (≈ 7 min)
  3. Jump buffer: in press_jump(), set self.buffer = BUFFER_TIME when self.buffer_on. (≈ 3 min)
  4. Jump cut: in release_jump(), multiply self.vel.y by JUMP_CUT if the cut is on, the hero is jumping and still rising. (≈ 5 min)
  5. Make the jump check accept a buffered press and coyote time. The two lines that use both up when the jump fires are already there; read their comments. (≈ 10 min)
  6. Fill in pick_state() so the right clip plays: JUMP, FALL, LAND, RUN or IDLE. (≈ 7 min)
  7. Test each feature with keys 1, 2 and 3: switch it off, try the same jump, switch it back on. (≈ 10 min)

You are done when:

  • you can run off a ledge and still jump for a moment afterward, but not a whole second later;
  • pressing jump just before landing jumps as soon as you touch down;
  • a quick tap gives a short hop and a held press a full jump;
  • pressing jump in mid-air after walking off a ledge (once the coyote window has closed) does nothing;
  • the HUD shows IDLE, RUN, JUMP, FALL and a brief LAND at the right moments, and dust puffs appear as you run.
💡 Hint

If coyote time never seems to work, check where you refill it: it must use last frame's on_ground, at the very start of update(), before anything changes it. If the hero can jump twice in the air, look for the line that sets self.coyote = 0.0 when the jump fires. For pick_state(), check the air first (not self.on_ground), then land_timer, then speed.

✅ Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.

"""Forgiving Jump: Intermediate Lesson 13 practice exercise (solution).

A platformer character controller with acceleration, air control, coyote
time, a jump buffer and a variable-height jump, plus an animation that is
picked from the player's state. Keys 1, 2 and 3 switch each forgiveness
feature on and off so you can feel the difference.

Controls: Left/Right or A/D to run, Space/W/Up to jump, 1/2/3 toggles.
"""
from enum import Enum

import pygame


WIDTH, HEIGHT = 800, 450
FPS = 60

# Feel parameters: speeds in px/s, accelerations in px/s², times in seconds.
RUN_SPEED = 240
GROUND_ACCEL = 1800
AIR_ACCEL = 900             # less control in the air than on the ground
GROUND_FRICTION = 2200
GRAVITY = 980
MAX_FALL_SPEED = 700
JUMP_SPEED = 470            # about 110 px high: 470² / (2 × 980)
JUMP_CUT = 0.5              # releasing jump early keeps half the upward speed
COYOTE_TIME = 0.10          # you can still jump this long after leaving a ledge
BUFFER_TIME = 0.12          # a press this long before landing still counts
LAND_TIME = 0.12            # how long the landing squash plays

BG_COLOR = (22, 27, 44)
PLATFORM_COLOR = (92, 112, 140)
TEXT_COLOR = (225, 230, 240)
JUMP_KEYS = (pygame.K_SPACE, pygame.K_w, pygame.K_UP)


class State(Enum):
    IDLE = "idle"
    RUN = "run"
    JUMP = "jump"
    FALL = "fall"
    LAND = "land"


def make_level():
    """Two floors with a gap, plus three ledges. FRects keep float positions."""
    return [
        pygame.FRect(0, 400, 330, 50),
        pygame.FRect(430, 400, 370, 50),
        pygame.FRect(110, 300, 150, 16),
        pygame.FRect(470, 270, 170, 16),
        pygame.FRect(290, 180, 120, 16),
    ]


def approach(value, target, max_step):
    """Move value toward target by at most max_step, without overshooting."""
    if value < target:
        return min(value + max_step, target)
    return max(value - max_step, target)


class Player:
    def __init__(self, x, y):
        self.rect = pygame.FRect(x, y, 24, 36)
        self.vel = pygame.Vector2(0, 0)
        self.on_ground = False
        self.facing = 1
        self.coyote = 0.0           # seconds of coyote time left
        self.buffer = 0.0           # seconds a jump press is remembered
        self.pressed = False        # a jump press arrived this frame
        self.jumping = False        # rising from our own jump (can be cut)
        self.land_timer = 0.0
        self.state = State.FALL
        self.jumps = 0
        # Forgiveness features (keys 1, 2, 3)
        self.coyote_on = True
        self.buffer_on = True
        self.cut_on = True

    def press_jump(self):
        self.pressed = True
        if self.buffer_on:
            self.buffer = BUFFER_TIME

    def release_jump(self):
        if self.cut_on and self.jumping and self.vel.y < 0:
            self.vel.y *= JUMP_CUT
        self.jumping = False

    def update(self, move_x, dt, platforms):
        was_on_ground = self.on_ground

        # 1. Coyote time: refilled on every grounded frame, drains in the air.
        if self.on_ground and self.coyote_on:
            self.coyote = COYOTE_TIME
        else:
            self.coyote = max(0.0, self.coyote - dt)

        # 2. Horizontal speed: accelerate toward the target, brake with friction.
        if move_x != 0:
            accel = GROUND_ACCEL if self.on_ground else AIR_ACCEL
            self.vel.x = approach(self.vel.x, move_x * RUN_SPEED, accel * dt)
            self.facing = move_x
        elif self.on_ground:
            self.vel.x = approach(self.vel.x, 0, GROUND_FRICTION * dt)

        # 3. Jump: a press this frame or a buffered press, while grounded or in coyote time.
        wants_jump = self.pressed or self.buffer > 0
        if wants_jump and (self.on_ground or self.coyote > 0):
            self.vel.y = -JUMP_SPEED
            self.on_ground = False
            self.coyote = 0.0       # no second jump from the same ledge
            self.buffer = 0.0       # the buffered press is used up
            self.jumping = True
            self.jumps += 1
        self.pressed = False
        self.buffer = max(0.0, self.buffer - dt)

        # 4. Gravity, capped at a terminal speed.
        self.vel.y = min(self.vel.y + GRAVITY * dt, MAX_FALL_SPEED)

        # 5. Move and resolve one axis at a time.
        self.rect.x += self.vel.x * dt
        for p in platforms:
            if self.rect.colliderect(p):
                if self.vel.x > 0:
                    self.rect.right = p.left
                elif self.vel.x < 0:
                    self.rect.left = p.right
                self.vel.x = 0
        self.rect.x = max(0, min(self.rect.x, WIDTH - self.rect.width))

        self.rect.y += self.vel.y * dt
        for p in platforms:
            if self.rect.colliderect(p):
                if self.vel.y > 0:
                    self.rect.bottom = p.top
                elif self.vel.y < 0:
                    self.rect.top = p.bottom
                    self.jumping = False
                self.vel.y = 0

        # 6. Ground probe: a 1 px strip under the feet, so the flag never flickers.
        probe = pygame.FRect(self.rect.left, self.rect.bottom, self.rect.width, 1)
        self.on_ground = self.vel.y >= 0 and probe.collidelist(platforms) != -1
        if self.on_ground:
            self.jumping = False

        # 7. Landing and state.
        if self.on_ground and not was_on_ground:
            self.land_timer = LAND_TIME
        self.land_timer = max(0.0, self.land_timer - dt)
        self.state = self.pick_state()

        if self.rect.top > HEIGHT:          # fell into the gap: respawn
            self.rect.topleft = (40, 300)
            self.vel.update(0, 0)

    def pick_state(self):
        if not self.on_ground:
            return State.JUMP if self.vel.y < 0 else State.FALL
        if self.land_timer > 0:
            return State.LAND
        if abs(self.vel.x) > 10:
            return State.RUN
        return State.IDLE


def make_frame(color, width, height):
    """One animation frame drawn in code: a body with an eye stripe."""
    surf = pygame.Surface((32, 40), pygame.SRCALPHA)
    body = pygame.Rect(0, 0, width, height)
    body.midbottom = (16, 40)
    pygame.draw.rect(surf, color, body, border_radius=5)
    pygame.draw.rect(surf, (30, 30, 40), (body.centerx + 2, body.top + 8, 6, 4))
    return surf


def make_clips():
    """{state: (frames, seconds per frame, loops)}. Squash and stretch sells the motion."""
    idle = [make_frame((250, 200, 90), 24, 36), make_frame((250, 200, 90), 25, 35)]
    run = [make_frame((250, 180, 70), w, h) for w, h in ((24, 36), (26, 34), (24, 36), (22, 37))]
    jump = [make_frame((255, 220, 120), 20, 40)]
    fall = [make_frame((240, 170, 90), 26, 34)]
    land = [make_frame((250, 160, 60), 30, 28), make_frame((250, 180, 70), 27, 32)]
    return {
        State.IDLE: (idle, 0.5, True),
        State.RUN: (run, 0.09, True),
        State.JUMP: (jump, 0.1, True),
        State.FALL: (fall, 0.1, True),
        State.LAND: (land, LAND_TIME / 2, False),
    }


class Animator:
    """Plays the clip for the current state. Times are in seconds."""

    def __init__(self, clips):
        self.clips = clips
        self.state = None
        self.index = 0
        self.timer = 0.0
        self.finished = False
        self.events = {}            # {(state, frame index): callback}

    def on_frame(self, state, index, callback):
        self.events[(state, index)] = callback

    def play(self, state):
        if state == self.state:
            return                  # already playing: don't restart the clip
        self.state = state
        self.index = 0
        self.timer = 0.0
        self.finished = False
        self._fire()

    def update(self, dt):
        frames, frame_time, loops = self.clips[self.state]
        self.timer += dt
        while self.timer >= frame_time and not self.finished:
            self.timer -= frame_time            # keep the remainder
            if self.index + 1 < len(frames):
                self.index += 1
            elif loops:
                self.index = 0
            else:
                self.finished = True            # one-shot clips hold the last frame
                break
            self._fire()

    def _fire(self):
        callback = self.events.get((self.state, self.index))
        if callback is not None:
            callback()

    def image(self):
        return self.clips[self.state][0][self.index]


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Forgiving Jump")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 24)           # fonts load once, before the loop

    platforms = make_level()
    player = Player(40, 300)
    animator = Animator(make_clips())
    dust = []                                    # [x, y, seconds left]

    def footstep():
        dust.append([player.rect.centerx - player.facing * 10, player.rect.bottom, 0.3])

    animator.on_frame(State.RUN, 1, footstep)    # animation events: puff on each footfall
    animator.on_frame(State.RUN, 3, footstep)
    animator.play(player.state)

    held_left = held_right = False
    running = True
    while running:
        dt = clock.tick(FPS) / 1000

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key in (pygame.K_LEFT, pygame.K_a):
                    held_left = True
                elif event.key in (pygame.K_RIGHT, pygame.K_d):
                    held_right = True
                elif event.key in JUMP_KEYS:
                    player.press_jump()
                elif event.key == pygame.K_1:
                    player.coyote_on = not player.coyote_on
                elif event.key == pygame.K_2:
                    player.buffer_on = not player.buffer_on
                elif event.key == pygame.K_3:
                    player.cut_on = not player.cut_on
            elif event.type == pygame.KEYUP:
                if event.key in (pygame.K_LEFT, pygame.K_a):
                    held_left = False
                elif event.key in (pygame.K_RIGHT, pygame.K_d):
                    held_right = False
                elif event.key in JUMP_KEYS:
                    player.release_jump()

        move_x = int(held_right) - int(held_left)
        player.update(move_x, dt, platforms)
        animator.play(player.state)
        animator.update(dt)
        for puff in dust:
            puff[2] -= dt
        dust = [puff for puff in dust if puff[2] > 0]

        screen.fill(BG_COLOR)
        for p in platforms:
            pygame.draw.rect(screen, PLATFORM_COLOR, p)
        for x, y, left in dust:
            pygame.draw.circle(screen, (170, 170, 190), (x, y - 3), 2 + 10 * (0.3 - left))
        image = animator.image()
        if player.facing < 0:
            image = pygame.transform.flip(image, True, False)
        screen.blit(image, image.get_rect(midbottom=player.rect.midbottom))

        hud = [
            f"[1] coyote {'ON ' if player.coyote_on else 'OFF'}  {player.coyote * 1000:4.0f} ms",
            f"[2] buffer {'ON ' if player.buffer_on else 'OFF'}  {player.buffer * 1000:4.0f} ms",
            f"[3] jump cut {'ON' if player.cut_on else 'OFF'}",
            f"state: {player.state.name}   jumps: {player.jumps}",
        ]
        for i, line in enumerate(hud):
            screen.blit(font.render(line, True, TEXT_COLOR), (10, 10 + i * 22))
        pygame.display.flip()

    pygame.quit()
    print(f"Jumps: {player.jumps}. Final state: {player.state.name}.")


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal. 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. With coyote time off, how many of your ledge jumps failed? Describe how it felt as a player, not as a programmer.
  2. Pick a platformer you know. Does it seem to use coyote time, a buffer or variable jumps? What makes you think so?
  3. Which bug took you longest today, and what finally showed you where it was?

📝 Summary

You built a complete platformer controller. Horizontal speed moves toward a target with acceleration and friction, with weaker steering in the air. The hero moves and resolves one axis at a time on an FRect, and a 1 px probe keeps the ground flag steady. Coyote time is refilled on every grounded frame and drains in the air; a jump press fills a buffer that fires the jump on landing; both are used up when the jump fires, so there are no free mid-air jumps. Releasing early cuts the jump. Finally, the physics picks a State, and the state picks the animation clip, with one-shot clips and events on chosen frames.

🎓 Key Takeaways

  • Keep feel values as named constants in real units (px/s, px/s², seconds) so tuning never breaks frame-rate independence.
  • Use approach() for acceleration and friction so speed never overshoots or jitters around zero.
  • Coyote time forgives late presses, the buffer forgives early ones; refill, drain and use up each timer on the right frames.
  • A jump needs the ground or coyote time; any extra jump must come from a counter that refills only on the ground.
  • Physics decides the state, the state decides the animation; one-shot clips and frame events handle landings and footsteps.

🔭 Looking Ahead

Your hero moves well, but the world behind it is flat. In the next lesson, Parallax Scrolling, you add layers of scenery that slide at different speeds, so a 2D level suddenly has depth.

❓ Common Questions

How long should coyote time and the jump buffer be?

There is no single right answer: they are feel settings, so tune them by playing. This lesson uses 0.1 s and 0.12 s. Long enough that honest near-misses succeed, short enough that players can't jump from thin air. Ask someone else to play, because you have already learned your own timing.

Why do the jump presses come from events instead of get_pressed()?

get_pressed() tells you the key is down now, not that it just went down. Holding jump would then fire a new jump every time you land, and the buffer would have no single press to remember. The KEYDOWN event happens exactly once per press. The exercise tracks left and right with events too, which also lets the instructor's checker replay a recorded input script.

My hero pops up onto the top of walls it runs into. Why?

That happens when you move on both axes and then resolve the overlap once. The code can't tell which axis caused the overlap and pushes the hero out the shortest way, which is often up. Move and resolve x, then move and resolve y, as the skeleton does.

Why an FRect instead of a Rect?

pygame.Rect stores whole numbers. At 60 FPS, a hero creeping at 30 px/s moves 0.5 px per frame, which a Rect rounds away, so the hero never moves. pygame.FRect keeps the fractions. You can still draw it directly with pygame.draw.rect.

Should the state machine control the physics instead?

For movement states like these, letting the physics pick the state is simpler and can't disagree with what the hero is doing. For actions with their own rules, such as an attack that roots the hero for 0.3 s, a state with enter and exit steps (like the scenes in Game States & Scenes) is a good fit. Many games mix both.

Coyote time and the jump buffer count down with dt. Do they work with a fixed timestep?

Yes. This lesson keeps the capped variable step from Tile Maps, but every timer here is in seconds and counts down by dt, so it works the same inside the fixed-timestep accumulator from Velocity & Timesteps: pass STEP as dt. One caution: read the jump key's KEYDOWN event once per frame, outside the physics loop, and just set the buffer timer. Otherwise a frame that runs two physics steps could use the same press twice.

🎯 Quick Quiz

Question 1: In this lesson's controller, when is the coyote timer set back to COYOTE_TIME?

Question 2: BUFFER_TIME is 0.12. A player presses jump 0.08 s before the hero lands. What happens?

Question 3: Why does the controller move and resolve x first, and only then move and resolve y?

Question 4: The run clip is playing and the code calls animator.play(State.RUN) again. What happens?

Question 5: A playtester walks off a ledge, waits a whole second, and can still jump in mid-air. Which bug is most likely?

🌟 Going Further

  • Double jump: add air_jumps, refilled to 1 only when the ground probe finds a platform. A mid-air press with no coyote time left spends one.
  • Wall slide and wall jump: add a 1 px probe on each side. While falling against a wall, cap the fall speed at about 80 px/s; a jump pushes away from the wall (vel.x = -wall_side * RUN_SPEED). Add a WALL_SLIDE state and clip.
  • Dash: on Shift, set the speed to 600 px/s in the facing direction for 0.15 s, ignore gravity during the dash, and allow one dash per landing.
  • Apex hang: near the top of a jump (when abs(vel.y) is small), use half gravity for a floatier peak. Compare the feel with the jump cut on and off.
  • Read the docs: the pygame-ce pages for pygame.Rect and FRect and pygame.key.
  • Coming up in Game Dev III: Advanced: Build a Physics Engine, where you write the integrator and collision solver behind movement like this yourself.