Project Page | Director Ai

Director Ai for Shooter and Survival Games

Jan 30th, 2023 (Updated: Feb 15th)






Project Overview

An Ai Director is a system that manages the intensity and flow of the player's experience. It is used to dynamically control the difficulty and progression of the game in real time based on player performance, and aims to create a unique and challenging experience on each play so that no two games are the same. This typically involves the Director deciding when and where to spawn enemies, how many enemies should be in play at any one time, and modifying the level layout to open up new paths dynamically.

The goal for this project was to create a versatile Director Ai system that would allow designers to build their own rules and behaviours for games of the shooter and survival genre.


Technical Challenges

Constructing a flexible rules system for the Director to utilise effectively for both shooter and survival games proved to be a challenging task. It was important that rules could be processed for both game genres by a single system.

...


Features

  • Highly Customisable - Designers can tweak and customise the director according to their own needs and preferences.
  • Flexible Rule Processing System - Rules for both shooter and survival games can be processed by the system with the capability to adapt to other genre types.
  • Easy Deployment - Simply drop the director prefab into the scene and then drag-and-drop the necessary requirements for the inspector (such as a reference to the player).

Limitations

  • Rules Must Be Manually Added - Designers are required to access the code directly to write rules and add them to the rules list. A custom editor tool should be built that will allow a designer to easily create and add rules to the rule engine without having to access the code directly.
  • Lack of Rule Evaluation Techniques - Intensity rules are only fired based on the highest weighting output. Further techniques could be employed such as executing rules sequentially, at random, based on priority, or firing the first to match a condition.

What I Learnt

  • The Rule Pattern - Abstracting messy logic into their own respective rule classes.
  • The State Pattern
  • ...

Future Improvements

  • Rework director state logic using a state machine
  • Utilise more methods of rule execution
  • Rule creation using a custom editor tool
  • Adapt the active area set to work with 3D environments

Code Samples

Core


public enum DirectorEvent
{
    EnteredNewState,
    EnteredRespiteState,
    EnteredBuildUpState,
    EnteredPeakState,
    EnteredPeakFadeState,
    ReachedPeakIntensity
}
        

public class Director : MonoBehaviour
{
    public static Director Instance;

    private enum Difficulty
    {
        Easy = 0,
        Normal = 1,
        Hard = 2
    }

    [SerializeField] private Difficulty difficulty;
    private Dictionary<Difficulty, float> _intensityModifierDict;

    [Header("INTENSITY")] [SerializeField] private float intensityCalculationRate = 0.5f;

    [Header("TEMPO")] [SerializeField] [Range(70, 100)]
    private int peakIntensityThreshold;

    [Space] [Tooltip("Default value, but can be dynamically altered by the Director")] [SerializeField]
    private float defaultPeakDuration;

    [SerializeField] private float defaultRespiteDuration;
    [Space] [SerializeField] private int maxBuildUpPopulation;
    [SerializeField] private int maxPeakPopulation;
    [SerializeField] private int maxRespitePopulation;

    [Header("ENEMY DATA")] [SerializeField]
    private List<GameObject> activeEnemies = new();
    [SerializeField] private int maxPopulationCount;

    [Header("PLAYER DATA")] [SerializeField]
    private Player player;

    public float RespiteDuration { get; set; }
    public float PeakDuration { get; set; }
    public int MaxPopulationCount { get; set; }

    private readonly StateMachine _stateMachine = new();
    private Dictionary<Type, IState> _cachedIntensityStatesDict;

    private readonly DirectorIntensityCalculator _intensityCalculator = new();
    private float _perceivedIntensity;

