Skip to main content

Interpolation and Easing

Making Movement Smooth and Natural

Interpolation and easing are the secret ingredients that make games feel polished and professional. They transform robotic movements into smooth animations, harsh transitions into gentle flows, and static UIs into dynamic experiences. Let's master the art of smooth motion! 🎬✨

What is Interpolation?

🚂 The Train Journey Analogy

Think of interpolation like a train journey:

graph TD A["Interpolation Types"] --> B["Linear"] A --> C["Easing Functions"] A --> D["Spline"] A --> E["Physics-Based"] B --> F["Constant Speed"] C --> G["Ease In/Out/InOut"] D --> H["Smooth Curves"] E --> I["Spring/Bounce"]

Interactive Easing Visualizer

Watch different easing functions in action!

Current: Linear

Linear Interpolation (Lerp)

def lerp(start, end, t):
    """Linear interpolation between start and end by factor t (0 to 1)"""
    return start + (end - start) * t

# Examples
position = lerp(0, 100, 0.5)    # Returns 50 (halfway)
color = lerp(0, 255, 0.25)      # Returns 63.75 (quarter way)
angle = lerp(0, 360, 0.75)      # Returns 270 (three quarters)

# Lerp for 2D points
def lerp_point(start_pos, end_pos, t):
    x = lerp(start_pos[0], end_pos[0], t)
    y = lerp(start_pos[1], end_pos[1], t)
    return (x, y)

# Lerp for colors (RGB)
def lerp_color(color1, color2, t):
    r = int(lerp(color1[0], color2[0], t))
    g = int(lerp(color1[1], color2[1], t))
    b = int(lerp(color1[2], color2[2], t))
    return (r, g, b)

# Smooth movement over time
class SmoothMover:
    def __init__(self, start_pos):
        self.current_pos = start_pos
        self.target_pos = start_pos
        self.move_time = 0
        self.move_duration = 1.0  # 1 second
        self.start_pos = start_pos
    
    def move_to(self, target, duration=1.0):
        self.start_pos = self.current_pos
        self.target_pos = target
        self.move_time = 0
        self.move_duration = duration
    
    def update(self, dt):
        if self.move_time < self.move_duration:
            self.move_time += dt
            t = min(self.move_time / self.move_duration, 1.0)
            self.current_pos = lerp_point(self.start_pos, self.target_pos, t)

Common Easing Functions

import math

class Easing:
    @staticmethod
    def linear(t):
        return t
    
    @staticmethod
    def ease_in_quad(t):
        """Slow start, fast end"""
        return t * t
    
    @staticmethod
    def ease_out_quad(t):
        """Fast start, slow end"""
        return 1 - (1 - t) * (1 - t)
    
    @staticmethod
    def ease_in_out_quad(t):
        """Slow start and end"""
        if t < 0.5:
            return 2 * t * t
        else:
            return 1 - pow(-2 * t + 2, 2) / 2
    
    @staticmethod
    def ease_in_cubic(t):
        return t * t * t
    
    @staticmethod
    def ease_out_cubic(t):
        return 1 - pow(1 - t, 3)
    
    @staticmethod
    def ease_in_out_cubic(t):
        if t < 0.5:
            return 4 * t * t * t
        else:
            return 1 - pow(-2 * t + 2, 3) / 2
    
    @staticmethod
    def ease_in_sine(t):
        return 1 - math.cos((t * math.pi) / 2)
    
    @staticmethod
    def ease_out_sine(t):
        return math.sin((t * math.pi) / 2)
    
    @staticmethod
    def ease_in_out_sine(t):
        return -(math.cos(math.pi * t) - 1) / 2
    
    @staticmethod
    def ease_in_expo(t):
        return 0 if t == 0 else pow(2, 10 * t - 10)
    
    @staticmethod
    def ease_out_expo(t):
        return 1 if t == 1 else 1 - pow(2, -10 * t)
    
    @staticmethod
    def ease_in_out_expo(t):
        if t == 0:
            return 0
        if t == 1:
            return 1
        if t < 0.5:
            return pow(2, 20 * t - 10) / 2
        else:
            return (2 - pow(2, -20 * t + 10)) / 2
    
    @staticmethod
    def ease_in_elastic(t):
        if t == 0:
            return 0
        if t == 1:
            return 1
        c4 = (2 * math.pi) / 3
        return -pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4)
    
    @staticmethod
    def ease_out_elastic(t):
        if t == 0:
            return 0
        if t == 1:
            return 1
        c4 = (2 * math.pi) / 3
        return pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1
    
    @staticmethod
    def ease_out_bounce(t):
        n1 = 7.5625
        d1 = 2.75
        
        if t < 1 / d1:
            return n1 * t * t
        elif t < 2 / d1:
            t -= 1.5 / d1
            return n1 * t * t + 0.75
        elif t < 2.5 / d1:
            t -= 2.25 / d1
            return n1 * t * t + 0.9375
        else:
            t -= 2.625 / d1
            return n1 * t * t + 0.984375
    
    @staticmethod
    def ease_in_bounce(t):
        return 1 - Easing.ease_out_bounce(1 - t)
    
    @staticmethod
    def ease_in_back(t):
        c1 = 1.70158
        c3 = c1 + 1
        return c3 * t * t * t - c1 * t * t
    
    @staticmethod
    def ease_out_back(t):
        c1 = 1.70158
        c3 = c1 + 1
        return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)

