Back to Gaming

Unity Game Engine Series Part 11: AI & Gameplay Systems

March 31, 2026 Wasil Zafar 44 min read

Bring your game worlds to life with intelligent agents, dynamic encounters, and procedurally generated content. From NavMesh pathfinding to behavior trees, from finite state machines to procedural dungeon generation, this part covers the AI and gameplay systems that separate amateur projects from professional ones.

Table of Contents

  1. Navigation Systems
  2. AI Decision Systems
  3. Procedural Generation
  4. Core Game Systems
  5. Exercises & Self-Assessment
  6. AI Design Document Generator
  7. Conclusion & Next Steps

Introduction: The Soul of Your Game

Series Overview: This is Part 11 of our 16-part Unity Game Engine Series. Here we tackle the systems that make your game world feel alive: AI decision-making, pathfinding, procedural content generation, and the core gameplay systems (quests, inventory, dialogue, save/load) that form the backbone of any complete game.

Game AI is fundamentally different from academic AI. While machine learning focuses on training models on massive datasets, game AI is about creating the illusion of intelligence within strict performance budgets. Your NPCs don't need to actually think; they need to appear to think in ways that create compelling gameplay experiences.

The history of game AI stretches back to the earliest days of video games. Pac-Man's four ghosts (1980) each had distinct personality-driven behaviors: Blinky chased directly, Pinky ambushed from ahead, Inky flanked, and Clyde wandered randomly. These simple rules created emergent complexity that players found endlessly engaging. Modern NPCs in games like The Last of Us Part II use layered behavior trees with hundreds of nodes, but the core principle remains: simple rules, complex emergence.

Key Insight: The best game AI is not the smartest AI; it's the AI that creates the most fun. An unbeatable chess engine makes for a terrible game opponent. Great game AI deliberately makes mistakes, telegraphs attacks, and gives the player opportunities to feel clever. Design AI for player experience, not for optimal play.

A Brief History of Game AI

Era Technique Example
1980s Hard-coded rules, pattern matching Pac-Man ghost behaviors, Space Invaders patterns
1990s Finite state machines, A* pathfinding Half-Life marines, StarCraft unit AI
2000s Behavior trees, GOAP, nav meshes Halo 2 combat AI, F.E.A.R. squad tactics
2010s Utility AI, dynamic directors, ML agents Left 4 Dead AI Director, Alien: Isolation xenomorph
2020s Hybrid systems, ML-enhanced behaviors, LLM NPCs The Last of Us Part II, Unity ML-Agents
Case Study

Left 4 Dead's AI Director

Valve's Left 4 Dead (2008) introduced the "AI Director," a meta-AI system that dynamically adjusts game difficulty based on player performance. Rather than scripting fixed encounters, the Director monitors player stress (health, ammo, pacing) and procedurally spawns enemies, items, and dramatic events. When players are doing well, the Director ramps up intensity with horde rushes and special infected. When players are struggling, it provides breathing room with health kits and ammo. This creates a unique "emotional arc" in every playthrough, keeping tension high without ever feeling unfair.

Dynamic Difficulty Procedural Encounters Emotional Pacing Meta-AI

2. AI Decision Systems

Once your agents can navigate, they need to make decisions. The four major paradigms for game AI decision-making are: Finite State Machines (simple, explicit), Behavior Trees (modular, hierarchical), GOAP (goal-driven, emergent), and Utility AI (scored, fluid). Each has trade-offs.

2.1 Finite State Machines (FSMs)

An FSM is the simplest and most intuitive AI architecture. The agent is always in exactly one state, and transitions between states are triggered by conditions. Think of it like a flowchart: if the enemy sees the player, transition from "Patrol" to "Chase." If the player escapes, transition back to "Patrol."

// Enum-based FSM — simple and fast for small AI
using UnityEngine;
using UnityEngine.AI;

public class EnemyFSM : MonoBehaviour
{
    public enum AIState { Idle, Patrol, Chase, Attack, Flee, Dead }

    [Header("FSM Configuration")]
    [SerializeField] private float detectionRange = 15f;
    [SerializeField] private float attackRange = 2f;
    [SerializeField] private float fleeHealthThreshold = 20f;

    private AIState currentState = AIState.Idle;
    private NavMeshAgent agent;
    private Transform player;
    private float currentHealth = 100f;

