Classcade : A Pattern for Modular Controllers in Unity

Modular code with well-defined singular responsibilities is A Good Thing, and far better minds than mine are a Google search away to tell you why. However, in some situations, avoiding a monolothic block of code or a spaghetti of dependencies is a challenge. 

For me, player-character control logic (PCL) always presents an architectural headache, especially in an evolving project. PCL seems to demand an awkward tangle of states and layers that all have a finger or two in each other's pies, and edge-cases abound. Modularity can seem like wasted effort, as everything ends up so project-specific that little can be reused.

Rather than continue to try to force it into ill-fitting 'good' code patterns, I decided to embrace the need for PCL code to be aware of other systems and refocus. My new goals were:
  • A super lightweight framework to nudge me in the right direction at all times
  • Genuine modularity with arm's-length interactions, substitution, inheritance, ideally via interfaces
  • Clean support for optional modules to maximise reusability without null checks
  • Well-defined upstream/downstream model of control. Upstream modules are aware of the existence of those downstream, but not vice-versa, to minimise ongoing interference with well-tested downstream modules
  • Welcoming of code layered on to handle edge-cases or override states (eg FTUE constraints, cutscenes, special sequences)
  • Readable and holds its shape well as a project evolves
I've dubbed the result Classcade, which consists of three simple elements:
  • A blackboard class that serves as a common point of reference for modules in a system and manages execution order
  • An optional MonoBehaviour-based template class for modules supplying boilerplate and syntactic sugar
  • A template class representing the channels through which modules communicate
Here's a look at some real code using the pattern (I'm using Rewired in my project to handle input, which I can heartily recommend).

So what's going on here?
  • The Classcade<T> template class supplies boilerplate on Awake() to automatically define 'externals' and share it with the blackboard.
  • PlayerInput's Externals class derives from Classcade's Proplet base class (pun based programming at its finest)
  • The blackboard calls 'Prep' on all registered Proplets at the start of an update, allowing modules to set up any default values
  • The blackboard has a list of modules in the order I want them to be executed, and calls Pump() on each in turn.
  • PlayerInput checks whether anything has turned it off before doing any work.
  • The Access() method interrogates the blackboard for the externals of other modules for PlayerInput to feed.

Quick FAQ:

  • Why is Proplet a class rather than an interface? You said you wanted interfaces.
This was a tough choice and goes against best practice, but making it a class allows the blackboard to instantiate, cache, and prep any given Proplet in response to an Access request, even if a module of the relevant type is not present. This increases the reusability of modules in different scenarios without having to clutter Unity objects up with stubs.
  • Why isn't enableInput a property?
Personally, it felt like putting a hat on a hat. Proplets already serve as an interface to an otherwise private module, and the values inside are only ever meaningfully accessed from the outside, so it's hard to think of a use case for constraining that access. Ready and willing to be corrected, though.
  • Why use Proplets defined in other modules at all? Wouldn't plain old named values on a shared blackboard do a better job of decoupling modules, and allow multiple downstream modules to hook into the same flags or values?
On balance, I think it's of greater value that Proplets, like an interface, encapsulate everything you need to worry about when creating a replacement module. Nor does it prevent other downstream modules checking the same flags: it just preserves the provenance of those flags and - if anything - makes it safer to hook into them because their purpose is clearer.
  • Why Pump() and not Update()?
Manually pumping each module from a list in the blackboard makes it easier, as far as I'm concerned, to customise and visualise the execution order of modules rather than relying on Unity's script execution order settings. 

Perhaps more significantly, it also ensures that the entire Classcade system for an entity gets an update in one go. Custom script execution orders force Unity to round-robin, potentially leading to inconsistency between upstream decisions and downstream actions.

The blackboard also performs a PumpFixed() on fixed updates, omitted here for the sake of brevity.
  • What's the overhead of Access() compared to, say, GetComponent<>()?
Access() uses a dictionary lookup, and in testing is ~2 orders of magnitude faster than GetComponent. For a player controller, of which there will usually only be one, I'm happy to Access() without caching, knowing that as a result my modules are all compatible with runtime-replacement. For heavier use-cases, caching via an Access() in Start() works fine.
  • Explain Prep() to me.
On each update, Prep() is called on ALL the Proplets registered with a blackboard, prior to the first module being pumped. This accomplishes a couple of things. 

First, a Classcade is intended to represent a strict upstream-to-downstream flow of control, and Prep() helps guard against the tail of one frame wagging the dog of the next.

Second, in a scenario where multiple modules might individually or simultaneously veto a downstream module, I personally prefer a dead man's switch to a block/unblock tally. Prep() allows a module to default to its preferred state unless continually and actively overridden. 

In Summary:

To build a Classcade system, you:
  • Drop a Classcade Monobehaviour on your entity. This is the blackboard.
  • Derive modules from Classcade<T>, where T is a class derived from Proplet and serves as the interface of the module
  • Drag your modules onto the entity, and arrange their execution order in the blackboard.
  • Use Access(out X blah) when you want to get at another module's proplet.
  • Stick to the upstream/downstream convention: downstream modules do what they're told, upstream modules take responsibility for it.

The Classcade Source Code:


Comments

Popular posts from this blog

#1 The Flow System