Smooth Following (Lerp Smoothing)

class Camera:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.smoothness = 0.1  # Lower = smoother/slower
    
    def follow(self, target_x, target_y):
        """Smoothly follow a target using lerp"""
        self.x = lerp(self.x, target_x, self.smoothness)
        self.y = lerp(self.y, target_y, self.smoothness)
    
    def follow_with_offset(self, target_x, target_y, offset_x=0, offset_y=0):
        """Follow with look-ahead offset"""
        goal_x = target_x + offset_x
        goal_y = target_y + offset_y
        self.x = lerp(self.x, goal_x, self.smoothness)
        self.y = lerp(self.y, goal_y, self.smoothness)

# Smooth value changes
class SmoothValue:
    def __init__(self, initial_value, smoothing=0.1):
        self.current = initial_value
        self.target = initial_value
        self.smoothing = smoothing
    
    def set_target(self, value):
        self.target = value
    
    def update(self):
        self.current = lerp(self.current, self.target, self.smoothing)
    
    def snap_to(self, value):
        """Instantly set value without smoothing"""
        self.current = value
        self.target = value

# Example: Smooth health bar
class HealthBar:
    def __init__(self, max_health):
        self.max_health = max_health
        self.actual_health = max_health
        self.display_health = SmoothValue(max_health, 0.1)
    
    def take_damage(self, amount):
        self.actual_health = max(0, self.actual_health - amount)
        self.display_health.set_target(self.actual_health)
    
    def update(self):
        self.display_health.update()
    
    def draw(self, screen, x, y, width, height):
        # Background
        pygame.draw.rect(screen, (50, 50, 50), (x, y, width, height))
        
        # Smooth animated health bar
        health_width = (self.display_health.current / self.max_health) * width
        pygame.draw.rect(screen, (255, 0, 0), (x, y, health_width, height))

Animation System with Easing

class Animation:
    def __init__(self, start_value, end_value, duration, easing_func=None):
        self.start_value = start_value
        self.end_value = end_value
        self.duration = duration
        self.elapsed = 0
        self.easing_func = easing_func or Easing.linear
        self.finished = False
        self.current_value = start_value
    
    def update(self, dt):
        if self.finished:
            return
        
        self.elapsed += dt
        
        if self.elapsed >= self.duration:
            self.elapsed = self.duration
            self.finished = True
        
        # Calculate t (0 to 1)
        t = self.elapsed / self.duration
        
        # Apply easing
        eased_t = self.easing_func(t)
        
        # Interpolate
        self.current_value = lerp(self.start_value, self.end_value, eased_t)
    
    def reset(self):
        self.elapsed = 0
        self.finished = False
        self.current_value = self.start_value
    
    def reverse(self):
        """Swap start and end values"""
        self.start_value, self.end_value = self.end_value, self.start_value
        self.reset()

# Animation sequence
class AnimationSequence:
    def __init__(self):
        self.animations = []
        self.current_index = 0
    
    def add(self, animation):
        self.animations.append(animation)
        return self
    
    def update(self, dt):
        if self.current_index < len(self.animations):
            current = self.animations[self.current_index]
            current.update(dt)
            
            if current.finished:
                self.current_index += 1
    
    def reset(self):
        self.current_index = 0
        for anim in self.animations:
            anim.reset()
    
    def is_finished(self):
        return self.current_index >= len(self.animations)

