Skip to main content

Character Controllers

Creating Responsive Character Movement

Character controllers define how your game feels to play! Learn responsive movement, coyote time, jump buffering, variable jump height, wall jumps, dashing, and all the techniques that make platformers feel amazing! 🎮🏃‍♂️✨

Understanding Character Controllers

🏃‍♂️ The Athlete Analogy

Think of character controllers like an athlete's movements:

graph TD A["Character Controller"] --> B["Movement"] A --> C["Jumping"] A --> D["Advanced Tech"] B --> E["Acceleration"] B --> F["Deceleration"] B --> G["Max Speed"] C --> H["Variable Height"] C --> I["Double Jump"] C --> J["Wall Jump"] D --> K["Coyote Time"] D --> L["Jump Buffer"] D --> M["Dash/Air Dash"] N["Physics"] --> O["Gravity"] N --> P["Collision"] N --> Q["State Machine"]

Interactive Character Controller Demo

Use Arrow Keys/WASD to move, Space to jump, Shift to dash. Test different mechanics!

State: IDLE | Velocity: (0, 0) | Grounded: true

Jumps: 1 | Can Dash: true | Coyote: 0ms | Buffer: 0ms

Character Controller Implementation

import pygame
import math
from enum import Enum
from typing import List, Optional

class PlayerState(Enum):
    """Player state machine states"""
    IDLE = "idle"
    RUNNING = "running"
    JUMPING = "jumping"
    FALLING = "falling"
    WALL_SLIDING = "wall_sliding"
    DASHING = "dashing"
    LANDING = "landing"

