Excessive use of nested Resources?

Godot Version

Godot 4.2

Question

Hello!

I’ve been messing around in Godot 4.2 for the past week trying to figure out what is a suitable way to approach project architecture in this engine.

I have a system built for determining if an AI can use a certain attack depending on conditions defined using custom resources. Each resource has a condition that it checks for and they can be nested within each other to construct more complex conditions. I built this system with the intention of it being easily maintainable and scalable with an ever increasing roster of different AI behaviours.

Here is a screenshot of one such condition. It results in the enemy being able to perform a specific melee attack only if it is in range and on a diagonal to its target:

Is this too much?

I am unfamiliar with best practices when it comes to Godot and I’m scared that defining so many custom Resources, each with its own class_name definition, all nested within each other, may be excessive and detrimental in the long run.

Is this an ok solution? Are there any other approaches for a maintainable and reusable AI solution that I should look into?

I appreciate any and all discussion and help!

2 Likes

Hmm… it might be a little much. For me, looking at your screenshot kinda hurts my brain for a second. It’s not that readable.

I’ve studied AI behaviour systems in games a tiny bit and have also created some of my own. Here’s what I think about creating extensible AI systems:

The AI Problem

Creating extensible AI systems requires an architecture that is adequately generic such that any type of behaviour can be implemented. A lot of thought must be put into such architecture as there is a variety of information that an individual agent may utilize to inform its custom behaviour:

  • Player state (e.g. position, rotation, velocity, grounded etc.)
  • Agent state (e.g. position, rotation, velocity, grounded etc.)
  • Navigation data (e.g. path cost, traversable areas, navigation links etc.)
  • Line-of-sight algorithm
  • Nearby entities (for example, for flocking algorithms)
  • …and so on

Even more importantly, you should first think about the problem in an abstract manner to extract the aspects of the system that must be generalized.
Depending on the game you are making, you may not need a complex, sophisticated AI framework. However, It’s always nice to have the capability to author complex behaviour, so…

Existing AI Architectures

…here’s a section of the AI architectures I know of that allows complex behaviour:

Blackboard System

In short, the blackboard system suggests that each agent has a “blackboard”.

Blackboard: a large collection of data that holds any and all information needed to represent the world (as seen by the agent).

Entities write to blackboards to update the state (and thus the behaviour) of agents.
Each agent may also have access to a global blackboard; a blackboard that contains information that does not vary per instance (e.g. gravity).

Essentially, the blackboard acts as an interface between the game world and an agent’s behaviour i.e. a behaviour doesn’t directly modify the agent. The agent is informed by the blackboard, and so is the behaviour.

Unity’s Pluggable AI

This is the first AI system I came across years ago. I think it’s a great place to start, and while the tutorial is focused on ScriptableObjects in Unity, the concept can easily be carried over to Godot’s Resource counterpart.

It has its limitations though as the system must get a direct reference to the object whose behaviour it wishes to modify - a big nono when building maintable decoupled systems.

Your current Godot solution

I think you’re on the right track to creating a good system, it’s just very rough right now.

I would recommend that you try and create a state-machine system: a simple system that can switch between states based on a condition (even multiple ones). The aforementioned architecture(s) should give you a good idea of how to do this - but don’t be afraid to make your own modifications or solutions to your problem.

I would also recommend that you store your condition-resources as files and name them instead of creating sub-resources. It’s not that nice to work with.

Other helpful resources

Here’s some GDC talks that discuss AI systems that were implemented into some games:

If you got any other specific questions regarding this, I’ll be happy to discuss them with you.

3 Likes

Thank you for such a thoughtful and thorough reply!

Hmm… it might be a little much. For me, looking at your screenshot kinda hurts my brain for a second. It’s not that readable.

Agree 100%.

This is only one attack pattern. It’s going to become a complete mess with a more complex AI (such as that of a boss). I feel like the system itself of using resources to construct these conditions is pretty good, but the UI doesn’t really support such a deeply nested structure. I’d love to find a different UI solution to this, though I’m not sure what that would be.

