State machines

2024-05-25    4 minute read

During development of a character combat game, it is important to be able to materialize and iterate on ideas quickly. Some games use XML files, others use Microsoft Excel, and games like Balatro and Hades 2 are written mostly in Lua (to the delight of their modding communities). Overwatch uses a visual programming language called "Statescript", described in Dan Reed's GDC 2017 talk (alt link), and it is a particularly exciting example of how such a system can be designed. According to the talk, Statescript is used to manage all the game logic, animations, and sounds that comprise a hero ability (including their primary fire weapons), as well as things like HUD UI elements (ex: ultimate meter, character portrait, health bar, ability icons).

A whirlwind primer on finite state machines

Feel free to skip this section if you are familiar with the term "finite state machine"
A state machine with two unconnected states 'A' and 'B'
A state machine has states (duh?)
A state machine with starting state 'A' and ending state 'B', where 'A' transitions into 'B'.
Each state can transition into another. The arrow on the left indicates that that is the "starting" state, and the double circle on the right indicates that it is a valid "ending" state.
A state machine with state 'A' transitioning to ending state 'B' on condition `var`, and to 'C' on condition `!var`.
	'C' transtitions to 'B'	unconditionally.
Transitions can have conditions. In this case, if var is true, then the state machine goes A -> B, and if var is false, then it goes A -> C -> B.
A state machine with lots of states and transitions as a non-trivial example
State machines can be arbitrarily complex.

Note that for every node (that is not an ending node) always transitions to exactly one another node. For example, the conditions of the outgoing edges of node C are a bit weird, but no matter what the values of u and w are, C will always transition to only one of F, B, or D:

uw!u (to F)u && w (to B)u && !w (to D)node
โŒโŒโœ…โŒโŒF
โœ…โŒโŒโŒโœ…D
โŒโœ…โœ…โŒโŒF
โœ…โœ…โŒโœ…โŒB

Now that we're on the same page about what a finite state machine is, it's time for something completely different because:

Actually, I don't want a finite state machine

Though finite state machines are one of the most common types of state machines in the context of programming, they aren't the only one. In the aforementioned Statescript, for example, multiple states can be "active" at once, and the flow of states interacts with the "tick" timing system used to synchronize game logic in Overwatch. Loosening the rules like this allows for a more flexible system and (hopefully) cleaner state machines.

Current design

  • Each spell/skill is its own state machine containing:
    • states
    • attributes - in-game variables turning speed, position
    • resources - skill-specific variables such as ammo and fire rate, as well as timers
  • States can transition between each other
  • States can be triggered to be "active" even without a preceding state
  • When activated, states perform actions, such as starting a timer or dealing damage

Here is an extremely simple example of a "dash" skill that moves the player quickly in the direction they are currently facing:

// skill
"dash": {

	// state that triggers on button press
	"do": (
		activate_on: All([
			// on button input
			Ext(Input(Rise)),
			// if cooldown is not active
			TimerExpired("dash::cooldown"),
		]),
		action: [
			StartTimer("dash::cooldown", 0.5),
			StartTimer("dash::duration", 0.05),
			Ext(SelfEffect([
				// forces player to move in the direction it is facing (i.e. not strafing)
				SetMoveControl(Forwards),
				// "Stats" are game variables that directly control some aspect of game logic
				AddStatMod(
					// Set turn speed to 0, preventing turning
					TurnLimit,
					// Information for order of operations. The name is for when we want to delete the modifier later.
					(priority: 2, modifier: UpperLimit, name: "dash::"),
					0,
				),
				AddStatMod(
					MoveSpeed, 
					(priority: 2, modifier: Add, name: "dash::"),
					80.,
				),
			])),
		],
	),

	// state that runs at end of dash to reset the player
	"end": (
		activate_on: TimerExpired("dash::duration"),
		// `pre_state` is the list of states that this "end" state transitions from.
		// These states must be active in order for "end" to trigger, and will all be deactivated when that happens.
		pre_states: ["do"],
		// Deactivate this state ("end") after triggering
		transient: true,
		action: [
			Ext(SelfEffect([
				RemoveStatMod(
					TurnLimit, 
					(priority: 2, modifier: UpperLimit, name: "dash::"),
				),
				RemoveStatMod(
					MoveSpeed, 
					(priority: 2, modifier: Add, name: "dash::"),
				),
				// return movement to the default
				RevertMoveControl
			])),
		],
	),

},

Note that all the variables are prefixed with dash::. Variables are all global, and the prefix serves as a sort of namespace so that the dash "cooldown" timer doesn't interfere with the shoot "cooldown" timer.

next time

why is ammo a non-integer value

meters

implementation details:

  • colliders
  • variables
  • serialization
  • stats (move speed, etc)

Considering wiping the slate and reimplementing it as Lua scripts. depends on how much i need the Lua functionality, and how consise the scripts end up looking.