Skip to content

Chapter 5. Best Practices

Antonio Maiorano edited this page Nov 10, 2015 · 8 revisions

Introduction

By now you should be fairly comfortable with how HSM works, and have been exposed to many different techniques on how to use it. This chapter will focus on some best practices to follow when developing state machines with HSM. Many of these were already covered to some degree in the previous chapters, so the aim of this chapter is to provide a high level summary of what you should or should not do.

Before we begin, it's important to understand that these best practices are derived from years of experience implementing state machines in real software. Having said that, they are not hard and fast rules, and in certain cases, it may make sense to go against them. As usual, you'll have to use your judgement.

Prefer InnerEntry + Sibling to Inner

Inner transitions are almost never the right type of transition to use. The only times it makes sense to use Inner transitions is when a state needs to force transitions to specific inner states immediately. Typically, these inner states map directly to an enum value or to a series of simple if-else conditions, for example:

	struct Locomotion : BaseState
	{
		virtual Transition GetTransition()
		{
			if (Owner().PressedMove())
				return InnerTransition<Move>();
			else
				return InnerTransition<Stand>();
		}
	};

The key thing to realize about Inner transitions is that inner states have no control over when and where transitions between siblings occur. The outer state is the "boss". This becomes problematic as soon as you want an inner state to have some control over its transitions; for instance, in the example above, let's say that when we stop pressing the move input, we want the character to play a stopping animation before going to Stand. Using Inner transitions would make this very awkward:

	struct Locomotion : BaseState
	{
		bool mStopping;
	
		Locomotion() : mStopping(false) {}
	
		virtual Transition GetTransition()
		{
			if (Owner().PressedMove())
			{
				return InnerTransition<Move>();
			}
			else if (IsInInnerState<Move>()) // We were in Move and just stopped pressing move input
			{
				mStopping = true;
			}
			
			if (mStopping && IsInInnerState<Stop_Done>()) // Stop animation complete?
			{
				mStopping = false;
			}
			
			if (mStopping)
			{
				return InnerTransition<Stop>();
			}
			
			return InnerTransition<Stand>();
		}
	};
	
	struct Stand : BaseState
	{
		virtual void OnEnter()
		{
			PlayAnim("Stand");
		}
	};

	struct Move : BaseState
	{
		virtual void OnEnter()
		{
			PlayAnim("Move");
		}
	};
	
	struct Stop : BaseState	
	{
		virtual void OnEnter()
		{
			PlayAnim("Stop");
		}
		
		virtual Transition GetTransition()
		{
			if (IsAnimDone())
				return SiblingTransition<Stop_Done>();
			
			return NoTransition();
		}
	};
	
	struct Stop_Done : BaseState
	{
	};	

The transition logic above is not easy to follow. The main problem is that the outer state, Locomotion, is deeply coupled with the transition logic of its inner states. Let's rewrite the above logic using InnerEntry + Sibling transitions:

	struct Locomotion : BaseState
	{
		virtual Transition GetTransition()
		{
			return InnerEntryTransition<Stand>();
		}
	};
	
	struct Stand : BaseState
	{
		virtual void OnEnter()
		{
			PlayAnim("Stand");
		}
	
		virtual Transition GetTransition()
		{
			if (Owner().PressedMove())
				return SiblingTransition<Move>();
				
			return NoTransition();
		}
	};

	struct Move : BaseState
	{
		virtual void OnEnter()
		{
			PlayAnim("Move");
		}
	
		virtual Transition GetTransition()
		{
			if (Owner().PressedMove())
				return SiblingTransition<Stop>();
				
			return NoTransition();
		}
	};
	
	struct Stop : BaseState	
	{
		virtual void OnEnter()
		{
			PlayAnim("Stop");
		}
	
		virtual Transition GetTransition()
		{
			if (Owner().PressedMove())
				return SiblingTransition<Move>();
				
			if (IsAnimDone())
				return SiblingTransition<Stand>();
				
			return NoTransition();
		}
	};

Understanding the transition logic in this second version is much easier. The three states, Stand, Move, and Stop, form a flat state machine that take care of transitioning between each other. The outer state, Locomotion, simply chooses the initial state, Stand in this case, and doesn't interfere afterwards.

In general, InnerEntry transitions coupled with Sibling transitions is almost always the best way to manage a state machine. The main reason is that it allows inner states full control over its sibling transitions. In effect, when an outer state uses an InnerEntry transition, it is starting up a new flat state machine. It is easier to both reason about and manipulate flat state machines. Using InnerEntry + Sibling transitions allows you to build your hierarchical state machine as a series of flat state machines, where certain states push new flat state machines.