I would recommend that you try and create a state-machine system

I’m actually already using a state machine to handle wander and pursue states! Love the things.

This “AttackPattern” solution as I like to call it is an attempt at making a generic system to answer a specific question of the AI:

“OK, I am currently in combat. What attacks do I have and what do I need to do to use them.”

Currently, they AI wanders around it’s target using the state machine randomly until a condition is met, then it attacks. The systems are decoupled in that sense.

The reason I considered this sort of “condition” approach for this specific problem instead of just using the already existing state machine solution were the following:

  1. My state machine uses generic “Node” objects to store behaviour and using resources felt like a more performant solution. I have no data to back this claim however, I’m just guessing.
  2. Attack conditions seemed like a part of the AI ripe for generalisation. Most ranged attacks have the same condition, just with different values. The Godot “Resource” object also seemed like a good use for this problem, since you can save them as files and reuse really similar conditions wherever you need.

two talks in one that discuss how to make AI behaviour that feels impactful

This seems really interesting to me. I’ll definitely give that one a listen when I get the time!

I would also recommend that you store your condition-resources as files and name them instead of creating sub-resources. It’s not that nice to work with.

This interests me the most at the moment. What do you mean by “as files and name them”? Like make them a json file that I then read in to construct the conditional? It could definitely improve readability and reduce the amount of Resources I’d need to create.

What do you mean by “as files and name them”?

Godot allows you to create custom Resources similar to how Unity does with ScriptableObjects.

From Godot Docs:

Godot makes it easy to create custom Resources in the Inspector.

  1. Create a plain Resource object in the Inspector. This can even be a type that derives Resource, so long as your script is extending that type.
  2. Set the script property in the Inspector to be your script.

Here’s a an in-engine screenshot of the custom resources in my current project that I made for the player (and, down the line, enemies and other entities):

…and the code for Ability.cs and Attack.cs:

Ability Script
public abstract partial class Ability : Resource
{
    [Flags]
    private enum AttackActivationContext
    {
        WhenGrounded = 1 << 0,
        WhenAirborn = 1 << 1,
    }

    [Export(PropertyHint.Flags)] private AttackActivationContext activationContext = AttackActivationContext.WhenGrounded | AttackActivationContext.WhenAirborn;

    protected bool isHeld;
    protected Node3D owner;
    protected Godot.Collections.Dictionary info;

    protected float TimeSinceUse => (Time.GetTicksMsec() - useStartStamp) / 1000f;
    private ulong useStartStamp;

    protected float TimeSinceHold => (Time.GetTicksMsec() - holdStartStamp) / 1000f;
    private ulong holdStartStamp;

    protected float TimeSinceRelease => (Time.GetTicksMsec() - releaseTime) / 1000f;
    private ulong releaseTime;

    /// <summary>Runs every frame.<br />NOTE: Has no base.</summary>
    public virtual void Process(double delta) { }
    /// <summary>Runs every physics frame.<br />NOTE: Has no base.</summary>
    public virtual void PhysicsProcess(double delta) { }

    /// <summary>
    /// Copies and initializes the ability.<br />
    /// Abilities must be copied before use to avoid multiple instances overriding the ability state of one another.
    /// </summary>
    /// <returns>The ability duplicate.</returns>
    public Ability CopyAndInitialize(Node3D owner, Godot.Collections.Dictionary info = null)
    {
        Ability copy = (Ability)this.Duplicate();
        copy.Initialize(owner, info);
        copy.ResourceName = this.ResourcePath.GetFile().TrimSuffix(".tres");
        return copy;
    }

    /// <summary>
    /// Used to initialize the environment and variables needed for the ability.
    /// </summary>
    public virtual void Initialize(Node3D owner, Godot.Collections.Dictionary info)
    {
        this.owner = owner;
        this.info = info;
    }
    /// <summary>
    /// Used to cleanup any nodes, variables, or effects made/used by the ability.<br />
    /// NOTE: Has no base.
    /// </summary>
    public virtual void Cleanup() { }

