Tiered Modules

25 min read

How you place a new module in the right tier, pick an initialization priority, and keep dependencies flowing in only one direction as the framework grows.

Summary

Scylla organizes modules into three tiers:

  1. Foundation
  2. Features
  3. Gameplay

The tier convention is encoded as an integer priority on ScyllaModuleInfo.InitPriority. The framework does not enforce tier membership directly at compile time; what it does enforce is the dependency graph (hard dependencies must be present, no cycles) and the topological plus priority initialization order. The convention lets a glance at a module’s priority indicate roughly where it sits in the framework, keeps foundation modules from accidentally pulling in feature modules, and forces same-tier modules to communicate through the event bus instead of through direct references.

The conventional ranges are 10-50 for Tier 1 (foundation), 60-99 for Tier 2 (features), and 100+ for Tier 3 (gameplay). The eight built-in modules each occupy a deliberate slot: Input at 10, Graphics at 20, UI at 30, TextMode at 40, Data at 50, Console at 60, Stats at 70, Camera at 80. When you add a custom module, you pick a priority based on what it depends on: foundation infrastructure goes in the 10-50 range, cross-cutting features built on top of foundation modules go in 60-99, and project-specific gameplay sits at 100+. Use round numbers (110, 120, 150) rather than sequential ones (101, 102, 103) so future modules can slot in between without renumbering.

Two rules emerge from the tier convention. First, dependencies flow only downward: a Tier 2 module may declare a hard dependency on a Tier 1 module, but a Tier 1 module must not declare a hard dependency on a Tier 2 module. The framework’s startup pass validates the declared dependency graph; an upward dependency does not break the validation per se, but it violates the design rule and the Module System audit flags it during code review. Second, modules within the same tier never reference each other directly. Same-tier coordination flows through the Event System: Tier 1 modules publish typed events; other Tier 1 modules subscribe. Direct references compile but produce “module A stops working when module B is disabled” surprises that defeat the framework’s enable/disable guarantees.

The convention exists to keep a growing framework predictable. Without a tier rule, it is easy to end up with the audio module depending on the gameplay module which in turn depends on the audio module, a cycle that the framework’s ValidateNoCycles pass would catch only after the cycle has already been written. With the tier rule, the cycle is a visible policy violation at the design step: audio is foundation infrastructure, gameplay sits on top, the dependency direction is unambiguous. The rule applies recursively as new modules are added; the system stays disable-able, swappable, and testable as it grows.

Use this page for:

  • Understanding which tier a custom module belongs in.
  • Picking an appropriate InitPriority for a custom module.
  • Resolving a “where should this code live” question between a module and a Core utility.
  • Spotting and refactoring tier-rule violations in an existing codebase.

Features

  • Three-tier convention. Foundation (Tier 1), Features (Tier 2), Gameplay (Tier 3). A glance at a module’s tier tells you roughly where it sits in the framework and what it is allowed to depend on.
  • Priority-encoded tier membership. ScyllaModuleInfo.InitPriority ranges of 10..50, 60..99, and 100+ express tier membership in a single integer that the framework also uses as the initialization-order tiebreaker.
  • Strict downward dependency direction. Tier 2 modules may depend on Tier 1; Tier 1 modules must not depend on Tier 2. The rule keeps foundation infrastructure swappable and prevents the cycles that emerge when tiers blur.
  • Same-tier communication via events. Modules within the same tier never reference each other directly. Coordination flows through the Event System, preserving the framework’s enable/disable guarantees.
  • Fixed slots for the eight built-in modules. Input (10), Graphics (20), UI (30), TextMode (40), Data (50), Console (60), Stats (70), Camera (80). Custom modules slot into the gaps or extend into Tier 3.
  • Round-number priority guidance. Use 110, 120, 150 rather than 101, 102, 103 so future modules can slot in between without renumbering. The convention preserves room for growth without forcing later refactors.
  • Tier-agnostic subsystems. ScyllaCore, ScyllaTime, ScyllaEvents, Log, and the configuration system are usable from every tier. They are not modules and have no tier; modules at any depth can rely on them.
  • No compile-time enforcement of tier membership. The framework validates the dependency graph (presence, no cycles) but not the tier rule itself. The rule is a design convention enforced by code review, the module-system audit, and the visible priority number.
  • Recursive scaling. As new modules are added, the same rules apply: foundation pieces go low, features in the middle, project gameplay at the top. The framework stays predictable as the codebase grows.
  • Three dependency declaration types apply across tiers. Hard, Soft, and EventBased. The tier rule shapes which kind of dependency is appropriate: cross-tier hard dependencies flow downward, same-tier coordination uses EventBased.