Bezier Curves

def bezier_quadratic(p0, p1, p2, t):
    """Quadratic Bezier curve with 3 control points"""
    # Linear interpolations
    q0 = lerp_point(p0, p1, t)
    q1 = lerp_point(p1, p2, t)
    
    # Final interpolation
    return lerp_point(q0, q1, t)

def bezier_cubic(p0, p1, p2, p3, t):
    """Cubic Bezier curve with 4 control points"""
    # First level interpolations
    q0 = lerp_point(p0, p1, t)
    q1 = lerp_point(p1, p2, t)
    q2 = lerp_point(p2, p3, t)
    
    # Second level interpolations
    r0 = lerp_point(q0, q1, t)
    r1 = lerp_point(q1, q2, t)
    
    # Final interpolation
    return lerp_point(r0, r1, t)

class BezierPath:
    def __init__(self, points):
        """Create a Bezier path from control points"""
        self.points = points
        self.length = self.calculate_length()
    
    def calculate_length(self, samples=100):
        """Approximate the length of the curve"""
        total_length = 0
        prev_point = self.get_point(0)
        
        for i in range(1, samples + 1):
            t = i / samples
            current_point = self.get_point(t)
            dx = current_point[0] - prev_point[0]
            dy = current_point[1] - prev_point[1]
            total_length += math.sqrt(dx * dx + dy * dy)
            prev_point = current_point
        
        return total_length
    
    def get_point(self, t):
        """Get point on curve at parameter t (0 to 1)"""
        if len(self.points) == 3:
            return bezier_quadratic(self.points[0], self.points[1], 
                                  self.points[2], t)
        elif len(self.points) == 4:
            return bezier_cubic(self.points[0], self.points[1], 
                              self.points[2], self.points[3], t)
        else:
            # Fallback to linear
            return lerp_point(self.points[0], self.points[-1], t)
    
    def get_point_at_distance(self, distance):
        """Get point at specific distance along curve"""
        if distance <= 0:
            return self.points[0]
        if distance >= self.length:
            return self.points[-1]
        
        # Binary search for correct t value
        low, high = 0.0, 1.0
        epsilon = 0.001
        
        while high - low > epsilon:
            mid = (low + high) / 2
            mid_length = self.calculate_length_to_t(mid)
            
            if mid_length < distance:
                low = mid
            else:
                high = mid
        
        return self.get_point((low + high) / 2)
    
    def calculate_length_to_t(self, t, samples=20):
        """Calculate length from start to parameter t"""
        total_length = 0
        prev_point = self.get_point(0)
        
        for i in range(1, samples + 1):
            sample_t = (i / samples) * t
            current_point = self.get_point(sample_t)
            dx = current_point[0] - prev_point[0]
            dy = current_point[1] - prev_point[1]
            total_length += math.sqrt(dx * dx + dy * dy)
            prev_point = current_point
        
        return total_length

Spring Physics

class Spring:
    def __init__(self, target, stiffness=0.1, damping=0.9):
        self.target = target
        self.current = target
        self.velocity = 0
        self.stiffness = stiffness  # Spring constant
        self.damping = damping      # Friction
    
    def update(self, target=None):
        if target is not None:
            self.target = target
        
        # Calculate spring force
        displacement = self.target - self.current
        spring_force = displacement * self.stiffness
        
        # Apply force to velocity
        self.velocity += spring_force
        
        # Apply damping
        self.velocity *= self.damping
        
        # Update position
        self.current += self.velocity
    
    def set_target(self, target):
        self.target = target
    
    def snap_to(self, value):
        self.current = value
        self.target = value
        self.velocity = 0

# 2D Spring
class Spring2D:
    def __init__(self, x, y, stiffness=0.1, damping=0.9):
        self.spring_x = Spring(x, stiffness, damping)
        self.spring_y = Spring(y, stiffness, damping)
    
    def update(self, target_x=None, target_y=None):
        self.spring_x.update(target_x)
        self.spring_y.update(target_y)
    
    def set_target(self, x, y):
        self.spring_x.set_target(x)
        self.spring_y.set_target(y)
    
    def get_position(self):
        return (self.spring_x.current, self.spring_y.current)