    /// <summary>
    /// Called when the ability should be triggered (instant one-frame use).
    /// </summary>
    public virtual void Use()
    {
        useStartStamp = Time.GetTicksMsec();
    }

    /// <summary>
    /// Called when the ability should START being held i.e. not called continously.
    /// </summary>
    public virtual void Hold()
    {
        if (isHeld)
            return;

        isHeld = true;
        holdStartStamp = Time.GetTicksMsec();
    }

    /// <summary>
    /// Called when the ability should STOP being held i.e. not called continously.
    /// </summary>
    public virtual void Release()
    {
        if (!isHeld)
            return;

        isHeld = false;
        releaseTime = Time.GetTicksMsec();
    }

    protected bool HasCorrectContext()
    {
        CharacterBody3D cb = owner as CharacterBody3D;

        if (cb.IsValid())
        {
            // TODO: Implement context check here (activationContext)
            
            return true;
        }
        else
        {
            // Context cannot be determined. Context is redundant.
            return true;
        }
    }
}
Attack Script (inherits Ability)
public partial class Attack : Ability
{
    // Mandatory parameterless constructor
    public Attack() { }

    [Export] private Animation animation;
    [ExportGroup("Timing")]
    [Export] private float startupTime = 0.1f;
    [Export] private float activeTime = 0.1f;
    [Export] private float releaseTime = 0.5f;
    private float FullAttackTime => startupTime + activeTime + releaseTime;
    [ExportSubgroup("Detailed")]
    /// <summary>The window of input in which the user can activate this attack relative to the end of the current attack.</summary>
    [Export] public float inputWindow = 1f;
    /// <summary>The offset of the input window (window start is the end of the current attack).</summary>
    [Export] public float windowOffset = 0f;
    /// <summary>Whether this attack can be cancelled by the next attack, if the input window of the next attack is activated and lies within the timing of this.</summary>
    [Export] public bool canBeCancelled = false;
    [ExportSubgroup("")]
    [ExportGroup("")]

    [Export] private Attack[] links = new Attack[0];

    // /// <summary>The id of the target collision shape on the owner object.</summary>
    // [Export] public string collisionShapeID;

    private bool useAttack;

    private List<Node3D> targetsHit = new List<Node3D>();

    public Action<Attack> OnGoToNextAttack;
    public Action OnFinished;

    public override void Process(double delta)
    {
        base.Process(delta);

        if (useAttack)
        {
            // Move owner based on supplied animation
            if (animation.IsValid())
            {
                if (animation.GetTrackCount() > 0)
                {
                    var type = animation.TrackGetType(0);

                    // ==================================================================================
                    // TODO: Good for now, but it's unlikely that this is the correct long term solution!
                    // ==================================================================================
                    double t;
                    double tOld;
                    switch (type)
                    {
                        case Animation.TrackType.Position3D:
                            t = Mathf.Clamp(TimeSinceUse, 0, FullAttackTime);
                            tOld = Mathf.Clamp(TimeSinceUse - delta, 0, FullAttackTime);
                            Vector3 posDelta = animation.PositionTrackInterpolate(0, t) - animation.PositionTrackInterpolate(0, tOld);
                            owner.GlobalPosition += owner.Basis * posDelta;
                            break;
                        case Animation.TrackType.Value:
                            t = Mathf.Clamp(TimeSinceUse, 0, FullAttackTime);
                            tOld = Mathf.Clamp(TimeSinceUse - delta, 0, FullAttackTime);
                            Vector3 valueDelta = (Vector3)animation.ValueTrackInterpolate(0, t) - (Vector3)animation.ValueTrackInterpolate(0, tOld);
                            owner.GlobalPosition += owner.Basis * valueDelta;
                            break;
                    }
                }
            }

            // Startup
            if (TimeSinceUse < startupTime)
            {
                DebugOverlay.ShowInfo(GetInstanceId().ToString(), $"{this.ResourceName} time: {TimeSinceUse.ToString("0.000")} (startup)");
            }
            // Active
            else if (TimeSinceUse < (startupTime + activeTime))
            {
                Shape3D sh = new BoxShape3D() { Size = Vector3.One * 2f };
                Vector3 start = owner.GlobalPosition - owner.GlobalBasis.Z * 1f;
                Vector3 end = start;
                var result = owner.Shapecast(sh, start, end);

                // Damage all targets
                for (int i = 0; i < result.Count; i++)
                {
                    Node3D n = (Node3D)result[i]["collider"];
                    if (!targetsHit.Contains(n))
                    {
                        n.Dmg(1);
                        targetsHit.Add(n);
                    }
                }

                DebugOverlay.ShowInfo(GetInstanceId().ToString(), $"{this.ResourceName} time: {TimeSinceUse.ToString("0.000")} (active)");
            }
            // Release
            else if (TimeSinceUse < (startupTime + activeTime + releaseTime))
            {
                DebugOverlay.ShowInfo(GetInstanceId().ToString(), $"{this.ResourceName} time: {TimeSinceUse.ToString("0.000")} (release)");
            }
            // End
            else
            {
                DebugOverlay.ShowInfo(GetInstanceId().ToString(), $"{this.ResourceName} time: {TimeSinceUse.ToString("0.000")} {(TimeSinceUse >= FullAttackTime ? "(Waiting for input)" : "")}");

                // Determine whether all links are missed before ending the attack
                bool allLinkWindowsMissed = true;
                foreach (Attack l in links)
                {
                    if (TimeSinceUse < FullAttackTime + Mathf.Abs(l.inputWindow + windowOffset))
                        allLinkWindowsMissed = false;
                }

                if (allLinkWindowsMissed)
                {
                    // End the attack
                    useAttack = false;
                    OnFinished?.Invoke();

                    GD.Print("Ending attack.");
                }
            }
        }
    }