class CharacterController:
    """Advanced 2D platformer character controller"""
    def __init__(self, x: float, y: float):
        # Position and dimensions
        self.rect = pygame.Rect(x, y, 24, 32)
        
        # Velocity
        self.velocity = pygame.Vector2(0, 0)
        self.facing = 1  # 1 for right, -1 for left
        
        # Movement parameters
        self.run_speed = 250
        self.acceleration = 1500
        self.friction = 1500
        self.air_friction = 100
        self.air_control = 0.5
        
        # Jump parameters
        self.jump_power = 450
        self.gravity = 1000
        self.max_fall_speed = 600
        self.jump_cut_multiplier = 0.5
        
        # State
        self.state = PlayerState.IDLE
        self.grounded = False
        self.touching_wall = False
        self.wall_direction = 0
        
        # Advanced mechanics
        self.setup_advanced_mechanics()
        
    def setup_advanced_mechanics(self):
        """Initialize advanced movement mechanics"""
        # Coyote time (grace period for jumping after leaving platform)
        self.coyote_time = 0
        self.max_coyote_time = 0.1  # 100ms
        self.was_grounded = False
        
        # Jump buffering (register jump input before landing)
        self.jump_buffer_time = 0
        self.max_jump_buffer_time = 0.1  # 100ms
        
        # Variable jump height
        self.jump_hold_time = 0
        self.max_jump_hold_time = 0.2
        self.is_jumping = False
        
        # Double/multi jump
        self.jumps_remaining = 1
        self.max_jumps = 1  # Set to 2 for double jump
        
        # Wall jump
        self.wall_jump_power = 350
        self.wall_jump_horizontal = 200
        self.wall_slide_speed = 50
        self.can_wall_jump = True
        
        # Dash
        self.dash_speed = 600
        self.dash_duration = 0.15
        self.dash_time = 0
        self.can_dash = True
        self.dash_cooldown = 0.5
        self.dash_cooldown_timer = 0
        
    def update(self, dt: float, input_state: dict, platforms: List):
        """Update character controller"""
        # Store previous state
        self.was_grounded = self.grounded
        
        # Update timers
        self.update_timers(dt)
        
        # Handle input
        self.handle_input(dt, input_state)
        
        # Apply physics
        self.apply_physics(dt)
        
        # Check collisions
        self.check_collisions(platforms)
        
        # Update state machine
        self.update_state()
        
    def update_timers(self, dt: float):
        """Update various timers"""
        # Coyote time
        if self.was_grounded and not self.grounded:
            self.coyote_time = self.max_coyote_time
        elif self.grounded:
            self.coyote_time = 0
        else:
            self.coyote_time = max(0, self.coyote_time - dt)
        
        # Jump buffer
        if self.jump_buffer_time > 0:
            self.jump_buffer_time = max(0, self.jump_buffer_time - dt)
        
        # Dash cooldown
        if self.dash_cooldown_timer > 0:
            self.dash_cooldown_timer -= dt
            if self.dash_cooldown_timer <= 0:
                self.can_dash = True
        
        # Dash duration
        if self.dash_time > 0:
            self.dash_time -= dt
            if self.dash_time <= 0:
                self.state = PlayerState.FALLING
    
    def handle_input(self, dt: float, input_state: dict):
        """Process player input"""
        # Horizontal movement
        move_x = 0
        if input_state.get('left'):
            move_x = -1
            self.facing = -1
        elif input_state.get('right'):
            move_x = 1
            self.facing = 1
        
        # Apply movement based on state
        if self.state != PlayerState.DASHING:
            self.apply_horizontal_movement(move_x, dt)
        
        # Jump input
        if input_state.get('jump_pressed'):
            self.try_jump()
        elif input_state.get('jump_released') and self.velocity.y < 0:
            # Variable jump height - cut jump short
            if self.is_jumping:
                self.velocity.y *= self.jump_cut_multiplier
                self.is_jumping = False
        
        # Dash input
        if input_state.get('dash_pressed') and self.can_dash:
            self.start_dash(input_state)
    
    def apply_horizontal_movement(self, move_x: float, dt: float):
        """Apply horizontal movement with acceleration"""
        if self.grounded:
            # Ground movement
            if move_x != 0:
                self.velocity.x += move_x * self.acceleration * dt
                self.velocity.x = max(-self.run_speed, 
                                     min(self.run_speed, self.velocity.x))
            else:
                # Apply friction
                if abs(self.velocity.x) > 10:
                    friction_force = self.friction * dt
                    if self.velocity.x > 0:
                        self.velocity.x = max(0, self.velocity.x - friction_force)
                    else:
                        self.velocity.x = min(0, self.velocity.x + friction_force)
                else:
                    self.velocity.x = 0
        else:
            # Air control
            if move_x != 0:
                self.velocity.x += move_x * self.acceleration * self.air_control * dt
                self.velocity.x = max(-self.run_speed, 
                                     min(self.run_speed, self.velocity.x))
    
    def try_jump(self):
        """Attempt to jump with various conditions"""
        can_jump = False
        
        # Ground jump or coyote time
        if self.grounded or self.coyote_time > 0:
            can_jump = True
            self.jumps_remaining = self.max_jumps
        # Multi-jump
        elif self.jumps_remaining > 0:
            can_jump = True
        # Wall jump
        elif self.touching_wall and self.can_wall_jump:
            self.perform_wall_jump()
            return
        else:
            # Buffer the jump
            self.jump_buffer_time = self.max_jump_buffer_time
        
        if can_jump:
            self.perform_jump()
    
    def perform_jump(self):
        """Execute a jump"""
        self.velocity.y = -self.jump_power
        self.jumps_remaining -= 1
        self.grounded = False
        self.coyote_time = 0
        self.jump_buffer_time = 0
        self.is_jumping = True
        self.state = PlayerState.JUMPING
    
    def perform_wall_jump(self):
        """Execute a wall jump"""
        self.velocity.y = -self.wall_jump_power
        self.velocity.x = -self.wall_direction * self.wall_jump_horizontal
        self.facing = -self.wall_direction
        self.jumps_remaining = self.max_jumps - 1
        self.state = PlayerState.JUMPING
    
    def start_dash(self, input_state: dict):
        """Start a dash move"""
        self.state = PlayerState.DASHING
        self.dash_time = self.dash_duration
        self.can_dash = False
        self.dash_cooldown_timer = self.dash_cooldown
        
        # Determine dash direction
        dash_x = self.facing
        dash_y = 0
        
        if input_state.get('up'):
            dash_y = -1
        elif input_state.get('down'):
            dash_y = 1
        
        # Normalize diagonal dash
        magnitude = math.sqrt(dash_x**2 + dash_y**2)
        if magnitude > 0:
            dash_x /= magnitude
            dash_y /= magnitude
        
        self.velocity.x = dash_x * self.dash_speed
        self.velocity.y = dash_y * self.dash_speed
        
        # Reset jumps after dash
        self.jumps_remaining = self.max_jumps