The three tiers

The framework’s eight built-in modules occupy fixed priority slots that establish the conventional ranges. New modules fit into the existing structure by choosing a priority that matches their actual dependency depth and intended scope.

A few terms appear repeatedly below. Tier is the conceptual layer a module belongs to; the framework recognizes three. Priority is the integer that controls initialization order within the tier convention; lower numeric values initialize earlier. Topological order is the dependency-respecting initialization sequence the framework computes from Info.Dependencies; priority is the tiebreaker when two modules have the same dependency depth. Hard dependency is a declared requirement (ScyllaModuleDependencyType.Hard) that the framework validates at startup; missing it aborts startup. Soft dependency is a declared hint (Soft); missing it produces a warning but startup continues. Event-based dependency is a documentation marker (EventBased) for relationships that flow through the event bus.

TierPriority rangeRoleBuilt-in modules
Tier 110..50Foundation. Provides services that the rest of the framework can depend on; depends only on Core (which is the singleton, not a module).Input (10), Graphics (20), UI (30), TextMode (40), Data (50). All five are optional; a project enables only what it needs.
Tier 260..99Features. Builds on Tier 1 services to provide cross-cutting feature sets that are useful in many but not all projects. May depend on multiple Tier 1 modules.Console (60, depends on UI + Input), Stats (70, no module dependencies but conceptually feature-layer), Camera (80, depends on Input + Unity Splines).
Tier 3100..Gameplay. Project-specific modules built on top of foundation and feature modules. May depend on any combination of Tier 1 and Tier 2.None in the framework; the project’s own gameplay modules (HealthModule, CombatModule, AIDirector, etc.).

The Core singleton (ScyllaCore) sits above all three tiers conceptually but is not a module in the manager registry. It is the framework’s owner; modules can read it via ScyllaCore.Instance regardless of tier. The Time and Event subsystems (ScyllaTime, ScyllaEvents) are similarly tier-agnostic: they exist before any module runs and are callable from every tier.

                          +--------------------+
                          |     ScyllaCore     |     (singleton, not a module)
                          +--------------------+
                                    |
        +---------------------------+---------------------------+
        |                                                       |
+-------+-------------+                                +--------+-----------+
|     Tier 1          |                                |   Subsystems       |
|   (Foundation)      |                                |   (not modules)    |
|                     |                                |                    |
|   Input        10   |                                |   ScyllaTime       |
|   Graphics     20   |                                |   ScyllaEvents     |
|   UI           30   |                                |   Log              |
|   TextMode     40   |                                |   Configuration    |
|   Data         50   |                                |                    |
+---------------------+                                +--------------------+
        |
+-------+-------------+
|     Tier 2          |
|    (Features)       |
|                     |
|   Console      60   |
|   Stats        70   |
|   Camera       80   |
+---------------------+
        |
+-------+-------------+
|     Tier 3          |
|    (Gameplay)       |
|                     |
|   HealthModule 100  |   (custom modules picked deliberately)
|   CombatModule 110  |
|   AIDirector   120  |
|   ...               |
+---------------------+

Recipes

These recipes are self-contained: each one answers a single practical question about placing and wiring modules in the tier structure. You do not need to read them in order; scan the names and jump to the one that matches what you are trying to do. If you are starting from scratch, the first recipe is the right place to begin, because the rest of them assume you have already declared a priority and dependency list.

Declare a module’s tier through priority

Problem. You are writing a new module and need to declare where it sits in the tier structure. The priority number is the only thing the framework reads to determine initialization order; getting it wrong means the module may initialize before its dependencies are ready, or it slots into the wrong conceptual layer and tier-direction checks in code review flag it.

