Scylla’s tiered module architecture: how you define modules, declare dependencies, hook into the lifecycle, and query or control them at runtime.
Summary
The Scylla ModUlar Topology (SMUT) is the framework’s deterministic, dependency-aware, lifecycle-driven container for cross-cutting subsystems. A module is a MonoBehaviour that inherits from ScyllaModule and lives as a direct child of the bootstrap GameObject. Each module declares its identity, version, dependencies, and initialization priority through a single Info property; the framework discovers the module automatically (the base class’s Awake calls ScyllaCore.RegisterModule), validates the dependency graph, then walks the module through a fixed sequence of lifecycle phases (Initialize, Start, Enable). You override protected hooks (OnInitialize, OnStart, OnEnableModule, OnDisableModule, OnShutdown, OnInjectDependencies) to plug behaviour into specific phases.
Two public surfaces matter. ScyllaModule is the abstract base class to inherit from when authoring a module. IScyllaModuleManager is the interface for querying or controlling modules at runtime; the default implementation ScyllaModuleManager is owned by ScyllaCore.Instance and exposed via ScyllaCore.Instance.ModuleManager. Two convenience lookups exist: ScyllaCore.Instance.GetModule<T>(moduleID) for external code, and the protected GetRequiredModule<T>(moduleID) / GetOptionalModule<T>(moduleID) helpers inside a module’s body for cross-module references. The static ScyllaModuleID struct holds the canonical ID constants for every built-in module so cross-module code does not hard-code strings.
The framework arranges modules into three tiers, following what is called SMUT (Scylla ModUlar Topology). Dependencies flow only downward; same-tier modules cannot depend on each other directly (they communicate via the Event System instead). The dependency declaration uses ScyllaModuleDependency records with a ScyllaModuleDependencyType (Hard, Soft, or EventBased) and an optional MinVersion. Hard dependencies cause startup to fail when missing; soft dependencies log a warning and continue; event-based dependencies signal that the relationship is via the event bus and the framework should not enforce a direct dependency.
Startup order is the problem this solves. A system that needs the audio manager has no clean way to wait for it, and a module that depends on another without declaring the dependency breaks the day a domain reload shuffles the initialization order. The module system makes the dependency explicit, validates the graph at startup (failing fast on anything missing or circular), and orders initialization deterministically by topological sort with an InitPriority tiebreak. Startup is then either correct or it refuses to run, and the failure message names the specific module and the specific problem.
Use the module system for:
- Cross-cutting subsystems that the rest of your game needs to find and rely on (audio manager, persistence layer, AI director, weather system).
- Lifecycle-bound infrastructure that must initialize before gameplay code and shut down deterministically (network listener, save controller, input remapping).
- Optional features that can be enabled or disabled per build or per scene (a debug HUD, a streaming overlay, a frame-budget profiler).
- Editor tools that need to inspect runtime state (the module browser, the validation runner, the configuration inspector).
Do not use the module system for gameplay scripts (per-entity controllers, per-scene UI components), per-frame loops (use a dedicated MonoBehaviour or job), or any object that does not need to be discoverable framework-wide.
Features
ScyllaModulebase class. Inherit from this abstractMonoBehaviourand override theInfoproperty; the framework handles discovery, registration, dependency resolution, lifecycle, and shutdown.- Auto-discovery via
base.Awake(). EveryScyllaModuleplaced as a direct child of the bootstrap GameObject registers itself automatically. No central registry to maintain; no manifest to update. - Declarative
ScyllaModuleInfo. ID, name, version, description, dependencies, and init priority bundled in one read-only metadata object. The framework reads it once at registration; the module body stays focused on behaviour. - Three dependency kinds.
Hardaborts startup on missing dependency;Softlogs a warning and continues;EventBaseddocuments that the relationship is via the event bus and the framework should not enforce a direct dependency. - Strict eight-state lifecycle.
Discovered->Validated->Initialized->Started->Enabled<->Disabled->Shutdown(plus anErrorterminal state). The framework drives every transition; you override the matching protected hook to plug behaviour in. - Six protected hooks per module.
OnInitialize,OnStart,OnEnableModule,OnDisableModule,OnShutdown,OnInjectDependencies. Override only the ones your module needs; the rest are no-ops. - Topological sort with priority tiebreak. The module manager computes initialization order once: dependencies always run before dependents, and
InitPriorityorders modules that have the same dependency depth. Startup is fully deterministic. - Fail-fast validation. Missing hard dependencies, circular dependencies, duplicate module IDs, version mismatches, and hierarchy violations are all caught at startup with a structured
ScyllaModuleExceptionnaming the offending module. ScyllaModuleIDcanonical constants. String constants for every built-in module ID (CORE,INPUT,UI,CONSOLE,GRAPHICS,TEXT_MODE,CAMERA,STATS,DATA). Cross-module code references these instead of magic strings.- Cross-module access via protected helpers. Inside a module body,
GetRequiredModule<T>(moduleID)returns the resolved dependency or throws;GetOptionalModule<T>(moduleID)returns null when absent. External code usesScyllaCore.Instance.GetModule<T>(moduleID). - Runtime enable / disable.
EnableModuleandDisableModuletoggle modules at runtime without rebuilding the dependency graph. The right primitive for “turn off the debug HUD in the shipping build” without code branches. - Zombie cleanup.
CleanupZombieModulesfinds modules that should have unregistered but did not (destroyed GameObject without a proper shutdown) and removes them from the registry. Keeps long-lived editor sessions honest.
Concepts
A module is a participant in the framework’s startup pipeline with a stable identity, a declared dependency graph, and a predictable lifecycle. The framework’s job is to validate the graph, resolve dependencies, and run every module through the lifecycle in the right order. Your job is to override the lifecycle hooks and (optionally) consume the resolved dependencies.
A few terms appear repeatedly. Module ID is the stable string identifier returned by Info.ID; it must be unique across every loaded module. Init priority is the integer tiebreaker used by the framework’s topological sort; lower values initialize earlier within the same dependency depth. Topological order is the dependency-respecting initialization sequence: dependencies always initialize before their dependents. Hard dependency is a strong requirement; missing it aborts startup. Soft dependency is a hint; missing it produces a warning but startup continues. Event-based dependency is a documentation marker indicating the relationship is via the event bus.
| Concept | Type | Notes |
|---|---|---|
ScyllaModule | abstract MonoBehaviour | Base class for every module. Required override: Info returning a ScyllaModuleInfo. Optional lifecycle hooks: OnInitialize, OnStart, OnEnableModule, OnDisableModule, OnShutdown, OnInjectDependencies(IScyllaModuleManager). Provides GetRequiredModule<T>/GetOptionalModule<T> helpers. |
IScyllaModule | interface | The contract every module implements. Carries the public lifecycle methods (Initialize, StartModule, EnableModule, DisableModule, Shutdown, ValidateDependencies, InjectDependencies). You rarely call these directly; the framework drives them. |
ScyllaModuleInfo | sealed class | Read-only metadata returned by Info. Positional constructor: (id, name, version, description, dependencies, initPriority). Properties: ID, Name, Version, Description, Dependencies (IReadOnlyList<ScyllaModuleDependency>), InitPriority. |
ScyllaModuleDependency | sealed class | Single dependency entry. Constructor: (moduleID, type, minVersion). Properties: ModuleID, Type, MinVersion. Constructed in the module’s Info-builder list literal. |
ScyllaModuleDependencyType | enum | Three values: Hard (missing aborts startup), Soft (missing logs warning, startup continues), EventBased (relationship is via the event bus; no direct framework enforcement). |
IScyllaModuleManager | interface | The runtime registry of modules. Owned by ScyllaCore.Instance and exposed via ScyllaCore.Instance.ModuleManager. Methods: RegisterModule, UnregisterModule, GetModule, HasModule, GetModulesInState, the phase-3 entry points (ValidateAllDependencies, InitializeAllModules, StartAllModules, ShutdownAllModules), CleanupZombieModules. |
ScyllaModuleManager | sealed class | Default implementation. Holds the registered modules in a dictionary keyed by Info.ID; computes initialization order once via topological sort plus priority tiebreak; runs each module through the lifecycle in that order. |
ScyllaModuleState | enum | Eight values: Discovered, Validated, Initialized, Started, Enabled, Disabled, Shutdown, Error. module.State carries the current phase. |
ScyllaModuleID | static struct | String constants for the built-in module IDs (CORE, INPUT, UI, CONSOLE, GRAPHICS, TEXT_MODE, CAMERA, STATS, DATA). Use these instead of literal strings when referencing built-in modules. |
ScyllaModuleException | class | Typed exception thrown by the framework on module-related failures (missing dependency, duplicate ID, circular dependency). Carries the offending ModuleID so catch blocks can branch on the specific module. |
The framework arranges modules into three tiers. Tier 1 (foundation) contains Input, Graphics, UI, TextMode, Data; these have no dependencies on other modules (only on Core). Tier 2 (features) contains Console, Stats, Camera; each depends on one or more Tier 1 modules. Tier 3 (gameplay) is reserved for project-specific modules that depend on Tier 1 and / or Tier 2. Dependencies flow only downward across tiers. Modules within the same tier never declare a direct dependency on each other; cross-cutting communication happens via the Event System. See Tiered Modules for the full dependency rules.
Recipes
These recipes cover every public surface for authoring and operating modules. Each one is a self-contained answer to a single problem, so jump to the one that matches what you are building. If you are new to the API, start with “Define a module” – the other recipes extend that foundation.
Define a module
Problem. You want to introduce a new cross-cutting subsystem – a health tracker, a weather director, a save controller – and have it participate in the framework’s lifecycle and be discoverable by the rest of the game.
Solution. Start with the smallest viable shape: a MonoBehaviour that inherits from ScyllaModule and overrides the Info property. The framework’s auto-registration runs in the base class’s Awake, so any override of Awake must call base.Awake() (or skip the override entirely). The module ID returned by Info.ID must be unique across every loaded module.
using Scylla.Core;
using Scylla.Core.Modules;
public sealed class HealthModule : ScyllaModule
{
/* Public constant: callers reference this when querying for the module
* via ScyllaCore.Instance.GetModule<HealthModule>(HealthModule.ModuleID). */
public const string ModuleID = "Health";
/* Required override: the framework reads this once during discovery and
* caches the result. The positional constructor is the canonical form. */
public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
id: ModuleID,
name: "Health",
version: "1.0.0",
description: "Tracks player health and publishes change events.",
dependencies: null, /* no required modules */
initPriority: 100 /* mid-range; lower values init earlier */
);
/* Lifecycle hook: fires once during the framework's Initialize pass.
* Configuration is already applied. Other modules may not yet be Started. */
protected override void OnInitialize()
{
Log.Info("Health module initialized", LogCategory.Module);
}
}
Add the script as a component on a direct child of the Scylla bootstrap GameObject. The base class’s Awake calls ScyllaCore.Instance.RegisterModule(this); the framework picks the module up during phase 3 of bootstrap (see Bootstrap Lifecycle).
Declare a dependency on another module
Problem. Your module needs to talk to at least one other module, and you want the framework to validate that relationship at startup – catching the “forgot to add the Health module to the scene” case before it becomes a mid-gameplay crash.
Solution. Pass a List<ScyllaModuleDependency> into the Info constructor. Each dependency carries a target module ID, a type (Hard, Soft, or EventBased), and an optional minimum version. A missing Hard dependency aborts startup with a structured error; a missing Soft dependency logs a warning and startup continues; an EventBased dependency is documentation-only and is not validated.
using System.Collections.Generic;
using Scylla.Core;
using Scylla.Core.Modules;
public sealed class CombatModule : ScyllaModule
{
public const string ModuleID = "Combat";
public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
id: ModuleID,
name: "Combat",
version: "1.0.0",
description: "Damage resolution and combat events.",
dependencies: new List<ScyllaModuleDependency>
{
/* Hard dependency: missing aborts startup. Use for modules whose
* absence would make this module non-functional. */
new ScyllaModuleDependency(
moduleID: HealthModule.ModuleID,
type: ScyllaModuleDependencyType.Hard,
minVersion: "1.0.0"
),
/* Soft dependency: missing logs a warning and startup continues.
* Use for optional integrations (analytics, telemetry). */
new ScyllaModuleDependency(
moduleID: "Analytics",
type: ScyllaModuleDependencyType.Soft
),
/* Event-based dependency: documentation marker for relationships
* that flow through the event bus. The framework does not validate
* presence; the dependency is informational for the module browser
* and for static analysis tools. */
new ScyllaModuleDependency(
moduleID: ScyllaModuleID.INPUT,
type: ScyllaModuleDependencyType.EventBased
)
},
initPriority: 110 /* runs after Health (priority 100) by default */
);
}
Hook into the lifecycle
Problem. You need to know exactly when to run initialization code, when it is safe to call into other modules, when to subscribe to events, and when to release resources at shutdown. The module lifecycle gives you named hooks for each of those moments.
Solution. The framework walks every module through a strict, named sequence of phases. Validation calls InjectDependencies then ValidateDependencies; initialization calls Initialize (fires OnInitialize); start calls StartModule then EnableModule (fires OnStart then OnEnableModule). At shutdown the framework runs the reverse pass: DisableModule (fires OnDisableModule) then Shutdown (fires OnShutdown), in reverse initialization order. Override the protected hooks to plug behaviour into specific phases.
public sealed class CombatModule : ScyllaModule
{
/* ...Info as above... */
private HealthModule _health;
/* InjectDependencies phase: cache references to other modules. Runs after
* every module is registered but before any module's Initialize. The
* IScyllaModuleManager parameter is the resolver. */
protected override void OnInjectDependencies(IScyllaModuleManager moduleManager)
{
/* GetRequiredModule throws when the dependency is missing. Use for
* hard dependencies (declared as Hard in Info.Dependencies). */
_health = GetRequiredModule<HealthModule>(HealthModule.ModuleID);
}
/* Initialize phase: fires once after every module has been validated.
* Configuration is already applied. Other modules are still in the
* Validated state; do not call StartModule-only APIs here. */
protected override void OnInitialize()
{
Log.Info("Combat module initialized", LogCategory.Module);
}
/* Start phase: fires once after every module has been Initialized. Safe
* to call into other modules' state because every module has reached at
* least Initialized by now. */
protected override void OnStart()
{
/* Cross-module setup goes here. */
}
/* EnableModule phase: fires every time the module transitions from
* Disabled to Enabled. Subscribe to events here so subscriptions match
* the enable/disable lifecycle. */
protected override void OnEnableModule()
{
/* Subscribe to events, enable per-frame ticks, start coroutines. */
}
/* DisableModule phase: counterpart of OnEnableModule. Fires every time
* the module transitions from Enabled to Disabled. */
protected override void OnDisableModule()
{
/* Unsubscribe, pause ticks, stop coroutines. */
}
/* Shutdown phase: fires once during framework shutdown, in reverse
* initialization order. Release resources, dispose subscriptions,
* persist any state that should survive the next session. */
protected override void OnShutdown()
{
Log.Info("Combat module shutting down", LogCategory.Module);
}
}
The framework also exposes two configuration-related hooks. OnBeforeApplyConfigurations runs right before the framework applies the loaded configuration assets to the module; OnAfterApplyConfigurations runs immediately after. Both are useful when configuration changes require module-side reset logic (for example, resubscribing to events after the configuration changes).
/* Optional configuration hooks. */
protected override void OnBeforeApplyConfigurations()
{
/* Snapshot pre-config state for diff-based reaction. */
}
protected override void OnAfterApplyConfigurations()
{
/* Re-apply state that depends on the new configuration values. */
}
Reach another module from inside yours
Problem. Your module needs to call into another module to get work done: your combat module reads from the health module, your UI module subscribes to the input module, your camera module asks the player module where the focus target is. You want the resolved reference cached early and available throughout the module’s lifetime.
Solution. Inside a module’s body, the protected helpers GetRequiredModule<T>(moduleID) and GetOptionalModule<T>(moduleID) are the right entry points. Cache them in OnInjectDependencies so the reference is ready by the time OnInitialize fires. GetRequiredModule throws a ScyllaModuleException when the dependency is missing; the failure surfaces during the bootstrap’s phase-3 pass and produces an actionable error message. GetOptionalModule returns null when the dependency is missing; branch on it accordingly.
public sealed class AnalyticsModule : ScyllaModule
{
public const string ModuleID = "Analytics";
public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
id: ModuleID, name: "Analytics", version: "1.0.0",
description: "Telemetry routing.",
dependencies: null, initPriority: 200
);
private HealthModule _health; /* optional */
private CombatModule _combat; /* optional */
protected override void OnInjectDependencies(IScyllaModuleManager moduleManager)
{
/* Optional dependencies: handle null gracefully. The analytics module
* works whether or not these modules are present in the scene. */
_health = GetOptionalModule<HealthModule>(HealthModule.ModuleID);
_combat = GetOptionalModule<CombatModule>(CombatModule.ModuleID);
}
private void OnSomeEvent()
{
/* Use the null-coalescing-friendly pattern. The framework's lookup is
* cheap (dictionary by ID) but caching the reference in
* OnInjectDependencies avoids the per-call cost. */
if (_health != null)
{
/* Send health-specific telemetry. */
}
}
}
Reach a module from outside the framework
Problem. You have gameplay scripts, UI controllers, or one-off MonoBehaviours in a scene that need to talk to a module, but they are not modules themselves and do not have access to the protected helpers.
Solution. External code accesses modules through the ScyllaCore.Instance singleton. GetModule<T>(moduleID) is the entry point; it returns null when the module is not registered. Wait for the framework to be ready via RunWhenReady (or check IsInitialized) before calling it, because a module may not have reached Enabled yet when your Start fires.
using UnityEngine;
using Scylla.Core;
using Scylla.Core.Modules;
public sealed class HUDController : MonoBehaviour
{
private CombatModule _combat;
private void Start()
{
/* Wait for the framework to finish initializing before touching modules.
* RunWhenReady handles the race where the framework completes init
* between the caller's check and the subscription. */
ScyllaCore.Instance.RunWhenReady(() =>
{
/* GetModule<T> returns null when the module is not registered. */
_combat = ScyllaCore.Instance.GetModule<CombatModule>(CombatModule.ModuleID);
if (_combat == null)
{
Log.Warning("Combat module not present; HUD will not show damage.", LogCategory.Module);
}
});
}
private void Update()
{
if (_combat == null) return;
/* Use the cached module reference. */
}
}
/* Direct manager access for advanced cases (editor tooling, integration tests,
* lifecycle inspection). The manager is exposed on ScyllaCore.Instance.ModuleManager. */
var manager = ScyllaCore.Instance.ModuleManager;
/* Inspect every registered module. */
foreach (var module in manager.Modules)
{
Log.Info($"{module.Info.ID} v{module.Info.Version} state={module.State}", LogCategory.Module);
}
/* Check for a specific module without retrieving it. */
bool hasCombat = manager.HasModule(CombatModule.ModuleID);
/* Get every module in a specific lifecycle state. Useful for editor tooling
* that visualizes the framework's startup progress. */
var enabledModules = manager.GetModulesInState(ScyllaModuleState.Enabled);
Inspect module state at runtime
Problem. Something in the startup chain is stalling, the editor module browser shows a half-initialized module, or a hot reload left a module in a partial state. You need to know exactly which lifecycle phase each module is in right now.
Solution. Every module exposes its current lifecycle state through the State property, which returns a ScyllaModuleState enum value. The enum carries eight values that map to the lifecycle phases plus an Error state for modules that failed during validation or initialization. Use the state for runtime inspection (debug overlays, the module browser editor window) and for guarded access in code that needs to act only on fully-initialized modules.
foreach (var module in ScyllaCore.Instance.ModuleManager.Modules)
{
/* Eight possible states: Discovered, Validated, Initialized, Started,
* Enabled, Disabled, Shutdown, Error. */
switch (module.State)
{
case ScyllaModuleState.Enabled:
/* Module is fully operational. */
break;
case ScyllaModuleState.Disabled:
/* Module is registered and initialized but its OnDisableModule
* has run. Re-enable via Bootstrap or by toggling the
* MonoBehaviour's Inspector enabled flag. */
break;
case ScyllaModuleState.Error:
/* Module failed during validation or initialization. Inspect the
* Console log for the specific error. */
Log.Error($"Module {module.Info.ID} is in error state.", LogCategory.Module);
break;
case ScyllaModuleState.Shutdown:
/* Module has been shut down. Treat references as invalid. */
break;
}
}
/* Read-only properties on every module. */
ScyllaModuleInfo info = someModule.Info;
ScyllaModuleState state = someModule.State;
bool enabled = someModule.IsEnabled; /* State == Enabled shorthand */
Query and manage the module registry
Problem. You are writing editor tooling, integration tests, or advanced framework code that needs to enumerate all registered modules, register custom ones, or drive lifecycle phases manually outside the normal bootstrap flow.
Solution. Most of your code never touches IScyllaModuleManager directly; the convenience helpers on ScyllaCore.Instance handle the common cases. When you do need the registry directly, it is available via ScyllaCore.Instance.ModuleManager. The phase-3 entry points (ValidateAllDependencies, InitializeAllModules, StartAllModules, ShutdownAllModules) are called by the framework during bootstrap (see Bootstrap Lifecycle); manual invocation is rare and is documented there.
var manager = ScyllaCore.Instance.ModuleManager;
/* Read the full module list. The list is read-only; modify via RegisterModule
* / UnregisterModule. */
IReadOnlyList<IScyllaModule> modules = manager.Modules;
int moduleCount = manager.ModuleCount;
/* Look up by ID. Returns the IScyllaModule interface; cast to the concrete
* type or use the generic overload. */
IScyllaModule combat = manager.GetModule(CombatModule.ModuleID);
CombatModule combatT = manager.GetModule<CombatModule>(CombatModule.ModuleID);
/* Check presence without retrieving. */
bool hasCombat = manager.HasModule(CombatModule.ModuleID);
/* Filter by lifecycle state. */
List<IScyllaModule> initialized = manager.GetModulesInState(ScyllaModuleState.Initialized);
List<IScyllaModule> enabled = manager.GetModulesInState(ScyllaModuleState.Enabled);
/* Manual registration: rare. The framework auto-registers MonoBehaviour
* modules in their Awake. Manual registration is for non-MonoBehaviour modules
* (rare) and integration tests that construct modules without a scene. */
manager.RegisterModule(someCustomModule);
/* Manual unregistration: rare. Useful in tests and hot-reload tooling that
* needs to swap a module instance. Returns true if a module was removed. */
bool removed = manager.UnregisterModule("CustomModule");
/* Cleanup orphan modules: removes registered modules whose underlying GameObject
* was destroyed without first calling UnregisterModule. Returns the number
* removed. Useful in editor scripts that load and unload scenes repeatedly. */
int cleaned = manager.CleanupZombieModules(suppressWarning: false);
Enable and disable a module at runtime
Problem. You want a module off for a particular level, a particular gameplay mode, or a particular debug build, and you want to control that with the Unity Inspector flag rather than code branches. When you bring it back, you want the event subscriptions to reconnect cleanly.
Solution. A module’s enabled flag mirrors the Unity component’s enabled property: setting component.enabled = false on a ScyllaModule triggers OnDisableModule and moves the state to Disabled; setting it back to true triggers OnEnableModule. A disabled module consumes zero per-frame cost because the framework’s pipeline only ticks modules that opt into per-frame work and are currently Enabled.
/* Disable a module at runtime by toggling its Unity component. The framework
* routes the toggle through OnDisableModule / OnEnableModule. */
var combatComponent = combatModule as MonoBehaviour;
combatComponent.enabled = false; /* fires OnDisableModule, state -> Disabled */
combatComponent.enabled = true; /* fires OnEnableModule, state -> Enabled */
/* Inspect the runtime flag. */
bool isOn = combatModule.IsEnabled; /* mirrors State == Enabled */
/* For a long-disabled module that should be re-enabled later, the state
* transitions are deterministic: Disabled -> Enabled flows through
* OnEnableModule, not through Initialize / StartModule. The module retains
* its Initialized state through the disable cycle. */
Recover from a failed module
Problem. The framework aborts startup when a hard dependency is missing, a version mismatches, or a lifecycle method throws. You need to understand what each failure mode looks like, where the error surfaces, and how to respond in tests or custom bootstrap flows.
Solution. Modules that fail during validation or initialization transition to the Error state. The framework logs a fatal error and aborts startup; the bootstrap’s catch-and-terminate path (see Bootstrap Lifecycle) stops Editor play mode or calls Application.Quit. Three categories of failure are recognized: missing hard dependencies, version mismatches, and exceptions thrown from a module’s own lifecycle method.
/* Failure mode 1: missing hard dependency. The framework's
* ValidateAllDependencies pass detects the missing module and throws
* ScyllaModuleException with the offending module's ID. */
/* Failure mode 2: version mismatch. If a module declares
* MinVersion = "2.0.0" but the dependency is at "1.0.0", ValidateDependencies
* fails. The framework logs the mismatch and throws. */
/* Failure mode 3: exception from a lifecycle method. If OnInitialize throws,
* the module transitions to Error and the exception propagates to
* ScyllaCore.InitializeFramework, which catches and re-throws. The bootstrap's
* catch path terminates the application. */
/* Your code can catch and branch on ScyllaModuleException. */
try
{
ScyllaCore.Instance.InitializeFramework();
}
catch (ScyllaModuleException ex)
{
Log.Fatal($"Module {ex.ModuleID} failed: {ex.Message}", LogCategory.Module);
/* The framework will already have terminated by the time this catch
* runs in a normal startup path; the catch is useful in custom flows
* that drive InitializeFramework manually (tests, editor scripts). */
}
/* Module state after a failure: query the registry to find which module
* landed in Error. */
foreach (var module in ScyllaCore.Instance.ModuleManager.Modules)
{
if (module.State == ScyllaModuleState.Error)
{
Log.Error($"{module.Info.ID} is in Error state.", LogCategory.Module);
}
}
API Sketch
The public surface for module authoring and inspection. Most of your code touches ScyllaModule plus the protected hooks; the manager interface is for inspection and tooling.
namespace Scylla.Core.Modules
{
/* Base class for every module. */
public abstract class ScyllaModule : MonoBehaviour, IScyllaModule
{
/* Required override. */
public abstract ScyllaModuleInfo Info { get; }
/* State accessors. */
public ScyllaModuleState State { get; }
public bool IsEnabled { get; }
/* Lifecycle entry points (called by the framework). */
public virtual void Initialize();
public virtual void StartModule();
public virtual void EnableModule();
public virtual void DisableModule();
public virtual void Shutdown();
public virtual bool ValidateDependencies();
public virtual void InjectDependencies(IScyllaModuleManager moduleManager);
/* Protected hooks (override to plug behaviour into specific phases). */
protected virtual void Awake();
protected virtual void OnInitialize();
protected virtual void OnStart();
protected virtual void OnEnableModule();
protected virtual void OnDisableModule();
protected virtual void OnShutdown();
protected virtual void OnInjectDependencies(IScyllaModuleManager moduleManager);
protected virtual void OnBeforeApplyConfigurations();
protected virtual void OnAfterApplyConfigurations();
protected virtual ConfigurationValidationResult[] ValidateCrossConfiguration();
/* Cross-module access. */
protected T GetRequiredModule<T>(string moduleID) where T : class, IScyllaModule;
protected T GetOptionalModule<T>(string moduleID) where T : class, IScyllaModule;
/* Configuration accessors. */
public IConfigurationRegistry ConfigurationRegistry { get; }
public virtual ScyllaConfiguration GetConfiguration();
public virtual Type GetConfigurationType();
public virtual string GetConfigurationFileName();
public virtual Type[] GetConfigurationTypes();
public virtual string[] GetConfigurationFileNames();
public T GetConfiguration<T>() where T : ScyllaConfiguration;
public virtual ConfigurationValidationResult[] ValidateAllConfigurations();
public virtual void ApplyAllConfigurations();
}
/* Interface implemented by every module. */
public interface IScyllaModule
{
ScyllaModuleInfo Info { get; }
ScyllaModuleState State { get; }
bool IsEnabled { get; }
void Initialize();
void StartModule();
void EnableModule();
void DisableModule();
void Shutdown();
bool ValidateDependencies();
void InjectDependencies(IScyllaModuleManager moduleManager);
}
/* Read-only module metadata. */
public sealed class ScyllaModuleInfo
{
public ScyllaModuleInfo(string id, string name, string version,
string description = "", List<ScyllaModuleDependency> dependencies = null,
int initPriority = 100);
public string ID { get; }
public string Name { get; }
public string Version { get; }
public string Description { get; }
public IReadOnlyList<ScyllaModuleDependency> Dependencies { get; }
public int InitPriority { get; }
public string GetNameAndVersion();
public bool HasDependencyOfType(ScyllaModuleDependencyType type);
public List<ScyllaModuleDependency> GetDependenciesOfType(ScyllaModuleDependencyType type);
public static bool IsVersionCompatible (string actualVersion, string minVersion);
public static string GetVersionMismatchMessage (string moduleName, string actualVersion, string minVersion);
}
/* Single dependency entry. */
public sealed class ScyllaModuleDependency
{
public ScyllaModuleDependency(string moduleID,
ScyllaModuleDependencyType type = ScyllaModuleDependencyType.Hard,
string minVersion = null);
public string ModuleID { get; }
public ScyllaModuleDependencyType Type { get; }
public string MinVersion { get; }
}
public enum ScyllaModuleDependencyType { Hard, Soft, EventBased }
public enum ScyllaModuleState
{
Discovered, Validated, Initialized, Started, Enabled, Disabled, Shutdown, Error
}
/* Runtime registry. */
public interface IScyllaModuleManager
{
IReadOnlyList<IScyllaModule> Modules { get; }
/* int ModuleCount available via the default implementation. */
void RegisterModule(IScyllaModule module);
bool UnregisterModule(string moduleID);
IScyllaModule GetModule(string moduleID);
T GetModule<T>(string moduleID) where T : class, IScyllaModule;
bool HasModule(string moduleID);
List<IScyllaModule> GetModulesInState(ScyllaModuleState state);
/* Phase-3 entry points (called by ScyllaCore.InitializeFramework). */
bool ValidateAllDependencies();
void InitializeAllModules();
void StartAllModules();
void ShutdownAllModules();
int CleanupZombieModules(bool suppressWarning = false);
}
public sealed class ScyllaModuleManager : IScyllaModuleManager
{
/* Default implementation owned by ScyllaCore.Instance.ModuleManager. */
}
/* String constants for the built-in modules. */
public static class ScyllaModuleID
{
public const string CORE = "ScyllaCore";
public const string INPUT = "ScyllaInput";
public const string UI = "ScyllaUI";
public const string CONSOLE = "ScyllaConsole";
public const string GRAPHICS = "ScyllaGraphics";
public const string TEXT_MODE = "ScyllaTextMode";
public const string CAMERA = "ScyllaCamera";
public const string STATS = "ScyllaStats";
public const string DATA = "ScyllaData";
}
/* Typed exception for module-related failures. */
public class ScyllaModuleException : Exception
{
public string ModuleID { get; }
public ScyllaModuleException(string message);
public ScyllaModuleException(string message, string moduleID);
public ScyllaModuleException(string message, Exception innerException);
public ScyllaModuleException(string message, string moduleID, Exception innerException);
}
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Lifecycle reference
The framework walks every module through a fixed lifecycle. The protected hooks (OnInitialize, OnStart, etc.) fire inside the corresponding public lifecycle method, which the framework calls in topological plus priority order. Override the hooks to plug behaviour in; do not override the lifecycle methods themselves unless implementing a custom module manager.
| Phase | Lifecycle method | State transition | Hook to override | When to use it |
|---|---|---|---|---|
| Discovery | Awake (Unity) | -> Discovered | Awake (always call base.Awake) | The base class registers the module. Override only when essential, and always call base.Awake() first. |
| Dependency wiring | InjectDependencies | (in Validated) | OnInjectDependencies(IScyllaModuleManager) | Cache references to other modules. Runs after every module is registered but before any Initialize. Use GetRequiredModule here. |
| Validation | ValidateDependencies | -> Validated | ValidateDependencies (override the method itself) | Check that hard dependencies are present and version-compatible. Default implementation scans Info.Dependencies. |
| Initialization | Initialize | -> Initialized | OnInitialize | First post-validation setup. Configuration is already applied. Other modules may not yet be Started. |
| Start | StartModule | -> Started | OnStart | Cross-module setup. Every module has reached at least Initialized; safe to query for siblings via GetRequiredModule. |
| Enable | EnableModule | -> Enabled | OnEnableModule | Subscribe to events, enable per-frame ticks, start coroutines. Fires every time the module re-enables, not just on initial start. |
| Disable | DisableModule | -> Disabled | OnDisableModule | Counterpart of OnEnableModule. Unsubscribe, pause ticks, stop coroutines. Fires whenever the module disables. |
| Shutdown | Shutdown | -> Shutdown | OnShutdown | Final cleanup. Called once at framework shutdown (application quit or an explicit ShutdownFramework). With scene persistence on (the default) modules are NOT shut down on scene loads; they persist across them. Release resources, close files, dispose subscriptions. |
| Configuration apply | (handled inline) | – | OnBeforeApplyConfigurations / OnAfterApplyConfigurations | Bracket the configuration-apply step. Useful when configuration changes require module-side reset logic. |
| Failure | – | -> Error | – | Set automatically when validation or initialization fails. The module is logged and the framework aborts startup. |
The ScyllaModuleState enum carries the current phase: Discovered, Validated, Initialized, Started, Enabled, Disabled, Shutdown, Error. Use module.State for runtime inspection; the lifecycle hooks themselves are the right place to react to phase transitions.
Built-in modules
The framework ships with nine built-in module ID constants in ScyllaModuleID. Cross-module code should reference these constants rather than literal strings. The Core entry is a special case: ScyllaCore is the framework’s singleton, not a module that participates in the manager pipeline; the constant exists for consistency.
| Constant | Module | Tier | Notes |
|---|---|---|---|
ScyllaModuleID.CORE | ScyllaCore (the singleton) | n/a | Not a module in the manager. The constant exists for cross-assembly references to the framework itself. |
ScyllaModuleID.INPUT | ScyllaInput | Tier 1 | Input handling via Unity’s Input System. |
ScyllaModuleID.GRAPHICS | ScyllaGraphics | Tier 1 | SDF (Signed Distance Field)-based 2D vector shape rendering. |
ScyllaModuleID.UI | ScyllaUI | Tier 1 | UI infrastructure: TMP, EventSystem, debug canvas. |
ScyllaModuleID.TEXT_MODE | ScyllaTextMode | Tier 1 | Grid-based SDF text rendering and terminal emulation. |
ScyllaModuleID.DATA | ScyllaData | Tier 1 | Data records, runtime instances, registry, validation, migrations. |
ScyllaModuleID.CONSOLE | ScyllaConsole | Tier 2 | In-game debug console, CLI, performance monitor. Depends on UI and Input. |
ScyllaModuleID.STATS | ScyllaStats | Tier 2 | Stat resolution pipeline, formulas, modifiers, resource pools, progression. |
ScyllaModuleID.CAMERA | ScyllaCamera | Tier 2 | Universal camera system. Depends on Input and Unity Splines. |
See Tiered Modules for the dependency rules and the rationale behind the three-tier organization.
Best Practices
- Override
Infoonce and treat the returned value as a contract. Renaming theID, changing theVersion, or shufflingDependenciesrequires care because dependent modules and save data may reference them. Pick deliberately at module-creation time. - Use
ScyllaModuleID.*constants instead of literal strings. The constants survive renames and avoid typo-driven dependency failures that only show up at runtime in a build you cannot easily reproduce. - Declare every cross-module dependency in
Info.Dependencies. The framework’s validation runs at startup and fails fast with an actionable error. Discovering missing dependencies inUpdateproduces hard-to-diagnose runtime errors that only show up after gameplay state diverges. - Pick
HardvsSoftvsEventBaseddeliberately.Hardfor “this module cannot function without that module”;Softfor “this module integrates with that module when present but works without it”;EventBasedis documentation only and does not enforce presence. - Cache module references in
OnInjectDependencies, not inUpdate. The framework’s lookup is cheap (dictionary by ID) but not free. Repeated lookups in a hot path add up; caching is the right pattern. - Use
GetRequiredModule<T>for hard dependencies andGetOptionalModule<T>for soft.GetRequiredModulethrows when missing, which surfaces the configuration error during phase-3 validation.GetOptionalModulereturnsnulland lets the caller branch. - Subscribe in
OnEnableModule, dispose inOnDisableModule. The bracket pattern makes the subscription lifetime explicit and lets a module be re-enabled cleanly after a disable. Subscribing inOnInitializeand disposing inOnShutdownis also valid but does not survive a disable/enable cycle. - Override the protected
On*hooks, not the public lifecycle methods. The framework calls the public methods in a specific order; the hooks let you plug behaviour into specific phases without disturbing the order. - Set
InitPrioritydeliberately. Default is100. Use values like10to50for foundational modules and150to200for late-stage gameplay modules. Avoid sequential values when the relative order does not matter; large gaps signal intent better than100, 101, 102, .... - Disable unused modules rather than commenting them out. A disabled module retains its registration but consumes no per-frame cost. Toggling at the Inspector level produces a deterministic re-enable when needed; commented-out modules drift out of sync with the rest of the codebase.
- Use
manager.GetModulesInStatefor tooling, not for hot-path logic. The method walks the entire module list every call. Editor tooling, validation runners, and debug overlays use it; per-frame gameplay code should not. - Treat module exceptions as bugs, not as runtime conditions to handle. A
ScyllaModuleExceptionduring startup names the specific module and the specific problem. Fix the underlying configuration error rather than catching and continuing.
Pitfalls
Related
- Architecture Overview
- Bootstrap Lifecycle
- Tiered Modules
- Event System
- Getting Started
- API reference:
Scylla.Core.Modules.ScyllaModule - API reference:
Scylla.Core.Modules.IScyllaModule - API reference:
Scylla.Core.Modules.ScyllaModuleInfo - API reference:
Scylla.Core.Modules.ScyllaModuleDependency - API reference:
Scylla.Core.Modules.ScyllaModuleDependencyType - API reference:
Scylla.Core.Modules.IScyllaModuleManager - API reference:
Scylla.Core.Modules.ScyllaModuleManager - API reference:
Scylla.Core.Modules.ScyllaModuleState - API reference:
Scylla.Core.Modules.ScyllaModuleID - API reference:
Scylla.Core.Modules.ScyllaModuleException