Best Practices

⚡ Character Controller Tips

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Forgive the Player — Coyote + Buffer + Variable Jump

Objective: Build a runnable pygame mini-platformer that integrates the three forgiveness/responsiveness pillars from the lesson — coyote time, jump buffering, and variable jump height — into one ~90-line program. Three runtime toggles (1/2/3) flip each layer ON/OFF independently so the difference each mechanic makes is felt directly through play.

Instructions:

  1. Set up a 640×360 pygame window with three rectangles: a floor and two raised platforms with a gap between them (the gap is what makes coyote time and jump buffer feel necessary).
  2. Track player x, y, vx, vy, grounded, plus three forgiveness state variables: coyote_t, buffer_t, and jump_holding (used by variable jump).
  3. For horizontal movement, apply the ground/air asymmetry from the lesson: ground uses vx += move_x * ACCEL * dt, air uses vx += move_x * ACCEL * AIR_CONTROL * dt with AIR_CONTROL = 0.5. On ground with no input, decay vx toward zero by FRICTION * dt.
  4. Each frame, count down coyote_t and buffer_t by dt (clamped at zero). The frame the player goes from grounded to airborne (and toggle 1 is ON), set coyote_t = COYOTE_MAX (≈100 ms).
  5. On SPACE press, fire a jump (vy = -JUMP_POWER) if either grounded OR coyote_t > 0; otherwise (and if toggle 2 is ON), set buffer_t = BUFFER_MAX to remember the press. The moment the player transitions back to grounded with buffer_t > 0, consume the buffer and fire a jump.
  6. For variable jump, on SPACE release while vy < 0 and jump_holding is True (and toggle 3 is ON), halve vy — short tap = small hop, hold = full jump.
  7. Apply gravity (vy += GRAVITY * dt) and integrate position. Resolve platform collisions: on vy > 0 overlap, snap player.bottom = plat.top and set grounded = True; on vy < 0 overlap, snap player.top = plat.bottom and zero vy.
  8. Render the platforms, the player, and a HUD showing each toggle's ON/OFF state plus the live coyote_t / buffer_t millisecond countdowns. Try walking off the right edge of the left platform with coyote OFF vs ON, and try pressing SPACE just before landing with buffer OFF vs ON — each mechanic's contribution becomes obvious through the contrast.
💡 Hint

Track was_grounded separately from grounded across the frame so you can detect the two transition edges: was_grounded and not grounded is the moment to arm coyote, and not was_grounded and grounded is the moment to consume the buffer. Without that two-state comparison the timers fire on the wrong frames.

✅ Example Solution
"""Forgive the Player — coyote time + jump buffer + variable jump in pygame."""
import pygame, sys

pygame.init()
SCREEN = pygame.display.set_mode((640, 360))
pygame.display.set_caption("Forgive the Player")
CLOCK = pygame.time.Clock()
FONT = pygame.font.SysFont("monospace", 14)

# Level: floor + two platforms with a gap (x=240..380)
PLATFORMS = [
    pygame.Rect(0, 320, 640, 40),
    pygame.Rect(60, 230, 180, 12),
    pygame.Rect(380, 230, 200, 12),
]

# Player + state
player = pygame.Rect(80, 200, 24, 32)
vx, vy = 0.0, 0.0
grounded = False

# Tunables from the lesson
RUN_SPEED, ACCEL, FRICTION = 220.0, 1500.0, 1500.0
AIR_CONTROL, GRAVITY, JUMP_POWER = 0.5, 1400.0, 480.0

# Forgiveness state (seconds)
coyote_t, buffer_t = 0.0, 0.0
jump_holding = False
COYOTE_MAX, BUFFER_MAX = 0.10, 0.10

toggles = {"coyote": True, "buffer": True, "variable": True}