Solution. Set InitPriority in your ScyllaModuleInfo to a value inside the range that matches the module’s role, leaving round-number gaps for future additions.

using System.Collections.Generic;
using Scylla.Core;
using Scylla.Core.Modules;

/* Tier 3 (gameplay) module. Priority 110 leaves room for other Tier 3
 * modules at 100, 120, 130, etc. */
public sealed class HealthModule : ScyllaModule
{
    public const string ModuleID = "Health";

    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 hard dependencies for this example */
        initPriority: 110              /* mid-Tier-3 */
    );
}

/* Tier 2 (feature) module. Priority 75 sits between Stats (70) and Camera (80). */
public sealed class TargetingModule : ScyllaModule
{
    public const string ModuleID = "Targeting";

    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id:           ModuleID,
        name:         "Targeting",
        version:      "1.0.0",
        description:  "Aim-assist and target resolution.",
        dependencies: new List<ScyllaModuleDependency>
        {
            /* Downward dependency: Tier 2 depending on a Tier 1 module is fine. */
            new ScyllaModuleDependency(ScyllaModuleID.INPUT, ScyllaModuleDependencyType.Hard)
        },
        initPriority: 75
    );
}

/* Tier 1 (foundation) module. Priority 45 fits between TextMode (40) and Data (50).
 * Tier 1 modules typically have no hard dependencies on other modules. */
public sealed class AudioModule : ScyllaModule
{
    public const string ModuleID = "Audio";

    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id:           ModuleID,
        name:         "Audio",
        version:      "1.0.0",
        description:  "Audio mixing and routing.",
        dependencies: null,           /* foundation: no module dependencies */
        initPriority: 45
    );
}

Keep dependencies flowing downward

Problem. There is one rule that holds the entire tier system together: dependencies flow downward across tiers, never upward. Tier 2 may depend on Tier 1; Tier 3 may depend on Tier 1 or Tier 2; Tier 1 must not depend on Tier 2 or Tier 3. The framework’s validation pass (ValidateAllDependencies in ScyllaModuleManager) catches missing modules and circular dependencies; it does not enforce tier membership directly because the tier is a convention, not a code-level concept. Code review and the module-system audit are the enforcement mechanism, so you want to get it right from the start.

Solution. Declare dependencies only on lower-tier modules. The examples below show two correct patterns and one violation so the wrong shape is easy to recognise and avoid.

/* Correct: Tier 2 module depending on Tier 1 modules. */
public sealed class TargetingModule : ScyllaModule
{
    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: "Targeting", name: "Targeting", version: "1.0.0",
        description: "Tier 2 feature module.",
        dependencies: new List<ScyllaModuleDependency>
        {
            new ScyllaModuleDependency(ScyllaModuleID.INPUT,    ScyllaModuleDependencyType.Hard),
            new ScyllaModuleDependency(ScyllaModuleID.GRAPHICS, ScyllaModuleDependencyType.Hard)
        },
        initPriority: 75
    );
}

/* Correct: Tier 3 module depending on both Tier 1 and Tier 2 modules. */
public sealed class CombatModule : ScyllaModule
{
    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: "Combat", name: "Combat", version: "1.0.0",
        description: "Tier 3 gameplay module.",
        dependencies: new List<ScyllaModuleDependency>
        {
            new ScyllaModuleDependency(ScyllaModuleID.INPUT,        ScyllaModuleDependencyType.Hard),
            new ScyllaModuleDependency(ScyllaModuleID.STATS,        ScyllaModuleDependencyType.Hard),
            new ScyllaModuleDependency(HealthModule.ModuleID,        ScyllaModuleDependencyType.Hard) /* sibling Tier 3 */
        },
        initPriority: 130
    );
}

/* Wrong: Tier 1 (foundation) declaring a hard dependency on Tier 2 (feature).
 * This compiles and the framework will validate the graph successfully if
 * Stats is present, but it violates the tier rule. Audio should not need
 * Stats; if it does, the abstraction is wrong (Stats should be foundation
 * or Audio should be a feature-layer module). */
