Physics and Enemy AI

Pygame Game Development Course

Felipe A. Moreno-Vera

πŸ€– Enemies & Basic AI

Steering Behaviors

Steering behaviors are simple force calculations that, combined, produce complex-looking AI movement.

Desired Velocity = direction Γ— max_speed
Steering Force   = Desired Velocity - Current Velocity
New Velocity     = Current Velocity + Steering Force Γ— dt

Seek

Move toward a target at full speed:

def seek(ex, ey, evx, evy, tx, ty, max_speed, max_force):
    dx, dy   = tx - ex, ty - ey
    dist     = math.hypot(dx, dy)
    if dist < 0.1: return 0, 0
    desired_vx = (dx / dist) * max_speed
    desired_vy = (dy / dist) * max_speed
    fx = desired_vx - evx   # Steering = desired - current
    fy = desired_vy - evy
    # Clamp to max_force
    fmag = math.hypot(fx, fy)
    if fmag > max_force:
        fx, fy = fx / fmag * max_force, fy / fmag * max_force
    return fx, fy

Flee

The inverse of seek β€” steer away:

def flee(ex, ey, evx, evy, tx, ty, max_speed, max_force):
    # Seek toward a point on the opposite side
    return seek(ex, ey, evx, evy,
                ex - (tx - ex),   # Mirrored target X
                ey - (ty - ey),   # Mirrored target Y
                max_speed, max_force)

Arrival (Seek with deceleration)

Slow down as the agent approaches the target to avoid overshooting:

def seek_with_arrival(ex, ey, evx, evy, tx, ty,
                      max_speed, max_force, slow_radius=80):
    dx, dy   = tx - ex, ty - ey
    dist     = math.hypot(dx, dy)
    speed    = max_speed if dist > slow_radius else max_speed * (dist / slow_radius)
    desired_vx = (dx / dist) * speed if dist > 0.1 else 0
    desired_vy = (dy / dist) * speed if dist > 0.1 else 0
    fx = desired_vx - evx
    fy = desired_vy - evy
    fmag = math.hypot(fx, fy)
    if fmag > max_force:
        fx, fy = fx / fmag * max_force, fy / fmag * max_force
    return fx, fy

Arrival (Seek with deceleration)

Slow down as the agent approaches the target to avoid overshooting:

def seek_with_arrival(ex, ey, evx, evy, tx, ty,
                      max_speed, max_force, slow_radius=80):
    dx, dy   = tx - ex, ty - ey
    dist     = math.hypot(dx, dy)
    speed    = max_speed if dist > slow_radius else max_speed * (dist / slow_radius)
    desired_vx = (dx / dist) * speed if dist > 0.1 else 0
    desired_vy = (dy / dist) * speed if dist > 0.1 else 0
    fx = desired_vx - evx
    fy = desired_vy - evy
    fmag = math.hypot(fx, fy)
    if fmag > max_force:
        fx, fy = fx / fmag * max_force, fy / fmag * max_force
    return fx, fy
Arrival behavior β€” speed vs distance to target
  max_speed=150 px/s   slow_radius=80 px

  Distance     Speed        Behavior
  --------------------------------------
  200          150.0        full speed
  150          150.0        full speed
  100          150.0        full speed
  80           150.0        slowing
  60           112.5        slowing
  40           75.0         slowing
  20           37.5         slowing
  5            9.4          stopped
  0            0.0          stopped

Line of Sight (LOS)

LOS checks whether there is a clear path between two points. Uses parametric line-AABB intersection:

def line_of_sight(ax, ay, bx, by, wall_rects):
    """Returns True if A can see B without hitting any wall."""
    dx, dy = bx - ax, by - ay
    for wall in wall_rects:
        inv_dx = 1 / dx if dx != 0 else float('inf')
        inv_dy = 1 / dy if dy != 0 else float('inf')
        tx1 = (wall.left   - ax) * inv_dx
        tx2 = (wall.right  - ax) * inv_dx
        ty1 = (wall.top    - ay) * inv_dy
        ty2 = (wall.bottom - ay) * inv_dy
        tmin = max(min(tx1, tx2), min(ty1, ty2))
        tmax = min(max(tx1, tx2), max(ty1, ty2))
        if tmax >= 0 and tmin <= tmax and tmin <= 1:
            return False   # Blocked
    return True