    private void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        player = GameObject.FindGameObjectWithTag("Player").transform;
    }

    private void Update()
    {
        float distToPlayer = Vector3.Distance(transform.position, player.position);

        switch (currentState)
        {
            case AIState.Idle:
                // Transition: detected player?
                if (distToPlayer < detectionRange)
                    ChangeState(AIState.Chase);
                break;

            case AIState.Patrol:
                // Transition: detected player?
                if (distToPlayer < detectionRange)
                    ChangeState(AIState.Chase);
                break;

            case AIState.Chase:
                agent.SetDestination(player.position);
                // Transition: close enough to attack?
                if (distToPlayer < attackRange)
                    ChangeState(AIState.Attack);
                // Transition: player escaped?
                else if (distToPlayer > detectionRange * 1.5f)
                    ChangeState(AIState.Patrol);
                // Transition: low health?
                if (currentHealth < fleeHealthThreshold)
                    ChangeState(AIState.Flee);
                break;

            case AIState.Attack:
                transform.LookAt(player);
                // Attack logic here...
                if (distToPlayer > attackRange * 1.2f)
                    ChangeState(AIState.Chase);
                break;

            case AIState.Flee:
                Vector3 fleeDir = (transform.position - player.position).normalized;
                agent.SetDestination(transform.position + fleeDir * 10f);
                if (distToPlayer > detectionRange * 2f)
                    ChangeState(AIState.Idle);
                break;
        }
    }

    private void ChangeState(AIState newState)
    {
        // Exit current state
        OnStateExit(currentState);
        currentState = newState;
        // Enter new state
        OnStateEnter(newState);
    }

    private void OnStateEnter(AIState state)
    {
        Debug.Log($"Entering state: {state}");
        switch (state)
        {
            case AIState.Chase: agent.speed = 6f; break;
            case AIState.Patrol: agent.speed = 3f; break;
            case AIState.Flee: agent.speed = 8f; break;
            case AIState.Attack: agent.isStopped = true; break;
        }
    }

    private void OnStateExit(AIState state)
    {
        if (state == AIState.Attack) agent.isStopped = false;
    }
}
FSM Limitation: Enum-based FSMs become unmanageable when you have more than 6-8 states because every state must know about every other state it can transition to. With 10 states, you potentially need 90 transition checks (10 x 9). This "state explosion" problem is why behavior trees were invented.

2.2 Behavior Trees

Behavior trees solve the state explosion problem by organizing AI decisions into a hierarchical tree of nodes. They process top-down, left-to-right, and each node returns Success, Failure, or Running. The four fundamental node types are:

Node Type Behavior Analogy
Selector (OR) Tries children left-to-right; succeeds on first success "Try Plan A; if that fails, try Plan B"
Sequence (AND) Runs children left-to-right; fails on first failure "Do step 1, then step 2, then step 3"
Decorator Wraps a child node with a condition or modifier "Only do this if health > 50%"
Action (Leaf) Performs an actual action (move, attack, wait) "Walk to the door" or "Shoot at player"
// Behavior Tree implementation
using UnityEngine;
using System.Collections.Generic;

public enum BTStatus { Success, Failure, Running }

// Base node
public abstract class BTNode
{
    public abstract BTStatus Tick(AIBlackboard blackboard);
}

// Selector: tries children until one succeeds (OR logic)
public class Selector : BTNode
{
    private List<BTNode> children;
    public Selector(params BTNode[] nodes) => children = new List<BTNode>(nodes);

    public override BTStatus Tick(AIBlackboard bb)
    {
        foreach (var child in children)
        {
            var status = child.Tick(bb);
            if (status != BTStatus.Failure) return status;
        }
        return BTStatus.Failure;
    }
}

// Sequence: runs children until one fails (AND logic)
public class Sequence : BTNode
{
    private List<BTNode> children;
    public Sequence(params BTNode[] nodes) => children = new List<BTNode>(nodes);

    public override BTStatus Tick(AIBlackboard bb)
    {
        foreach (var child in children)
        {
            var status = child.Tick(bb);
            if (status != BTStatus.Success) return status;
        }
        return BTStatus.Success;
    }
}

// Condition decorator
public class Condition : BTNode
{
    private System.Func<AIBlackboard, bool> condition;
    public Condition(System.Func<AIBlackboard, bool> cond) => condition = cond;
    public override BTStatus Tick(AIBlackboard bb) =>
        condition(bb) ? BTStatus.Success : BTStatus.Failure;
}

// Blackboard: shared data for the behavior tree
public class AIBlackboard
{
    public Transform Self;
    public Transform Target;
    public float Health;
    public float DetectionRange;
    public float AttackRange;
    public UnityEngine.AI.NavMeshAgent Agent;
}

// Example action nodes
public class MoveToTarget : BTNode
{
    public override BTStatus Tick(AIBlackboard bb)
    {
        if (bb.Target == null) return BTStatus.Failure;
        bb.Agent.SetDestination(bb.Target.position);
        float dist = Vector3.Distance(bb.Self.position, bb.Target.position);
        return dist < bb.AttackRange ? BTStatus.Success : BTStatus.Running;
    }
}

public class AttackTarget : BTNode
{
    private float attackCooldown = 1f;
    private float lastAttackTime;

    public override BTStatus Tick(AIBlackboard bb)
    {
        if (Time.time - lastAttackTime < attackCooldown) return BTStatus.Running;
        lastAttackTime = Time.time;
        Debug.Log($"Attacking {bb.Target.name}!");
        return BTStatus.Success;
    }
}

// Building the tree
public class EnemyBehaviorTree : MonoBehaviour
{
    private BTNode root;
    private AIBlackboard blackboard;

    private void Start()
    {
        blackboard = new AIBlackboard
        {
            Self = transform,
            Target = GameObject.FindGameObjectWithTag("Player").transform,
            Health = 100f,
            DetectionRange = 15f,
            AttackRange = 2f,
            Agent = GetComponent<UnityEngine.AI.NavMeshAgent>()
        };

        // Build the behavior tree
        root = new Selector(
            // Priority 1: Flee when low health
            new Sequence(
                new Condition(bb => bb.Health < 20f),
                new MoveToTarget() // Would be a FleeFromTarget in real code
            ),
            // Priority 2: Attack when in range
            new Sequence(
                new Condition(bb => Vector3.Distance(bb.Self.position, bb.Target.position) < bb.AttackRange),
                new AttackTarget()
            ),
            // Priority 3: Chase when detected
            new Sequence(
                new Condition(bb => Vector3.Distance(bb.Self.position, bb.Target.position) < bb.DetectionRange),
                new MoveToTarget()
            )
        );
    }