public sealed class AudioModuleBadExample : ScyllaModule
{
    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: "Audio", name: "Audio", version: "1.0.0",
        description: "Tier 1, but with a wrong upward dependency.",
        dependencies: new List<ScyllaModuleDependency>
        {
            /* Violates the tier rule: Tier 1 should not declare Tier 2 deps. */
            new ScyllaModuleDependency(ScyllaModuleID.STATS, ScyllaModuleDependencyType.Hard)
        },
        initPriority: 45
    );
}

Communicate across same-tier modules with events

Problem. You have two modules in the same tier that need to react to each other. The tempting path is a direct reference: get the sibling from the module manager and call its methods. Anyone who has gone down that road has eventually noticed that disabling one module breaks the other in surprising ways, because the framework’s enable/disable guarantees rest on the assumption that no same-tier module holds a direct reference to another. You need coordination without coupling.

Solution. Route cross-tier communication within a tier through the Event System. One module publishes a typed event; the others listen for it. The bus does not know about tiers; it routes by event type regardless of the publisher’s or subscriber’s tier membership.

using Scylla.Core.Events;

/* Tier 1: Input publishes a typed event when the player presses fire. */
public sealed class FirePressedEvent : ScyllaEvent
{
    public Vector3 AimDirection { get; init; }
}

/* Inside the Input module (Tier 1). */
ScyllaEvents.Publish(new FirePressedEvent { AimDirection = aim });

/* Tier 1: Audio subscribes to the same event. No direct reference to Input;
 * the bus routes by event type. Disabling either module does not break the
 * other - Audio just stops receiving the event when Input is disabled. */
public sealed class AudioModule : ScyllaModule
{
    private ScyllaEventSubscription _sub;

    protected override void OnEnableModule()
    {
        _sub = ScyllaEvents.Listen<FirePressedEvent>(
            subscriber: this,
            handler:    evt => PlayFireSound(evt.AimDirection),
            priority:   ScyllaEventPriority.Medium
        );
    }

    protected override void OnDisableModule()
    {
        _sub?.Dispose();
    }

    private void PlayFireSound(Vector3 direction) { /* ... */ }
}

/* WRONG: Same-tier modules holding direct references. Compiles, runs, but
 * couples Audio to Input. If Input is later disabled or removed, Audio
 * breaks at runtime. The tier rule exists to prevent this. */
public sealed class AudioModuleBadExample : ScyllaModule
{
    private ScyllaInput _input;

    protected override void OnInjectDependencies(IScyllaModuleManager moduleManager)
    {
        /* Violates the same-tier rule: Audio (Tier 1) directly referencing
         * Input (Tier 1). Route through events instead. */
        _input = GetRequiredModule<ScyllaInput>(ScyllaModuleID.INPUT);
    }
}

Pick the right tier for a new module

Problem. You are designing a new module and you are not sure whether it belongs in Tier 1, Tier 2, or Tier 3. The tier shapes which other modules it can depend on, how it is disabled, and how reusable it is across projects. Putting a gameplay-specific system in Tier 1 artificially constrains the whole framework; putting a general-purpose service in Tier 3 makes it unavailable to feature-layer modules that would benefit from it.

Solution. The tier picks itself once the module’s role is clear. A short checklist:

  1. Foundational service that other modules depend on? Tier 1 (priority 10-50). Input, rendering, persistence, audio routing, network transport: services that the rest of the framework consumes. Tier 1 modules typically have no module dependencies (they depend only on the Core singleton and on Unity APIs).
  2. Cross-cutting feature built on top of foundation? Tier 2 (priority 60-99). Camera control, performance monitoring, stat resolution, console/CLI: features that use Tier 1 services but are not yet game-specific. Tier 2 modules declare hard dependencies on the Tier 1 modules they require.
  3. Project-specific gameplay system? Tier 3 (priority 100+). Health, combat, AI, inventory, quest tracking, dialog: systems that exist because of the specific game being built. Tier 3 modules can depend on any combination of Tier 1, Tier 2, and other Tier 3 modules.
