Advanced Code Architecture for Core, Part 3: Inversion of Control (and Script Loading)

GUIDE TITLE: Advanced Code Architecture for Core, Part 3: Inversion of Control (and Script Loading)
ESTIMATED COMPLETION TIME: 10 minutes
CORE VERSION: Any

SUGGESTED PREREQUISITES: Intermediate-level coding

TUTORIAL SUMMARY:
Part 3 of a series of articles on designing a better code architecture that solves the problems with the way code is typically structured in Core.

EXPECT TO LEARN:

  • What Inversion of Control is
  • Why Inversion of Control is beneficial to code in Core
  • How Inversion of Control can be implemented in Core
  • How script loading works in Core, and how to manage it

END PRODUCT VISUALS:

  • (Imagine beautiful code here.)

TUTORIAL:


Part 2 examined Entity-Component-System (ECS) architecture as the answer to how game logic within the Model layer should be structured. In the implementation of ECS architecture for Core games, almost all game logic is segmented into Systems, with Entity ID's and Components also being System-specific. This centralization of code into Systems was meant to support modular design for integration with frameworks and code from other creators, and for facilitating distribution as Community Content packages.

Although the Core platform hosts a wealth of project frameworks and Community Content packages, their conventional code architecture is prone to tightly-coupled dependencies, leading to monolithic design. This fundamental flaw makes packages difficult to integrate with other code, or customize with game-specific game logic, as doing so typically necessitates invasive modifications to the package's code. Such modifications also make packages difficult to update to newer versions.

The application of MVC and ECS architectures was designed to enable further, pivotal improvements to architecture, which solve these inherent problems of conventional code architecture in Core games.

Inversion of Control

Inversion of Control is a widely-used architectural concept that's one of the distinguishing characteristics of frameworks, as opposed to libraries. Whereas typical flow of control has app-specific code call shared code (in a library), Inversion of Control reverses this relationship, and has shared code (in a framework) call app-specific code instead.

When applied to Systems in Core games, Inversion of Control enables other creators to customize a System's behavior, without modifying the System's code. This is accomplished using a technique similar to the Template Method design pattern, where a System's game logic contains calls to designated hook functions, which are meant to be overridden by another script (outside of the System) at runtime to inject game-specific game logic. Overriding functions at runtime is also known as "monkey patching".

This technique can also be used, in conjunction with the Adapter design pattern, to decouple dependencies between Systems. Instead of hook functions in a System being overridden to inject game-specific game logic, the hook functions are overridden to inject code that translates each call into corresponding calls to another System. Since the scripts that override hook functions exist outside of Systems, this liberates Systems from needing references to their dependencies.

A System's hook functions should be defined in script assets in Project Content, which are then loaded as modules by other scripts that override the relevant hook functions. These override scripts should be placed in a Client Context or Server Context group, and have a "Client" or "Server" suffix (or both) to indicate their intended network context.

Without references to other Systems, each System can be cleanly isolated into a modular Community Content package. And with code for implementing integrations and customizations kept in override scripts outside of Systems, updating to newer versions of these packages remains a simple process.

Script Loading

Among the most challenging, oft-encountered problems in bigger, more-complex Core games are race conditions, and other timing issues stemming from asynchronous code or script loading.

A common source of timing issues in Core games is when events are broadcast during startup—if an event is broadcast before a script has finished loading and connected listeners for that event, that script will miss the broadcast. Similarly, attempting to call functions in another script's context will throw an error, if that script hasn't finished loading yet.

When a script object in the Hierarchy is loaded, the actual code in the script object isn't processed until later, after other CoreObjects in the Hierarchy have finished loading. Code in script objects is then processed according to each script's order in the Hierarchy: from top-to-bottom, depth-first (descendants before siblings).

Calling WaitForObject() on a custom property, that points to a script object in the Hierarchy, will wait for that script object to be valid, but not for the actual code in that script object to be processed. The only way to be certain a script object's code has been processed is by calling Task.Wait() in a loop, until a known field in that script's context is no longer nil.

Script Loading for Inversion of Control

Proper management of script loading is vital for implementing Inversion of Control in Core games, because Systems must ensure override scripts have finished loading, before performing any game logic that can be affected by injected code.

MVC architecture avoids many potential timing issues by keeping game logic in script assets (in Project Content) within the Model layer, since require() guarantees script assets are loaded as-needed. Calling require() to load a script asset will also process that script's code immediately, as part of the call to require().

Meanwhile, the centralization of code into Systems under ECS architecture makes it possible to track the loading of override scripts, and other script objects in the Hierarchy, by using "checklist" scripts. Checklist scripts call Task.Wait() in a loop for each of their tracked scripts, until a known field in the tracked script's context is no longer nil.

Checklist scripts should be placed in a Client Context or Server Context group, and have a "Client" or "Server" suffix (or both) to indicate their intended network context. Once all of the scripts tracked by a checklist script have finished loading, the checklist script should call a function in its System, to signal that the System can safely begin performing game logic.

Too Long; Didn't Read

  • Pre-existing project frameworks and Community Content packages are difficult to integrate or customize, as doing so typically necessitates invasive modifications to their code; Inversion of Control enables other creators to integrate or customize a System, without modifying the System's code.
  • Systems call hook functions, which are overridden by override scripts to integrate or customize the System; hook functions should be defined in scripts in Project Content, while override scripts should be placed in Client Context or Server Context.
  • When a script in the Hierarchy is loaded, the actual code in the script isn't processed until later; WaitForObject() will wait for that script to be valid, but not for the actual code in that script to be processed.
  • Checklist scripts track the loading of scripts in the Hierarchy by calling Task.Wait() in a loop for each of their tracked scripts, until a known field in that script's context is no longer nil; scripts should be in Client Context or Server Context.

What's Next

Part 4 will bring together all of the concepts discussed so far, into a comprehensive code architecture for Core games.