Hey everyone,
I’ve been pondering over possible implementations of a Hierarchical State Machine for in-game entities (controlled by either players or AI) that could scale well for character/entity systems of increasing complexity.
[NOTE]
I understand this post is long and winding, so I’d be happy to clarify any points or questions. Heck, I’ll even make pictures if it’d help!
I’m especially prone to overthinking things and haven’t personally witnessed the performance impact of my code at a true “game”-level scale (I guess think Metroidvania scale for starters?). I respect KISS and YAGNI, but I believe this system could come in handy. Any guidance, advice WELCOME!!!
In terms of features and optimization, I’ve been shooting for the following:
-
Avoid garbage collection whenever possible, if not always. Inspired by Playdead’s (Limbo, INSIDE devs) Unite 2016 talk on optimization
https://www.youtube.com/watch?v=mQ2KTRn4BMI -
Check for transition conditions per frame-tick ONLY when necessary.
-
I think checking for ground contact in a humanoid GameObject’s locomotion states every fixed update can be reasonable if the entity functions like a platformer character (Megaman X, Hollow Knight, Guacamelee) with abilities like wall-jumping, air-dashing, etc.), or if they move along uneven terrain and calculate their velocity based on the ground’s normals (Sonic with its curves, ramps, and loops).
-
I think checking for damage or other character interactions every tick is excessive.
-
Check for transition conditions by events whenever possible
-
Given one typically initiates damage-damageable interactions is via callbacks such as a MonoBehaviour’s OnCollisionEnter method, it’d seem reasonable to implement a state callback that fires only when the entity receives the damage event and handles it on a state-by-state basis.
-
For instance, consider a character with multiple top-level state machines, one dedicated to the character’s “vulnerability” (vulnerable, invulnerable due to invincibility frame post-damage) and the other to handling movement, attacks, and calling for their corresponding animations. Some script managing both state machines would subscribe to the Dameagable component’s OnDamageReceived callback, and feed any damage results to each state machine, Vulnerability first and General second. If the character is briefly in the “invulnerable” state, Vulnerability could “consume” the event and prevent it from propagating to General, as General should only need to worry about triggering recoil from actual damage events.
My design approaches so far
Common setups
Represent a state with a State class/object. To start, all states would share the methods OnEnter and OnExit.
The StateMachine class extends State itself, so that one may layer states without explicit class inheritance.
-
Ex: Idle and Walk states belong under the Grounded state, so just make Grounded a state machine that contains both states. Assuming the character can jump normally from both states, have Grounded handle the transition to an Airborne state.
-
I’d like to take this approach after seeing it utilized in LavaAfterburner’s UnityHSM (GitHub - Inspiaaa/UnityHFSM: A simple yet powerful class-based hierarchical finite state machine for Unity). Nesting state machines seems like a good option to have.
States are not MonoBehaviours. They don’t have FixedUpdate or Update functions magically cached by Unity. Instead, an external MonoBehaviour will contain the State Machines and handle ticking them (if necessary, more details in approaches below) as well as passing events to them.
States machines contain a pool of States, so states themselves do not initialize new state objects, only for them to be discarded between transitions.
Approach A: Coroutine-Driven
During OnEnter(), a State could start a Coroutine which in yields to Coroutines that execute in-line with Update and/or FixedUpdate, by yielding to WaitOnUpdate() and WaitOnFixedUpdate() respectively (Via the Flyweight pattern, I cache these values within a static CoroutineHelper class to reduce/eliminate garbage collection, rather than instantiating new objects each yield). The State would stop these Coroutines during OnExit().
During its execution, the State machine would run a Coroutine which should simply yield for the completion of the current state’s execution coroutine. It would feed events into the states via delegates.
While I enjoy the concept of individual states handling whether they include an Update and/or FixedUpdate-based tick, it seems very hard to manage correctly. On the other hand, I’d likely drive animation through Animancer, a great code-based add-on that allows easily yielding for an animation clip to complete rather than checking whether it has completed via a Tick method, so I guess a guy can dream.
Also, I haven’t explicitly profiled the overhead of stopping and starting coroutines on a state-by-state basis as opposed to settling with empty TickUpdate() and TickFixedUpdate() methods in certain states.
Approach B: Tick-Driven
Expand the state interface to include TickUpdate() and TickFixedUpdate(). Unfortunately, the State Machine would then tick states that may only require events; these states would have empty TickUpdate() and TickFixedUpdate() methods.
Other additions
States accept references to their State machine and a Monobehaviour via their method parameters. States would also also accept a reference to a Blackboard object that caches any relevant components on the GameObject.
I suppose, for the sake of easier testability, I should work to prevent states from calling any MonoBehaviour components directly; I could instead have more controllers to abstract these components.
- EX: A GroundMovementController and AirMovementController with functions that accept directional vectors and handle moving the character via their Rigidbody component. Upon receiving input, a “ground-walk” state could send it to the GroundMovementController.
[HELP] My obstacles/shortcomings
Following object-oriented programming by using a State interface / abstract class, but also feeding custom events on a state-by-state basis.
Let’s theoretically add an OnEvent() function to State. The MonoBehaviour containing the State Machines (let’s call it CharacterController for now), would subscribe to events/delegates on other components, such as OnDamage() on the DamageableController. It would then pass these events on to the State Machines, which in turn pass them to the active states. Given the hierarchical nature of this state machine, each state can attempt to handle the event before passing it to the active sub-state. For instance, the Grounded state machine could handle damage events directly without passing them to the Idle or Run state.
Ideally, OnDamage() would also pass parameters related to the damage received-- consider OnDamage(DamageTypeEnum type, int amount). I’d like CharacterController to pass these parameters to the corresponding State Machines. However, different parameters would require further extending the State interface, hardcoding its possible events. Does there exist a means of wrapping these parameters without creating garbage?
I could redefine OnEvent() as OnEvent(string/enum eventType, IEventParams params), having all possible parameters extend from an empty interface IEventParams; individual states could then cast the parameter based on the eventType. In most cases, however, I’d imagine this method would generate garbage. I could attempt to cache/reuse a single instance for each type of event and change its values per event, but there’d be trouble if multiple damage events occured in a single frame.
Another approach would involve removing the IEventParams argument from OnEvent(), and instead storing event-based information in the Blackboard accessible by all states. I believe, however, that a specific implementation of the Blackboard interface/class would require making States/State Machines generic, restricting the sharing of states between characters with similar actions but slightly different Blackboards / event listeners. Having the Blackboard contain a Dictionary of component types mapped to objects would bypass the Generic type approach, but at the cost of (most likely) boxing value types.
I’d say this issue extends to input-driven events, both those by human players and AI; one could describe a button-based input (“jump”, “attack”) with a simple string, but axis-driven inputs would also require a value (a Vector2 containing the horizontal and vertical axes, perhaps).
[SIDE SPECULATION, ADVICE APPRECIATED] “Dream” Features beyond my mortal comprehension
Making state transitions extendable at runtime.
Say your player character begins with a basic jump ability, but obtains a double jump later on. Sure, one could manage a blackboard variable, like a boolean hasDoubleJump, and have the Jump state check it upon receiving a “jump” input event… but what if the Jump state had no knowledge of this transition prior to the character receiving this upgrade? Upon the player character receiving the upgrade, some script / ScriptableObject / whatnot could append the jump-to-double-jump transition to the character’s movement state machine.
[Heavy Speculation]
We could abstract transitions with objects, themselves, and pair each State with a dictionary of event names mapped to transitions, but now we run the risk of processing input event logic both inside and outside of States.
Consider the Walk state, passes input-based movement vectors to the GroundMovementController as they come along. If the vector’s squared magnitude falls below a certain value (into the deadzone), then the Walk transitions to Idle state. Now, would one handle this transition within the extendable transition dictionary/list/container, or within the state?
[More Possible Approach?]
Another route involves the State Machine’s pool of States. If a destination state does not exist in the pool, we simply don’t end the current state or transition to the destination state. In this case, we simply add the Double Jump state to the movement machine’s pool after the character obtains this upgrade.