/* Question 1: Does the rest of the framework consume this module's API? */
public sealed class PersistenceModule : ScyllaModule
{
    /* Yes -> Tier 1 (foundation). Other modules will read save data through
     * this module's API; nothing about this module is game-specific. */
    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: "Persistence", name: "Persistence", version: "1.0.0",
        description: "Save and load. Tier 1.",
        dependencies: null, initPriority: 55
    );
}

/* Question 2: Does this module use foundation services but is not yet
 * tied to a specific game? */
public sealed class TelemetryModule : ScyllaModule
{
    /* Yes -> Tier 2 (feature). Uses Persistence (Tier 1) but is itself
     * not game-specific; the same telemetry layer can ship in multiple
     * projects. */
    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: "Telemetry", name: "Telemetry", version: "1.0.0",
        description: "Analytics routing. Tier 2.",
        dependencies: new List<ScyllaModuleDependency>
        {
            new ScyllaModuleDependency("Persistence", ScyllaModuleDependencyType.Hard)
        },
        initPriority: 90
    );
}

/* Question 3: Is this module specific to the game being built? */
public sealed class QuestModule : ScyllaModule
{
    /* Yes -> Tier 3 (gameplay). The quest system reads save data through
     * Persistence (Tier 1) and routes analytics through Telemetry (Tier 2),
     * but exists only because of this specific game. */
    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: "Quest", name: "Quest", version: "1.0.0",
        description: "Quest tracking. Tier 3.",
        dependencies: new List<ScyllaModuleDependency>
        {
            new ScyllaModuleDependency("Persistence", ScyllaModuleDependencyType.Hard),
            new ScyllaModuleDependency("Telemetry",   ScyllaModuleDependencyType.Soft)
        },
        initPriority: 150
    );
}

Choose an initialization priority within a tier

Problem. You have picked the right tier, but you still need to land on a specific priority number inside that range. When ordering matters within a tier (one module should run before another at the same dependency depth), you want the ordering to be obvious from the numbers alone, and you want room to slot future modules between existing ones without renumbering.

Solution. Use round numbers with deliberate gaps. The built-in modules use multiples of 10 for exactly this reason. When ordering matters within a tier (one module should run before another at the same dependency depth), pick the priorities so the desired order is obvious from the numbers alone.

/* Five game systems, priorities chosen with round-number gaps. Health runs
 * first because Combat depends on it; AI runs after Combat because it
 * reacts to combat state changes. Inventory and Quest are independent. */
public sealed class HealthModule    : ScyllaModule { /* priority 100 */ }
public sealed class CombatModule    : ScyllaModule { /* priority 110 */ }
public sealed class AIDirector      : ScyllaModule { /* priority 120 */ }
public sealed class InventoryModule : ScyllaModule { /* priority 130 */ }
public sealed class QuestModule     : ScyllaModule { /* priority 140 */ }

/* When a new module needs to slot between CombatModule (110) and AIDirector
 * (120), pick 115. The round-number gap accommodates the addition without
 * renumbering anything. */
public sealed class TargetingModule : ScyllaModule { /* priority 115 */ }

Fix a tier-rule violation

Problem. Sometimes a codebase accumulates tier-rule violations over time: upward dependencies that were quick fixes, same-tier direct references that grew out of expedience and never got cleaned up. Each violation tightens coupling in ways that make the framework harder to disable selectively and harder to test in isolation. You need to untangle them without rewriting everything at once.

Solution. The refactor is incremental. Each violation suggests one of three fixes: move the depended-on functionality to a lower-tier module or utility; introduce an interface that the lower-tier module owns and the higher-tier module implements (inverting the direction); or replace the direct reference with an event subscription.

/* Violation: Audio (Tier 1) directly references Stats (Tier 2). */
public sealed class AudioModule : ScyllaModule
{
    private ScyllaStats _stats; /* WRONG: upward dependency */

    protected override void OnInjectDependencies(IScyllaModuleManager moduleManager)
    {
        _stats = GetRequiredModule<ScyllaStats>(ScyllaModuleID.STATS);
    }
}

/* Fix 1: Move the shared functionality into a lower-tier abstraction.
 * If Audio needs to read a "MusicVolume" stat, move that value into a
 * pure-data structure that both Audio and Stats consume, rather than
 * making Audio depend on Stats. */