Vision Cone (Field of View)

Check if a target falls within the enemy’s forward arc:

def in_fov(ex, ey, facing_angle, fov_degrees, tx, ty):
    dx, dy       = tx - ex, ty - ey
    target_angle = math.atan2(dy, dx)
    # Shortest angular distance (handles wraparound)
    delta = abs(math.atan2(
        math.sin(target_angle - facing_angle),
        math.cos(target_angle - facing_angle)
    ))
    return delta <= math.radians(fov_degrees / 2)

The Finite State Machine (FSM)

A FSM is a set of states and transitions between them. An enemy is always in exactly one state.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   ENEMY FSM                          β”‚
β”‚                                                      β”‚
β”‚  IDLE ──timer──► PATROL ──detect──► ALERT            β”‚
β”‚   β–²                 β–²                  β”‚             β”‚
β”‚   β”‚                 β”‚             timer / lost       β”‚
β”‚   β”‚                 β”‚                  β–Ό             β”‚
β”‚   β”‚                 └───lost──── CHASE ◄─── far      β”‚
β”‚   β”‚                                   β”‚              β”‚
β”‚   β”‚                              closeβ”‚              β”‚
β”‚   β”‚                                   β–Ό              β”‚
β”‚   β”‚                               ATTACK ──low HP──► β”‚
β”‚   β”‚                                                  β”‚
β”‚   β”‚                  FLEE ──safe / hp──► PATROL      β”‚
β”‚   └────────────────────────────────────────────────  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

State base class pattern

class State:
    def enter(self, enemy): pass
    def update(self, enemy, dt, target): pass
    def exit(self, enemy):  pass

class IdleState(State):
    def enter(self, enemy):
        enemy.vx = enemy.vy = 0

    def update(self, enemy, dt, target):
        dist = math.hypot(target.x - enemy.x, target.y - enemy.y)
        if dist < DETECT_RADIUS:
            enemy.transition_to("ALERT")
        elif enemy.state_timer > IDLE_DURATION:
            enemy.transition_to("PATROL")

class ChaseState(State):
    def update(self, enemy, dt, target):
        if math.hypot(target.x - enemy.x, target.y - enemy.y) < ATTACK_RADIUS:
            enemy.transition_to("ATTACK")
        # Steer toward target
        fx, fy = seek(enemy.x, enemy.y, enemy.vx, enemy.vy,
                      target.x, target.y, CHASE_SPEED, MAX_FORCE)
        enemy.vx += fx * dt
        enemy.vy += fy * dt

Memory β€” last seen position

When the player leaves the field of view, the enemy moves to the last known position instead of giving up immediately:

MEMORY_DURATION = 3.0   # seconds

# When player is visible:
last_seen_x   = player.x
last_seen_y   = player.y
memory_timer  = MEMORY_DURATION

# When player is not visible:
memory_timer -= dt
if memory_timer > 0:
    steer_toward(last_seen_x, last_seen_y)   # Move to last known pos
else:
    transition_to("PATROL")                  # Give up

Memory β€” last seen position

Enemy FSM simulation
================================================
    Time  State         HP   Player dist
  ----------------------------------------
    2.99  PATROL       100           400
    3.98  PATROL       100           400
    4.48  ALERT        100           180
    4.98  ALERT        100           180
    6.98  CHASE        100           120
    7.47  ATTACK       100            30
    7.97  ATTACK     80.15999999999998            30
    8.46  ATTACK     60.319999999999965            30
   10.46  CHASE      60.319999999999965           350

Transitions:
  IDLE β†’ PATROL
  PATROL β†’ ALERT
  ALERT β†’ CHASE
  CHASE β†’ ATTACK
  ATTACK β†’ CHASE

Enemy AI β€” Summary

  • Steering = desired_velocity - current_velocity β†’ apply as force
  • Seek: steer toward target at full speed
  • Flee: steer away (inverse seek)
  • Arrival: seek with speed scaled by proximity β†’ no overshooting
  • Wander: random noise on a projected circle β†’ natural idle movement
  • LOS: parametric ray–AABB test β†’ True if no wall blocks the path
  • FOV: angular difference between facing and target direction
  • FSM: one active state at a time; explicit transitions with enter/exit hooks
  • Memory: store last seen position; pursue it before giving up