    private void OnEnable() => _stateMachine.OnStateChanged += StateMachineOnStateChangedEvent;
    private void OnDisable() => _stateMachine.OnStateChanged -= StateMachineOnStateChangedEvent;

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            Debug.LogError("Only one instance of the Director should exist at any one time");
        }
    }

    private void Start()
    {
        RespiteDuration = defaultRespiteDuration;
        PeakDuration = defaultPeakDuration;

        _cachedIntensityStatesDict = new Dictionary<Type, IState>
        {
            {typeof(DirectorRespiteState), new DirectorRespiteState(this, _stateMachine)},
            {typeof(DirectorBuildUpState), new DirectorBuildUpState(this, _stateMachine)},
            {typeof(DirectorPeakState), new DirectorPeakState(this, _stateMachine)},
            {typeof(DirectorPeakFadeState), new DirectorPeakFadeState(this, _stateMachine)}
        };
        _stateMachine.SetStates(_cachedIntensityStatesDict);
        _stateMachine.ChangeState(typeof(DirectorRespiteState));

        _intensityModifierDict = new Dictionary<Difficulty, float>
        {
            {Difficulty.Easy, 30},
            {Difficulty.Normal, 60},
            {Difficulty.Hard, 90}
        };
    }

    private void Update()
    {
        _stateMachine.Update();
    }

    public void IncreasePerceivedIntensity() => StartCoroutine(IncreasePerceivedIntensityCoroutine());
    public void DecreasePerceivedIntensity() => StartCoroutine(DecreasePerceivedIntensityCoroutine());

    private IEnumerator IncreasePerceivedIntensityCoroutine()
    {
        float intensity = _intensityCalculator.CalculatePerceivedIntensityOutput(this);
        _perceivedIntensity += intensity * _intensityModifierDict[difficulty] * Time.deltaTime;

        if (_perceivedIntensity > 100)
        {
            _perceivedIntensity = 100;
        }

        yield return new WaitForSeconds(intensityCalculationRate);
    }

    private IEnumerator DecreasePerceivedIntensityCoroutine()
    {
        float intensity = _intensityCalculator.CalculatePerceivedIntensityOutput(this);
        _perceivedIntensity -= intensity * _intensityModifierDict[difficulty] * Time.deltaTime;

        if (_perceivedIntensity < 0)
        {
            _perceivedIntensity = 0;
        }

        yield return new WaitForSeconds(intensityCalculationRate);
    }

    public void AddEnemy(GameObject enemy)
    {
        activeEnemies.Add(enemy);
    }

    public void RemoveEnemy(GameObject enemy)
    {
        activeEnemies.Remove(enemy);
    }
    
    private void StateMachineOnStateChangedEvent()
    {
        DirectorEventBus.Publish(DirectorEvent.EnteredNewState);
    }

    public Player GetPlayer() => player;

    public IState GetDirectorState() => _stateMachine.GetCurrentState();

    public float GetPeakIntensityThreshold() => peakIntensityThreshold;
    public float GetDefaultRespiteDuration() => defaultRespiteDuration;
    public float GetDefaultPeakDuration() => defaultPeakDuration;

    public float GetEnemyPopulationCount() => activeEnemies.Count;
    public int GetMaxRespitePopulation() => maxRespitePopulation;
    public int GetMaxBuildUpPopulation() => maxBuildUpPopulation;
    public int GetMaxPeakPopulation() => maxPeakPopulation;

    public float GetPerceivedIntensity() => _perceivedIntensity;
    public float GetIntensityCalculationRate() => intensityCalculationRate;
    public float GetIntensityScalar() => _intensityModifierDict[difficulty];
};

        

public static class DirectorEventBus
{
    private static readonly Dictionary<DirectorEvent, Action> _eventsDict = new();

    public static void Subscribe(DirectorEvent eventName, Action listener)
    {
        if (_eventsDict.ContainsKey(eventName))
        {
            _eventsDict[eventName] += listener;
        }
        else
        {
            _eventsDict.Add(eventName, listener);
        }
    }

    public static void Unsubscribe(DirectorEvent eventName, Action listener)
    {
        if (_eventsDict.ContainsKey(eventName))
        {
            _eventsDict[eventName] -= listener;
        }
    }

    public static void Publish(DirectorEvent eventName)
    {
        if (_eventsDict.TryGetValue(eventName, out Action thisEvent))
        {
            thisEvent.Invoke();
        }
    }
}
          

public class DirectorEventTest : MonoBehaviour
{
    private void OnEnable() => DirectorEventBus.Subscribe(DirectorEvent.ReachedPeakIntensity, SomeMethod);
    private void OnDisable() => DirectorEventBus.Unsubscribe(DirectorEvent.ReachedPeakIntensity, SomeMethod);

    private void SomeMethod()
    {
        Debug.Log("Perceived intensity has reached the maximum threshold. <color=red>You Are Dead</color>.");
    }
}
            

Rule Engine


public interface IDirectorIntensityRule
{
    public float CalculatePerceivedIntensity(Director director);
}
        

public class DirectorIntensityCalculator 
{
    private List<IDirectorIntensityRule> _rules;

    public DirectorIntensityCalculator()
    {
        _rules = new List<IDirectorIntensityRule>
        {
            new PassiveHauntIncreaseRule()
        };
    }
    
    public float CalculatePerceivedIntensityOutput(Director director) 
    {
        var engine = new DirectorIntensityRuleEngine(_rules);
        return engine.CalculatePerceivedIntensityPercentage(director);
        
        // Using the DirectorIntensityRuleEngine to evaluate the rules and produce an output
        // Outputs the greatest intensity value
    }
}
            

public class DirectorIntensityRuleEngine 
{
    private readonly List<IDirectorIntensityRule> _rules = new List<IDirectorIntensityRule>();

