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"
var is true, then the state machine goes A -> B, and if var is
false, then it goes A -> C -> B.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:
u | w | !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.