# Example: Springy camera
class SpringCamera:
    def __init__(self, x, y):
        self.spring = Spring2D(x, y, stiffness=0.05, damping=0.85)
    
    def follow(self, target_x, target_y):
        self.spring.set_target(target_x, target_y)
        self.spring.update()
        return self.spring.get_position()

Complete Interpolation Demo Game

import pygame
import math

class InterpolationDemo:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((800, 600))
        pygame.display.set_caption("Interpolation and Easing Demo")
        self.clock = pygame.time.Clock()
        
        # Create different moving objects with different easing
        self.objects = [
            {
                'name': 'Linear',
                'x': 100, 'y': 100,
                'start_x': 100, 'end_x': 700,
                'time': 0, 'duration': 2,
                'easing': Easing.linear,
                'color': (255, 100, 100)
            },
            {
                'name': 'Ease In Out',
                'x': 100, 'y': 150,
                'start_x': 100, 'end_x': 700,
                'time': 0, 'duration': 2,
                'easing': Easing.ease_in_out_cubic,
                'color': (100, 255, 100)
            },
            {
                'name': 'Bounce',
                'x': 100, 'y': 200,
                'start_x': 100, 'end_x': 700,
                'time': 0, 'duration': 2,
                'easing': Easing.ease_out_bounce,
                'color': (100, 100, 255)
            },
            {
                'name': 'Elastic',
                'x': 100, 'y': 250,
                'start_x': 100, 'end_x': 700,
                'time': 0, 'duration': 2,
                'easing': Easing.ease_out_elastic,
                'color': (255, 255, 100)
            },
            {
                'name': 'Back',
                'x': 100, 'y': 300,
                'start_x': 100, 'end_x': 700,
                'time': 0, 'duration': 2,
                'easing': Easing.ease_out_back,
                'color': (255, 100, 255)
            }
        ]
        
        # Spring follower
        self.mouse_follower = Spring2D(400, 400, stiffness=0.05, damping=0.8)
        
        # Bezier path
        self.bezier_points = [
            (100, 500),
            (300, 400),
            (500, 400),
            (700, 500)
        ]
        self.bezier_path = BezierPath(self.bezier_points)
        self.bezier_t = 0
        
        # UI animations
        self.ui_scale = Animation(0, 1, 0.5, Easing.ease_out_back)
        self.ui_alpha = Animation(0, 255, 1, Easing.ease_out_cubic)
        
        # Smooth camera
        self.camera = Camera(0, 0)
        self.camera.smoothness = 0.1
        
        # Color lerping
        self.color_time = 0
        self.color1 = (255, 0, 0)
        self.color2 = (0, 0, 255)
    
    def handle_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_SPACE:
                    # Reset all animations
                    for obj in self.objects:
                        obj['time'] = 0
                    self.ui_scale.reset()
                    self.ui_alpha.reset()
        return True
    
    def update(self, dt):
        # Update moving objects
        for obj in self.objects:
            obj['time'] += dt
            if obj['time'] >= obj['duration']:
                # Reverse direction
                obj['start_x'], obj['end_x'] = obj['end_x'], obj['start_x']
                obj['time'] = 0
            
            t = obj['time'] / obj['duration']
            eased_t = obj['easing'](t)
            obj['x'] = lerp(obj['start_x'], obj['end_x'], eased_t)
        
        # Update spring follower
        mouse_x, mouse_y = pygame.mouse.get_pos()
        self.mouse_follower.set_target(mouse_x, mouse_y)
        self.mouse_follower.update()
        
        # Update Bezier animation
        self.bezier_t += dt * 0.2
        if self.bezier_t > 1:
            self.bezier_t = 0
        
        # Update UI animations
        self.ui_scale.update(dt)
        self.ui_alpha.update(dt)
        
        # Update color lerping
        self.color_time += dt
    
    def draw(self):
        self.screen.fill((30, 30, 30))
        
        # Draw moving objects
        font = pygame.font.Font(None, 24)
        for obj in self.objects:
            pygame.draw.circle(self.screen, obj['color'], 
                             (int(obj['x']), int(obj['y'])), 15)
            
            # Draw trail
            for i in range(5):
                trail_t = max(0, obj['time'] / obj['duration'] - i * 0.05)
                trail_eased = obj['easing'](trail_t)
                trail_x = lerp(obj['start_x'], obj['end_x'], trail_eased)
                alpha = 255 - i * 50
                trail_color = (*obj['color'], alpha)
                pygame.draw.circle(self.screen, obj['color'], 
                                 (int(trail_x), int(obj['y'])), 
                                 15 - i * 2, 1)
            
            # Label
            label = font.render(obj['name'], True, (255, 255, 255))
            self.screen.blit(label, (10, obj['y'] - 10))
        
        # Draw spring follower
        spring_pos = self.mouse_follower.get_position()
        pygame.draw.circle(self.screen, (100, 255, 255), 
                         (int(spring_pos[0]), int(spring_pos[1])), 20)
        pygame.draw.line(self.screen, (100, 255, 255),
                        (mouse_x, mouse_y), spring_pos, 2)
        
        # Draw Bezier curve
        # Draw control points
        for i, point in enumerate(self.bezier_points):
            pygame.draw.circle(self.screen, (100, 100, 100), 
                             (int(point[0]), int(point[1])), 5)
            if i > 0:
                pygame.draw.line(self.screen, (50, 50, 50),
                               self.bezier_points[i-1], point, 1)
        
        # Draw curve
        prev_point = self.bezier_path.get_point(0)
        for i in range(1, 51):
            t = i / 50
            current_point = self.bezier_path.get_point(t)
            pygame.draw.line(self.screen, (255, 200, 100),
                           prev_point, current_point, 2)
            prev_point = current_point
        
        # Draw object on Bezier curve
        bezier_pos = self.bezier_path.get_point(self.bezier_t)
        pygame.draw.circle(self.screen, (255, 255, 0),
                         (int(bezier_pos[0]), int(bezier_pos[1])), 10)
        
        # Draw animated UI element
        if self.ui_scale.current_value > 0:
            size = int(50 * self.ui_scale.current_value)
            alpha = int(self.ui_alpha.current_value)
            ui_surf = pygame.Surface((size * 2, size * 2), pygame.SRCALPHA)
            pygame.draw.circle(ui_surf, (*[200, 100, 255], alpha),
                             (size, size), size)
            self.screen.blit(ui_surf, (650 - size, 450 - size))
        
        # Draw color lerping example
        t = (math.sin(self.color_time) + 1) / 2  # Oscillate between 0 and 1
        current_color = lerp_color(self.color1, self.color2, t)
        pygame.draw.rect(self.screen, current_color, (650, 50, 100, 100))
        
        # Instructions
        instructions = [
            "Space: Reset animations",
            "Move mouse for spring effect"
        ]
        for i, text in enumerate(instructions):
            rendered = font.render(text, True, (150, 150, 150))
            self.screen.blit(rendered, (10, 550 + i * 25))
    
    def run(self):
        running = True
        dt = 0
        
        while running:
            running = self.handle_events()
            self.update(dt)
            self.draw()
            pygame.display.flip()
            dt = self.clock.tick(60) / 1000.0
        
        pygame.quit()