    private void Update()
    {
        root.Tick(blackboard);
    }
}

2.3 Goal-Oriented Action Planning (GOAP)

GOAP, pioneered in F.E.A.R. (2005), flips the AI paradigm. Instead of hand-authoring transitions, you define goals (desired world states) and actions (things the agent can do, with preconditions and effects). A planner searches for a sequence of actions that achieves the current goal, creating emergent behavior without explicit state machines.

using UnityEngine;
using System.Collections.Generic;
using System.Linq;

// A world state is a dictionary of facts
[System.Serializable]
public class WorldState : Dictionary<string, bool>
{
    public WorldState() : base() { }
    public WorldState(WorldState other) : base(other) { }

    public bool Satisfies(WorldState goal)
    {
        foreach (var kvp in goal)
            if (!ContainsKey(kvp.Key) || this[kvp.Key] != kvp.Value)
                return false;
        return true;
    }
}

// An action the agent can perform
public abstract class GOAPAction : MonoBehaviour
{
    public string ActionName;
    public float Cost = 1f;
    public WorldState Preconditions = new WorldState();
    public WorldState Effects = new WorldState();

    public abstract bool IsValid(WorldState currentState);
    public abstract bool Perform(GameObject agent);
}

// Example: PickUpWeapon action
public class PickUpWeaponAction : GOAPAction
{
    private void Awake()
    {
        ActionName = "PickUpWeapon";
        Cost = 2f;
        Preconditions["hasWeapon"] = false;
        Preconditions["weaponAvailable"] = true;
        Effects["hasWeapon"] = true;
    }

    public override bool IsValid(WorldState state) =>
        state.ContainsKey("weaponAvailable") && state["weaponAvailable"];

    public override bool Perform(GameObject agent)
    {
        Debug.Log("Picking up weapon...");
        return true;
    }
}

// Example: AttackEnemy action
public class AttackEnemyAction : GOAPAction
{
    private void Awake()
    {
        ActionName = "AttackEnemy";
        Cost = 3f;
        Preconditions["hasWeapon"] = true;
        Preconditions["enemyVisible"] = true;
        Effects["enemyDead"] = true;
    }

    public override bool IsValid(WorldState state) =>
        state.ContainsKey("hasWeapon") && state["hasWeapon"];

    public override bool Perform(GameObject agent)
    {
        Debug.Log("Attacking enemy!");
        return true;
    }
}
When to Use GOAP: GOAP excels when you need NPCs that feel autonomous and adaptive, such as survival game villagers, strategy game units, or simulation NPCs. The downside is that it's harder to debug than FSMs or behavior trees because the planner generates emergent action sequences you may not have anticipated.

2.4 Utility AI & Steering Behaviors

Utility AI assigns a numerical score to every possible action and picks the one with the highest score. This creates smooth, fluid decision-making without hard-coded transition thresholds.

using UnityEngine;
using System.Collections.Generic;

[System.Serializable]
public class AIAction
{
    public string Name;
    public System.Func<float> ScoreFunction;
    public System.Action Execute;
}

public class UtilityAI : MonoBehaviour
{
    private List<AIAction> actions = new List<AIAction>();
    private float health = 80f;
    private float hunger = 60f;
    private float distanceToEnemy = 10f;

    private void Start()
    {
        // Define actions with scoring curves
        actions.Add(new AIAction
        {
            Name = "Attack",
            ScoreFunction = () => {
                // Higher score when enemy is close and health is high
                float proximity = 1f - Mathf.Clamp01(distanceToEnemy / 20f);
                float confidence = Mathf.Clamp01(health / 100f);
                return proximity * confidence * 0.9f;
            },
            Execute = () => Debug.Log("Attacking!")
        });

        actions.Add(new AIAction
        {
            Name = "Flee",
            ScoreFunction = () => {
                // Higher score when health is low and enemy is close
                float danger = 1f - Mathf.Clamp01(health / 100f);
                float proximity = 1f - Mathf.Clamp01(distanceToEnemy / 15f);
                return danger * proximity * 0.85f;
            },
            Execute = () => Debug.Log("Fleeing!")
        });

        actions.Add(new AIAction
        {
            Name = "FindFood",
            ScoreFunction = () => {
                // Higher score when hungry and no immediate danger
                float hungerNeed = Mathf.Clamp01(hunger / 100f);
                float safety = Mathf.Clamp01(distanceToEnemy / 20f);
                return hungerNeed * safety * 0.7f;
            },
            Execute = () => Debug.Log("Finding food!")
        });
    }

    private void Update()
    {
        // Evaluate all actions and pick the best
        AIAction bestAction = null;
        float bestScore = float.MinValue;

        foreach (var action in actions)
        {
            float score = action.ScoreFunction();
            if (score > bestScore)
            {
                bestScore = score;
                bestAction = action;
            }
        }

        bestAction?.Execute();
    }
}

Steering behaviors provide smooth, natural-looking movement by combining simple forces. Craig Reynolds' classic algorithms (1987) remain the gold standard for flocking, crowd simulation, and organic NPC movement:

Behavior Description Use Case
Seek Steer toward a target position Basic movement to a point
Flee Steer away from a threat Running from enemies
Arrive Seek with deceleration near the target Smooth stopping, parking
Wander Random but smooth directional changes Idle NPC movement, insects
Path Following Follow a predefined path with smooth curves Guard patrols, vehicle routes
Flocking Separation + Alignment + Cohesion combined Bird flocks, fish schools, crowds
using UnityEngine;