public sealed class AudioModuleFixed1 : ScyllaModule
{
    /* No dependency on Stats. The audio module reads MusicVolume from
     * its own ScyllaConfiguration; the Stats module also reads from the
     * same config but does not own the value. */
}

/* Fix 2: Subscribe to events instead. If Stats publishes a "MusicVolume
 * changed" event when the player adjusts the slider, Audio subscribes and
 * applies the new value without holding a reference to Stats. */
public sealed class AudioModuleFixed2 : ScyllaModule
{
    private ScyllaEventSubscription _sub;

    protected override void OnEnableModule()
    {
        _sub = ScyllaEvents.Listen<MusicVolumeChangedEvent>(
            subscriber: this,
            handler:    evt => SetMusicVolume(evt.NewValue)
        );
    }

    protected override void OnDisableModule()
    {
        _sub?.Dispose();
    }
}

/* Fix 3: Inversion via interface. The lower-tier module declares an
 * interface; the higher-tier module implements it and registers itself
 * with the lower-tier module. The direction of the dependency reverses,
 * matching the tier order. */
public interface IAudioVolumeProvider
{
    float GetMusicVolume();
}

public sealed class StatsModule : ScyllaStats, IAudioVolumeProvider
{
    public float GetMusicVolume() { return _musicVolume; }
}

public sealed class AudioModuleFixed3 : ScyllaModule
{
    private IAudioVolumeProvider _volumeProvider;

    protected override void OnInjectDependencies(IScyllaModuleManager moduleManager)
    {
        /* The Audio module declares a Soft dependency on whichever module
         * implements IAudioVolumeProvider. Without a provider, Audio falls
         * back to its own default volume. */
        _volumeProvider = GetOptionalModule<ScyllaModule>("VolumeProvider") as IAudioVolumeProvider;
    }
}

Understand how the framework resolves initialization order

Problem. You have declared priorities and dependencies, and you want to understand exactly what order modules will initialize in. Knowing the algorithm tells you how to predict initialization order, how to reason about tie-breaking when two modules share the same priority, and how shutdown order relates to startup order. This is also useful context when reading the Bootstrap Lifecycle phase documentation.

Solution. The framework computes initialization order once during phase 3 of bootstrap. The algorithm is a topological sort over the declared dependency graph (Info.Dependencies), with InitPriority as the tiebreaker when two modules have the same dependency depth. The result is cached and reused for the rest of the framework’s lifetime; reverse order is used for shutdown.

/* The algorithm in plain prose:
 *
 *   1. For every registered module, read Info.Dependencies. Build a directed
 *      graph where each edge points from a module to its hard dependency.
 *   2. Run a topological sort over the graph. Modules with no incoming edges
 *      (no other module declares them as a dependency) end up first; modules
 *      that depend on nothing run earliest within their depth level.
 *   3. When two modules have the same topological depth (neither depends on
 *      the other and neither is depended on by the other), the tiebreaker
 *      is InitPriority: lower numeric values run first.
 *   4. Run every module through Initialize in the resulting order, then
 *      StartModule + EnableModule in the same order.
 *
 * The shutdown pass uses the reverse of the initialization order: dependents
 * shut down before their dependencies.
 *
 * The framework's ValidateNoCycles check runs during ValidateAllDependencies
 * and aborts startup with a structured error if a cycle is detected. The
 * cycle is logged with the specific modules involved. */

API Sketch

The tier-relevant public surface. Most of the work happens through the ScyllaModuleInfo constructor (especially dependencies and initPriority) and the ScyllaModuleID constants for referencing built-in modules.

namespace Scylla.Core.Modules
{
    /* Module base class. */
    public abstract class ScyllaModule : MonoBehaviour, IScyllaModule
    {
        public abstract ScyllaModuleInfo Info { get; }
        public ScyllaModuleState State    { get; }
        public bool              IsEnabled { get; }

        /* Cross-module access. Use inside other modules; the helpers validate
         * that the requested module is in the right state. */
        protected T GetRequiredModule<T>(string moduleID) where T : class, IScyllaModule;
        protected T GetOptionalModule<T>(string moduleID) where T : class, IScyllaModule;
    }

