RPG Dungeon Crawler: Architecture & AI
Build a 3D dungeon crawler with FSM, ScriptableObjects, NavMesh AI, and JSON saves.
Introduction
30 Second Summary
Every video game enemy that patrols a hallway, spots you, and gives chase is running a State Machine under the hood. It's the same pattern backend engineers use for managing request lifecycles or connection states.
In this project, you will build a 3D dungeon crawler prototype in Unity with AI enemies that patrol, chase, and attack. You'll architect the game using professional patterns and implement a save/load system that persists player progress to disk.
What You'll Build
A top-down 3D dungeon room with keyboard-controlled player movement, AI enemies using NavMesh pathfinding and Finite State Machines, a ScriptableObject data layer, and a JSON-based save/load system.
By the end of this project, you'll have:
- A playable 3D dungeon room with a player character and AI enemies that patrol, chase, and attack using NavMesh pathfinding.
- A professional game architecture using the State Machine pattern, ScriptableObject data layer, and event-driven communication.
- A working save/load system that persists player position and health to disk using JSON serialization.
- Secret Mission: A collectible item system with an inventory that persists across saves.
Are there any prerequisites?
You'll need basic C# knowledge (classes, interfaces, inheritance) and familiarity with an IDE. No prior Unity experience is required.
Before We Start
Before you can build a dungeon crawler, you need a Unity project configured with the right tools. This step sets up the foundation that every script and scene in the project will rely on.
You will create a new Unity project using the Universal 3D template, install the AI Navigation package for enemy pathfinding, and organise your folder structure so scripts have a clean home from the start.
In this step, get ready to:
- Create a new 3D (URP) project named DungeonCrawler.
- Install the AI Navigation package for enemy pathfinding.
- Configure VS Code as your script editor and set up the project folder structure.
Create a new 3D (URP) project in Unity Hub
Unity Hub is where you manage all your Unity projects and editor installations. You will use it to create a fresh project with the Universal Render Pipeline pre-configured.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Unity Hub and press Enter to open it.
- Select the Projects tab in the left sidebar.
- Click New project in the top right corner.
- Select the Universal 3D template from the template list.
- In the Project name field, enter DungeonCrawler.
- Click Create project.
Unity will spend a minute or two importing the URP starter assets. Once it finishes, the Unity Editor opens with your new project.
What is URP?
URP stands for Universal Render Pipeline. It is Unity's recommended rendering system for most games because it balances visual quality with performance across platforms.
The Universal 3D template pre-configures URP so you do not have to set it up manually. This keeps your project ready for 3D rendering from the start.
Don't see the Universal 3D template?
Make sure you selected your Unity 6.4 installation in the Editor Version dropdown at the top of the New Project screen. The Universal 3D template ships with Unity 6 and will not appear if an older editor version is selected.
Why can't I find the Universal 3D template in Unity Hub?
Install the AI Navigation package
The AI Navigation package gives your enemies the ability to find paths around obstacles. Without it, enemies would walk through walls instead of navigating around them.
- In the Unity Editor menu bar, click Window.
- Select Package Manager.
- In the Package Manager window, click the Packages dropdown in the top left and select Unity Registry.
- Type AI in the search bar.
- Select AI Navigation from the results list.
- Click Install.
Wait for the installation to complete. You will see a green checkmark next to the package name when it is ready.
What is AI Navigation?
AI Navigation (com.unity.ai.navigation) provides NavMesh baking and NavMeshAgent components. A NavMesh is a map of walkable surfaces that agents use to calculate obstacle-free paths.
You will use this package in a later step to make enemies patrol waypoints and chase the player through the dungeon without clipping through walls.
Package not appearing in the registry?
Make sure the dropdown at the top of the Package Manager says Unity Registry and not In Project. The AI Navigation package is in the Unity Registry and will not appear under other filter options.
Help me find the AI Navigation package in Package Manager.
Configure VS Code and create your folder structure
Connecting VS Code as your script editor gives you IntelliSense (code autocomplete) for Unity's API. The folder structure keeps your scripts organised by responsibility as the project grows.
- In the Unity Editor menu bar, click Edit (Windows) or Unity (macOS).
- Select Preferences.
- Click External Tools in the left sidebar.
- Click the External Script Editor dropdown and select Visual Studio Code.
Regenerate project files
If IntelliSense is not working after you open a script, return to Preferences > External Tools and click Regenerate project files. This rebuilds the .csproj files that VS Code needs for autocomplete.
Now create the folder structure that will house all your scripts.
- In the Project window at the bottom of the Unity Editor, click the Assets folder in the left panel to select it.
- Right-click in the empty area of the right panel and select Create > Folder.
- Name the new folder Scripts.
- Double-click the Scripts folder to open it.
- Create six subfolders inside Scripts by repeating the right-click > Create > Folder process for each: StateMachine, Player, Enemy, Data, Save, Core.
- Click back on the Assets folder in the left panel.
- Right-click in the right panel and create one more folder named ScriptableObjects.
Why this folder structure?
Each folder maps to a layer of the architecture you will build. StateMachine holds the reusable state machine framework. Player and Enemy hold controller scripts and their states. Data holds ScriptableObject definitions. Save holds the persistence system. Core holds shared utilities like events and the camera.
Separating scripts by responsibility keeps the codebase navigable as it grows. This is the same organisational pattern used in professional Unity projects.
Verify your folder structure by expanding Assets in the Project window. You should see Scripts (with six subfolders inside it) and ScriptableObjects at the same level.
Created a folder in the wrong place?
You can drag any folder to a new location inside the Project window. If you accidentally created a subfolder at the wrong level, drag it into the correct parent folder. Unity will move the folder and update all internal references automatically.
Help me fix my project folder structure.
Your project is ready to go. Next up, you will plan the game architecture and build the reusable State Machine framework that powers both the player and enemy behaviour.
Plan Your Game Architecture
Your project is set up with all the folders and tools you need. Now it's time to plan what you'll actually build before writing a single line of gameplay code.
Professional game studios don't jump straight into coding. They start with a design document that defines what the game does, then choose architectural patterns that keep the codebase clean as it grows. In this step, you'll do the same.
In this step, get ready to:
- Create a Game Design Document that defines your player, enemies, and systems.
- Understand the three-pillar architecture: State Machine, ScriptableObjects, and events.
- Implement the State Machine framework that drives all entity behavior.
Create a Game Design Document
A Game Design Document (GDD) captures what your game does before you write any code. It keeps you focused on the goal instead of wandering into feature creep.
- In the Unity Editor, navigate to the Assets folder in the **Project** window.
- Right-click inside the Assets folder, then select Create > Text File.
- Name the file GameDesignDocument.
- Double-click the file to open it in VS Code, then paste the following content:
# Dungeon Crawler - Game Design Document
## Player
- Top-down movement (WASD/Arrow keys)
- Health pool (starts at 100)
- Basic melee attack (Space key)
- States: Idle, Move, Attack
## Enemies
- Patrol between waypoints when player is far away
- Chase player when detected within range
- Attack player when within melee distance
- Return to patrol route when player escapes
- States: Patrol, Chase, Attack
## Win/Lose Condition
- Survive as long as possible
- Game over when player health reaches 0
## Save/Load System
- Persist player position between sessions
- Persist player health between sessions
- Save with F5, Load with F9
- Store data as JSON on disk
What does a GDD do for you?
This document defines the scope of your project. Every feature you build from here on traces back to a line in this GDD.
Notice how the GDD already hints at the technical structure. Both the player and enemies have "states" listed. That's the State Machine pattern revealing itself from the design.
- Save GameDesignDocument.txt.
Understand the three-pillar architecture
Your dungeon crawler uses three architectural patterns working together. These are the same patterns used in Unity's official Boss Room sample and professional studios shipping real games.
What are the three pillars?
Pillar 1: The State Machine pattern manages behavior. Each entity (player, enemy) has discrete states with Enter, Update, and Exit methods. Only one state is active at a time, and transitions between states are explicit.
Pillar 2: ScriptableObjects act as your read-only data layer. They store stats and configuration (like health, speed, damage) as shared assets you edit in the Unity Inspector. Think of them like config files or DTOs from backend development.
Pillar 3: C# events (Action delegates) provide decoupled communication between systems. Any script can subscribe to an event without knowing who publishes it. This is the same pub/sub pattern you'd use in a message queue on the backend.
Here's the mental model: the Data Layer (ScriptableObjects) feeds configuration into the Behavior Layer (State Machines), which communicates changes via the Event Layer (C# events). Each layer can evolve independently without breaking the others.
Why State Machine over simple if/else?
With if/else chains, adding a new behavior means editing a growing tangle of conditions. With the State pattern, adding a new state is a new class with zero impact on existing ones. Each state is small, testable, and single-responsibility.
This satisfies the Open-Closed Principle: open for extension (new states), closed for modification (existing states untouched).
Implement the State Machine framework
Now you'll build the backbone that both the player and enemy controllers will use. The framework has two parts: an IState interface defining the contract, and a StateMachine class managing transitions.
- In the Unity Editor's Project window, navigate to Assets/Scripts/StateMachine.
- Right-click inside the folder, then select Create > Scripting > Empty C# Script.
- Name the new script IState.
- Double-click IState to open it in VS Code, then replace all the content with this code:
public interface IState
{
void Enter();
void Update();
void Exit();
}
What does this code do?
The IState interface defines the contract every state must follow. Any class that implements IState guarantees it has these three methods:
- Enter() runs once when the state becomes active. Use it for setup (start animations, set speed).
- Update() runs every frame while the state is active. Use it for ongoing logic (check input, check distance to player).
- Exit() runs once when leaving the state. Use it for cleanup (stop animations, reset flags).
- Save IState.cs in VS Code.
- Switch back to the Unity Editor. It will auto-compile the script.
- Check the Console window (Window > General > Console if not visible). You should see no red error messages.
Seeing a compile error?
Make sure the filename is exactly IState.cs (capital I, capital S). Unity matches filenames to class/interface names.
If you see errors about MonoBehaviour or using UnityEngine, make sure you replaced ALL the template content. The interface does not use any Unity namespaces.
Help me fix my IState compile error
Now create the class that uses this interface to manage state transitions.
- Right-click inside Assets/Scripts/StateMachine again, then select Create > Scripting > Empty C# Script.
- Name this script StateMachine.
- Double-click StateMachine to open it in VS Code, then replace all the content with this code:
public class StateMachine
{
private IState currentState;
public IState CurrentState => currentState;
public void Initialize(IState startingState)
{
currentState = startingState;
currentState.Enter();
}
public void ChangeState(IState newState)
{
currentState.Exit();
currentState = newState;
currentState.Enter();
}
public void Update()
{
currentState?.Update();
}
}
What does this code do?
The StateMachine class holds a reference to whichever state is currently active and orchestrates transitions:
- Initialize() sets the first state and calls its Enter() method to kick things off.
- ChangeState() exits the current state, swaps in the new one, and enters it. This guarantees cleanup always happens before setup.
- Update() delegates to the current state's Update() each frame. The ?. (null-conditional) prevents crashes if no state is set yet.
Notice this class has no Unity dependencies. It's a plain C# class, which means you could unit test it outside Unity if needed.
- Save StateMachine.cs in VS Code.
- Switch back to the Unity Editor and wait for compilation to finish.
- Check the Console window. You should see no red error messages.
Seeing errors about IState not found?
This means IState.cs didn't compile successfully. Go back and fix that file first. StateMachine.cs depends on the IState interface existing.
Also verify both files are in the same folder (Assets/Scripts/StateMachine). Unity compiles all scripts in the Assets folder together, so location doesn't strictly matter for compilation, but it keeps your project organized.
Help me fix my StateMachine compile error
✔️ Awesome, I've got everything!
Great. Make sure both files are saved. You now have the core framework that every entity in your game will use.
ⓧ I'd like to double check the full code
Here are both files in their complete form. Make sure yours match exactly:
public interface IState
{
void Enter();
void Update();
void Exit();
}
public class StateMachine
{
private IState currentState;
public IState CurrentState => currentState;
public void Initialize(IState startingState)
{
currentState = startingState;
currentState.Enter();
}
public void ChangeState(IState newState)
{
currentState.Exit();
currentState = newState;
currentState.Enter();
}
public void Update()
{
currentState?.Update();
}
}
Your architecture is planned and the core framework is built. Next up, you'll construct the actual dungeon room where your player and enemies will live.
Build the Dungeon Scene
Your architecture is planned and the State Machine framework is ready. Now it's time to build the physical space where everything happens.
Every game needs a stage. In this step, you'll construct a 3D dungeon room, place a player character inside it, and set up enemies that will eventually patrol the space. Think of this as building the theatre before the actors learn their lines.
In this step, get ready to:
- Build a dungeon room with a floor, walls, and obstacles.
- Create a player character with a top-down camera.
- Place enemy characters with patrol waypoints.
Build the dungeon floor, walls, and obstacles
The dungeon room is made entirely from Unity primitives. A Plane serves as the floor, and four stretched Cubes form the walls.
- In the Hierarchy panel (left side of the Unity Editor), right-click in empty space.
- Select 3D Object > Plane.
- In the Inspector panel (right side), set the Plane's Position to X: 0, Y: 0, Z: 0.
- Set the Scale to X: 3, Y: 1, Z: 3 to create a 30x30 unit dungeon floor.
- Rename this object to Floor by double-clicking its name in the Hierarchy.
Now add the four walls that enclose the room.
- Right-click in the Hierarchy and select 3D Object > Cube.
- Rename it to Wall_North.
- Set its Position to X: 0, Y: 1.5, Z: 15.
- Set its Scale to X: 30, Y: 3, Z: 1.
- Repeat this process three more times for Wall_South (Position 0, 1.5, -15), Wall_East (Position 15, 1.5, 0, Scale 1, 3, 30), and Wall_West (Position -15, 1.5, 0, Scale 1, 3, 30).
Duplicate to save time
Select an existing wall and press Cmd+D (macOS) or Ctrl+D (Windows) to duplicate it. Then adjust the position and scale in the Inspector.
Finally, add a few obstacles inside the room.
- Create 3-4 Cubes and place them at various positions inside the room.
- Scale them to different sizes (for example, X: 2, Y: 2, Z: 2 or X: 3, Y: 1, Z: 1) to create variety.
- Rename each one to Obstacle_1, Obstacle_2, etc.
Now apply materials so you can distinguish the floor, walls, and obstacles.
- In the Project panel (bottom of the editor), right-click inside the Assets folder.
- Select Create > Material.
- Name it FloorMaterial. In the Inspector, click the color box next to Base Map and choose a dark grey.
- Create a second material named WallMaterial with a dark brown color.
- Create a third material named ObstacleMaterial with a medium grey color.
- Drag each material from the Project panel onto the corresponding objects in the Scene view or Hierarchy.
Your Scene view should now show a rectangular room with a dark floor, brown walls, and grey obstacles scattered inside.
Can't see the walls or floor?
Make sure you're looking at the Scene view (not the Game view). Use the scroll wheel to zoom out. If everything looks white, you haven't applied materials yet. Drag them onto the objects in the Hierarchy panel.
Help me find my dungeon objects in the Unity scene.
Create the player character and camera
The player is a simple Capsule with physics applied. A Rigidbody component lets Unity's physics engine control the character, and freezing rotation prevents it from toppling over when it bumps into walls.
- Right-click in the Hierarchy and select 3D Object > Capsule.
- Rename it to Player.
- Set its Position to X: 0, Y: 1, Z: 0 so it sits on top of the floor.
- With the Player selected, click Add Component in the Inspector.
- Search for Rigidbody and add it.
- In the Rigidbody component, expand Constraints.
- Check all three boxes under Freeze Rotation (X, Y, Z).
- At the top of the Inspector, find the Tag dropdown (currently set to Untagged). Change it to Player.
- Create a new material named PlayerMaterial with a bright blue color. Drag it onto the Player capsule.
Why freeze rotation?
Without frozen rotation, the Capsule would tip over from physics collisions. Freezing rotation keeps the character upright while still allowing physics-based movement.
Now create the camera script that follows the player from above.
- In the Project panel, navigate to Assets/Scripts/Core.
- Right-click and select Create > C# Script. Name it CameraFollow.
- Double-click CameraFollow.cs to open it in VS Code. Replace all existing content with the following code:
using UnityEngine;
public class CameraFollow : MonoBehaviour
{
// Reference to the player (or any target) Transform
[SerializeField] private Transform target;
// How far behind and above the camera sits
[SerializeField] private Vector3 offset = new Vector3(0f, 10f, -5f);
// How smoothly the camera catches up
[SerializeField] private float smoothSpeed = 5f;
private void LateUpdate()
{
if (target == null) return;
// Calculate where the camera wants to be
Vector3 desiredPosition = target.position + offset;
// Smoothly interpolate toward that position
Vector3 smoothedPosition = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed * Time.deltaTime);
transform.position = smoothedPosition;
// Always look at the target
transform.LookAt(target);
}
}
What does this code do?
[SerializeField] exposes private fields in the Unity Inspector so you can assign values without making them public. The offset positions the camera 10 units above and 5 units behind the player.
Vector3.Lerp smoothly moves the camera toward its target position each frame instead of snapping instantly. LateUpdate runs after all Update calls, ensuring the player has finished moving before the camera follows.
- Save CameraFollow.cs and return to the Unity Editor.
- In the Hierarchy, click on Main Camera.
- Click Add Component in the Inspector and search for CameraFollow. Add it.
- In the CameraFollow component, drag the Player object from the Hierarchy into the Target field.
- Press the Play button at the top of the editor. The camera should show a top-down view centered on your blue capsule.
Camera not following the player?
Check that you dragged the Player object into the Target field. If the Target field says None, the script has nothing to follow. Also make sure you saved the script before adding the component.
Help me debug my CameraFollow script in Unity.
✔️ Awesome, I've got everything!
Great. Make sure you've saved CameraFollow.cs before moving on.
ⓧ I'd like to double check the full code
using UnityEngine;
public class CameraFollow : MonoBehaviour
{
[SerializeField] private Transform target;
[SerializeField] private Vector3 offset = new Vector3(0f, 10f, -5f);
[SerializeField] private float smoothSpeed = 5f;
private void LateUpdate()
{
if (target == null) return;
Vector3 desiredPosition = target.position + offset;
Vector3 smoothedPosition = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed * Time.deltaTime);
transform.position = smoothedPosition;
transform.LookAt(target);
}
}
Create enemy characters with patrol waypoints
Enemies need two things: a physical body in the scene, and waypoints that define their patrol routes. The waypoints are invisible markers (empty GameObjects) positioned where you want each enemy to walk.
- Right-click in the Hierarchy and select 3D Object > Capsule.
- Rename it to Enemy_1.
- Set its Position to X: 5, Y: 1, Z: 5.
- Create a material named EnemyMaterial with a red color. Drag it onto Enemy_1.
- Duplicate Enemy_1 (Cmd+D / Ctrl+D) and rename the copy to Enemy_2.
- Move Enemy_2 to Position X: -5, Y: 1, Z: -5.
Now create patrol waypoints for each enemy.
- Right-click in the Hierarchy and select Create Empty.
- Rename it to E1_Waypoint_A and set its Position to X: 5, Y: 0, Z: 10.
- Create another empty GameObject named E1_Waypoint_B at Position X: 10, Y: 0, Z: 5.
- Create E2_Waypoint_A at Position X: -5, Y: 0, Z: -10.
- Create E2_Waypoint_B at Position X: -10, Y: 0, Z: -5.
Why empty GameObjects?
Empty GameObjects have no visual appearance in the game. They serve purely as position markers. You'll reference these transforms later when the enemy AI needs to know where to walk.
- Save your scene by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Press Play to verify your scene. You should see the blue player capsule in the center, two red enemy capsules in opposite corners, and the camera looking down from above.
Enemies or player falling through the floor?
Make sure the Y position for capsules is 1 (not 0). Capsules are 2 units tall with their center at the origin. Setting Y to 1 places their base exactly on the floor. If objects still fall, check that the Plane has a Mesh Collider (it does by default).
Help me fix objects falling through the floor in Unity.
Your dungeon stage is set. You've got a room, a player, and enemies waiting for their AI brains. Next up, you'll build the ScriptableObject data layer that configures how fast characters move, how much health they have, and how hard they hit.
Implement the ScriptableObject Data Layer
Your dungeon scene is built and ready to populate with characters. Before you wire up player movement or enemy AI, you need a way to define the stats that drive those systems.
In this step, you'll create ScriptableObjects to hold character and enemy configuration data. ScriptableObjects act like read-only config files that live as assets in your project. You'll also build an event system so your game's systems can talk to each other without being tightly coupled.
In this step, get ready to:
- Create a CharacterStats ScriptableObject for player data.
- Create an EnemyStats ScriptableObject for enemy data.
- Set up a GameEvents class for decoupled system communication.
Create the CharacterStats ScriptableObject
Every character in an RPG has stats like health, speed, and damage. Instead of hardcoding these values into scripts, you'll define them as ScriptableObject assets. This lets you tweak values in the Unity Inspector without touching code.
- In VS Code, create a new file at Assets/Scripts/Data/CharacterStats.cs and add the following code:
using UnityEngine;
[CreateAssetMenu(fileName = "NewCharacterStats", menuName = "RPG/Character Stats")]
public class CharacterStats : ScriptableObject
{
public int maxHealth = 100;
public float moveSpeed = 5f;
public int attackDamage = 10;
public float attackRange = 2f;
public float attackCooldown = 1f;
}
What does this code do?
The [CreateAssetMenu] attribute adds a menu option to Unity's Assets > Create menu. This lets you create instances of this ScriptableObject directly from the Editor.
The class inherits from ScriptableObject instead of MonoBehaviour. That means it lives as a project asset rather than a component on a GameObject. Each public field becomes an editable property in the Inspector.
- Save CharacterStats.cs.
- Switch back to the Unity Editor and wait for it to compile the script.
Now create a PlayerStats asset instance using this template.
- In the Unity Editor's Project panel, navigate to the Assets/ScriptableObjects folder.
- Right-click inside the folder and select Create.
- Select RPG, then Character Stats.
- Rename the new asset to PlayerStats.
- Click on PlayerStats and verify the default values in the Inspector: Max Health 100, Move Speed 5, Attack Damage 10, Attack Range 2, Attack Cooldown 1.
Don't see RPG in the Create menu?
Make sure Unity finished compiling after you saved the script. Check the bottom-left of the Editor for a spinning compile indicator. If compilation failed, check the Console for errors in your script.
Help me troubleshoot the Create menu not showing my ScriptableObject option.
Create the EnemyStats ScriptableObject
Enemies need their own stats template with fields specific to AI behavior, like detection range and separate patrol and chase speeds.
- In VS Code, create a new file at Assets/Scripts/Data/EnemyStats.cs and add the following code:
using UnityEngine;
[CreateAssetMenu(fileName = "NewEnemyStats", menuName = "RPG/Enemy Stats")]
public class EnemyStats : ScriptableObject
{
public int maxHealth = 50;
public float moveSpeed = 3f;
public float detectionRange = 8f;
public float attackRange = 1.5f;
public int attackDamage = 5;
public float patrolSpeed = 2f;
public float chaseSpeed = 4f;
}
What does this code do?
This follows the same ScriptableObject pattern as CharacterStats. The detectionRange field defines how far an enemy can "see" the player. The separate patrolSpeed and chaseSpeed values let enemies move slowly when patrolling but accelerate when chasing.
- Save EnemyStats.cs.
- Switch back to the Unity Editor and wait for compilation to finish.
- In the Project panel, navigate to Assets/ScriptableObjects.
- Right-click and select Create > RPG > Enemy Stats.
- Rename the new asset to SlimeStats.
- Click on SlimeStats and verify the default values in the Inspector match: Max Health 50, Move Speed 3, Detection Range 8, Attack Range 1.5, Attack Damage 5, Patrol Speed 2, Chase Speed 4.
Why can't I change ScriptableObjects at runtime?
A ScriptableObject is a single shared asset in memory. Every object referencing it points at the same instance. If you mutate a field at runtime, you change it for every enemy sharing that asset.
The fix is to treat ScriptableObject data as read-only at runtime. When a script starts, it copies the values it needs into per-instance variables. You'll see this pattern in action when you build the PlayerController and EnemyController in the next steps.
Values in the Inspector don't match?
If you already created the asset before setting default values in the script, the asset may have older defaults. Click on the asset in the Project panel and manually update the values in the Inspector to match.
Help me fix my ScriptableObject default values.
Create the GameEvents communication class
Your player and enemies will need to communicate events like "player took damage" or "enemy defeated" without knowing about each other directly. A static events class gives every script a shared mailbox to publish and subscribe to events.
- In VS Code, create a new file at Assets/Scripts/Core/GameEvents.cs and add the following code:
using System;
public static class GameEvents
{
public static Action<int, int> OnPlayerHealthChanged;
public static Action OnEnemyDefeated;
public static Action OnGameSaved;
public static Action OnGameLoaded;
}
What does this code do?
Each Action field is a C# delegate that works like a pub/sub channel. Any script can subscribe to GameEvents.OnEnemyDefeated without knowing which script fires it.
The Action<int, int> on OnPlayerHealthChanged means it passes two integers when fired: the current health and max health. The other events pass no data.
Why events instead of direct references?
Without events, the SaveManager would need a direct reference to the UI, the enemy counter, and any other system that cares about saves. Events flip that relationship. The SaveManager just fires OnGameSaved and any interested system subscribes on its own.
This is the Observer pattern. It keeps systems independent so you can add or remove features without rewriting existing scripts.
- Save GameEvents.cs.
- Switch back to the Unity Editor and confirm that compilation succeeds with no errors in the Console.
Seeing a compile error about System namespace?
Make sure the first line of your file is using System; and not using UnityEngine;. The Action delegate lives in the System namespace, not Unity's.
Help me fix my GameEvents compile error.
✔️ Awesome, I've got everything!
Great. Make sure all three scripts compile without errors before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file in this step.
using UnityEngine;
[CreateAssetMenu(fileName = "NewCharacterStats", menuName = "RPG/Character Stats")]
public class CharacterStats : ScriptableObject
{
public int maxHealth = 100;
public float moveSpeed = 5f;
public int attackDamage = 10;
public float attackRange = 2f;
public float attackCooldown = 1f;
}
using UnityEngine;
[CreateAssetMenu(fileName = "NewEnemyStats", menuName = "RPG/Enemy Stats")]
public class EnemyStats : ScriptableObject
{
public int maxHealth = 50;
public float moveSpeed = 3f;
public float detectionRange = 8f;
public float attackRange = 1.5f;
public int attackDamage = 5;
public float patrolSpeed = 2f;
public float chaseSpeed = 4f;
}
using System;
public static class GameEvents
{
public static Action<int, int> OnPlayerHealthChanged;
public static Action OnEnemyDefeated;
public static Action OnGameSaved;
public static Action OnGameLoaded;
}
Your data layer is in place. You have ScriptableObject templates for both player and enemy stats, plus an event system ready to wire up. Next, you'll build the PlayerController that reads from these stats and uses the State Machine to manage movement and attack behavior.
Build the Player Controller with State Machine
In the previous step, you set up the data layer for your game. You have ScriptableObject assets defining player and enemy stats, plus a static event system for decoupled communication.
Now it is time to bring your player character to life. You will build a PlayerController that uses the State Machine framework from Step 2 to manage behavior. Each state (Idle, Move) handles its own logic and transition conditions, keeping your code clean and extensible.
In this step, get ready to:
- Create the PlayerController MonoBehaviour with runtime state and attack methods.
- Create PlayerIdleState and PlayerMoveState for input-driven behavior.
- Attach the controller to your Player Capsule and test movement in Play Mode.
Create the PlayerController MonoBehaviour
The PlayerController is the central hub for your player character. It holds a reference to the StateMachine, reads stats from the CharacterStats ScriptableObject, and manages runtime state like health and attack cooldowns.
- In VS Code, create a new file at Assets/Scripts/Player/PlayerController.cs and paste the following code:
using UnityEngine;
public class PlayerController : MonoBehaviour
{
// Reference to the CharacterStats ScriptableObject asset
[SerializeField] private CharacterStats stats;
private StateMachine stateMachine;
private Rigidbody rb;
// Runtime state - copied from stats, safe to mutate
public int CurrentHealth { get; private set; }
public CharacterStats Stats => stats;
public Rigidbody Rb => rb;
public StateMachine StateMachine => stateMachine;
private float attackCooldownTimer;
private void Start()
{
rb = GetComponent<Rigidbody>();
stateMachine = new StateMachine();
// Copy maxHealth from the shared SO into per-instance runtime state
CurrentHealth = stats.maxHealth;
var idleState = new PlayerIdleState(this);
stateMachine.Initialize(idleState);
}
private void Update()
{
// Delegate frame logic to whichever state is active
stateMachine.Update();
if (attackCooldownTimer > 0f)
attackCooldownTimer -= Time.deltaTime;
}
public bool CanAttack()
{
return attackCooldownTimer <= 0f;
}
public void PerformAttack()
{
// Reset cooldown timer
attackCooldownTimer = stats.attackCooldown;
// Detect all colliders within attack range
Collider[] hits = Physics.OverlapSphere(transform.position, stats.attackRange);
foreach (Collider hit in hits)
{
EnemyController enemy = hit.GetComponent<EnemyController>();
if (enemy != null)
{
enemy.TakeDamage(stats.attackDamage);
}
}
}
public void TakeDamage(int damage)
{
CurrentHealth -= damage;
// Fire the health changed event for any listeners (UI, save system, etc.)
GameEvents.OnPlayerHealthChanged?.Invoke(CurrentHealth, stats.maxHealth);
if (CurrentHealth <= 0)
{
CurrentHealth = 0;
Debug.Log("Player defeated!");
}
}
public void SetHealth(int health)
{
CurrentHealth = health;
GameEvents.OnPlayerHealthChanged?.Invoke(CurrentHealth, stats.maxHealth);
}
}
What does this code do?
This script is your player's brain. It coordinates everything the player can do without implementing the details of each behavior directly.
- The [SerializeField] attribute on stats exposes a private field in the Unity Inspector, letting you drag in your PlayerStats asset.
- In Start(), runtime health is copied from the ScriptableObject. This avoids mutating the shared asset.
- The Update() method delegates all per-frame logic to the active state via stateMachine.Update().
- Attack uses Physics.OverlapSphere to find enemies within range and deal damage to each one.
- The TakeDamage method fires GameEvents.OnPlayerHealthChanged so other systems can react without a direct reference.
- Save PlayerController.cs.
Why copy health from the ScriptableObject?
ScriptableObjects are shared assets. Every object referencing a ScriptableObject points at the same instance in memory. If you mutated stats.maxHealth directly at runtime, it would change for ALL enemies and characters using that asset.
The fix is to treat ScriptableObject data as read-only templates. Copy values into per-instance fields (like CurrentHealth) in Start().
Seeing red squiggles under EnemyController?
That is expected. You have not created the EnemyController class yet. The compiler error will resolve in the next step when you build the enemy AI. For now, VS Code will show a warning, but it will not prevent Unity from compiling once all scripts are in place.
help me understand why I'm seeing compiler errors in my PlayerController
Create PlayerIdleState and PlayerMoveState
Each state encapsulates one behavior. IdleState watches for input and transitions to MoveState. MoveState moves the Rigidbody and transitions back when input stops. This clean separation means adding new states later never touches existing ones.
- Create a new file at Assets/Scripts/Player/PlayerIdleState.cs and paste this code:
using UnityEngine;
public class PlayerIdleState : IState
{
private PlayerController player;
public PlayerIdleState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
}
public void Update()
{
// Check for movement input each frame
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
// Transition to MoveState if any input detected
if (horizontal != 0f || vertical != 0f)
{
player.StateMachine.ChangeState(new PlayerMoveState(player));
return;
}
// Handle attack input while idle
if (Input.GetKeyDown(KeyCode.Space) && player.CanAttack())
{
player.PerformAttack();
}
}
public void Exit()
{
}
}
What does PlayerIdleState do?
- Every frame, it reads Input.GetAxisRaw("Horizontal") and Input.GetAxisRaw("Vertical") which map to WASD and Arrow keys.
- If any direction is pressed, it immediately transitions to PlayerMoveState.
- While idle, pressing Space triggers an attack via player.PerformAttack().
- The state only knows its own logic. It does not care what MoveState does internally.
- Save PlayerIdleState.cs.
- Create a new file at Assets/Scripts/Player/PlayerMoveState.cs and paste this code:
using UnityEngine;
public class PlayerMoveState : IState
{
private PlayerController player;
public PlayerMoveState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
}
public void Update()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
// Return to Idle when input stops
if (horizontal == 0f && vertical == 0f)
{
player.StateMachine.ChangeState(new PlayerIdleState(player));
return;
}
// Calculate and apply movement via Rigidbody
Vector3 movement = new Vector3(horizontal, 0f, vertical).normalized;
player.Rb.MovePosition(player.Rb.position + movement * player.Stats.moveSpeed * Time.deltaTime);
// Rotate player to face movement direction
if (movement != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(movement);
player.transform.rotation = Quaternion.Slerp(player.transform.rotation, targetRotation, 10f * Time.deltaTime);
}
// Handle attack input while moving
if (Input.GetKeyDown(KeyCode.Space) && player.CanAttack())
{
player.PerformAttack();
}
}
public void Exit()
{
}
}
What does PlayerMoveState do?
- It reads input each frame and transitions back to PlayerIdleState when no keys are pressed.
- Movement uses Rb.MovePosition instead of directly setting transform.position. This respects physics collisions with walls and obstacles.
- The Quaternion.Slerp smoothly rotates the player to face the direction of movement.
- Attack via Space works while moving too, so the player does not have to stop to fight.
- Save PlayerMoveState.cs.
VS Code not recognizing IState or StateMachine?
Make sure your IState.cs is in Assets/Scripts/StateMachine/ and the file name matches the interface name exactly. Unity requires class and file names to match for MonoBehaviours, and while interfaces do not strictly require it, keeping them in sync avoids confusion.
help me fix IntelliSense errors with IState and StateMachine in my Player scripts
Attach the PlayerController and test movement
Your scripts are ready. Now you need to attach the PlayerController component to your Player Capsule and wire up the PlayerStats asset so Unity can read the data at runtime.
- Switch back to the Unity Editor.
- In the Hierarchy window, click on your Player Capsule to select it.
- In the Inspector window, click Add Component at the bottom.
- Type PlayerController in the search field and select it from the results.
- In the new PlayerController component, find the Stats field. It shows None (Character Stats).
- In the Project window, navigate to Assets/ScriptableObjects.
- Drag the PlayerStats asset into the Stats field on the PlayerController component.
Why drag instead of code?
Unity's [SerializeField] attribute lets you assign references in the Inspector via drag-and-drop. This is the standard Unity workflow for connecting assets to components. The reference is saved with the scene file, so it persists across sessions.
- Press the Play button at the top of the Unity Editor (or press Cmd+P on macOS, Ctrl+P on Windows).
- Press the W, A, S, D keys or Arrow keys to move your player around the dungeon.
You should see your Player Capsule moving smoothly around the room. It collides with walls and obstacles instead of passing through them. The camera follows the player from above.
- Press Play again to exit Play Mode.
Player not moving when you press WASD?
- Make sure the Game view is focused. Click inside the Game window before pressing keys.
- Check that the Player Capsule has a Rigidbody component. The GetComponent<Rigidbody>() call in Start() will return null without one.
- Confirm Freeze Rotation is checked on all three axes (X, Y, Z) in the Rigidbody. Without this, the Capsule tips over on contact.
- Verify the PlayerStats asset is assigned in the Stats field. A null reference there causes a crash on Play.
help me debug why my player character is not moving in Play Mode
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file in this step.
using UnityEngine;
public class PlayerController : MonoBehaviour
{
[SerializeField] private CharacterStats stats;
private StateMachine stateMachine;
private Rigidbody rb;
public int CurrentHealth { get; private set; }
public CharacterStats Stats => stats;
public Rigidbody Rb => rb;
public StateMachine StateMachine => stateMachine;
private float attackCooldownTimer;
private void Start()
{
rb = GetComponent<Rigidbody>();
stateMachine = new StateMachine();
CurrentHealth = stats.maxHealth;
var idleState = new PlayerIdleState(this);
stateMachine.Initialize(idleState);
}
private void Update()
{
stateMachine.Update();
if (attackCooldownTimer > 0f)
attackCooldownTimer -= Time.deltaTime;
}
public bool CanAttack()
{
return attackCooldownTimer <= 0f;
}
public void PerformAttack()
{
attackCooldownTimer = stats.attackCooldown;
Collider[] hits = Physics.OverlapSphere(transform.position, stats.attackRange);
foreach (Collider hit in hits)
{
EnemyController enemy = hit.GetComponent<EnemyController>();
if (enemy != null)
{
enemy.TakeDamage(stats.attackDamage);
}
}
}
public void TakeDamage(int damage)
{
CurrentHealth -= damage;
GameEvents.OnPlayerHealthChanged?.Invoke(CurrentHealth, stats.maxHealth);
if (CurrentHealth <= 0)
{
CurrentHealth = 0;
Debug.Log("Player defeated!");
}
}
public void SetHealth(int health)
{
CurrentHealth = health;
GameEvents.OnPlayerHealthChanged?.Invoke(CurrentHealth, stats.maxHealth);
}
}
using UnityEngine;
public class PlayerIdleState : IState
{
private PlayerController player;
public PlayerIdleState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
}
public void Update()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
if (horizontal != 0f || vertical != 0f)
{
player.StateMachine.ChangeState(new PlayerMoveState(player));
return;
}
if (Input.GetKeyDown(KeyCode.Space) && player.CanAttack())
{
player.PerformAttack();
}
}
public void Exit()
{
}
}
using UnityEngine;
public class PlayerMoveState : IState
{
private PlayerController player;
public PlayerMoveState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
}
public void Update()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
if (horizontal == 0f && vertical == 0f)
{
player.StateMachine.ChangeState(new PlayerIdleState(player));
return;
}
Vector3 movement = new Vector3(horizontal, 0f, vertical).normalized;
player.Rb.MovePosition(player.Rb.position + movement * player.Stats.moveSpeed * Time.deltaTime);
if (movement != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(movement);
player.transform.rotation = Quaternion.Slerp(player.transform.rotation, targetRotation, 10f * Time.deltaTime);
}
if (Input.GetKeyDown(KeyCode.Space) && player.CanAttack())
{
player.PerformAttack();
}
}
public void Exit()
{
}
}
Your player is alive and responding to input. Next up, you will build the enemy AI that patrols the dungeon, detects the player, and gives chase using NavMesh pathfinding and the same State Machine framework.
Build Enemy AI with NavMesh and FSM
Your player can move and attack, but the dungeon feels empty without enemies that react.
In this step, you will bake a NavMesh so enemies can navigate around obstacles, then build a full Finite State Machine for enemy AI that patrols, chases, and attacks the player.
In this step, get ready to:
- Bake a NavMesh on the dungeon floor and mark walls and obstacles as not walkable.
- Create the EnemyController and EnemyPatrolState that cycles between waypoints.
- Create the EnemyChaseState and EnemyAttackState for full Patrol to Chase to Attack behavior.
Bake the NavMesh and configure enemy agents
A NavMesh is a data structure that tells AI agents which surfaces they can walk on. Without it, NavMeshAgent components have no path to follow.
- In the Hierarchy, select the dungeon floor (the Plane GameObject).
- In the Inspector, click Add Component.
- Search for and select Navigation > NavMesh Surface.
- Leave the default settings as they are.
- Click Bake in the NavMesh Surface component.
You should see a blue overlay appear on the floor in the Scene view. This overlay represents the walkable area.
What is a NavMesh?
A NavMesh is a simplified representation of your scene's geometry that AI agents use for pathfinding. It calculates which surfaces are walkable and generates efficient paths between any two points.
The blue overlay you see is Unity showing you exactly where agents can walk. Any surface not covered by the overlay is off-limits.
Now you need to tell the NavMesh that walls and obstacles are not walkable.
- Select one of your wall GameObjects in the Hierarchy.
- In the Inspector, click Add Component.
- Search for and select Navigation > NavMesh Modifier.
- Check the Override Area checkbox.
- Set the Area Type dropdown to Not Walkable.
- Repeat this for all 4 walls and every obstacle Cube in your scene.
- Select the floor again and click Bake in the NavMesh Surface component to regenerate the NavMesh.
The blue overlay should now have gaps where your walls and obstacles are. Enemies will navigate around these areas.
NavMesh still covers the walls?
Make sure you clicked Bake again after adding the NavMesh Modifier components. The NavMesh only updates when you re-bake.
Also confirm that Override Area is checked and the Area Type is set to Not Walkable on each wall and obstacle.
help me debug why my NavMesh is not excluding walls after adding NavMesh Modifier
Next, add a NavMeshAgent to each enemy so they can pathfind on the baked surface.
- Select one of your enemy Capsule GameObjects in the Hierarchy.
- In the Inspector, click Add Component.
- Search for and add Nav Mesh Agent.
- Set Speed to 3.5.
- Set Stopping Distance to 1.5.
- Set Radius to 0.5.
- Repeat this for the second enemy Capsule with the same values.
What is a NavMeshAgent?
A NavMeshAgent is a Unity component that handles pathfinding and movement automatically. You give it a destination, and it calculates the shortest path along the NavMesh while avoiding obstacles.
The Speed value here is just the NavMeshAgent's default. Your state machine scripts will override this at runtime with the patrol or chase speed from the EnemyStats ScriptableObject.
Create the EnemyController and patrol state
The EnemyController is the enemy equivalent of the PlayerController. It owns the StateMachine, reads from the EnemyStats ScriptableObject, and holds runtime state like health.
- Create a new C# script in Assets/Scripts/Enemy named EnemyController.cs by pasting the following code:
using UnityEngine;
using UnityEngine.AI;
public class EnemyController : MonoBehaviour
{
[SerializeField] private EnemyStats stats;
[SerializeField] private Transform[] patrolWaypoints;
private StateMachine stateMachine;
private NavMeshAgent agent;
private Transform playerTransform;
public int CurrentHealth { get; private set; }
public EnemyStats Stats => stats;
public NavMeshAgent Agent => agent;
public Transform[] PatrolWaypoints => patrolWaypoints;
public Transform PlayerTransform => playerTransform;
public StateMachine StateMachine => stateMachine;
private void Start()
{
agent = GetComponent<NavMeshAgent>();
playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
CurrentHealth = stats.maxHealth;
stateMachine = new StateMachine();
var patrolState = new EnemyPatrolState(this);
stateMachine.Initialize(patrolState);
}
private void Update()
{
stateMachine.Update();
}
public void TakeDamage(int damage)
{
CurrentHealth -= damage;
if (CurrentHealth <= 0)
{
CurrentHealth = 0;
GameEvents.OnEnemyDefeated?.Invoke();
Destroy(gameObject);
}
}
public float DistanceToPlayer()
{
return Vector3.Distance(transform.position, playerTransform.position);
}
}
What does this code do?
The EnemyController follows the same pattern as your PlayerController. It caches the NavMeshAgent reference and finds the player by the Player tag you set earlier.
Health is copied from stats.maxHealth into a runtime field so you never mutate the shared ScriptableObject. The DistanceToPlayer() helper centralizes the range check that every enemy state will use.
- Save EnemyController.cs.
Now create the patrol state that makes enemies walk between waypoints.
- Create a new C# script in Assets/Scripts/Enemy named EnemyPatrolState.cs by pasting the following code:
using UnityEngine;
public class EnemyPatrolState : IState
{
private EnemyController enemy;
private int currentWaypointIndex;
public EnemyPatrolState(EnemyController enemy)
{
this.enemy = enemy;
this.currentWaypointIndex = 0;
}
public void Enter()
{
enemy.Agent.speed = enemy.Stats.patrolSpeed;
if (enemy.PatrolWaypoints.Length > 0)
{
enemy.Agent.SetDestination(enemy.PatrolWaypoints[currentWaypointIndex].position);
}
}
public void Update()
{
if (enemy.DistanceToPlayer() <= enemy.Stats.detectionRange)
{
enemy.StateMachine.ChangeState(new EnemyChaseState(enemy));
return;
}
if (enemy.PatrolWaypoints.Length == 0) return;
if (enemy.Agent.remainingDistance < 0.5f && !enemy.Agent.pathPending)
{
currentWaypointIndex = (currentWaypointIndex + 1) % enemy.PatrolWaypoints.Length;
enemy.Agent.SetDestination(enemy.PatrolWaypoints[currentWaypointIndex].position);
}
}
public void Exit()
{
}
}
What does this code do?
On Enter(), the patrol state sets the agent speed to patrolSpeed from the EnemyStats asset and picks the first waypoint as its destination.
Every frame, Update() first checks if the player is within detectionRange. If so, it immediately transitions to the chase state. Otherwise, it checks if the agent reached its waypoint (remainingDistance < 0.5f) and advances to the next one using modulo to loop back to the start.
- Save EnemyPatrolState.cs.
Seeing compiler errors?
Unity will show errors about EnemyChaseState not existing yet. This is expected because EnemyPatrolState references it. The errors will resolve once you create the chase and attack states in the next substep.
help me understand why I'm getting compiler errors after adding EnemyPatrolState
Create the chase and attack states
The chase state makes the enemy follow the player. If the player escapes far enough, the enemy returns to patrol. If the player gets too close, the enemy switches to attack.
- Create a new C# script in Assets/Scripts/Enemy named EnemyChaseState.cs by pasting the following code:
using UnityEngine;
public class EnemyChaseState : IState
{
private EnemyController enemy;
public EnemyChaseState(EnemyController enemy)
{
this.enemy = enemy;
}
public void Enter()
{
enemy.Agent.speed = enemy.Stats.chaseSpeed;
}
public void Update()
{
enemy.Agent.SetDestination(enemy.PlayerTransform.position);
float distanceToPlayer = enemy.DistanceToPlayer();
if (distanceToPlayer > enemy.Stats.detectionRange * 1.5f)
{
enemy.StateMachine.ChangeState(new EnemyPatrolState(enemy));
return;
}
if (distanceToPlayer <= enemy.Stats.attackRange)
{
enemy.StateMachine.ChangeState(new EnemyAttackState(enemy));
}
}
public void Exit()
{
}
}
What does this code do?
On Enter(), the agent speed increases to chaseSpeed so the enemy moves faster when pursuing the player.
Every frame, it updates the NavMeshAgent destination to the player's current position. If the player runs beyond 1.5x the detectionRange, the enemy gives up and transitions back to patrol. If the player is within attackRange, the enemy transitions to the attack state.
- Save EnemyChaseState.cs.
Finally, create the attack state that stops the enemy and deals damage on a cooldown.
- Create a new C# script in Assets/Scripts/Enemy named EnemyAttackState.cs by pasting the following code:
using UnityEngine;
public class EnemyAttackState : IState
{
private EnemyController enemy;
private float attackTimer;
private float attackCooldown = 1.5f;
public EnemyAttackState(EnemyController enemy)
{
this.enemy = enemy;
}
public void Enter()
{
enemy.Agent.isStopped = true;
attackTimer = 0f;
}
public void Update()
{
float distanceToPlayer = enemy.DistanceToPlayer();
if (distanceToPlayer > enemy.Stats.attackRange)
{
enemy.StateMachine.ChangeState(new EnemyChaseState(enemy));
return;
}
attackTimer += Time.deltaTime;
if (attackTimer >= attackCooldown)
{
attackTimer = 0f;
PlayerController player = enemy.PlayerTransform.GetComponent<PlayerController>();
if (player != null)
{
player.TakeDamage(enemy.Stats.attackDamage);
}
}
}
public void Exit()
{
enemy.Agent.isStopped = false;
}
}
What does this code do?
On Enter(), the agent stops moving by setting isStopped = true. The attack timer resets to zero so the enemy doesn't deal instant damage.
Each frame, if the player moves out of attackRange, the enemy returns to chasing. Otherwise it accumulates time and deals attackDamage every 1.5 seconds. On Exit(), the agent resumes movement.
- Save EnemyAttackState.cs.
- Return to the Unity Editor and wait for scripts to compile.
Why does the chase state use detectionRange * 1.5?
This creates a "hysteresis buffer" so the enemy doesn't flicker between patrol and chase when the player stands right at the detection boundary. The enemy needs to detect the player at 8 units, but once chasing, it only gives up at 12 units. This makes the AI feel intentional rather than glitchy.
Now wire up the EnemyController component to your enemy GameObjects.
- Select one of your enemy Capsule GameObjects in the Hierarchy.
- Click Add Component and add the EnemyController script.
- Drag your SlimeStats ScriptableObject asset into the Stats field in the Inspector.
- Drag the empty GameObjects you created as patrol waypoints into the Patrol Waypoints array field.
- Repeat this for the second enemy Capsule with its own patrol waypoints.
- Press the Play button in the Unity toolbar to enter Play Mode.
You should see the enemies walking between their waypoints. Walk your player close to an enemy and it should start chasing you.
Enemies not moving?
Check that each enemy has both a NavMeshAgent and an EnemyController component. The NavMeshAgent must be on the same GameObject as EnemyController.
Verify that the patrol waypoint Transforms are assigned in the Inspector's Patrol Waypoints array. An empty array causes the patrol state to do nothing.
Also confirm your Player capsule still has the Player tag. The EnemyController uses FindGameObjectWithTag("Player") to locate it.
help me debug why my enemies are not moving or patrolling in Play Mode
✔️ Awesome, I've got everything!
Great. Double check you have saved all four enemy scripts.
ⓧ I'd like to double check the full code
Here is the full code for all four enemy scripts.
using UnityEngine;
using UnityEngine.AI;
public class EnemyController : MonoBehaviour
{
[SerializeField] private EnemyStats stats;
[SerializeField] private Transform[] patrolWaypoints;
private StateMachine stateMachine;
private NavMeshAgent agent;
private Transform playerTransform;
public int CurrentHealth { get; private set; }
public EnemyStats Stats => stats;
public NavMeshAgent Agent => agent;
public Transform[] PatrolWaypoints => patrolWaypoints;
public Transform PlayerTransform => playerTransform;
public StateMachine StateMachine => stateMachine;
private void Start()
{
agent = GetComponent<NavMeshAgent>();
playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
CurrentHealth = stats.maxHealth;
stateMachine = new StateMachine();
var patrolState = new EnemyPatrolState(this);
stateMachine.Initialize(patrolState);
}
private void Update()
{
stateMachine.Update();
}
public void TakeDamage(int damage)
{
CurrentHealth -= damage;
if (CurrentHealth <= 0)
{
CurrentHealth = 0;
GameEvents.OnEnemyDefeated?.Invoke();
Destroy(gameObject);
}
}
public float DistanceToPlayer()
{
return Vector3.Distance(transform.position, playerTransform.position);
}
}
using UnityEngine;
public class EnemyPatrolState : IState
{
private EnemyController enemy;
private int currentWaypointIndex;
public EnemyPatrolState(EnemyController enemy)
{
this.enemy = enemy;
this.currentWaypointIndex = 0;
}
public void Enter()
{
enemy.Agent.speed = enemy.Stats.patrolSpeed;
if (enemy.PatrolWaypoints.Length > 0)
{
enemy.Agent.SetDestination(enemy.PatrolWaypoints[currentWaypointIndex].position);
}
}
public void Update()
{
if (enemy.DistanceToPlayer() <= enemy.Stats.detectionRange)
{
enemy.StateMachine.ChangeState(new EnemyChaseState(enemy));
return;
}
if (enemy.PatrolWaypoints.Length == 0) return;
if (enemy.Agent.remainingDistance < 0.5f && !enemy.Agent.pathPending)
{
currentWaypointIndex = (currentWaypointIndex + 1) % enemy.PatrolWaypoints.Length;
enemy.Agent.SetDestination(enemy.PatrolWaypoints[currentWaypointIndex].position);
}
}
public void Exit()
{
}
}
using UnityEngine;
public class EnemyChaseState : IState
{
private EnemyController enemy;
public EnemyChaseState(EnemyController enemy)
{
this.enemy = enemy;
}
public void Enter()
{
enemy.Agent.speed = enemy.Stats.chaseSpeed;
}
public void Update()
{
enemy.Agent.SetDestination(enemy.PlayerTransform.position);
float distanceToPlayer = enemy.DistanceToPlayer();
if (distanceToPlayer > enemy.Stats.detectionRange * 1.5f)
{
enemy.StateMachine.ChangeState(new EnemyPatrolState(enemy));
return;
}
if (distanceToPlayer <= enemy.Stats.attackRange)
{
enemy.StateMachine.ChangeState(new EnemyAttackState(enemy));
}
}
public void Exit()
{
}
}
using UnityEngine;
public class EnemyAttackState : IState
{
private EnemyController enemy;
private float attackTimer;
private float attackCooldown = 1.5f;
public EnemyAttackState(EnemyController enemy)
{
this.enemy = enemy;
}
public void Enter()
{
enemy.Agent.isStopped = true;
attackTimer = 0f;
}
public void Update()
{
float distanceToPlayer = enemy.DistanceToPlayer();
if (distanceToPlayer > enemy.Stats.attackRange)
{
enemy.StateMachine.ChangeState(new EnemyChaseState(enemy));
return;
}
attackTimer += Time.deltaTime;
if (attackTimer >= attackCooldown)
{
attackTimer = 0f;
PlayerController player = enemy.PlayerTransform.GetComponent<PlayerController>();
if (player != null)
{
player.TakeDamage(enemy.Stats.attackDamage);
}
}
}
public void Exit()
{
enemy.Agent.isStopped = false;
}
}
Your enemies now have full AI behavior. Next up, you will build a save/load system so the player's progress persists between sessions.
Implement the Save/Load System
Your enemies are alive and dangerous. They patrol their routes, detect the player, give chase, and attack on contact. The dungeon feels responsive and threatening.
Now you need a way to preserve the player's progress. Without a save system, every time Play Mode ends, the player's position and health reset to their starting values. In this step, you will build a JSON-based save/load system that writes game state to disk and restores it on demand.
In this step, get ready to:
- Create a serializable data class to hold save information.
- Build a SaveManager that writes game state to a JSON file on disk.
- Implement loading from disk and test the full save/load cycle in Play Mode.
Create the SaveData class
Before you can save anything, you need a plain C# class that defines exactly which fields get written to disk. This is your Data Transfer Object (DTO). Unity's JsonUtility can only serialize classes marked with [System.Serializable], and it only handles public fields of supported types.
- Create a new C# script called SaveData.cs inside Assets/Scripts/Save by right-clicking the Save folder in the Project window, then selecting Create > C# Script.
- Replace the entire file contents with the following code:
[System.Serializable]
public class SaveData
{
public float playerPositionX;
public float playerPositionY;
public float playerPositionZ;
public int playerHealth;
public int enemiesDefeated;
public string saveTimestamp;
}
What does this code do?
This class is a simple data container with no logic. Each field maps to one piece of game state you want to persist.
- Player position is stored as three separate floats (playerPositionX, playerPositionY, playerPositionZ) because JsonUtility handles primitives more reliably than Unity structs.
- The [System.Serializable] attribute tells Unity's JSON serializer this class is safe to convert to and from JSON.
- The saveTimestamp field records when the save happened, which is useful for debugging and future multi-save support.
- Save SaveData.cs.
You won't see a change yet. The next chunk uses this class to actually write data to disk.
Create the SaveManager with Save()
The SaveManager is responsible for gathering the current game state, converting it to JSON, and writing it to a file. It uses the singleton pattern so any system in the game can access it without passing references around.
- Create a new C# script called SaveManager.cs inside Assets/Scripts/Save.
- Replace the entire file contents with the following code:
using UnityEngine;
using System.IO;
public class SaveManager : MonoBehaviour
{
public static SaveManager Instance { get; private set; }
[SerializeField] private PlayerController player;
private string SavePath => Path.Combine(Application.persistentDataPath, "savegame.json");
private int enemiesDefeated;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
private void OnEnable()
{
GameEvents.OnEnemyDefeated += HandleEnemyDefeated;
}
private void OnDisable()
{
GameEvents.OnEnemyDefeated -= HandleEnemyDefeated;
}
private void HandleEnemyDefeated()
{
enemiesDefeated++;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.F5))
{
Save();
}
if (Input.GetKeyDown(KeyCode.F9))
{
Load();
}
}
public void Save()
{
SaveData data = new SaveData
{
playerPositionX = player.transform.position.x,
playerPositionY = player.transform.position.y,
playerPositionZ = player.transform.position.z,
playerHealth = player.CurrentHealth,
enemiesDefeated = enemiesDefeated,
saveTimestamp = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
};
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(SavePath, json);
Debug.Log("Game saved to: " + SavePath);
GameEvents.OnGameSaved?.Invoke();
}
}
What does this code do?
The SaveManager uses several patterns you will recognize from backend development.
- The singleton in Awake() ensures only one SaveManager exists. If a duplicate appears, it destroys itself.
- OnEnable and OnDisable subscribe and unsubscribe from the OnEnemyDefeated event. This tracks kills without the SaveManager needing a direct reference to enemies.
- Save() gathers the current state into a SaveData instance, serializes it with JsonUtility.ToJson, then writes the JSON string to Application.persistentDataPath using File.WriteAllText.
- The Debug.Log line prints the exact file path so you can find and inspect the JSON file on disk.
Why Application.persistentDataPath?
This path points to a platform-specific folder that survives app updates and is writable at runtime. On Windows it resolves to %userprofile%\AppData\LocalLow\<Company>\<Product>. On macOS it resolves to ~/Library/Application Support/<Company>/<Product>.
Using Path.Combine instead of string concatenation ensures the path separator is correct on every operating system.
Never hard-code a file path in a Unity project. Application.persistentDataPath gives you the right location automatically.
- Save SaveManager.cs.
The file will show a compiler error because the Load() method referenced in Update() does not exist yet. You will add it next.
Implement Load() and test the save/load cycle
Loading is the mirror of saving. You read the JSON file from disk, deserialize it back into a SaveData object, and apply those values to the player.
- In SaveManager.cs, add the following Load() method directly below the closing brace of Save(), but still inside the class:
public void Load()
{
if (!File.Exists(SavePath))
{
Debug.LogWarning("No save file found at: " + SavePath);
return;
}
string json = File.ReadAllText(SavePath);
SaveData data = JsonUtility.FromJson<SaveData>(json);
player.transform.position = new Vector3(data.playerPositionX, data.playerPositionY, data.playerPositionZ);
player.SetHealth(data.playerHealth);
enemiesDefeated = data.enemiesDefeated;
Debug.Log("Game loaded! Save from: " + data.saveTimestamp);
GameEvents.OnGameLoaded?.Invoke();
}
What does this code do?
- File.Exists(SavePath) prevents a crash if the player presses F9 before ever saving. If no file exists, it logs a warning and exits early.
- JsonUtility.FromJson<SaveData>(json) deserializes the JSON string back into a SaveData object with all fields populated.
- The player's position is reconstructed from the three float fields using new Vector3(...).
- player.SetHealth() restores health and fires the OnPlayerHealthChanged event so any UI listening will update.
- GameEvents.OnGameLoaded?.Invoke() notifies any other system that a load just happened.
- Save SaveManager.cs. The compiler error should now disappear since Load() exists.
Still seeing a compiler error?
- Make sure Load() is inside the SaveManager class (before the final closing brace }).
- Check that using System.IO; is at the top of the file. Without it, File.Exists and File.ReadAllText will not resolve.
- Confirm that SaveData.cs exists and is saved. The JsonUtility.FromJson<SaveData> call requires the class to be compiled first.
Still stuck? Help me fix a compiler error in my SaveManager script.
Now wire up the SaveManager in the scene so it has a reference to the player.
- In the Hierarchy window, right-click and select Create Empty.
- Rename the new GameObject to SaveManager.
- With SaveManager selected, click Add Component in the Inspector and search for SaveManager.
- Select the SaveManager script from the results to attach it.
- Drag your Player GameObject from the Hierarchy into the Player field on the SaveManager component in the Inspector.
Why a separate GameObject?
Managers like SaveManager are not tied to any visible object in the game world. Placing them on their own empty GameObject keeps the Hierarchy organized and makes it clear which objects are logic-only versus visual.
Time to test the full save/load cycle.
- Click the Play button at the top of the Unity Editor to enter Play Mode.
- Move your player using WASD to a different position in the dungeon.
- Press F5 to save.
- Check the Console window at the bottom of the editor. You should see a log message starting with Game saved to: followed by a file path.
- Click Play again to exit Play Mode. The player resets to its starting position.
- Click Play once more to re-enter Play Mode.
- Press F9 to load. Your player should teleport back to the position where you saved.
You should see Game loaded! Save from: followed by the timestamp in the Console.
Player not teleporting on load?
- Make sure you dragged the Player GameObject into the SaveManager's Player field in the Inspector. A missing reference causes a NullReferenceException.
- Check that you pressed F5 before F9. If no save file exists, the Console will show a warning instead of loading.
- Verify the Console shows the Game saved to: message. If it does not appear, the SaveManager component may not be attached to the GameObject.
Need help? My save/load system is not working. The player does not move back to the saved position when I press F9.
✔️ Awesome, I've got everything!
Great. Double check you have saved both SaveData.cs and SaveManager.cs.
ⓧ I'd like to double check the full code
[System.Serializable]
public class SaveData
{
public float playerPositionX;
public float playerPositionY;
public float playerPositionZ;
public int playerHealth;
public int enemiesDefeated;
public string saveTimestamp;
}
using UnityEngine;
using System.IO;
public class SaveManager : MonoBehaviour
{
public static SaveManager Instance { get; private set; }
[SerializeField] private PlayerController player;
private string SavePath => Path.Combine(Application.persistentDataPath, "savegame.json");
private int enemiesDefeated;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
private void OnEnable()
{
GameEvents.OnEnemyDefeated += HandleEnemyDefeated;
}
private void OnDisable()
{
GameEvents.OnEnemyDefeated -= HandleEnemyDefeated;
}
private void HandleEnemyDefeated()
{
enemiesDefeated++;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.F5))
{
Save();
}
if (Input.GetKeyDown(KeyCode.F9))
{
Load();
}
}
public void Save()
{
SaveData data = new SaveData
{
playerPositionX = player.transform.position.x,
playerPositionY = player.transform.position.y,
playerPositionZ = player.transform.position.z,
playerHealth = player.CurrentHealth,
enemiesDefeated = enemiesDefeated,
saveTimestamp = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
};
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(SavePath, json);
Debug.Log("Game saved to: " + SavePath);
GameEvents.OnGameSaved?.Invoke();
}
public void Load()
{
if (!File.Exists(SavePath))
{
Debug.LogWarning("No save file found at: " + SavePath);
return;
}
string json = File.ReadAllText(SavePath);
SaveData data = JsonUtility.FromJson<SaveData>(json);
player.transform.position = new Vector3(data.playerPositionX, data.playerPositionY, data.playerPositionZ);
player.SetHealth(data.playerHealth);
enemiesDefeated = data.enemiesDefeated;
Debug.Log("Game loaded! Save from: " + data.saveTimestamp);
GameEvents.OnGameLoaded?.Invoke();
}
}
Secret mission
Add a Collectible Item & Inventory System
Your dungeon has enemies, movement, and a save system. But what about loot? Extend the game with collectible items that persist in your save file, proving you can wire a new feature through every architectural layer.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources to continue building, pause them, or delete them entirely.
Resources you used:
- DungeonCrawler Unity project folder (all scripts, scenes, and ScriptableObject assets).
- Save file (savegame.json) stored at Application.persistentDataPath.
- AI Navigation package (installed within the Unity project).
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue with Project 2 in this series.
- Your DungeonCrawler project stays in Unity Hub ready to open.
- Your save file persists at the persistent data path for testing.
- The AI Navigation package remains installed in the project.
Pause - I'll come back to this later
Since this is a local Unity project, there is nothing to pause. Your project stays on disk and uses no resources when Unity is closed.
- Close the Unity Editor.
- Your project remains in Unity Hub's project list. Open it again anytime by clicking on it.
Delete - I don't want to use this again
Remove all project files and the save data from your machine.
- Close the Unity Editor if it is open.
- In Unity Hub, right-click the DungeonCrawler project and select Remove project from list.
- Delete the DungeonCrawler project folder from your file system. On macOS, open Finder and move the folder to Trash. On Windows, open File Explorer and delete the folder.
- Delete the save file. On Windows, navigate to %userprofile%\AppData\LocalLow\DefaultCompany\DungeonCrawler and delete the savegame.json file. On macOS, navigate to ~/Library/Application Support/DefaultCompany/DungeonCrawler and delete savegame.json.
What about Unity and VS Code?
Unity Hub, the Unity Editor, and VS Code are general-purpose development tools you will likely use for other projects. There is no need to uninstall them unless you are sure you will not use them again.
Nice Work!
Nice Work!
Nice work! You just built a professionally-architected Unity dungeon crawler with AI enemies, player combat, and a persistent save system.
You've learned how to:
- Implemented the State Machine pattern with an IState interface and StateMachine class to manage both player and enemy behavior through clean, separated states.
- Built enemy AI using NavMesh pathfinding combined with a Finite State Machine that transitions between Patrol, Chase, and Attack states.
- Created a JSON-based save/load system using Application.persistentDataPath to persist player position, health, and progress to disk.
- Secret Mission: Added a persistent inventory system with collectible items that serialize into your save file.
Ready to quiz yourself?