public class SteeringBehaviors : MonoBehaviour
{
    [SerializeField] private float maxSpeed = 5f;
    [SerializeField] private float maxForce = 10f;
    private Vector3 velocity;

    public Vector3 Seek(Vector3 target)
    {
        Vector3 desired = (target - transform.position).normalized * maxSpeed;
        Vector3 steer = desired - velocity;
        return Vector3.ClampMagnitude(steer, maxForce);
    }

    public Vector3 Flee(Vector3 threat)
    {
        Vector3 desired = (transform.position - threat).normalized * maxSpeed;
        Vector3 steer = desired - velocity;
        return Vector3.ClampMagnitude(steer, maxForce);
    }

    public Vector3 Arrive(Vector3 target, float slowRadius = 5f)
    {
        Vector3 toTarget = target - transform.position;
        float dist = toTarget.magnitude;
        float speed = dist < slowRadius ? maxSpeed * (dist / slowRadius) : maxSpeed;
        Vector3 desired = toTarget.normalized * speed;
        return Vector3.ClampMagnitude(desired - velocity, maxForce);
    }

    public Vector3 Wander(ref float wanderAngle, float wanderRadius = 2f, float wanderJitter = 0.5f)
    {
        wanderAngle += Random.Range(-wanderJitter, wanderJitter);
        Vector3 circleCenter = velocity.normalized * 2f;
        Vector3 displacement = new Vector3(
            Mathf.Cos(wanderAngle) * wanderRadius,
            0,
            Mathf.Sin(wanderAngle) * wanderRadius
        );
        return circleCenter + displacement;
    }

    private void Update()
    {
        // Combine behaviors with weights
        Vector3 steeringForce = Vector3.zero;
        // Example: seek player while avoiding obstacles
        // steeringForce += Seek(playerPos) * 1.0f;
        // steeringForce += Flee(obstaclePos) * 1.5f;

        velocity += steeringForce * Time.deltaTime;
        velocity = Vector3.ClampMagnitude(velocity, maxSpeed);
        transform.position += velocity * Time.deltaTime;

        if (velocity.sqrMagnitude > 0.01f)
            transform.forward = velocity.normalized;
    }
}

3. Procedural Generation

Procedural generation creates game content algorithmically rather than by hand. From Minecraft's infinite worlds to Spelunky's dungeon layouts, procedural generation extends the lifespan of games exponentially while reducing the burden on content creators.

3.1 Perlin Noise & Terrain Generation

Perlin noise generates smooth, natural-looking random values. Unlike pure random numbers (which produce static/snow), Perlin noise produces coherent patterns that resemble natural phenomena like terrain, clouds, and caves.

using UnityEngine;

public class ProceduralTerrain : MonoBehaviour
{
    [Header("Terrain Settings")]
    [SerializeField] private int width = 256;
    [SerializeField] private int depth = 256;
    [SerializeField] private float heightScale = 20f;

    [Header("Noise Layers (Octaves)")]
    [SerializeField] private int octaves = 4;
    [SerializeField] private float baseFrequency = 0.01f;
    [SerializeField] private float persistence = 0.5f; // Amplitude decay per octave
    [SerializeField] private float lacunarity = 2.0f;  // Frequency increase per octave

    [Header("Seed")]
    [SerializeField] private int seed = 42;

    private void Start()
    {
        GenerateTerrain();
    }

    public void GenerateTerrain()
    {
        Terrain terrain = GetComponent<Terrain>();
        terrain.terrainData = GenerateTerrainData(terrain.terrainData);
    }

    private TerrainData GenerateTerrainData(TerrainData data)
    {
        data.heightmapResolution = width + 1;
        data.size = new Vector3(width, heightScale, depth);
        data.SetHeights(0, 0, GenerateHeights());
        return data;
    }

    private float[,] GenerateHeights()
    {
        float[,] heights = new float[width, depth];
        Random.InitState(seed);
        float offsetX = Random.Range(0f, 10000f);
        float offsetZ = Random.Range(0f, 10000f);

        for (int x = 0; x < width; x++)
        {
            for (int z = 0; z < depth; z++)
            {
                heights[x, z] = CalculateHeight(x, z, offsetX, offsetZ);
            }
        }
        return heights;
    }

    private float CalculateHeight(int x, int z, float offsetX, float offsetZ)
    {
        float amplitude = 1f;
        float frequency = baseFrequency;
        float height = 0f;
        float maxHeight = 0f;

        for (int i = 0; i < octaves; i++)
        {
            float sampleX = (x + offsetX) * frequency;
            float sampleZ = (z + offsetZ) * frequency;

            float perlinValue = Mathf.PerlinNoise(sampleX, sampleZ) * 2f - 1f;
            height += perlinValue * amplitude;
            maxHeight += amplitude;

            amplitude *= persistence;
            frequency *= lacunarity;
        }

        return Mathf.InverseLerp(-maxHeight, maxHeight, height);
    }
}
Key Insight: The seed is everything in procedural generation. Same seed = same world, every time. This enables players to share world codes (like Minecraft seeds), developers to reproduce bugs, and save systems to store only the seed instead of the entire world state.

3.2 Wave Function Collapse & BSP Tree Dungeons

Wave Function Collapse (WFC) is a constraint-based algorithm inspired by quantum mechanics. It generates content by collapsing possibilities: each tile starts as "any tile," and the algorithm progressively constrains neighbors based on adjacency rules until every position has exactly one tile.

