I remember my first introduction to state machines was by a professor exclaiming about how terrible they were. When I eventually discovered how they worked and what they did I thought he was crazy. In my head I thought “they give you so much control over logic flow and solve so many complexity problems, how is everyone not using these?!”, and in one sense I was right. They solve a lot of problems compared to not using any structure at all and they simplify a lot of common behavior and UI problems where you want distinct changes in functionality.
I had other students echo my professor’s sentiment “state machines bad”, but when I asked them why they didn’t even really know what state machines were. From there I knew it was my sworn duty to convince everyone of how great state machines are and why they should use them for everything; except I was wrong. State machines are a great tool and they work great in specific situations, particularly simple ones, but they definitely have flaws. They struggle in complex situations where many things can be happening at once and can cause huge issues with multithreaded systems. They are definitely not the golden egg of programing paradigms that I once praised them to be, but they definitely still have a purpose in every developer’s toolkit.
So then what is a state machine and why would you want to use one? For me it always comes back to logic flow. Having a concrete and traceable system where you can easily identify the order of execution in messy complicated systems is a huge boon to development and your sanity. This is where state machines come in. They separate logic or “states” into independent execution channels with consistent behavior. While that may sound complex (and reading articles about state machines will definitely make you believe that is true), the reality is that they don’t really need a ton of features for them to be useful. A simple example (C++) is a switch statement with an enumerated state:
Here we know that every loop will only ever be in one state and that every state individually controls what other states it can enter. So from start to finish a given state controls everything about that execution sequence. It doesn’t have to worry about other states interfering and it doesn’t need to check if we’re flying because we’re obviously dying instead, otherwise we wouldn’t be in the dying state to begin with. This example leads to my next bit of code, otherwise known as “indicators you might need a state machine”:
This is a not so exaggerated example of code similar to some I’ve seen in my teammates work on previous projects. These often tend to spread all over the place as systems get more complex and often act as band aid fixes when characters are doing things when they aren’t supposed to. The problem here is that aiming has to care about all the other actions the player could take and for each new one you add there’s an exponential growth of systems that need to check for the status of other systems until you’re stringing along conditionals that become impossible to manage. We don’t want “aiming” to have to worry about if you’re “dancing” or “climbing” or “jumping” (well maybe jumping, but we’ll get to that later). We want “aiming” to handle aiming. That is its one responsibility and removing all the extra complexity makes designing a system that handles aiming much simpler and more straightforward. This makes any system where you can compartmentalize logic into separate states easy to handle and develop.
All this sounds pretty good right? Well then what’s the catch? Well a good example of the catch is jumping. You might not want to dance when you’re jumping, but if you’re making something fast paced you may want to be able to aim while you’re jumping. Well ok, one combination is simple, we can just put together a new state called “jump-aim”. But then what if we want to do flips while jumping and aiming? Then we need “jump-flip” and “jump-aim-flip” and then we need to consider how you transition between them. Can you jump-flip into jump-aim-flip? Can you jump to jump-aim to jump-aim-flip? Then how does non jumping aiming transition into all of these states? Wait, this is starting to turn into the problem we had before where things were handling too much at once. And this is where state machines start to break down. There are two main issues at hand here; the first being that states without clear boundaries start to break down when you try to put them together i.e. composite states. The second, and arguably the bigger issue, is that whenever you add a new state, suddenly every other state has to be considered for transitions. While we’ve limited the growth of long if statements, the growth still exists. For every additional state we need to handle all the different transitions, both in and out, to all the relevant existing states. These two issues combined can cause state machines to balloon out of control and become difficult to manage and expand over time. While I personally haven’t had to deal with extreme cases of this, I’ve definitely had to toe the line and it’s easy to see how something complex could make an unmanageable mess.
So while state machines definitely have their uses, they aren’t a magic bullet to every problem. An easy way to tell if they’re good for a given system is to consider how well the system’s tasks separate into independent functionality. If they don’t cross the line into other tasks’ functionality often, then the system could be a good candidate. In addition if the system isn’t meant to be dramatically expanded on then it also could be a good candidate. If neither of these are true then it may be time to learn something a bit more complex or to try and re-design the system to better accommodate the separation of responsibilities.
All that said, just remember that using a state machine is probably better than going in without a plan at all. They can make your life easier when used correctly, just use caution so you don’t turn your team’s project into a monstrous machine.