if __name__ == "__main__":
    # Include Easing class definition here
    demo = InterpolationDemo()
    demo.run()

Performance Considerations

⚡ Optimization Tips

Practice Exercises

🎯 Interpolation Challenges!

  1. UI Animation System: Sliding menus with different easings
  2. Camera Controller: Smooth camera with deadzone and lookahead
  3. Particle Trail: Particles that follow Bezier curves
  4. Morphing Shapes: Interpolate between different polygon shapes
  5. Time Control: Game with slow-motion and speed-up effects
  6. Procedural Animation: Character that moves with spring physics

Key Takeaways

🏋️‍♂️ Practice Exercise: Three Boxes, Same Distance, Different Feels

🏋️‍♂️ Exercise 1: Lerp Drift — Three Eased Animations on a Shared Clock

Objective: Build a side-by-side comparison demo where three boxes race across the screen over the SAME duration but with DIFFERENT easing functions — proving by visible drift that easing functions transform the parameter t, not the endpoints. Box A uses linear (constant speed); Box B uses ease-out quadratic (fast then slow); Box C uses ease-in quadratic (slow then fast). All three call lerp(start_x, end_x, easing(t)) with the SAME start_x, the SAME end_x, and the SAME t = elapsed / duration; only the easing function differs. The boxes arrive at the same finish line at the same wall-clock moment but visibly drift apart mid-flight — the moneyshot of the lesson. Press SPACE to restart the animation and watch the drift again from t=0.