Binary Space Partitioning (BSP) is a classic dungeon generation algorithm that recursively splits a rectangle into smaller rooms:

using UnityEngine;
using System.Collections.Generic;

public class BSPDungeonGenerator : MonoBehaviour
{
    [SerializeField] private int dungeonWidth = 50;
    [SerializeField] private int dungeonHeight = 50;
    [SerializeField] private int minRoomSize = 6;
    [SerializeField] private int maxIterations = 5;
    [SerializeField] private GameObject floorPrefab;
    [SerializeField] private GameObject wallPrefab;

    private List<RectInt> rooms = new List<RectInt>();

    public void GenerateDungeon(int seed)
    {
        Random.InitState(seed);
        rooms.Clear();

        // Start with the full dungeon area
        RectInt fullArea = new RectInt(0, 0, dungeonWidth, dungeonHeight);
        SplitSpace(fullArea, 0);

        // Shrink partitions into rooms (add random padding)
        for (int i = 0; i < rooms.Count; i++)
        {
            var r = rooms[i];
            int padX = Random.Range(1, 3);
            int padY = Random.Range(1, 3);
            rooms[i] = new RectInt(
                r.x + padX, r.y + padY,
                r.width - padX * 2, r.height - padY * 2
            );
        }

        // Connect rooms with corridors
        for (int i = 0; i < rooms.Count - 1; i++)
            ConnectRooms(rooms[i], rooms[i + 1]);

        // Instantiate geometry
        InstantiateDungeon();
    }

    private void SplitSpace(RectInt space, int iteration)
    {
        if (iteration >= maxIterations ||
            space.width < minRoomSize * 2 || space.height < minRoomSize * 2)
        {
            rooms.Add(space);
            return;
        }

        // Randomly split horizontally or vertically
        bool splitHorizontal = Random.value > 0.5f;
        if (space.width > space.height * 1.25f) splitHorizontal = false;
        if (space.height > space.width * 1.25f) splitHorizontal = true;

        if (splitHorizontal)
        {
            int splitY = Random.Range(minRoomSize, space.height - minRoomSize);
            SplitSpace(new RectInt(space.x, space.y, space.width, splitY), iteration + 1);
            SplitSpace(new RectInt(space.x, space.y + splitY, space.width, space.height - splitY), iteration + 1);
        }
        else
        {
            int splitX = Random.Range(minRoomSize, space.width - minRoomSize);
            SplitSpace(new RectInt(space.x, space.y, splitX, space.height), iteration + 1);
            SplitSpace(new RectInt(space.x + splitX, space.y, space.width - splitX, space.height), iteration + 1);
        }
    }

    private void ConnectRooms(RectInt a, RectInt b)
    {
        Vector2Int centerA = new Vector2Int(a.x + a.width / 2, a.y + a.height / 2);
        Vector2Int centerB = new Vector2Int(b.x + b.width / 2, b.y + b.height / 2);
        // L-shaped corridor from centerA to centerB
        // Implementation: carve horizontal then vertical (or vice versa)
    }

    private void InstantiateDungeon()
    {
        foreach (var room in rooms)
        {
            for (int x = room.x; x < room.x + room.width; x++)
                for (int y = room.y; y < room.y + room.height; y++)
                    Instantiate(floorPrefab, new Vector3(x, 0, y), Quaternion.identity, transform);
        }
    }
}

3.3 L-Systems & Procedural Placement Rules

L-Systems (Lindenmayer Systems) use string rewriting rules to generate fractal-like structures, particularly effective for vegetation, branching structures, and organic patterns:

using UnityEngine;
using System.Text;
using System.Collections.Generic;

public class LSystemTreeGenerator : MonoBehaviour
{
    [Header("L-System Rules")]
    [SerializeField] private string axiom = "F";
    [SerializeField] private int iterations = 4;
    [SerializeField] private float angle = 25f;
    [SerializeField] private float length = 1f;
    [SerializeField] private float lengthDecay = 0.7f;

    [Header("Rendering")]
    [SerializeField] private GameObject branchPrefab;

    // Rule: F -> FF+[+F-F-F]-[-F+F+F]
    private Dictionary<char, string> rules = new Dictionary<char, string>
    {
        { 'F', "FF+[+F-F-F]-[-F+F+F]" }
    };

    public void Generate()
    {
        string current = axiom;

        // Apply rewriting rules
        for (int i = 0; i < iterations; i++)
        {
            StringBuilder next = new StringBuilder();
            foreach (char c in current)
            {
                next.Append(rules.ContainsKey(c) ? rules[c] : c.ToString());
            }
            current = next.ToString();
        }

        // Interpret the string as drawing instructions
        InterpretLSystem(current);
    }

    private void InterpretLSystem(string instructions)
    {
        Stack<(Vector3 pos, Quaternion rot, float len)> stack = new();
        Vector3 position = transform.position;
        Quaternion rotation = transform.rotation;
        float currentLength = length;

        foreach (char c in instructions)
        {
            switch (c)
            {
                case 'F': // Draw forward
                    Vector3 end = position + rotation * Vector3.up * currentLength;
                    DrawBranch(position, end, currentLength);
                    position = end;
                    currentLength *= lengthDecay;
                    break;
                case '+': rotation *= Quaternion.Euler(0, 0, angle); break;
                case '-': rotation *= Quaternion.Euler(0, 0, -angle); break;
                case '[': stack.Push((position, rotation, currentLength)); break;
                case ']':
                    var state = stack.Pop();
                    position = state.pos;
                    rotation = state.rot;
                    currentLength = state.len;
                    break;
            }
        }
    }