    /* Module metadata. Positional constructor; populate dependencies and
     * priority deliberately. */
    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; }
    }

    /* Single dependency entry. Use Hard for "must be present", Soft for
     * "use if available", EventBased for "relationship is via events". */
    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 }

    /* Constants for built-in module IDs. Use these instead of string literals
     * when declaring dependencies on Scylla-shipped 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";
    }
}

See the API reference for full signatures, remarks, and exception contracts on every member. The full module surface (lifecycle hooks, the manager interface, the state enum) is documented in Module System.

Built-in module tier reference

The eight built-in modules and their tier assignments. Use this as the basis for picking a priority for a custom module: foundation-layer modules sit before Tier 1 entries, feature-layer modules between Tier 1 and the lowest Tier 2 entry, gameplay modules after the highest Tier 2 entry.

ModuleTierPriorityHard dependenciesRole
ScyllaInput110(Core only)Input handling via Unity’s Input System. Foundation; everything that needs input depends on this.
ScyllaGraphics120(Core only)SDF-based 2D vector shape rendering. Foundation visual layer.
ScyllaUI130(Core only)UI infrastructure: TextMeshPro, EventSystem, debug canvas. Foundation UI layer.
ScyllaTextMode140(Core only)Grid-based SDF text rendering and terminal emulation. Foundation text-mode layer.
ScyllaData150(Core only)First-class data records, runtime instances, registry, field-type handlers, validation, migrations.
ScyllaConsole260UI, InputIn-game debug console, CLI, performance monitor. Builds on Tier 1 UI and Input.
ScyllaStats270(Core only)Genre-agnostic stats kit: resolution pipeline, formulas, modifiers, resource pools, progression.
ScyllaCamera280InputUniversal camera system with 7 modes, pipeline, collision, transitions, effects.

Custom modules typically sit at 100+ (Tier 3). Pick round numbers with gaps (110, 120, 150, 200) so future additions can slot in between without renumbering.

Best Practices

  • Document the tier in the module’s description. The description argument of ScyllaModuleInfo is a natural place to write “Tier 2 feature module” or “Tier 3 gameplay system”. Future maintainers benefit from the explicit reasoning; static analysis tools can also flag tier-rule violations by reading the description.
  • Keep Info.Dependencies minimal. Hard dependencies are the framework’s contract with the rest of the system; every entry constrains where the module can be used. Soft dependencies are the right shape for “use if available” integrations.
  • Use ScyllaModuleID.* constants for built-in module references. Literal strings ("ScyllaInput") survive at runtime but break refactors silently. The constants are the typed safety net.
  • Pick round-number priorities with gaps. 100, 110, 120, 150 are easier to insert into than 100, 101, 102, 103. The built-in modules use multiples of 10 deliberately.
  • Route same-tier communication through events. Direct references between sibling modules compile but couple their lifetimes; the Event System decouples them. Disabling one module should not break another at the same tier.
  • When two modules need each other, look for a missing abstraction. A mutual dependency is a design smell. Move the shared functionality into a lower-tier module or into a Core utility.
  • Refactor tier violations incrementally. Each violation suggests its own correct fix (move shared data down, subscribe to events, or invert via interface). Pick the fix per violation; do not mechanically convert every direct reference into an event.
  • Use ScyllaModuleDependencyType.EventBased for documented event relationships. The framework does not validate them, but listing them keeps the module browser’s dependency graph accurate.
  • Match module name and ID conventions. ScyllaInput (class) maps to ScyllaInput (ID) maps to ScyllaModuleID.INPUT (constant). Custom modules should follow the same pattern: HealthModule class, "Health" ID, no constants required for project-internal modules.
  • Test the dependency graph as part of CI. A test that instantiates every module and runs IScyllaModuleManager.ValidateAllDependencies catches missing dependencies and cycles before they reach production. The cost is small; the catch is high-value.
  • Read Module System for the full module surface. This page covers tier conventions; the module-system page covers lifecycle hooks, configuration integration, state inspection, and cross-module access in detail.

Pitfalls