Creating Effective State Machine Ghost AI #2
Paranormal Investigator: Ghost AI #2
What is this game?
Paranormal Investigator is a supernatural investigation game about investigating ghosts with specialised equipment and putting them to rest by collecting information about their lives.
Basic Concept
One of the most important aspect of this game is the ghost AI, as they are the main source of interactions. Therefore, creating a robust system is imperative. I wanted to try my best to replicate how I believe other ghost games would handle the ghost AI.
The basic premise for a stable and extendable Ghost AI system is to create a State Machine that will handle the different states that the ghost can be in, for example, idling, roaming, interacting with objects or triggering abilities.
Implementation of a State Machine - C#
*All code given will be generalised for understanding of the system rather than providing the full source.
Ghost AI is driven by a hierarchical state machine that dictates what the ghost is doing at any one time.
Ghost movement is driven by a Navmesh Agent, which is Unity's built in AI movement and is super simple to implement but it does have some problems that I have encountered in other projects, such as it not playing well with physics (by that I mean you cannot apply forces to a Navmesh agent and must disable the component before applying forces, which can be a real hassle)
This game currently has 7 main states that the ghost can be in at anytime, these are:
- Idle state - ghost wanders about their current room
- Roam state - ghost moves to a random room
- 'Manifest' state - ghosts will appear in front of the player to scare them
- SLS state - occurs when the ghost chooses to interact with an active SLS camera.
- Ability state - Triggered when the ghost uses it's ability
The Ghost can only be in one state at a time, and each state is defined as it's own class that inherits from an abstract 'GhostState' class
//Defines abstract class for State Machine [Serializable] public abstract class GhostState { public abstract void Execute(); public abstract void EndState(); protected Ghost controller; protected NavMeshAgent agent; protected bool isEnding; protected GhostState(Ghost controller, NavMeshAgent agent) { this.controller = controller; this.agent = agent; } }
An example of one of these state would look like so:
public class GhostExampleState: GhostState { public GhostExampleState(Ghost controller, NavMeshAgent agent) : base(controller, agent) { //Initialise any variables taken in } public override void EndState() { //Stop any Running behaviour from this state, like movement for example //Trigger the next state, in this game's context, most states return to the idle state, which decides the next behaviour } public override void Execute() { //This is called every frame and can be used to execute behaviour and or check if the behaviour is finished } }
The ghost script will then have a variable that denotes it's current state.
public class Ghost: MonoBehaviour { GhostState currentState; private void Update() { currentState.Execute(); } }
How are state transitions handled?
State transitions are handled in the 'Ghost' script and occur when the ghost finishes it's idle state.
A method is called by the idle state's ExitState method, which rolls a random number from 0-1 and goes down a list of probabilities to determine the next step. The probabilities are stored in a scriptable object, which is described in the next section.
public void CheckForStateChange() { //Roll a number from 0 - 1 float chance = Random.value; //If the number is lower than or equal to the roam chance, the ghost will roam if (chance <= stats.roamChance) { currentState = new GhostRoamState(this, GetComponent<NavMeshAgent>()); } //Here when we check we must make sure that else if (chance > stats.roamChance && chance <= (stats.worldInteractionChance) + (stats.roamChance)) { //This is a similar method that breaks down interactions further into manifest, ability, throw, etc. CheckForWorldInteractions(); } //If none of these probabilities are hit, we will restart the idle state. else { currentState = new GhostIdleState(this, GetComponent<NavMeshAgent>()); } }
*Ghost 'world interactions' follow a similar system, where each interaction has a chance and is checked against a rolled number from 0-1.
Issues with inheritance
Originally I intended to have each ghost type inherit from the ghost class and then override certain methods. This however presented problems when looking ahead to a feature I want to implement in the future, which is a randomised mode. As if the ghost type is tied to it's script, how would I easily instantiate that script type at the start of the level and get all the references it needs without manually getting them all.
My solution was to simply remove the inheritance and to instead give all ghosts the same script, with the references it needed pre set, but simply add 'scriptable objects', which would store variables about the ghost and can simply be slotted in and out as needed.
How are Ghost Types handled - C#?
Ghost types in this game are handled by scriptable objects that denote each ghosts stats, like their chances of interacting with different items.
What is a scriptable object?
A scriptable object is a data container that stores shared data independently of the scripts in your game. One scriptable object script can be made into multiple scriptable objects, allowing for quick expansion of the ghost list.
Why is it useful?
It allows me to define all the stats for each ghost without having to individually make scripts for each ghost. Each 'Ghost' script will just take in a reference to a scriptable object and they can then read the variables from it to determine their behaviour.
Here is a generalised example of what this might look like
//This line underneath allows me to create instances of this scriptable object in the asset menu. [CreateAssetMenu(fileName = "Ghost Data", menuName = "Ghost Investigator/Ghost Data", order = 1)] public class GhostStatsSO : ScriptableObject { public string ghostDescription; public EvidenceType[] evidences; //This is an enum declared outside of the class. public float worldInteractionChance = 0.5f; public float roamChance = 0.25f; public float interactionChance = 0.5f; public float maifestChance = 0.25f; public float returnToFavouriteChance = 0.25f; public float abilityChance = 0.25f; }
Closing Thoughts
Overall, I am very happy with this AI system for the ghost as it allows me to easily iterate and add new ghost types, while still being able to have control over their behaviour.
If you want to read more about state machines, I heavily suggest checking out some of these resources as they helped me a lot in making this system:
- Game Programming patterns - Robert Nystrom - Gives a great overview on the types of and implementing state machines.
- IHeartGameDev - Has a few videos on the topic and making the systems expandable and reusable across more than just one state machine. https://www.youtube.com/@iHeartGameDev
The development of the game is going well and I hope to be able to show more to you all in the coming months.
Until then, have a nice day and thanks for reading!
Get Paranormal Investigator (Prototype)
Paranormal Investigator (Prototype)
Ghost Investigation game
Status | Prototype |
Author | smallhorns |
Tags | 3D, Horror, Singleplayer |
More posts
- V0.2 - Equipment Interactability81 days ago
- V0.12 - Big Fixing89 days ago
- V0.11 - Equipment Fix91 days ago
- Paranormal-Investigator Game: Plans #1Oct 29, 2024
Leave a comment
Log in with itch.io to leave a comment.