Nest state-related variables as deeply as possible

When working with HSM, you often find yourself wondering where to declare state-related variables. In general, try to nest your variables as deeply as possible; that is, prefer the following order from most deeply nested to least:

  1. State : if your variable is only used by a single state, make it a data member of the state. This way, the lifetime of the variable is tied to that of the state instance.

  2. Outer State : if your variable must be accessed by a set of states, make it a data member of a common outer state. This is described in detail in the section Storing Data on Cluster Root State.

  3. Owner : if your variable must be accessed by many states in your state machine, make it a data member of the owner class.

Note that if you find yourself needing to often reset a state-related variable declared on the owner class because it would otherwise contain "stale" data, you are likely in a situation where this variable should be more deeply nested - perhaps on a state cluster. If this is not the case, consider using a state value instead to have its value automatically reset to a default value on state exit.

Clean up in cluster root state

One common situation you may come across when implementing state machines is one where you have a sequence of sibling states, where one state makes a change, and a separate state "undoes" this change. In such a situation, there's a possibility that the state that is meant to undo the change may never be reached due to an outer state transition. The strategy to avoid such a problem is to make sure to perform the undo code in an outer state - typically in its OnExit.

To better explain this best practice, let's take a look at an example:

// cluster_root_clean_up.cpp

#include "hsm/statemachine.h"

using namespace hsm;

class Character
{
public:
	Character();
	void Update();

private:
	bool IsHurt() const { return false; }
	bool ShouldGetOnLadder() const { return true; }
	bool ShouldGetOffLadder() const { return false; }
	void AttachToLadder() {}
	void DetachFromLadder() {}

	friend struct CharacterStates;
	StateMachine mStateMachine;
};

struct CharacterStates
{
	struct BaseState : StateWithOwner<Character>
	{
	};

	struct Alive : BaseState
	{
		virtual Transition GetTransition()
		{
			if (Owner().IsHurt())
				return SiblingTransition<Hurt>();

			return InnerEntryTransition<Stand>();
		}
	};

	struct Hurt : BaseState
	{
	};

	struct Stand : BaseState
	{
		virtual Transition GetTransition()
		{
			if (Owner().ShouldGetOnLadder())
				return SiblingTransition<Ladder>();

			return NoTransition();
		}
	};

	struct Ladder : BaseState
	{
		virtual Transition GetTransition()
		{
			return InnerEntryTransition<Ladder_GetOn>();
		}
	};

	struct Ladder_GetOn : BaseState
	{
		virtual void OnEnter()
		{
			Owner().AttachToLadder();
		}

		virtual Transition GetTransition()
		{
			return SiblingTransition<Ladder_OnLadder>();
		}
	};

	struct Ladder_OnLadder : BaseState
	{
		virtual Transition GetTransition()
		{
			if (Owner().ShouldGetOffLadder())
				return SiblingTransition<Ladder_GetOff>();
			
			return NoTransition();
		}
	};

	struct Ladder_GetOff : BaseState
	{
		virtual void OnEnter()
		{
			Owner().DetachFromLadder();
		}
	};
};

Character::Character()
{
	mStateMachine.Initialize<CharacterStates::Alive>(this);
	mStateMachine.SetDebugInfo("TestHsm", TraceLevel::Basic);
}

void Character::Update()
{
	// Update state machine
	mStateMachine.ProcessStateTransitions();
	mStateMachine.UpdateStates();
}

int main()
{
	Character character;
	character.Update();
}

In the example above, we have implemented a character controller that supports climbing ladders. Let's look at a plot of this state machine:

cluster_root_clean_up

This is a straightforward state machine: we have a state that takes care of getting our character onto a ladder, then it siblings to a state while we're on the ladder, which in turn siblings to a state that takes care of getting off the ladder. The important part to highlight is how Ladder_GetOn invokes Owner().AttachToLadder(), while Ladder_GetOff will later invoke Owner().DetachFromLadder():

	struct Ladder_GetOn : BaseState
	{
		virtual void OnEnter()
		{
			Owner().AttachToLadder();
		}
		
		<snip>
	};

	<snip>

	struct Ladder_GetOff : BaseState
	{
		virtual void OnEnter()
		{
			Owner().DetachFromLadder();
		}
	};