    private void DrawBranch(Vector3 start, Vector3 end, float thickness)
    {
        var branch = Instantiate(branchPrefab, transform);
        branch.transform.position = (start + end) / 2f;
        branch.transform.up = (end - start).normalized;
        branch.transform.localScale = new Vector3(thickness * 0.1f, (end - start).magnitude / 2f, thickness * 0.1f);
    }
}
Case Study

Spelunky's Procedural Generation

Derek Yu's Spelunky uses a hybrid approach: each level is a 4x4 grid of rooms, with a guaranteed critical path from entrance to exit. The algorithm first creates a solvable path (ensuring the player can always reach the exit), then fills remaining cells with random room templates. Each room template has hand-crafted sub-sections with randomized variations. This ensures every level is unique but always completable: the perfect balance between chaos and fairness.

Template + Random Guaranteed Solvability 4x4 Grid System Critical Path

4. Core Game Systems

Every game beyond the simplest prototype needs foundational systems: quests to drive the player forward, inventory to manage items, dialogue to tell stories, and save/load to preserve progress. These systems are the scaffolding of your game.

4.1 Quest & Inventory Systems

using UnityEngine;
using System.Collections.Generic;
using System.Linq;

// ScriptableObject-based quest definition
[CreateAssetMenu(fileName = "New Quest", menuName = "Game/Quest")]
public class QuestData : ScriptableObject
{
    public string QuestId;
    public string Title;
    [TextArea(3, 5)] public string Description;
    public QuestObjective[] Objectives;
    public QuestReward Reward;
    public QuestData[] Prerequisites; // Must complete these first
}

[System.Serializable]
public class QuestObjective
{
    public string Description;
    public ObjectiveType Type;
    public string TargetId;
    public int RequiredCount;
    [HideInInspector] public int CurrentCount;
    public bool IsComplete => CurrentCount >= RequiredCount;
}

public enum ObjectiveType { Kill, Collect, TalkTo, Reach, Escort, Defend }

[System.Serializable]
public class QuestReward
{
    public int ExperiencePoints;
    public int Gold;
    public ItemData[] Items;
}

// Quest Manager — tracks active and completed quests
public class QuestManager : MonoBehaviour
{
    public static QuestManager Instance { get; private set; }

    private Dictionary<string, QuestData> activeQuests = new();
    private HashSet<string> completedQuests = new();

    public event System.Action<QuestData> OnQuestStarted;
    public event System.Action<QuestData> OnQuestCompleted;
    public event System.Action<QuestData, QuestObjective> OnObjectiveProgress;

    private void Awake() => Instance = this;

    public bool CanAcceptQuest(QuestData quest)
    {
        if (activeQuests.ContainsKey(quest.QuestId)) return false;
        if (completedQuests.Contains(quest.QuestId)) return false;
        return quest.Prerequisites == null ||
               quest.Prerequisites.All(p => completedQuests.Contains(p.QuestId));
    }

    public void AcceptQuest(QuestData quest)
    {
        if (!CanAcceptQuest(quest)) return;
        activeQuests[quest.QuestId] = quest;
        OnQuestStarted?.Invoke(quest);
    }

    public void ReportProgress(ObjectiveType type, string targetId, int count = 1)
    {
        foreach (var quest in activeQuests.Values)
        {
            foreach (var obj in quest.Objectives)
            {
                if (obj.Type == type && obj.TargetId == targetId && !obj.IsComplete)
                {
                    obj.CurrentCount += count;
                    OnObjectiveProgress?.Invoke(quest, obj);

                    if (quest.Objectives.All(o => o.IsComplete))
                        CompleteQuest(quest);
                }
            }
        }
    }

    private void CompleteQuest(QuestData quest)
    {
        activeQuests.Remove(quest.QuestId);
        completedQuests.Add(quest.QuestId);
        // Grant rewards...
        OnQuestCompleted?.Invoke(quest);
    }
}
// Inventory System with ScriptableObject items
[CreateAssetMenu(fileName = "New Item", menuName = "Game/Item")]
public class ItemData : ScriptableObject
{
    public string ItemId;
    public string DisplayName;
    public string Description;
    public Sprite Icon;
    public ItemType Type;
    public int MaxStack;
    public float Weight;
    public int Value;
    public bool IsConsumable;
}

public enum ItemType { Weapon, Armor, Consumable, QuestItem, Material, Key }

[System.Serializable]
public class InventorySlot
{
    public ItemData Item;
    public int Count;

    public bool CanStack(ItemData newItem) =>
        Item != null && Item.ItemId == newItem.ItemId && Count < Item.MaxStack;
}

public class Inventory : MonoBehaviour
{
    [SerializeField] private int maxSlots = 24;
    private List<InventorySlot> slots = new();

    public event System.Action OnInventoryChanged;

    private void Awake()
    {
        for (int i = 0; i < maxSlots; i++)
            slots.Add(new InventorySlot());
    }

    public bool AddItem(ItemData item, int count = 1)
    {
        // Try stacking first
        var stackSlot = slots.FirstOrDefault(s => s.CanStack(item));
        if (stackSlot != null)
        {
            stackSlot.Count += count;
            OnInventoryChanged?.Invoke();
            return true;
        }

        // Find empty slot
        var emptySlot = slots.FirstOrDefault(s => s.Item == null);
        if (emptySlot != null)
        {
            emptySlot.Item = item;
            emptySlot.Count = count;
            OnInventoryChanged?.Invoke();
            return true;
        }

        Debug.Log("Inventory full!");
        return false;
    }

