Welcome to Lesson 6 of our 2D Platformer Game Development course! In this lesson, you'll learn how to create engaging enemy AI and behavior patterns that will bring your game world to life. By the end of this lesson, you'll have implemented three different enemy types with unique behaviors, collision detection, and damage systems.

What You'll Learn

  • How to design and implement enemy AI patterns
  • Collision detection between player and enemies
  • Damage systems and health management
  • Different enemy types with unique behaviors
  • Performance optimization for multiple enemies

Prerequisites

  • Completed Lesson 5: Level Design & Platforms
  • Basic understanding of Unity 2D physics
  • Familiarity with C# scripting fundamentals

Step 1: Enemy Design Planning

Before diving into code, let's plan our enemy types and their behaviors:

Enemy Types We'll Create

  1. Patrol Enemy - Walks back and forth on platforms
  2. Chase Enemy - Follows the player when in range
  3. Shooting Enemy - Attacks from a distance

Enemy Behavior Patterns

  • Patrol: Move between two points, turn around at edges
  • Chase: Detect player, follow until out of range
  • Shoot: Stay in position, fire projectiles at player

Step 2: Create Enemy Base Class

Let's start by creating a base enemy class that all enemy types will inherit from:

using UnityEngine;

public abstract class Enemy : MonoBehaviour
{
    [Header("Enemy Stats")]
    public int health = 3;
    public int damage = 1;
    public float moveSpeed = 2f;
    public float detectionRange = 5f;

    [Header("Components")]
    protected Rigidbody2D rb;
    protected SpriteRenderer spriteRenderer;
    protected Animator animator;
    protected Transform player;

    [Header("State")]
    protected bool isAlive = true;
    protected bool isFacingRight = true;

    protected virtual void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        spriteRenderer = GetComponent<SpriteRenderer>();
        animator = GetComponent<Animator>();

        // Find the player
        GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
        if (playerObj != null)
        {
            player = playerObj.transform;
        }
    }

    protected virtual void Update()
    {
        if (!isAlive) return;

        UpdateAI();
        UpdateAnimation();
    }

    protected abstract void UpdateAI();

    protected virtual void UpdateAnimation()
    {
        if (animator != null)
        {
            animator.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
            animator.SetBool("IsAlive", isAlive);
        }
    }

    public virtual void TakeDamage(int damageAmount)
    {
        health -= damageAmount;

        if (health <= 0)
        {
            Die();
        }
    }

    protected virtual void Die()
    {
        isAlive = false;
        rb.velocity = Vector2.zero;

        // Play death animation
        if (animator != null)
        {
            animator.SetTrigger("Die");
        }

        // Destroy after animation
        Destroy(gameObject, 1f);
    }

    protected virtual void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            // Deal damage to player
            PlayerController playerController = other.GetComponent<PlayerController>();
            if (playerController != null)
            {
                playerController.TakeDamage(damage);
            }
        }
    }
}

Step 3: Implement Patrol Enemy

Create a new script called PatrolEnemy.cs:

using UnityEngine;

public class PatrolEnemy : Enemy
{
    [Header("Patrol Settings")]
    public Transform leftPoint;
    public Transform rightPoint;
    public float waitTime = 1f;

    private bool movingRight = true;
    private float waitTimer = 0f;
    private bool isWaiting = false;

    protected override void Start()
    {
        base.Start();

        // Set initial position if patrol points aren't set
        if (leftPoint == null)
        {
            leftPoint = new GameObject("LeftPoint").transform;
            leftPoint.position = transform.position + Vector3.left * 3f;
        }

        if (rightPoint == null)
        {
            rightPoint = new GameObject("RightPoint").transform;
            rightPoint.position = transform.position + Vector3.right * 3f;
        }
    }