At the outset, there's nothing wrong with this code, and indeed it will probably work as expected most of the time. However, there is a potential problem here: what happens if the current state is Ladder_OnLadder, and suddenly outer state Alive makes a sibling transition to Hurt? If this happens, the call to Owner().DetachFromLadder() would never be made, and our character could end up in a strange and unexpected state.

The solution to this problem is to clean up in a common outer state. In this example, we could simply add a redundant call to DetachFromLadder to the Ladder cluster's root state:

	struct Ladder : BaseState
	{
		virtual void OnExit()
		{
			// In case we get booted out, make sure we're no longer attached
			Owner().DetachFromLadder();
		}
	
		virtual Transition GetTransition()
		{
			return InnerEntryTransition<Ladder_GetOn>();
		}
	};

Now there is no way for our character to be attached to a ladder when we're not in the Ladder state.

The best practice here is to make sure to clean up in a cluster's root state. Whenever you find yourself having to undo/release/clean up some operation that was performed in a sibling state, make sure to add or move this code to a common outer state.

Avoid state stack queries outside of states

In the section on state stack queries, we learned about how to use functions such as InInState to check if a state is on the stack. These query functions can be very useful; however, their use should be minimized outside of states themselves. It can be tempting to use functions like IsInState in the owner class, for instance, to perform some action every frame:

void Character::Update()
{
	if (mStateMachine.IsInState<CharacterStates::Jumping>())
	{
		ShowJumpingUI();
	}
}

Instead, it is almost always a better idea to push this code into the states themselves:

	struct Jumping : BaseState
	{
		virtual void Update()
		{
			Owner().ShowJumpUI();
		}
	}

The reasons why you should avoid state stack query functions outside of states include the following:

  • It reduces coupling between code outside of states and the states themselves. This allows you to more easily rename states, refactor them, etc.;

  • It unifies state-driven code in one place, rather than splitting it across states and sections of code outside of the state machine. Indeed, one of the main purposes of HSM is to allow you to drive state-specific code directly within the states themselves;

  • It's better for performance. The state stack query functions must traverse the state stack and compare state identifiers against the input one. This cost must be paid even when that state is not on the stack, and is worse in this case since the entire stack must be traversed. On the other hand, moving the code into the state means it only executes when that state is on the stack, and there is no extra cost for querying the state stack.

One potentially valid use case for using state stack query functions outside of states is when you need to communicate your current state without giving direct access to the state machine. For instance, we may want to add a public function to Character such as IsJumping, which we'd implement as follows:

class Character
{
public:
	bool IsJumping() const;
};

bool Character::IsJumping const
{
	return mStateMachine.IsInState<CharacterStates::Jumping>();
}

Even in this case, it may be preferable to have the Jumping state simply set a bool (or better yet, a StateValue) on the Owner to signify that it's jumping as this would reduce the dependency on state names.

Make each state machine update count

When authoring a state machine, try to minimize the number of updates required for states to transition between each other. Concretely, if a state A needs to get to D, ideally it would be best if it went from A -> B -> C - > D in a single call to StateMachine::ProcessStateTransitions, rather than multiple calls. If it takes multiple calls, then these "in-between" states may be more difficult to reason about and handle correctly.

To understand how do this, it's important to understand how StateMachine::ProcessStateTransitions works, which was covered in detail in Chapter 3. The idea is to take advantage of the fact that StateMachine::ProcessStateTransitions will only end once the state stack has settled. This means that in order to minimize the number of times you need to call this function to get from one state to another, you should:

  • Compute transition-related data in a state's OnEnter rather than its Update. The idea is that when a state is transitioned to, its OnEnter will get called, then GetTransition will be called on it (in fact, GetTransition will be called on all states from outermost to innermost). If you can determine what transition the state needs to make in its OnEnter, its GetTransition will be able to act on it immediately. If this decision is made in Update instead, then this transition can only be made on the subsequent StateMachine::ProcessStateTransitions. This is because State::Update is not called by StateMachine::ProcessStateTransitions, but rather by StateMachine::UpdateStates, which is typically called immediately following the call to ProcessStateTransitions.

  • Prefer to use transient states, such as Done States, over writing to shared variables. Transitioning to a transient state will trigger another GetTransition pass on the state stack, allowing outer states to make more transitions based on the existence of these transient states.

Of course, in some cases, you have no choice but to delay transitions between states, such as when you need to break infinite transition cycles, as shown in the section on Deferred Transitions. However, this is an exception, and you should try to avoid using this technique unless you really need to.

Prev (Chapter 4)