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.
1
Unity Basics & Interface
Editor overview, assets, prefabs, architecture
2
C# Scripting Fundamentals
MonoBehaviour, coroutines, input systems, patterns
3
GameObjects & Components
Transforms, renderers, custom components
4
Physics & Collisions
Rigidbody, colliders, raycasting, forces
5
UI Systems
Canvas, uGUI, UI Toolkit, responsive design
6
Animation & State Machines
Animator, blend trees, IK, Timeline
7
Audio & Visual Effects
AudioSource, particles, VFX Graph, post-processing
8
Building & Publishing
Build pipeline, optimization, platforms, monetization
9
Rendering Pipelines
URP, HDRP, Shader Graph, lighting systems
10
Data-Oriented Tech Stack
ECS, Jobs System, Burst Compiler
11
AI & Gameplay Systems
NavMesh, FSMs, behavior trees, procedural gen
You Are Here
12
Multiplayer & Networking
Netcode, RPCs, latency, prediction
13
Tools & Editor Scripting
Custom editors, debug tools, CI/CD
14
Architecture & Clean Code
Service locators, DI, ScriptableObject architecture
15
Performance Optimization
CPU/GPU profiling, memory, object pooling
16
Production & Industry Practices
Git, Agile, asset pipelines, debugging at scale
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
1. Navigation Systems
Before your AI can make smart decisions, it needs to move intelligently through your game world. Unity's NavMesh (Navigation Mesh) system provides robust pathfinding out of the box, handling complex environments that would be nightmarish to code from scratch.
1.1 NavMesh Baking & Configuration
A NavMesh is a simplified representation of your world's walkable surfaces. Think of it as an invisible "floor plan" that AI agents use to navigate. Unity generates this mesh by analyzing your scene geometry and determining where agents can walk.
| Parameter |
Description |
Typical Value |
| Agent Radius |
How close agents can get to walls/obstacles |
0.5 (humanoid), 0.2 (small creature) |
| Agent Height |
Minimum ceiling height for walkable areas |
2.0 (humanoid), 1.0 (crouching) |
| Max Slope |
Maximum angle of walkable inclines |
45 degrees |
| Step Height |
Maximum height of steps agents can climb |
0.4 (stairs), 0.1 (smooth terrain) |
| Voxel Size |
Resolution of the NavMesh bake (smaller = more precise) |
Default or 1/3 of agent radius |
1.2 NavMeshAgent & NavMeshObstacle
The NavMeshAgent component makes a GameObject navigate the NavMesh automatically. It handles pathfinding, obstacle avoidance, and movement:
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class AINavigator : MonoBehaviour
{
[Header("Navigation")]
[SerializeField] private Transform target;
[SerializeField] private float stoppingDistance = 2f;
[SerializeField] private float updateInterval = 0.25f; // Don't recalculate every frame
[Header("Patrol")]
[SerializeField] private Transform[] patrolPoints;
[SerializeField] private float patrolWaitTime = 2f;
private NavMeshAgent agent;
private int currentPatrolIndex = 0;
private float nextUpdateTime;
private bool isPatrolling = true;
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
agent.stoppingDistance = stoppingDistance;
}
private void Update()
{
if (Time.time < nextUpdateTime) return;
nextUpdateTime = Time.time + updateInterval;
if (target != null && !isPatrolling)
{
// Chase the target
agent.SetDestination(target.position);
}
else if (isPatrolling && patrolPoints.Length > 0)
{
Patrol();
}
}
private void Patrol()
{
if (!agent.pathPending && agent.remainingDistance < 0.5f)
{
// Wait at patrol point, then move to next
StartCoroutine(WaitAndMoveToNext());
}
}
private System.Collections.IEnumerator WaitAndMoveToNext()
{
agent.isStopped = true;
yield return new WaitForSeconds(patrolWaitTime);
agent.isStopped = false;
currentPatrolIndex = (currentPatrolIndex + 1) % patrolPoints.Length;
agent.SetDestination(patrolPoints[currentPatrolIndex].position);
}
// Called when player is detected (e.g., by a trigger collider)
public void ChaseTarget(Transform newTarget)
{
target = newTarget;
isPatrolling = false;
agent.isStopped = false;
agent.speed = 6f; // Faster when chasing
}
public void ReturnToPatrol()
{
target = null;
isPatrolling = true;
agent.speed = 3.5f; // Normal patrol speed
}
}
NavMeshObstacle is used for dynamic objects that block agent paths but do not themselves navigate. Set Carve to true for objects that should cut holes in the NavMesh in real time (e.g., a collapsing bridge, a barricade). Use Carve Only Stationary for objects that move occasionally but rest in place.
1.3 Runtime NavMesh & Off-Mesh Links
For dynamically generated levels, you need to bake NavMeshes at runtime using the NavMeshSurface component from the AI Navigation package:
using UnityEngine;
using Unity.AI.Navigation;
public class RuntimeNavMeshBuilder : MonoBehaviour
{
[SerializeField] private NavMeshSurface navMeshSurface;
public void RebuildNavMesh()
{
// Call after procedurally generating terrain/rooms
navMeshSurface.BuildNavMesh();
Debug.Log("NavMesh rebuilt at runtime!");
}
public void UpdateNavMeshAsync()
{
// Non-blocking update for large worlds
var asyncOp = navMeshSurface.UpdateNavMesh(navMeshSurface.navMeshData);
asyncOp.completed += (op) => Debug.Log("NavMesh async update complete");
}
}
Off-Mesh Links allow agents to traverse gaps that the NavMesh cannot cover, such as jumping across platforms, climbing ladders, or dropping off ledges. You define start/end points, and agents automatically use these connections when pathfinding.
Pro Tip: NavMesh pathfinding is expensive. Never call SetDestination() every frame. Use a timer (0.2-0.5s) or only recalculate when the target moves significantly (e.g., more than 2 units from the last calculated position). This alone can save 30-50% of your AI CPU budget.
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:
- Create a simple 3D environment with walls and floors
- Bake a NavMesh with appropriate agent settings
- Place 4-5 patrol waypoints and create a NavMeshAgent guard
- Implement a detection system using OverlapSphere + Raycast (line of sight)
- Add states: Patrol, Alert (investigate last known position), Chase, Return
- Add debug gizmos showing detection radius and current path
Exercise 2
Behavior Tree Combat AI
Implement a behavior tree for a combat NPC:
- Build the BT node classes (Selector, Sequence, Condition, Action)
- Create a combat tree: Check Health -> Flee / (Check Range -> Attack / Chase)
- Add a "Heal" branch: if low health AND has potion, use potion before fleeing
- Add a "Call for Backup" decorator that triggers when engaging multiple enemies
- 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:
- Implement the BSP algorithm to generate room layouts
- Connect rooms with L-shaped corridors
- Place the player spawn in the first room and the exit in the last
- Procedurally place enemies, treasure chests, and traps using placement rules
- Bake the NavMesh at runtime so enemies can navigate the generated dungeon
- Add a minimap that reveals rooms as the player explores them
Exercise 4
Reflective Questions
- Compare FSMs and behavior trees. When would you choose one over the other? What about a hybrid approach?
- Why is it important for game AI to be "good enough" rather than optimal? Give an example of AI being too smart ruining gameplay.
- Explain how a seed-based procedural system enables save/load without storing the entire generated world.
- Design a quest system that supports branching narratives (choices affect future quests). What data structures would you use?
- How would you debug AI behavior in a large game with 50+ NPC types? What tools would you build?
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.
Continue the Series
Part 12: Multiplayer & Networking
Master networked gameplay: Netcode for GameObjects, RPCs, state sync, client-side prediction, lag compensation, and multiplayer architecture.
Read Article
Part 13: Tools & Editor Scripting
Build custom editors, debug visualization tools, automated build pipelines, and CI/CD workflows for professional Unity development.
Read Article
Part 10: Data-Oriented Tech Stack
Entity Component System, Jobs System, and Burst Compiler for high-performance Unity development.
Read Article