    public bool RemoveItem(string itemId, int count = 1)
    {
        var slot = slots.FirstOrDefault(s => s.Item != null && s.Item.ItemId == itemId);
        if (slot == null) return false;
        slot.Count -= count;
        if (slot.Count <= 0) { slot.Item = null; slot.Count = 0; }
        OnInventoryChanged?.Invoke();
        return true;
    }

    public int GetItemCount(string itemId) =>
        slots.Where(s => s.Item != null && s.Item.ItemId == itemId).Sum(s => s.Count);
}

4.2 Dialogue Trees

A dialogue system connects narrative to gameplay. The core data structure is a tree/graph of dialogue nodes, where each node contains text and optional player choices that branch to other nodes:

using UnityEngine;
using System.Collections.Generic;

[CreateAssetMenu(fileName = "New Dialogue", menuName = "Game/Dialogue")]
public class DialogueData : ScriptableObject
{
    public string DialogueId;
    public string SpeakerName;
    public DialogueNode[] Nodes;
}

[System.Serializable]
public class DialogueNode
{
    public int NodeId;
    [TextArea(2, 4)] public string Text;
    public string SpeakerOverride; // If different from default speaker
    public DialogueChoice[] Choices;
    public bool IsEndNode;

    // Conditional display
    public string RequiredQuestId;
    public string RequiredItemId;
}

[System.Serializable]
public class DialogueChoice
{
    [TextArea(1, 2)] public string ChoiceText;
    public int NextNodeId;
    public DialogueEffect[] Effects;
}

[System.Serializable]
public class DialogueEffect
{
    public EffectType Type;
    public string TargetId;
    public int Value;
}

public enum EffectType { GiveItem, RemoveItem, StartQuest, GiveXP, ChangeRelation }

// Dialogue Manager
public class DialogueManager : MonoBehaviour
{
    public static DialogueManager Instance { get; private set; }

    [SerializeField] private GameObject dialoguePanel;
    [SerializeField] private UnityEngine.UI.Text speakerText;
    [SerializeField] private UnityEngine.UI.Text dialogueText;
    [SerializeField] private Transform choiceContainer;
    [SerializeField] private GameObject choiceButtonPrefab;

    private DialogueData currentDialogue;
    private int currentNodeIndex;

    public event System.Action OnDialogueStarted;
    public event System.Action OnDialogueEnded;

    private void Awake() => Instance = this;

    public void StartDialogue(DialogueData dialogue)
    {
        currentDialogue = dialogue;
        currentNodeIndex = 0;
        dialoguePanel.SetActive(true);
        OnDialogueStarted?.Invoke();
        DisplayNode(currentDialogue.Nodes[0]);
    }

    private void DisplayNode(DialogueNode node)
    {
        speakerText.text = !string.IsNullOrEmpty(node.SpeakerOverride)
            ? node.SpeakerOverride : currentDialogue.SpeakerName;
        dialogueText.text = node.Text;

        // Clear old choices
        foreach (Transform child in choiceContainer)
            Destroy(child.gameObject);

        if (node.IsEndNode)
        {
            CreateChoiceButton("End Conversation", -1);
            return;
        }

        if (node.Choices == null || node.Choices.Length == 0)
        {
            CreateChoiceButton("Continue...", currentNodeIndex + 1);
            return;
        }

        foreach (var choice in node.Choices)
            CreateChoiceButton(choice.ChoiceText, choice.NextNodeId);
    }

    private void CreateChoiceButton(string text, int nextNodeId)
    {
        var btn = Instantiate(choiceButtonPrefab, choiceContainer);
        btn.GetComponentInChildren<UnityEngine.UI.Text>().text = text;
        btn.GetComponent<UnityEngine.UI.Button>().onClick.AddListener(() => OnChoiceSelected(nextNodeId));
    }

    private void OnChoiceSelected(int nextNodeId)
    {
        if (nextNodeId < 0 || nextNodeId >= currentDialogue.Nodes.Length)
        {
            EndDialogue();
            return;
        }
        DisplayNode(currentDialogue.Nodes[nextNodeId]);
    }

    private void EndDialogue()
    {
        dialoguePanel.SetActive(false);
        currentDialogue = null;
        OnDialogueEnded?.Invoke();
    }
}

4.3 Save/Load with JSON Serialization

using UnityEngine;
using System.IO;
using System.Collections.Generic;

[System.Serializable]
public class SaveData
{
    public string PlayerName;
    public Vector3Serializable PlayerPosition;
    public float Health;
    public float Mana;
    public int Level;
    public int Experience;
    public List<string> CompletedQuests = new();
    public List<InventorySlotSave> Inventory = new();
    public string Timestamp;
}

// Unity's Vector3 isn't serializable by default
[System.Serializable]
public struct Vector3Serializable
{
    public float x, y, z;
    public Vector3Serializable(Vector3 v) { x = v.x; y = v.y; z = v.z; }
    public Vector3 ToVector3() => new Vector3(x, y, z);
}

[System.Serializable]
public class InventorySlotSave
{
    public string ItemId;
    public int Count;
}

public class SaveSystem : MonoBehaviour
{
    private static string SaveDirectory =>
        Path.Combine(Application.persistentDataPath, "Saves");

