State Machines

The behaviour of most game entities can be at least partially described by a state machine. The door might be opening, open, closing, or closed. The gold ring might be bouncing around, flickering as it's about to vanish, or disappearing in a flash of light as it's collected. The ghost might be playing an animation as it springs out of its hiding-place, or it might be slowly gliding towards the player character. The main menu might be fading in, showing the title screen, showing the settings screen, or fading out.

As a Rust programmer, you probably understand better than most the value of state machines for modelling programs. Rust's enums are one of its greatest strengths: they take what would be a vague, implicit set of state transitions, and change them into something completely explicit and obvious which can be statically checked by the compiler.

As we saw in the Coroutines chapter, describing a state machine using traditional object-oriented code is very challenging. Consider a frog which cycles between crouching, jumping and croaking:

(defclass Frog
  (field color 'green)

  (field state 'crouching)

  (field state-change-timer 3.0)
  (field next-state 'croaking)

  (field elevation 0.0)
  (field vertical-speed 0.0)
  (const gravity -9.81)

  (meth on-step (elapsed)
    (match @state
      ('crouching
        (dec! @state-change-timer elapsed)
        (when (<= 0.0 @state-change-timer)
          (match @next-state
            ('crouching
              (bail))
            ('jumping
              (= @vertical-speed 3.0)
              (= @state 'jumping))
            ('croaking
              (= @state 'croaking)
              (= @state-change-timer 2.0)))))

      ('jumping
        (inc! @elevation (* elapsed @vertical-speed))
        (inc! @vertical-speed (* elapsed @gravity))
        (when (>= @elevation 0.0)
          (= @elevation 0.0)
          (= @state 'crouching)
          (= @state-change-timer 1.0)
          (= @next-state 'croaking)))

      ('croaking
        (inc! @state-change-timer elapsed)
        (when (<= 0.0 @state-change-timer)
          (= @state 'crouching)
          (= @state-change-timer 3.0)
          (= @next-state 'jumping))))))

This is code which only a mother could love. The object scatters its state variables across a mess of different toplevel fields, which stick around even after their state is complete, and which are sometimes overloaded between one state and the next. The intended lifetime of each field is unclear. Understanding the flow of control takes a fair amount of conscious effort. Adding in nested state machines, or multiple simultaneous state machines, would be a great challenge.

On the other hand, if we were to try to formalize this state machine in a language like Java or Rust, it could easily become over-engineered. You don't want to create an entirely separate type, or potentially even a hierarchy of types, just to manage control flow in a forty-line class.

When programming a game, you'll face this dilemma for just about every entity which you come across. It wouldn't be unusual for a game to contain hundreds, or even thousands, of state machines. When they're all defined using code which looks like the above, it's not a pretty sight.

This brings us to GameLisp's state clauses:

(defclass Frog
  (field color 'green)

  (fsm
    (state* Crouching
      (field timer)
      (field next-state)

      (init-state ((? @timer 3.0) (? @next-state 'Jumping)))

      (meth on-step (elapsed)
        (dec! @timer elapsed)
        (when (<= @timer 0.0)
          (@enab! @next-state))))

    (state Jumping
      (field elevation 0.0)
      (field vertical-speed 3.0)
      (const gravity -9.81)

      (meth on-step (elapsed)
        (inc! @elevation (* elapsed @vertical-speed))
        (inc! @vertical-speed (* elapsed @gravity))
        (when (<= @elevation 0.0)
          (@enab! 'Crouching 1.0 'Croaking))))

    (state Croaking
      (field timer 2.0)

      (meth on-step (elapsed)
        (dec! @timer elapsed)
        (when (<= @timer 0.0)
          (@enab! 'Crouching))))))

States

By default, a state introduces a binary state machine. It represents part of a class which can be switched on and switched off. state* clauses are enabled by default, whereas state clauses are disabled by default.

States may contain most of the same clauses which are permitted at the toplevel of a class: field, const, prop, meth, classmacros, and so on. The difference is that, while the state is disabled, its contents "stop existing". Its fields, constants and properties can't be accessed, and its methods can't be invoked.

A state can be enabled by calling the function (enab! obj name), where name is the state's name as a symbol, e.g. 'Opening. Similarly, disab! can be used to disable a state, and enab? to test whether a state is currently enabled.

Within a method, (@enab! name) is shorthand for (enab! @self name), and likewise for @disab! and @enab?. @state-name will return the name of the enclosing state.

(defclass Gem
  ...

  (state Sparkling
    (meth stop-sparkling
      (@disab! @state-name))

    ...))

(let gem (Gem))
(prn (enab? gem 'Sparkling)) ; prints #f

(enab! gem 'Sparkling)
(prn (enab? gem 'Sparkling)) ; prints #t

(.stop-sparkling gem)
(prn (enab? gem 'Sparkling)) ; prints #f

(.stop-sparkling gem) ; an error

Finite State Machines

A single state by itself is rarely useful. What you usually need is a group of states which are mutually exclusive, so that no more than one of the states can be enabled at any given moment. This can be achieved using an fsm clause.

(defclass Fighter
  (fsm
    (state* Neutral
      (const defense 50))
    (state Guarding
      (const defense 150))
    (state Staggered
      (const defense 0))))

When a state within an fsm clause is about to be enabled, but one of its siblings is already enabled, that sibling is automatically disabled first. In this case, if we were to call (enab! fighter 'Guarding), it would automatically call (disab! fighter 'Neutral) first.

Nested States

A state clause may appear within another state clause, establishing a hierarchy of state machines.

If you attempt to enable a child state, and its parent is disabled, the parent will automatically be enabled first.

Similarly, if you disable a parent state when any of its children are enabled, those child states will automatically be disabled first.

(defclass Owl
  (fsm
    (state* Sleeping
      ...
      (meth on-startled ()
        (@enab! 'Awake))) ; disables Sleeping, enables Fleeing

    (state Awake
      (fsm
        (state* Fleeing
          ...
          (meth on-collide (other)
            (when (is? other TreeBranch)
              (@enab! 'Perching other)))) ; disables Fleeing
        
        (state Perching
          ...
          (meth on-step ()
            (unless (humans-nearby? @self)
              (@enab! 'Sleeping)))))))) ; disables Perching and Awake

Initialization and Finalization

A state may include an init-state clause and/or a fini-state clause. These are analogous to the init and fini clauses which can appear at the toplevel of a class.

init-state defines a method which is automatically invoked just after the state is enabled. Its arguments are the same arguments which were passed to enab! or @enab!.

(defclass Cog
  (fsm
    (state* Immobile
      (meth on-activate ()
        (@enab! 'Mobile (rand-select -1 1))))

    (state Mobile
      (field direction)
      (field rotation-rate)

      (init-state (@direction)
        (= rotation-rate (* @direction 0.3))))))

When a state is enabled automatically by GameLisp (e.g. if it's a parent state whose child state is enabled, or if it was defined using state* rather than state), then its initializer is invoked with no arguments.

fini-state defines a cleanup method which is automatically called just before the state is disabled. It will also be called if the object is killed.

It's possible to call @enab! and @disab! from within an init-state or fini-state method, but it's not recommended. It can make the order of operations confusing, and in the worst case it might trigger an endless loop of state changes.

Errors

If an error occurs during initialization or finalization, the object will be left in an incoherent state. Child state* forms may not have been automatically enabled, a state passed to disab! may not actually have been disabled, an init method may have only been executed halfway, and so on.

This is almost never a recoverable situation, so GameLisp takes no chances: if an error bubbles through an init, fini, init-state or fini-state method, the object is immediately killed without any finalizers being run.

Shadowing

If you have a global or local variable bound to the name dragon, and you define a new local variable using (let dragon ...), then any references to the name dragon will refer to the new binding rather than the older bindings. We say that the later local binding "shadows" the earlier bindings.

The same is true for fields and constants in states. It's possible for a name to be bound by several different states at the same time, all of which might be simultaneously enabled. Under those circumstances, when GameLisp evaluates an expression like @dragon or [obj 'dragon], it needs to choose which binding takes priority.

The rules are:

  • Names in child states will shadow names defined by their parent.

  • Names in any state will shadow names defined by sibling states which appear textually earlier in the class definition.

These are essentially the same rules which govern local variable bindings.

(defclass ShoppingCentre
  (field tax-revenue 10_000)

  (state WellKnown
    (field tax-revenue 15_000))

  (state Damaged
    (field tax-revenue 2_000)

    (state Demolished
      (field tax-revenue 0))))

(let shops (ShoppingCentre))
(prn [shops 'tax-revenue]) ; prints 10000

(enab! shops 'Demolished)
(prn [shops 'tax-revenue]) ; prints 0

(enab! shops 'WellKnown)
(prn [shops 'tax-revenue]) ; prints 0

Fully-Qualified Names

If you need to access a field or constant in a specific state, you can use its fully-qualified name, StateName:field-name, to bypass the normal shadowing rules. For the purpose of name lookup, all fields and constants defined in the toplevel of a class are considered to belong to a Main state which can never be disabled.

(defclass FancyChair
  (const comfort 75)
  (const room 40)
  (const fun 5)

  (state Grubby
    (const comfort 40)
    (const room 10)

    (state Filthy
      (const room -30))))

(let chair (FancyChair))
(enab! chair 'Filthy)

(prn [chair 'comfort]) ; prints 40
(prn [chair 'room]) ; prints -30
(prn [chair 'fun]) ; prints 5

(prn [chair 'Main:comfort]) ; prints 75
(prn [chair 'Grubby:room]) ; prints 10
(prn [chair 'Filthy:room]) ; prints -30

This highlights a quirky detail of how state namespaces work: state names don't actually form a hierarchy. A state Child defined within the state Parent defined within the Main state is just called Child, rather than Main:Parent:Child. This means that you can't simultaneously have, say, Defending:KnockedBack and Attacking:KnockedBack - that would be a name collision, because both states are actually just named KnockedBack.

This is a deliberate design choice. Being able to define multiple different states which share the same name would be confusing, and typing out fully-qualified names would be too much effort. Flattening the namespace hierarchy is an effective solution.

Wrapper Methods

You will sometimes want to change the behaviour of a method depending on which states are enabled. For example, when the main character is being controlled by a cutscene script rather than being directly controlled by the player, you might want to override their on-input event handler to do nothing.

A naive attempt to achieve this using name shadowing will fail:

(defclass Character
  (meth on-input (input-event)
    (match [input-event 'tag]
      ('left (@walk-left))
      ('right (@walk-right))
      ('pause (game:pause))))
  
  (state CutsceneControl
    (meth on-input (input-event)
      ; do nothing
      #n)))

(let mc (Character))
(enab! mc 'CutsceneControl) ; error: name collision for 'on-input

It's not possible to have multiple active meth forms which share the same name. This is because, although name-shadowing is adequate for fields and constants, it's not powerful enough for methods. We provide a better alternative.

A (wrap ...) clause defines a "wrapper method": a method which replaces, and modifies, a method in another state.

(defclass Character
  (meth on-input (input-event)
    (match [input-event 'tag]
      ('left (@walk-left))
      ('right (@walk-right))
      ('pause (game:pause))))
  
  (state CutsceneControl
    (wrap Main:on-input (input-event)
      ; do nothing
      #n)))

In this case, when the CutsceneControl state is active, any calls to (.on-input ch ev) will be routed to the wrapper method in CutsceneControl. It would still be possible to invoke the original method using its fully-qualified name: (.Main:on-input ch ev) or (@Main:on-input ev).

Within our wrapper method, we can invoke the original meth on-input by calling (@base). This is a versatile tool. We could ignore the base method altogether, execute some additional code before or after calling (@base), transform the base method's arguments or return value, or even call the base method multiple times!

For example, some cutscenes might want to give the player a limited ability to move the main character around, but still forbid them from opening the pause menu. This would be easy to achieve using (@base):

(defclass Character
  (meth on-input (input-event)
    (match [input-event 'tag]
      ('left (@walk-left))
      ('right (@walk-right))
      ('pause (game:pause))))
  
  (state CutsceneControl
    (wrap Main:on-input (input-event)
      (unless (eq? [input-event 'tag] 'pause)
        (@base input-event))))

Chained Wrappers

You will have noticed that the wrap clause receives a fully-qualified name for its target method: in this case, Main:on-input.

The target is usually a meth form, but it's also possible to recursively wrap another wrap form. The wrapper methods form an orderly stack, with each (@base) call moving down the stack until it reaches the meth.

Let's suppose that we're writing an action game (or a business simulation game?) with a BerserkerBoss entity who turns progressively more red and angry as the encounter goes on:

(defclass BerserkerBoss
  (meth ruddiness ()
    (+ @attacks-received @henchmen-defeated))

  (state Angry
    (wrap Main:ruddiness ()
      (match @difficulty-level
        ('easy
          (* 1.2 (@base)))
        ('hard
          (* 1.4 (+ 3 (@base))))))

    (state Furious
      (wrap Angry:ruddiness ()
        (* 1.5 (@base))))))

When the Furious state is enabled, its parent state Angry must also be enabled. The original definition of ruddiness is wrapped by Angry:ruddiness, which is in turn wrapped by Furious:ruddiness, so a non-specific call to the ruddiness method will end up invoking Furious:ruddiness. Each wrapper delegates down the chain through successive calls to (@base).

Property Wrappers

You can wrap a property in much the same way that you might wrap a method. Simply define a (wrap-prop ...) clause. If we wanted to refactor ruddiness to be a property rather than a method, we would write:

(defclass BerserkerBoss
  (prop ruddiness (get (+ @attacks-received @henchmen-defeated)))

  (state Angry
    (wrap-prop Main:ruddiness
      (get
        (match @difficulty-level
          ('easy
            (* 1.2 (@base)))
          ('hard
            (* 1.4 (+ 3 (@base)))))))

    (state Furious
      (wrap-prop Angry:ruddiness (get (* 1.5 (@base)))))))

Property wrappers can't use the @field shorthand to access the original property's backing storage. Instead, they should invoke the original getter or setter using (@base).

Zombie Methods

A state may be disabled partway through executing one of its own methods. Similarly, an object may call (obj-kill! @self) from within one of its own methods. In both cases, this will land you in an awkward grey area. Code will be executing which appears to belong to a state or object, even though the state or object no longer exists. Under those circumstances, any @name field accesses or (@name) method calls will usually trigger an error. I call this situation a "zombie method".

(defclass Person
  (meth check-health ()
    (when (<= @health 0)
      (obj-kill! @self))

    ; execution continues after the object is killed. braaains...

    (when (== @health 100) ; error: nonexistent field 'health
      (prn "feeling pretty healthy!"))))

(For the record, this is a problem which already exists in many game state machines - it's just something which GameLisp makes explicit, rather than leaving it as a silent logic error.)

There's no practical way for GameLisp to prevent this. It would require dynamic checks to be inserted every time control flow leaves the body of a method, which would be difficult to implement and carry a huge performance cost.

Zombie methods tend to fail loudly rather than introducing subtle bugs, so they're mostly just an annoyance. You could consider using two techniques to make zombie methods less common:

  • Get into the habit of calling return immediately after (@disab! 'CurrentStateName). You might like to combine the two calls by defining a macro - perhaps (done), (end), or (switch-to state-name).

  • Postpone any (obj-kill!) calls until the very end of each frame. (As it so happens, most game engines already do this by default. Entity deletion is a common source of bugs, probably 99% of which can be prevented by postponing the deletion.)