Instructions:

  1. Open an 800×600 window and start a clean clock.tick(60) game loop. Use dt = clock.tick(60) / 1000.0 so elapsed is in seconds (matches the lesson's Performance Tips Frame Independence rule).
  2. Define lerp(a, b, t) = a + (b - a) * t as a tiny helper; this is the foundation the lesson opens with.
  3. Define two easing helpers: ease_out(t) = 1 - (1 - t) * (1 - t) (fast then slow); ease_in(t) = t * t (slow then fast). Both take t in [0, 1] and return a transformed value also roughly in [0, 1].
  4. Set DURATION = 2.0 seconds, START_X = 50, END_X = 750; box Y positions at three horizontal lanes (e.g. 200 / 300 / 400).
  5. Each frame, advance elapsed by dt and compute t = min(elapsed / DURATION, 1.0) — the min() CLAMP is critical; without it t walks past 1.0 and the boxes shoot off the right edge.
  6. For each box, compute its X via the SAME lerp(START_X, END_X, easing(t)) call structure; only the easing function differs (linear is just t itself, no transform). Draw the three boxes at their three lanes.
  7. On KEYDOWN for K_SPACE, reset elapsed = 0.0 to restart the animation. Render a small label per lane ("Linear" / "Ease Out" / "Ease In") so the visual drift is unambiguous.
💡 Hint

The order of operations is the conceptual key. Easing transforms t BEFORE lerp uses it: lerp(a, b, easing(t)), NOT easing(lerp(a, b, t)). The first form is what the lesson teaches and what every game engine ships; the second form would warp the endpoints which is meaningless. Mid-flight at t=0.5: linear gives 0.5, ease-out gives 0.75 (Box B is already 75% across), ease-in gives 0.25 (Box C is only 25% across). All three reach 1.0 at exactly t=1.0 and end at END_X at exactly the same wall-clock moment — same start, same end, same duration, different mid-flight position. If your boxes shoot past the right edge, you forgot to clamp t.

✅ Example Solution
import pygame

pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Lerp Drift")
font = pygame.font.SysFont(None, 24)
clock = pygame.time.Clock()

DURATION = 2.0
START_X, END_X = 50, 750
BOX_W, BOX_H = 40, 40

def lerp(a, b, t):
    return a + (b - a) * t

def ease_out(t):
    return 1 - (1 - t) * (1 - t)

def ease_in(t):
    return t * t

elapsed = 0.0
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            elapsed = 0.0   # restart

    t = min(elapsed / DURATION, 1.0)   # CLAMP so boxes don't shoot past END_X

    screen.fill((30, 30, 30))
    for label, easing, lane_y, color in [
        ("Linear",   lambda x: x,   200, (200, 200, 200)),
        ("Ease Out", ease_out,      300, (60, 200, 60)),
        ("Ease In",  ease_in,       400, (220, 90, 90)),
    ]:
        x = lerp(START_X, END_X, easing(t))   # easing transforms t BEFORE lerp
        pygame.draw.rect(screen, color, (x, lane_y, BOX_W, BOX_H))
        screen.blit(font.render(label, True, color), (10, lane_y + 10))

    screen.blit(font.render(f"t = {t:.2f}   (SPACE to restart)", True, (240, 240, 240)), (10, 20))
    pygame.display.flip()
    elapsed += clock.tick(60) / 1000.0

pygame.quit()

🎯 Quick Quiz

Question 1: What is the linear-interpolation formula `lerp(a, b, t)` and what does it return when t=0, t=1, and t=0.5?

Question 2: In a 2-second tween, what does the parameter `t` represent and what is its valid range?

Question 3: An easing function like `ease_out` is applied to a tween. What does it actually transform, and where in the call chain?

What's Next?

Now that you've mastered smooth motion, next we'll explore random generation for games - creating variety, procedural content, and controlled randomness!