    public static void Save(SaveData data, string slotName = "save01")
    {
        if (!Directory.Exists(SaveDirectory))
            Directory.CreateDirectory(SaveDirectory);

        data.Timestamp = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
        string json = JsonUtility.ToJson(data, true); // Pretty print
        string path = Path.Combine(SaveDirectory, $"{slotName}.json");
        File.WriteAllText(path, json);
        Debug.Log($"Game saved to {path}");
    }

    public static SaveData Load(string slotName = "save01")
    {
        string path = Path.Combine(SaveDirectory, $"{slotName}.json");
        if (!File.Exists(path))
        {
            Debug.LogWarning($"No save file found at {path}");
            return null;
        }

        string json = File.ReadAllText(path);
        SaveData data = JsonUtility.FromJson<SaveData>(json);
        Debug.Log($"Game loaded from {path} (saved {data.Timestamp})");
        return data;
    }

    public static bool SaveExists(string slotName = "save01") =>
        File.Exists(Path.Combine(SaveDirectory, $"{slotName}.json"));

    public static void DeleteSave(string slotName = "save01")
    {
        string path = Path.Combine(SaveDirectory, $"{slotName}.json");
        if (File.Exists(path)) File.Delete(path);
    }

    public static string[] GetAllSaves()
    {
        if (!Directory.Exists(SaveDirectory)) return new string[0];
        return Directory.GetFiles(SaveDirectory, "*.json");
    }
}
Case Study

Hades: Encounter Design as Gameplay Loop

Supergiant's Hades masterfully blends procedural room selection with hand-crafted encounter design. Each room has pre-designed enemy compositions, but which rooms appear and in what order is procedurally determined. The game adjusts difficulty through a "Heat" system and uses the encounter design to create dramatic pacing. Boss encounters act as narrative checkpoints, while random room rewards (boons from gods) create unique build paths each run. This demonstrates that the most effective procedural generation enhances hand-crafted content rather than replacing it.

Roguelite Design Curated Randomness Dynamic Difficulty Narrative Integration

Exercises & Self-Assessment

Exercise 1

NavMesh Patrol System

Build a guard NPC that patrols between waypoints and chases the player on detection:

  1. Create a simple 3D environment with walls and floors
  2. Bake a NavMesh with appropriate agent settings
  3. Place 4-5 patrol waypoints and create a NavMeshAgent guard
  4. Implement a detection system using OverlapSphere + Raycast (line of sight)
  5. Add states: Patrol, Alert (investigate last known position), Chase, Return
  6. Add debug gizmos showing detection radius and current path
Exercise 2

Behavior Tree Combat AI

Implement a behavior tree for a combat NPC:

  1. Build the BT node classes (Selector, Sequence, Condition, Action)
  2. Create a combat tree: Check Health -> Flee / (Check Range -> Attack / Chase)
  3. Add a "Heal" branch: if low health AND has potion, use potion before fleeing
  4. Add a "Call for Backup" decorator that triggers when engaging multiple enemies
  5. Visualize the active tree branch in the Inspector using a custom editor
Exercise 3

Procedural Dungeon Generator

Build a BSP dungeon generator with gameplay integration:

  1. Implement the BSP algorithm to generate room layouts
  2. Connect rooms with L-shaped corridors
  3. Place the player spawn in the first room and the exit in the last
  4. Procedurally place enemies, treasure chests, and traps using placement rules
  5. Bake the NavMesh at runtime so enemies can navigate the generated dungeon
  6. Add a minimap that reveals rooms as the player explores them
Exercise 4

Reflective Questions

  1. Compare FSMs and behavior trees. When would you choose one over the other? What about a hybrid approach?
  2. Why is it important for game AI to be "good enough" rather than optimal? Give an example of AI being too smart ruining gameplay.
  3. Explain how a seed-based procedural system enables save/load without storing the entire generated world.
  4. Design a quest system that supports branching narratives (choices affect future quests). What data structures would you use?
  5. How would you debug AI behavior in a large game with 50+ NPC types? What tools would you build?

AI Design Document Generator

Generate a professional AI design document for your game agents. Download as Word, Excel, PDF, or PowerPoint.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

You now have a comprehensive toolkit for building intelligent, dynamic game worlds. Here are the key takeaways from Part 11:

  • NavMesh provides robust pathfinding out of the box. Use NavMeshAgent for moving AI, NavMeshObstacle for dynamic blockers, and NavMeshSurface for runtime baking in procedural worlds
  • FSMs are the simplest AI architecture: great for 3-6 states, but they suffer from "state explosion" in complex AI
  • Behavior trees solve the complexity problem with hierarchical, modular nodes: Selector (try until success), Sequence (do all in order), Decorators (conditions), and Actions (leaves)
  • GOAP creates emergent AI by planning action sequences to achieve goals, ideal for autonomous NPCs in simulation-style games
  • Utility AI scores every possible action and picks the highest, creating smooth transitions without hard thresholds
  • Procedural generation with Perlin noise, BSP trees, WFC, and L-systems creates infinite, replayable content from algorithmic rules
  • Core game systems (quests, inventory, dialogue, save/load) form the backbone of any complete game and benefit from ScriptableObject-driven data architecture

Next in the Series

In Part 12: Multiplayer & Networking, we'll tackle the challenging world of networked gameplay: client-server architecture, Netcode for GameObjects, state synchronization, client-side prediction, lag compensation, and building robust multiplayer experiences.

Gaming