while True:
    dt = CLOCK.tick(60) / 1000.0
    space_pressed = space_released = False

    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            pygame.quit(); sys.exit()
        if e.type == pygame.KEYDOWN:
            if e.key == pygame.K_SPACE: space_pressed = True
            if e.key == pygame.K_1: toggles["coyote"]   = not toggles["coyote"]
            if e.key == pygame.K_2: toggles["buffer"]   = not toggles["buffer"]
            if e.key == pygame.K_3: toggles["variable"] = not toggles["variable"]
        if e.type == pygame.KEYUP and e.key == pygame.K_SPACE:
            space_released = True

    keys = pygame.key.get_pressed()
    move_x = (1 if keys[pygame.K_RIGHT] or keys[pygame.K_d] else 0) \
           - (1 if keys[pygame.K_LEFT]  or keys[pygame.K_a] else 0)

    # Horizontal: ground vs air asymmetry
    mult = 1.0 if grounded else AIR_CONTROL
    if move_x != 0:
        vx = max(-RUN_SPEED, min(RUN_SPEED, vx + move_x * ACCEL * mult * dt))
    elif grounded:
        vx = max(0, vx - FRICTION*dt) if vx > 0 else min(0, vx + FRICTION*dt)

    # Forgiveness countdowns
    if toggles["coyote"] and not grounded:
        coyote_t = max(0.0, coyote_t - dt)
    if toggles["buffer"]:
        buffer_t = max(0.0, buffer_t - dt)

    # Jump press
    if space_pressed:
        if grounded or (toggles["coyote"] and coyote_t > 0):
            vy = -JUMP_POWER
            grounded = False; coyote_t = 0
            jump_holding = True
        elif toggles["buffer"]:
            buffer_t = BUFFER_MAX

    # Variable jump cut on early release
    if toggles["variable"] and space_released and vy < 0 and jump_holding:
        vy *= 0.5
        jump_holding = False

    # Gravity + integrate
    vy += GRAVITY * dt
    player.x += int(vx * dt)
    player.y += int(vy * dt)

    # Platform collisions
    was_grounded = grounded
    grounded = False
    for plat in PLATFORMS:
        if player.colliderect(plat):
            if vy > 0:
                player.bottom = plat.top; vy = 0
                grounded = True; jump_holding = False
            elif vy < 0:
                player.top = plat.bottom; vy = 0

    # Edge transitions: arm coyote on leave, consume buffer on land
    if was_grounded and not grounded and toggles["coyote"]:
        coyote_t = COYOTE_MAX
    if not was_grounded and grounded and toggles["buffer"] and buffer_t > 0:
        vy = -JUMP_POWER
        grounded = False; buffer_t = 0; jump_holding = True

    # Render
    SCREEN.fill((30, 30, 50))
    for plat in PLATFORMS:
        pygame.draw.rect(SCREEN, (90, 110, 130), plat)
    pygame.draw.rect(SCREEN, (240, 200, 80), player)
    hud = [
        f"[1] Coyote:   {'ON ' if toggles['coyote'] else 'OFF'}  ({coyote_t*1000:5.0f} ms)",
        f"[2] Buffer:   {'ON ' if toggles['buffer'] else 'OFF'}  ({buffer_t*1000:5.0f} ms)",
        f"[3] Variable: {'ON' if toggles['variable'] else 'OFF'}",
        f"grounded={grounded}  vy={vy:6.1f}",
    ]
    for i, line in enumerate(hud):
        SCREEN.blit(FONT.render(line, True, (220,220,220)), (10, 8 + i*18))
    pygame.display.flip()

🎯 Quick Quiz

Question 1: The lesson's try_jump() method gates a jump on if self.grounded or self.coyote_time > 0, where self.coyote_time counts down from a small positive value (~100 ms) the moment the player transitions from grounded to airborne. What player problem does this design solve?

Question 2: The lesson's try_jump() method has a fallback branch when no jump can fire immediately: self.jump_buffer_time = self.max_jump_buffer_time. On subsequent frames, the moment the player becomes grounded, this stored value is consumed and a jump fires. What player problem does this design solve?

Question 3: In the lesson's apply_horizontal_movement, ground movement applies velocity.x += move_x * self.acceleration * dt, while airborne movement applies velocity.x += move_x * self.acceleration * self.air_control * dt with self.air_control set to 0.5. Why apply the air_control multiplier mid-air but not on the ground?

What's Next?

With responsive character controls mastered, next we'll build level design tools to create engaging platformer levels!