    public override void Use()
    {
        if (useAttack)
        {
            // Invoke subscribed systems to go to the next attack
            for (int i = 0; i < links.Length; i++)
            {
                bool withinNextAttackWindow = (TimeSinceUse > FullAttackTime + links[i].windowOffset) && (TimeSinceUse < FullAttackTime + (links[i].windowOffset + links[i].inputWindow));
                if (withinNextAttackWindow)
                {
                    OnGoToNextAttack?.Invoke(links[i]);
                    useAttack = false;
                }
            }
        }
        else
        {
            base.Use();
            useAttack = true;
            DebugOverlay.Print($"{ResourceName} starting!");
            targetsHit.Clear();
        }
    }

    public override void Cleanup()
    {
        base.Cleanup();

        DebugOverlay.RemoveInfo(GetInstanceId().ToString());
    }
}
2 Likes

To me this looks like you should actually use Godot’s node system, not nesting lots of resources. Your screenshot even looks like a behaviour tree - there are some solutions for that on the Godot AssetLib, for example LimboAI, BeeHave or Yet Another Behavior Tree.

2 Likes

Hadn’t heard of behaviour trees before! Thanks for the tip. Have to find some reading on how they differ from state machines, since it sounds like they both have their place in AI behaviour development.

From a quick google search I got the impression that behaviour trees resolve to one specific “result”, which makes me wonder how it will work for attack decision making, considering that the AI can have multiple attack options at a time. I suppose a “choose randomly” decision node could also be made to account for that? Or some sort of weighted system, if that additional complexity is worth it.

I’m also interested in hearing the reasoning behind using Godot’s node system for the implementation. Are there other benefits to using it over Resources besides better readability?

I’m seeing a lot of people using Resources for everything for some reason. I would rethink that.

The basic node/object system is much easier to go with

Yes, BTs have a specific result per tick (which you can call every frame of course), depending on your conditions. Attack decision making is yours to define, like choosing a certain weapon depending on distance and ammo count. If you use the RUNNING result for leaf nodes e.g. while attacking, then yeah, a random composite node would work.

And yes, I’d use the node system mostly for better readability, but also much better maintainability. If you use Resources, changing a parent element means you lose all the children, while you can just move nodes around without worrying about such things. They’re also more compact and reusable IMHO.