GameDev - Technical

The Implicit State Machine

I don’t usually write about code smells. I believe in positive reinforcement — that it’s more useful to show what good code looks like than to catalog the ways code goes bad.

This one is an exception. The smell I want to talk about is so prevalent in game development, and so consistent across the codebases I’ve worked in, that singling it out earns its keep.

It shows up in more forms than most people realize, all of them rhyming with the same basic shape. One you’ve almost certainly seen.


The shape everyone’s seen

If you’ve ever written a character controller, you’ve written something like this:

C#
private bool isGrounded;
private bool isJumping;
private bool isCrouching;
private bool isAttacking;
private bool isStunned;

void Update()
{
    if (Input.GetKeyDown(KeyCode.Space) && isGrounded && !isCrouching && !isAttacking)
        Jump();

    if (Input.GetKeyDown(KeyCode.LeftControl) && isGrounded && !isAttacking)
        StartCrouch();

    if (Input.GetKeyDown(KeyCode.Mouse0) && !isAttacking && !isStunned)
        Attack();
}

It’s so familiar it barely registers. Five booleans across the top, an Update() that checks combinations of them, a different combination for each action. This is the textbook shape of a Unity character controller — every tutorial teaches it, and most teams never get past it before the bug reports start arriving.

If you’ve read Input, Action, Context, you’ll recognize the snippet — minus the input/gameplay separation we worked through there. The seam between input and gameplay is one problem; the smell I want to talk about today is a different one — the one that survives even after you’ve cleanly separated the two halves.

The problem with this code is that it has a state machine. It just doesn’t say so anywhere. I call this smell the implicit state machine.

The character clearly has modes — standing, jumping, crouching, attacking, stunned. Those are the states. There are rules about which mode can become which other — you can crouch from standing but not from mid-air; you can jump from standing but not while attacking. Those are the transitions. Together, they form a state machine you could draw on a whiteboard in thirty seconds.

But there’s no place in the code where that state machine exists. The states aren’t named, and the transitions aren’t written down. Both have been reconstructed, by hand, as long boolean expressions stitched into the conditions of every if. The state machine is real, but it’s smeared across a wall of && and !.


The math of why it bites

Here’s an exercise that explains, in one image, why every team writing code like this eventually hits the same class of bug. Take just three of the booleans — isGrounded, isCrouching, isAttacking — and write out every combination they can hold:

isGroundedisCrouchingisAttackingwhat is the character doing?
standing
crouching
attacking
crouch-attacking?
jumping
air-attacking?
crouching in mid-air???
crouch-attacking in mid-air???

Three flags, eight rows. A handful of those rows are real states the game wants. A couple are maybe states, depending on what kind of game you’re making — air-attacks are great in some genres and unthinkable in others. The rest are physically nonsensical, and the only thing keeping the character out of them is your personal vigilance about every if condition you ever write. Add a fourth boolean — isStunned, say — and you have sixteen rows. Add a fifth and you have thirty-two. Two-to-the-N grows fast. The list of states you actually want grows slow.

Every bug in this style of code is the same bug: the character ended up in a row that wasn’t supposed to exist. Player T-poses mid-air because two if blocks disagreed about what was going on. Enemy attacks while dead because a damage handler set isAttacking = true after isAlive went false. Cutscene starts while the inventory is open because nothing made those mutually exclusive. The booleans don’t know about each other, and the valid combinations live only in your head — and your head will eventually forget one.

That’s the cost of leaving the machine implicit.


What lifting looks like

A note before this section: the fix is well-trodden — every intermediate C# textbook has a version, and I covered a stripped-down one in Input, Action, Context . I’m walking through it again here because every later section will point back at it.

The fix has three moves. None of them are big.

The first is naming the states. Replace the pile of booleans with a single named type — an enum, or a sealed hierarchy if you need per-state behavior — that can hold exactly one value at a time:

C#
public enum CharacterState
{
    Standing,
    Crouching,
    Jumping,
    Attacking,
    Stunned,
}

private CharacterState state = CharacterState.Standing;

The compiler now enforces what your if conditions were failing to enforce by hand.

The second is centralizing the transitions. Introduce a single function that owns every state change, and route all mutations through it:

C#
private bool TryTransition(CharacterState target)
{
    bool allowed = (state, target) switch
    {
        (CharacterState.Standing,  CharacterState.Crouching) => true,
        (CharacterState.Standing,  CharacterState.Jumping)   => true,
        (CharacterState.Standing,  CharacterState.Attacking) => true,
        (CharacterState.Crouching, CharacterState.Standing)  => true,
        (CharacterState.Jumping,   CharacterState.Standing)  => true,
        (CharacterState.Attacking, CharacterState.Standing)  => true,
        (_,                        CharacterState.Stunned)   => true,
        (CharacterState.Stunned,   CharacterState.Standing)  => true,
        _ => false,
    };
    if (allowed) state = target;
    return allowed;
}

This is the move that does most of the actual work. Once every transition goes through one function, the rules of the state machine — which mode can become which other mode, under which conditions — live in exactly one readable place. Adding a new rule is editing one row. Auditing the machine is reading one function. Nothing else can modify the machine, because nothing else writes to state.1