    protected override void UpdateAI()
    {
        if (isWaiting)
        {
            waitTimer += Time.deltaTime;
            if (waitTimer >= waitTime)
            {
                isWaiting = false;
                waitTimer = 0f;
                movingRight = !movingRight;
            }
            return;
        }

        // Move towards current target
        Vector3 targetPosition = movingRight ? rightPoint.position : leftPoint.position;
        Vector3 direction = (targetPosition - transform.position).normalized;

        rb.velocity = new Vector2(direction.x * moveSpeed, rb.velocity.y);

        // Check if reached target
        float distanceToTarget = Vector2.Distance(transform.position, targetPosition);
        if (distanceToTarget < 0.5f)
        {
            isWaiting = true;
            rb.velocity = new Vector2(0, rb.velocity.y);
        }

        // Flip sprite based on movement direction
        if (direction.x > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (direction.x < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        spriteRenderer.flipX = !isFacingRight;
    }
}

Step 4: Implement Chase Enemy

Create a new script called ChaseEnemy.cs:

using UnityEngine;

public class ChaseEnemy : Enemy
{
    [Header("Chase Settings")]
    public float chaseSpeed = 3f;
    public float stopDistance = 1f;
    public LayerMask groundLayer;

    private bool isChasing = false;
    private Vector2 lastKnownPlayerPosition;

    protected override void UpdateAI()
    {
        if (player == null) return;

        float distanceToPlayer = Vector2.Distance(transform.position, player.position);

        // Check if player is in detection range
        if (distanceToPlayer <= detectionRange)
        {
            isChasing = true;
            lastKnownPlayerPosition = player.position;
        }
        else if (isChasing && distanceToPlayer > detectionRange * 1.5f)
        {
            isChasing = false;
        }

        if (isChasing)
        {
            ChasePlayer();
        }
        else
        {
            // Return to patrol or idle behavior
            rb.velocity = new Vector2(0, rb.velocity.y);
        }
    }

    private void ChasePlayer()
    {
        Vector2 direction = (player.position - transform.position).normalized;
        float distanceToPlayer = Vector2.Distance(transform.position, player.position);

        // Stop if too close to player
        if (distanceToPlayer <= stopDistance)
        {
            rb.velocity = new Vector2(0, rb.velocity.y);
            return;
        }

        // Move towards player
        rb.velocity = new Vector2(direction.x * chaseSpeed, rb.velocity.y);

        // Flip sprite based on movement direction
        if (direction.x > 0 && !isFacingRight)
        {
            Flip();
        }
        else if (direction.x < 0 && isFacingRight)
        {
            Flip();
        }
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        spriteRenderer.flipX = !isFacingRight;
    }
}

Step 5: Implement Shooting Enemy

Create a new script called ShootingEnemy.cs:

using UnityEngine;

public class ShootingEnemy : Enemy
{
    [Header("Shooting Settings")]
    public GameObject projectilePrefab;
    public Transform firePoint;
    public float fireRate = 2f;
    public float projectileSpeed = 8f;

    private float lastFireTime = 0f;
    private bool canSeePlayer = false;

    protected override void Start()
    {
        base.Start();

        if (firePoint == null)
        {
            firePoint = new GameObject("FirePoint").transform;
            firePoint.SetParent(transform);
            firePoint.localPosition = Vector3.right * 0.5f;
        }
    }

    protected override void UpdateAI()
    {
        if (player == null) return;

        float distanceToPlayer = Vector2.Distance(transform.position, player.position);

        // Check if player is in range and line of sight
        if (distanceToPlayer <= detectionRange)
        {
            canSeePlayer = CheckLineOfSight();

            if (canSeePlayer)
            {
                // Face the player
                Vector2 direction = (player.position - transform.position).normalized;
                if (direction.x > 0 && !isFacingRight)
                {
                    Flip();
                }
                else if (direction.x < 0 && isFacingRight)
                {
                    Flip();
                }

                // Shoot at player
                if (Time.time - lastFireTime >= 1f / fireRate)
                {
                    Shoot();
                }
            }
        }
        else
        {
            canSeePlayer = false;
        }
    }

    private bool CheckLineOfSight()
    {
        Vector2 direction = (player.position - transform.position).normalized;
        RaycastHit2D hit = Physics2D.Raycast(transform.position, direction, detectionRange);

        return hit.collider != null && hit.collider.CompareTag("Player");
    }

    private void Shoot()
    {
        if (projectilePrefab == null) return;

        Vector2 direction = (player.position - transform.position).normalized;

        GameObject projectile = Instantiate(projectilePrefab, firePoint.position, Quaternion.identity);
        Rigidbody2D projectileRb = projectile.GetComponent<Rigidbody2D>();

        if (projectileRb != null)
        {
            projectileRb.velocity = direction * projectileSpeed;
        }

        // Rotate projectile to face direction
        float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
        projectile.transform.rotation = Quaternion.AngleAxis(angle, Vector3.forward);

        lastFireTime = Time.time;
    }

    private void Flip()
    {
        isFacingRight = !isFacingRight;
        spriteRenderer.flipX = !isFacingRight;
    }
}

Step 6: Create Projectile System

Create a projectile script for the shooting enemy:

using UnityEngine;

public class Projectile : MonoBehaviour
{
    [Header("Projectile Settings")]
    public int damage = 1;
    public float lifetime = 3f;
    public float speed = 8f;

    private Rigidbody2D rb;

    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        Destroy(gameObject, lifetime);
    }

    void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            PlayerController playerController = other.GetComponent<PlayerController>();
            if (playerController != null)
            {
                playerController.TakeDamage(damage);
            }
            Destroy(gameObject);
        }
        else if (other.CompareTag("Ground") || other.CompareTag("Platform"))
        {
            Destroy(gameObject);
        }
    }
}