    public DirectorIntensityRuleEngine(IEnumerable<IDirectorIntensityRule> rules)
    {
        _rules.AddRange(rules); 
    }

    public float CalculatePerceivedIntensityPercentage(Director director) 
    {
        float intensity = 0; 
        foreach (var rule in _rules)
        {
            intensity = Mathf.Max(intensity, rule.CalculatePerceivedIntensity(director));
            // Applies the rule which outputs the greatest intensity weighting
        }
        return intensity;
    }
}
      

State Machine


public interface IState
{
    public void OnStateEnter();
    public void OnStateUpdate();
    public void OnStateExit();
}
          

public class StateMachine
{
    private IState _currentState;
    private Dictionary<Type, IState> _availableStatesDict;
    public event Action OnStateChanged;

    public void Update()
    {
        if (_currentState == null)
        {
            _currentState = _availableStatesDict.Values.First();
        }
        
        if (_currentState != null) _currentState.OnStateUpdate();
    }
    
    public void SetStates(Dictionary<Type, IState> statesDict)
    {
        _availableStatesDict = statesDict;
    }

    public void ChangeState(Type newState)
    {
        if (_currentState != null) _currentState.OnStateExit();

        _currentState = _availableStatesDict[newState];
        _currentState.OnStateEnter();
        OnStateChanged?.Invoke();
    }

    public IState GetCurrentState()
    {
        return _currentState;
    }
}
          

public class DirectorRespiteState : IState
{
    private Director _director;
    private StateMachine _stateMachine;
    
    public DirectorRespiteState(Director director, StateMachine stateMachine)
    {
        _director = director;
        _stateMachine = stateMachine;
    }

    public void OnStateEnter() => DirectorEventBus.Publish(DirectorEvent.EnteredRespiteState);

    public void OnStateUpdate()
    {
        _director.RespiteDuration -= Time.deltaTime;
        
        if (_director.RespiteDuration <= 0)
        {
            _director.MaxPopulationCount = _director.GetMaxBuildUpPopulation();
            _director.RespiteDuration = _director.GetDefaultRespiteDuration(); 
            _stateMachine.ChangeState(typeof(DirectorBuildUpState));
        }
    }

    public void OnStateExit() {}
}
      

public class DirectorBuildUpState : IState
{
    private Director _director;
    private StateMachine _stateMachine;
    
    public DirectorBuildUpState(Director director, StateMachine stateMachine)
    {
        _director = director;
        _stateMachine = stateMachine;
    }

    public void OnStateEnter() => DirectorEventBus.Publish(DirectorEvent.EnteredBuildUpState);

    public void OnStateUpdate()
    {
        _director.IncreasePerceivedIntensity();
        
        if (_director.GetPerceivedIntensity() >= _director.GetPeakIntensityThreshold())
        {
            _director.MaxPopulationCount = _director.GetMaxPeakPopulation();
            _stateMachine.ChangeState(typeof(DirectorPeakState));
        }
    }

    public void OnStateExit() => DirectorEventBus.Publish(DirectorEvent.ReachedPeakIntensity);
}
      

public class DirectorPeakState : IState
{
    private Director _director;
    private StateMachine _stateMachine;
    
    public DirectorPeakState(Director director, StateMachine stateMachine)
    {
        _director = director;
        _stateMachine = stateMachine;
    }

    public void OnStateEnter() => DirectorEventBus.Publish(DirectorEvent.EnteredPeakState);

    public void OnStateUpdate()
    {
        _director.PeakDuration -= Time.deltaTime;
        
        if(_director.PeakDuration <= 0)
        {
            _director.MaxPopulationCount = 0;
            _director.PeakDuration = _director.GetDefaultPeakDuration(); 
            _stateMachine.ChangeState(typeof(DirectorPeakFadeState));
        }
    }

    public void OnStateExit() {}
}
      

public class DirectorPeakFadeState : IState
{
    private Director _director;
    private StateMachine _stateMachine;
    
    public DirectorPeakFadeState(Director director, StateMachine stateMachine)
    {
        _director = director;
        _stateMachine = stateMachine;
    }

    public void OnStateEnter() => DirectorEventBus.Publish(DirectorEvent.EnteredPeakFadeState);

    public void OnStateUpdate()
    {
        _director.DecreasePerceivedIntensity();
        
        if (_director.GetEnemyPopulationCount() == 0 && _director.GetPerceivedIntensity() == 0)
        {
            _director.MaxPopulationCount = _director.GetMaxRespitePopulation();
            _stateMachine.ChangeState(typeof(DirectorRespiteState));
        }
    }

    public void OnStateExit() {}
}
      

Unity Inspector


Director Inspector View

ActiveAreaSet Inspector View

Other Resources

Thesis Paper | GitHub Repository | Project Showcase