System Architecture: Snatcher
Detailing the System Architecture Designs in Snatcher
Snatcher is an isometric action platformer developed by a team of students at Drexel University of which I was the lead programmer.
In this game, you play as Snatcher, a frog-like creature, who has the ability to snatch enemies’ limbs and use their corresponding abilites. Given the nature of numerous modalities of actions for the main character and the enemies, I developed two Finite State Machines (FSMs) for each respective use case. In addition to the FSMs, I also designed a ScriptableObject-based manager implementation based on the Singleton Pattern to handle various differen tasks and event in the game.
In a hierarchical state machine, there are generally two types of states in which the context (in this case the PlayerController) can be in: Super States and Sub States. The Super States dictate more generic attributes for a set of behaviors whereas the Sub States handles the more concrete behavior of the context.
An example of this is a character in air after a jump. While the character is in air, we might want to perform different actions, such as air dash, pound, or not move at all but just naturally fall. In this example, the in-air can be considered to be a Super State while air dash is a sub set of all the actions the character can perform while in air, thus it is a Sub State.
While I was researching a suitable implementation of hierarchical state machine for Snatcher, I found that in most implementations, Sub States are contained within a Super State, and a Sub State cannot be resued outside its Super State.
On the other hand, the Super States I envisioned for Snatcher represent the modalities resulting from snatching different enemies’ abilities/limbs. While Snatcher can cast different abilities in different Super States, for most of the time, he would perform actions common shared with different Super States, i.e., walking, dahsing, or squatting. In other words, Snatcher should be able to walk in both, say, the Invisible Super State and the Bird Super State.
In theory, we can make a walk Sub State for each and every Super State that is made. But that won’t be very D.R.Y. The solution is to create a system where the Sub States are unbound from the Super States.
We can imagine there two slider that we can tune. One slider controls which Super State to use, and the other Sub State. In diagram above, we have Super State 2 as our choice for Super State and Sub State 3 for Sub State. In this structure, we can freely slide these two slider independently, meaning that we can transition between different Sub State without a worry about the current Super State, and vice versa.
In the actual code, implementations of common Sub States include: IdleState, MoveState, FallState, DashState. For Super States, there is BasicState, InvisibilityState, and WingState.
Because both Super State and Sub State concern certain information about the PlayerController and they also share some common functionalities, they can derive from a common APlayerState which when constructed, is passed a reference to the PlayerController. The PlayerController can then expose some public properties for these derived states to use. Below are code snippets of APlayerState and the context. (In the actual code, the context, or PlayerController, is named PlayerStateMachine.)
As you can see in the Update method in PlayerStateMachine, it will always update two states it currently is in, namely its current Super State and Sub State. The concrete definitions of behaviors are delegated to the states themselves as in all other FSM implementations.
One thing worth noting is that there are two methods in PlayerStateMachine that handles state-switching: one for switching Super States and the other for Sub States. This is what I meant by having the ability to switch Sub States independent of the Super State and the other way around, too.
Finally, we have to talk about the abilities that Snatcher snatches from his enemies, specifically its implementation. Essentially, abilities are also Sub States. Some abilities can be encapsulated in one Sub State (GrappleTowardState) while the others require more than one Sub State to manifest (InvisibilityIdleState & InvisibilityMoveState).
When making a Super State, the designer also has to specify the corresponding ability by specifying the ability Sub State. If an ability requires more than one Sub State, just put in the entry point Sub State and let its rest ability-related Sub State handle the transitions among themselves.
We can revise the slider analogy from above into a diagram like this. While the context can cycle between different default Sub States, it can also at any point transition into the Ability Sub State specified in its current Super State.
Below is a diagram demonstrating the case where an ability requires more than one Sub State. I’m using a real example: the Invisibility ability. The entry point of the ability is Invisibility Idle State. So, if Snatcher is moving when triggering the Invisibility ability, it will first transition into Invisibility Idle State and immediately in the next update cycle transition into the Invisibility Move State. In either of the ability-related Sub States, the context has the option to transition back to the entry point of a different Super State, as illustrated by the “Cancel“ arrows.
Visit the GitHub page for more details.
Hierarchical FSM for PlayerController
Germane to the transcendent quality of ScriptableObjects is its potential for making manager classes based off the Singleton Pattern. One common issue with Singleton manager is that it may be referenced before it is created. Lazy instantiation can be a solution but the manager still needs to handle the inqueries to itself made before its creation. This can be further resolved by using a static event that collects these pre-creation callbacks. But then the order of invokation can lead to more problems.
The solution that I adopted in Snatcher is to make ScriptableObject-based managers. Since SO’s are assets that sit in the asset folder, these managers will be Karen’s favorite because they are guaranteed to show up when called at run time.
Above is a code snippet of ASingletonScriptableObject, the parent class of all manager classes. You may notice on Line 11 I implicitly require the developer to create a manager instance in the Resources folder. One might argue that it is the same amount of work as asking the developers to manually drop a manager GameObject in a scene. However, I would say this work is a one-time thing and the pros should outweigh the cons.
Visit the Github page for more details.
ScriptableObject-based Singleton Manager
Factory Pattern*
When talking about switching between player states in the Hierarchical FSM section, there is an important piece of information that I left out: How does a state know when to switch to what state?
I think we can break down this question into three parts, when, what, and how.
First is when. It is an obvious question that everyone will naturally ask when designing a controller with a FSM in mind. For example, we want to switch to jump state when the player presses jump and switch back to ground state when the character lands. In general, the context either constantly checks for conditions to meet and then switch states, or waits for an event to happen and then switch states.
Second is what, which starts to get tricky. In the first part, we implicitly assume that the states themselves know about the existence of other states. (The jump state knows there is a ground state so that when the player lands, it can switch to it.) We can bundle all the states together and expose it as a public property in the context. Because all states have references to the context, all states thus have access to other states.
At first, it might seem like terrible archtecture design where everything is hard-wired together. But if we draw out a graph diagram, detailing the relations between all nodes (or in this case, states), we’d expect a functioning FSM to have each node connected to at least one other node. From a more philosophical standpoint, all nodes thus know each other because one way or another, one can visit any node from anywhere on the graph.
Suppose that I have successfully sold you the idea to bundle all states and put it in the context, the next question is how exactly do we do that? The answer is to implement the Factory Pattern.*
Let’s imagine a class where, when initialized, it creates many other class instances with it. This class is our factory. When we create this factory instance in our context, it creates all the states we want for us.
The context does not directly handle each individual state, but instead interface with the factory. Somewhere in the factory class, there is a public method that context can call to generate all the states.
The context needs references to all states though, as mentioned above that a state will ask the context for another state to switch to. This is also simple. Because the factory has references to all the states it has created, having a reference to the factory means that the context will also have references to all of them.
Finally, let’s take a look at the code and I will show you an example of switching state to answer the question at the top of this section.
Below are three code snippets relevant to the factory pattern from the factory class, the context (PlayerStateMachine), and APlayerState (parent class to all player state classes).
Shown on the left, the factoy is implemented as a manager. (We could also just use a non-static instance embedded in the context. Honestly, I don’t remembery the reason why I opted for a manager.) In the factory class, it holds multiple states and there is an InitContext method that initializes all the states with the context that is passed to it. This method is called in the Awake method in the context as shown on the top right. At the bottom right, we see that the constructor of APlayerState requires a context as an arugment. After being constructed, an APlayerState will hold references to both the context and the factory manager.
The last piece of the puzzle is when a child class of APlayerState calls to switch states. The example here is the FallState which checks for touching the ground and thereafter switches to IdleState. (CheckSwitchState is called inside the Update method.)
Now, I put a big asterisk next to the title. And that is because I hesitate to call it a real Factory Pattern.
Sure, the class handles multiple creational logics without exposing them to the client. But it is only used once instead of continuing to make more factory objects. On top of that, the factory is actually a manager, meaning that there can only be one being initialized. Not quite the idea of being able to mass produce many identical class instances.
Nevertheless, I think the ability to handle mass instantiation of objects makes it close enough to be called a quasi implementation of the Factory Pattern. In the end, the context is what matters (no pun intended).
Enemy FSM
The enemy FSM in Snatcher is the AI system behind the enemy behaviors. It is based on a tutorial series that Unity put out. The backbone of the system is ScriptableObjects, giving it a “plugable” quality. The overview of the system is summarized into the following diagram.
Classes deriving from MonoBehaviour are marked with the abbreviation M.B., and those from ScriptableObject are marked S.O. For pure C# classes, there is no additional information to their titles.
In the diagram, classes are clustered into three categories: Context, Actions, and Transitions. The following sections will detail what each of them does.
Context
Context consists of the MonoBehaviour Controller on the enemy GameObject and the ScriptableObject State in which it exists.
Like in the player FSM, the Controller delegates most tasks to its current State while providing a public interface for States to switch to other States. The Controller updates its current State in its Update method.
The State is an SO. That’s why the Controller needs to pass itself as an argument to it. That way, the State can perform various Actions, knowing what objects to act upon. This also means that we can create a SO State and different MB Controller instances can use it without interfering one another.
Before going into Actions and Transitions, I’d like to point out that this is just the barebone structure of the SO FSM. Optionally, the Controller can expose more public properties, such as Animator and NavMeshAgent, for the States to use. Likewise, the States can add public methods such as EnterState and ExitState to allow for more comprehensive flow control.
One other thing you may notice is the remainState that is declared in the Controller. This is a utility State that, when checked against, basically means don’t transition. You will see that in the explanation for Transitions.
Actions
A State contains a collection of Actions to perform on every update. These Actions are SO’s as well. Again, because these SO’s do not by default hold information about the Context, a State will also have to pass its reference to a Controller instance to these Actions.
Simply, when the Controller updates every frame, it causes its current State to call UpdateState, which calls DoActions and cascades down to call all Act methods of all the Actions it holds.
A State also checks forTransitions every frame, which as usual requries passing the Controller instance down the stream.
Transitions
A Transition is a pure C# class that contains one reference to a Decision and two references to two States.
A Decision is an SO that has the method Decide which is a predicate that decides which State to transition into in next update cycle. If Decide returns true, it transitions into trueState, and if it returns false, it transitions into falseState.
Decisions will need a reference to a Controller instance to make a (quite literally) a decision. Again, this is because it is an SO.
A Transition can thus be thought of as a bridge between two States. Every udpate, the State checks for all its Transitions. If one of the Transitions made a decision to transition into a State that is not remainState, it will cause the Controller to switch state.
See the Github page for more details.