Step 7: Update Player Controller

Add damage handling to your existing PlayerController:

// Add these variables to your PlayerController class
[Header("Health System")]
public int maxHealth = 3;
public int currentHealth;
public float invulnerabilityTime = 1f;

private bool isInvulnerable = false;
private float invulnerabilityTimer = 0f;

// Add this method to your PlayerController class
public void TakeDamage(int damage)
{
    if (isInvulnerable) return;

    currentHealth -= damage;
    isInvulnerable = true;
    invulnerabilityTimer = 0f;

    // Flash effect (you can implement this)
    StartCoroutine(FlashEffect());

    if (currentHealth <= 0)
    {
        Die();
    }
}

private IEnumerator FlashEffect()
{
    float flashTime = 0.1f;
    Color originalColor = GetComponent<SpriteRenderer>().color;

    while (isInvulnerable)
    {
        GetComponent<SpriteRenderer>().color = Color.red;
        yield return new WaitForSeconds(flashTime);
        GetComponent<SpriteRenderer>().color = originalColor;
        yield return new WaitForSeconds(flashTime);
    }
}

private void Update()
{
    // Your existing update code...

    // Handle invulnerability
    if (isInvulnerable)
    {
        invulnerabilityTimer += Time.deltaTime;
        if (invulnerabilityTimer >= invulnerabilityTime)
        {
            isInvulnerable = false;
        }
    }
}

private void Die()
{
    // Implement death logic
    Debug.Log("Player Died!");
    // You can add respawn logic here
}

Step 8: Set Up Enemy Prefabs

  1. Create Enemy Sprites: Design simple enemy sprites for each type

  2. Create Prefabs:

    • Add sprites to GameObjects
    • Attach appropriate scripts (PatrolEnemy, ChaseEnemy, ShootingEnemy)
    • Set up colliders and rigidbodies
    • Configure animation controllers
  3. Configure Enemy Settings:

    • Set health, damage, and speed values
    • Configure detection ranges
    • Set up patrol points for patrol enemies
    • Create projectile prefabs for shooting enemies

Step 9: Testing and Balancing

Mini Challenge: Enemy Variety

Create 3 different enemy types in your level:

  • 1 Patrol enemy that walks back and forth
  • 1 Chase enemy that follows the player
  • 1 Shooting enemy that attacks from a distance

Test the difficulty and adjust enemy stats as needed.

Pro Tips for Enemy Design

  1. Visual Feedback: Make enemies flash when hit
  2. Audio Cues: Add sound effects for enemy actions
  3. Particle Effects: Add death effects when enemies are destroyed
  4. Difficulty Scaling: Increase enemy speed/health in later levels
  5. Behavior Variety: Mix different enemy types in the same area

Troubleshooting Common Issues

Enemy Not Moving

  • Check if the enemy has a Rigidbody2D component
  • Verify the moveSpeed value is greater than 0
  • Ensure the enemy script is attached and enabled

Collision Detection Not Working

  • Make sure both objects have colliders
  • Check that one collider has "Is Trigger" enabled
  • Verify the tags are set correctly ("Player", "Enemy")

Performance Issues with Many Enemies

  • Use object pooling for projectiles
  • Implement enemy culling (disable enemies far from player)
  • Optimize AI update frequency

What's Next?

In the next lesson, we'll add collectibles and power-ups to make your game more engaging. You'll learn how to create items that players can collect, implement scoring systems, and add progression mechanics.

Key Takeaways

  • Enemy AI can be simple but effective with basic state machines
  • Collision detection is crucial for game mechanics
  • Health and damage systems create challenge and progression
  • Different enemy types add variety and strategic depth
  • Performance optimization becomes important with many enemies

Community Challenge

Share your enemy designs in our Discord community! Show off your creative enemy types and get feedback from other developers. Try creating a unique enemy behavior that combines elements from different types we covered.


Ready to add some challenge to your game? Let's create enemies that will test your players' skills and make your platformer truly engaging!