The third is making consumers request transitions instead of writing state directly. The Update() that started this post becomes:

C#
void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))        TryTransition(CharacterState.Jumping);
    if (Input.GetKeyDown(KeyCode.LeftControl))  TryTransition(CharacterState.Crouching);
    if (Input.GetKeyDown(KeyCode.Mouse0))       TryTransition(CharacterState.Attacking);
}

Notice what happened to the wall of && and !. It’s gone. Every condition that was scattered across ifs — can you jump from this state? can you attack from this state? — has been pulled into the transition function and consolidated. The input handler doesn’t ask whether the action is currently legal; it asks for a transition, and the state machine itself decides.

That’s the fix. Three moves: name the states, centralize the transitions, route the mutations. Add whatever you need on top, and the picture gets richer without changing shape.2

But what made that fix easy wasn’t the technique — anyone can write an enum. It was the recognition. The controller was obviously stateful: the booleans were right there, with names like isJumping, practically advertising what they are.

Most of the time the smell isn’t this loud, though. It hides in code that doesn’t look stateful at all — in systems you’ve been using for years without ever asking where the state machine lived. And once you’ve spotted it there, you start spotting it everywhere — in code you wrote yourself last month without realizing what you were building.

Let’s generalize the idea and see its more subtle manifestations.


Events

Events are supposed to be fire-and-forget. That’s their whole identity — the producer publishes, the world responds, the producer doesn’t care what happens next. In practice, almost nobody honors the contract.

C#
GameEvents.OnPlayerSpawned?.Invoke(player);
player.GiveStartingInventory();
SetCameraToFollow(player);

The next two lines are written as if the Invoke were a guarantee — that the player is spawned, in a fresh-spawn pose, ready to receive inventory and have a camera follow them. The event makes no such guarantee. Subscribers could have killed the player on spawn or swapped the scene out from under the producer. The producer is making assumptions they aren’t entitled to make.

That entitlement question is the whole shape of the implicit state machine here. The producer holds a model in their head of what the world looks like after the event fires — an unauthored post-state, set by unknowable receivers, and assumed by every line of followup. There’s no name for it in the code and no contract that justifies it. The producer acts as if there were.

The trap also catches followups that look unrelated to the event. audioManager.PlayAmbient(currentLevel.Theme) placed after the Invoke doesn’t read the player. Looks independent. But it still assumes currentLevel hasn’t been swapped and the audio bus hasn’t been ducked — and the subscribers are free to have done either. The dependence got quieter; the entitlement question didn’t.

Events are clean when the producer makes no assumption about what comes after. The Input System emits an OnJumpPressed and walks away. Gameplay code fires an OnAchievementUnlocked and moves on. Neither producer is entitled to any assumption, so neither has an implicit state machine to be wrong about. An event is only safe when it sits at the I/O boundary of an isolated system — and few producers honor that contract. A substantial slice of the bugs I’ve had to fix in my career traces back to this.


RPCs

If you got the events section, you got this one. RPCs are events with a network gap. A producer fires target.TakeDamageRPC(amount), the receiver runs on a remote machine, the producer keeps going making decisions on the post-call world, and the diagnosis maps over without modification — unauthored post-state, unknowable receivers, followup that assumes a state nobody owns.

What makes it worse is the syntax. target.TakeDamageRPC(amount) reads as a method call: synchronous, atomic, returns when done. None of those hold. The call returns when the message is queued, not when the work is done; the “next line” you write runs during transit, before the receiver has even seen the packet. The function-call shape is lying.

Every property that made the local event smell hard is amplified. The post-state is physically remote: different machine, fallible wire, and each peer has their own view of it. It’s temporally displaced — it doesn’t exist yet when your followup runs. And failure is silent: the packet drops and your followup runs oblivious.

RPCs are worse in one more way: the smell is, to a degree, unavoidable. Networked games can’t wait for global confirmation before the muzzle flashes; predictive trust is required. The implicit state machine here isn’t a smell careful design can eliminate — it’s a property of the domain. The response isn’t to remove the assumption, it’s to name it: isolate the prediction layer, mark the values that are speculative, and make reconciliation a first-class step rather than a thing you hope doesn’t fire.

The bugs people call “weird netcode” — desync, hit-detection inconsistency, item duplication — are often predictable consequences of code that depends on remote state without naming the dependence.


Derived state

My first undergrad programming course was in Haskell, which was unusual — most programs start students on Java or Python. Years of imperative C++, C# and JavaScript later, the reflex that semester built in me is still the one I use most: a sense for whether a piece of state is primary, owned and mutated by the program as the world changes, or derived, with a value determined by other things and computed rather than set. Haskell made you draw that line every time you wrote a line. Imperative languages let you skip it.

Skipping it is a particularly sneaky way to write an implicit state machine. The implicit thing this time isn’t the states; it’s the transitions.

Here’s what that looks like. In a game I worked on, the mouse cursor needed to be visible whenever a menu was up. The menu script handled it directly:

C#
void OpenMenu()  { menuOpen = true;  cursor.SetVisible(true);  }
void CloseMenu() { menuOpen = false; cursor.SetVisible(false); }

Feels natural. The menu has reasons to care about the cursor; this is when those reasons fire; this is when we touch it. Done. Then the cursor needed to be visible at table seating too — same shape, in the seating script:

C#
void SitAtTable() { seatedAtTable = true;  cursor.SetVisible(true);  }
void StandUp()    { seatedAtTable = false; cursor.SetVisible(false); }

And immediately, the bug. Player opens a menu while seated, then stands up. The seating script turns the cursor off. The menu is still open. The cursor is gone when it shouldn’t be. Multiply this by half a dozen scripts, each with a variable named like isMouseVisible as if it owned the answer, and you have the full disaster — cursor flickering, vanishing at the wrong moment, every bug fix adding another toggle that fights the existing ones.

Two distinct problems sit underneath it.

WHEN. Each script picks its own moment to touch the cursor — the moment its own condition changed. But the cursor’s correctness depends on all the conditions, not any one. The right moment to update is “any time any of the dependencies changes,” which is not naturally any individual script’s problem.

WHAT. Each script has only partial truth. The seating script’s cursor.SetVisible(false) is correct under the assumption “I’m the only thing that determines the cursor” — true with one script, false the moment there are two. The fix-by-instinct is to make scripts read each other: the seating script asks the menu, asks the cutscene system, asks the input system. That’d be a disaster — every contributor coupled to every other, StandUp depending on the entire game’s state.

This is the implicit state machine again, just with the small piece implicit. The state itself is obvious — visible or hidden — but the function that decides between the two is what’s missing. It exists nowhere; it’s reconstructed across N scripts as fragments, evaluated by hand in whichever order their Updates run. The state space is small enough that the smell can hide in plain sight.

The function is what was missing all along:

C#
bool ComputeCursorVisibility()
{
    return (menuOpen || seatedAtTable || usingTV) && !inCutscene && !inLookMode;
}

One owner runs it — either every frame, or whenever any input notifies it that something changed. The contributing systems publish their own state and forget the cursor exists. 3

The diagnostic test, when something looks suspiciously like this in a codebase: does any code other than the owner call Set on it? If the answer is yes, you don’t have a property. You have a function being reconstructed by hand from N different files, each contributing one of its terms.


Engine machinery

The smell isn’t always in code you wrote. Engines ship with their own — pieces of state the runtime owns that your code has to react to without any direct way to name. You can’t refactor these out; you can only learn where they live. A fair amount of working in any engine is exactly that: knowing where the implicit state is, even when you didn’t put it there.4

The MonoBehaviour lifecycle. Awake, OnEnable, Start, FixedUpdate, Update, LateUpdate, OnDisable, OnDestroy — eight callback names that are really one state machine. The engine knows which phase each object is in; consumer code doesn’t. There’s no gameObject.lifecycleState to query, and more importantly no otherScript.isReadyToBeCalledInto. So every Unity project eventually hits the bug where Script A’s Start runs before Script B’s Awake has finished, A calls into B, B isn’t ready, NullReferenceException. The fix is the Script Execution Order panel — a global ordered list of which scripts initialize before which others, edited by hand, load-bearing for half your scenes. Institutionalized cope for an unnamed piece of state: “is this other thing ready yet.”

The active state of a GameObject. gameObject.activeSelf is a property the script owns. gameObject.activeInHierarchy is a function of the entire ancestor chain. OnEnable and OnDisable fire based on the latter. Toggling a parent silently flips its children’s activeInHierarchy without touching their activeSelf, and the resulting callbacks fire in a depth-first order that’s well-defined in the engine and unpredictable to humans. The state is derived across the hierarchy; nothing exposes the function that produces it.


Assumptions

Every section so far showed the same thing: a piece of code making an assumption it never stated. The boolean check banks on combinations that don’t come up. The line after the event proceeds as if no subscriber moved the world. The cursor Set acts as if nothing else also touches the cursor.

These cases aren’t the catalog. The implicit state machine isn’t a finite list of code patterns — it’s the shape that emerges anywhere code depends on something being true about the world that nobody wrote down. Concurrency, serialization, animation blends, undo/redo, save/load — the same diagnosis carries across all of them. There’s a lifetime of variations beyond what fits in a single article.

The system won’t always be there to stop you from making silent, potentially incorrect assumptions — and usually it won’t even know what assumptions you’re making in your head. When these accumulate, they start to form an implicit state machine, and fixing one is painful because it’s implicit. It isn’t in the code. It probably isn’t in the comments. It was made by you six months ago, or by a colleague who’s since moved on, and it lives entirely in someone’s head while quietly degrading the codebase.

There’s no easy fix once this has set in. By the time the bugs surface, the assumptions have fused with the shape of the system, and pulling them out means rewriting code that, on paper, works.5 Prevention is most of the cure. The reflex is small: every time you write a line that assumes something about the world, ask whether that assumption is named anywhere. If it lives only in your head, you’ve found one. Name it before it grows.

Leave a Reply

Your email address will not be published. Required fields are marked *