Quick Tour

25 min read

A guided walkthrough of the handful of building blocks every Scylla project ends up using: modules, events, time, logging, and configuration.

Summary

This page is a guided walkthrough of the five things used in almost every Scylla project: writing a custom module, publishing and subscribing to events, working with time and pause, routing log output by category, and exposing tunable values through a configuration asset. After the Install walkthrough and the First Bootstrap setup, this is the surface that gameplay code actually touches every day. Read it top to bottom; each section builds on the previous one and the running example (a small HealthModule) gradually accumulates the patterns covered.

The Scylla framework is opinionated about which abstraction does what. ScyllaModule is the single base class for everything that has a lifecycle (initialize, start, enable, disable, shutdown) and a discoverable identity (ScyllaModuleInfo). ScyllaEvents is the static facade for the project-wide event bus; modules publish from one place and subscribe from another without coupling to each other. ScyllaTime is the pause-aware time source that gameplay code reads instead of UnityEngine.Time, so slow-motion and pause flow through one central knob. Log is the static logger that every framework and gameplay log entry flows through, with categories that filter cleanly. ScyllaConfiguration is the base for tunable values, with a ScriptableObject for project defaults and a JSON file for end-user overrides.

The patterns covered are deliberately minimal. Production code adds error handling, defensive null checks, and proper cleanup in OnShutdown. The module-system, event-system, time, logging, and configuration pages cover the full surface of each subsystem; this tour is the first lap that connects them. By the end the reader has a running module, a typed event flowing between two places, a time-aware update tick, a categorized log line, and a config file the player could edit on disk.

Use this page for:

  • Writing a first custom module and seeing it discovered by the framework.
  • Publishing and subscribing to events across module boundaries without direct references.
  • Working with ScyllaTime instead of UnityEngine.Time so pause and slow-motion behave correctly.
  • Routing log output through Log with the right LogCategory for runtime filtering.
  • Exposing tunable values through a ScyllaConfiguration subclass and letting the player override them via JSON.
  • Hooking gameplay code into framework readiness via ScyllaCore.Instance.RunWhenReady.

Features

  • ScyllaModule base class. Inherit, override Info, override the lifecycle hooks the module needs. Auto-discovery, dependency resolution, and ordered initialization are handled by the framework.
  • Required Info declaration. ScyllaModuleInfo carries ID, name, version, description, dependencies, and InitPriority. The framework reads it once at registration; the module stays focused on behaviour.
  • Six lifecycle hooks. OnInitialize, OnStart, OnEnableModule, OnDisableModule, OnShutdown, OnInjectDependencies. Override only the ones the module needs; the rest are no-ops.
  • Static ScyllaEvents facade. Publish<T>(evt) and Listen<T>(subscriber, handler, priority) route through the singleton event bus. Modules talk to each other through typed events without holding direct references.
  • IDisposable subscription tokens. Listen returns an ScyllaEventSubscription. Dispose in OnShutdown to remove the listener cleanly; the bus also reaps destroyed Unity objects automatically via weak references.
  • Five event priority levels. Essential, High, Medium (default), Low, Cleanup. Priority controls firing order; Essential and Cleanup always fire even when a handler cancels propagation.
  • ScyllaTime.Instance for pause-aware time. DeltaTime returns 0 when paused and respects every active time-scale modifier. UnscaledDeltaTime always runs for UI animations and frame-budget code.
  • Pause, Resume, TransitionTimeScale, modifiers. The runtime time-scale knob covers slow-motion, fast-forward, hit-stop, and full gameplay pause through one consistent API.
  • Static Log facade with categories. Eight severity levels (Trace, Debug, System, Info, Notice, Warning, Error, Fatal) plus 28 LogCategory values for runtime filtering. Trace and Debug compile out of release builds.
  • ScyllaConfiguration for tunables. Subclass to expose project defaults through the Inspector; override ApplyConfigFileOverrides(ConfigFile) to read JSON overrides at runtime. The four-tier fallback (User Documents -> Application -> Resources -> compiled defaults) is automatic.
  • ConfigFile for JSON. TryGetInt, TryGetFloat, TryGetString, TryGetBool, TryGetColor plus Set* counterparts. Group/property structure mirrors JSON object/value shape; round-trips cleanly.
  • ScyllaCore.Instance.RunWhenReady. Single hook for “do this once the framework is fully up”. Fires immediately when already ready; otherwise queues for the readiness event. Race-safe; single-shot.

Concepts

A few terms appear repeatedly. Module is a MonoBehaviour that inherits from ScyllaModule and lives as a direct child of the bootstrap GameObject; the framework discovers it automatically. Event is a class that inherits from ScyllaEvent; the static ScyllaEvents.Publish/Listen facade carries instances across module boundaries. Subscription is the IDisposable token returned by Listen; disposing it removes the listener (this is how the framework prevents leaked subscriptions). Configuration asset is a ScriptableObject of type ScyllaConfiguration that holds project-level default values, optionally overridden at runtime by a JSON file. Pause-aware time means the value reflects whether the framework is paused; reading ScyllaTime.Instance.DeltaTime returns 0 while paused, but UnscaledDeltaTime keeps running.

ConceptTypeNotes
ScyllaModuleabstract MonoBehaviourBase class for every module. Required override: Info returning a ScyllaModuleInfo. Optional lifecycle hooks: OnInitialize, OnStart, OnEnableModule, OnDisableModule, OnShutdown. See Module System.
ScyllaModuleInfosealed classRead-only metadata returned by Info. Positional constructor: (id, name, version, description, dependencies, initPriority). Drives discovery order, dependency validation, and module browser UI.
ScyllaEventabstract classBase for every event type. Custom events inherit from it and add typed payload fields. Instances are passed to ScyllaEvents.Publish<T>.
ScyllaEventsstatic facadeProject-wide entry point. Publish<T>(evt) and Listen<T>(subscriber, handler, priority) route through the singleton IScyllaEventBus. The bus itself can be replaced via ScyllaEvents.SetBus for testing.
ScyllaEventSubscriptionsealed disposableReturned by Listen. Holding onto the token and disposing it on OnShutdown removes the listener. Disposing twice is safe.
ScyllaTimesealed singleton (ScyllaTime.Instance)Pause-aware time source. Read DeltaTime instead of UnityEngine.Time.deltaTime for gameplay code. Supports Pause, Resume, TogglePause, TransitionTimeScale, layered modifiers, normal / slow-motion / fast-forward presets.
Logstatic classRoutes log calls through any number of ILogReceiver implementations. Methods are Trace, Debug, System, Info, Notice, Warning, Error, Fatal, plus Delimiter. Each takes an optional LogCategory for filtering.
LogCategoryenumPre-defined categories: Analytics, Camera, Character, Command, Console, Core, Data, Editor, Event, File, Game, Gameplay, Graphics, Input, Lifecycle, Module, Network, Renderer, None, and more.
ScyllaConfigurationabstract ScriptableObjectBase for project configuration assets. Override ApplyConfigFileOverrides(ConfigFile) to read JSON values into the asset’s fields; override ExportToConfigFile() to emit the asset as a JSON ConfigFile. Used by every module that exposes settings.
ConfigFilesealed classIn-memory representation of a JSON config file. Use TryGetInt, TryGetFloat, TryGetString, TryGetBool, TryGetColor, plus the Set* counterparts. See Config Files for the full surface.

Walkthrough

The sections below stitch the five patterns into one running example: a HealthModule that exposes a MaxHealth setting, publishes a typed event when health changes, listens for the same event from another script, logs each change, and reads pause-aware time. Each section builds on the previous one.

1. A custom module

We want to track the player’s health in one place that every other system can find, and a module is the natural home for that kind of long-lived gameplay state. Modules are MonoBehaviour components that inherit from ScyllaModule. The minimum override is the Info property, which returns a ScyllaModuleInfo describing the module. Lifecycle hooks (OnInitialize, OnStart, OnEnableModule, OnDisableModule, OnShutdown) are optional and run in the corresponding framework phases.

using Scylla.Core;

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.
     * Positional constructor: id, name, version, description, dependencies, priority. */
    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 module dependencies */
        initPriority: 100              /* default mid-range priority */
    );

    /* Internal state. */
    private int _health;
    private int _maxHealth = 100;

    /* Fires once during the framework's Initialize pass. Configuration is
     * already applied at this point. Other modules may not yet be Started. */
    protected override void OnInitialize()
    {
        _health = _maxHealth;
        Log.Info("Health module initialized", LogCategory.Game);
    }

    /* Fires when the module is enabled. May fire multiple times during a
     * session if the module is disabled and re-enabled. Subscribe to events
     * here so subscriptions match the enable/disable lifecycle. */
    protected override void OnEnableModule()
    {
        /* Subscriptions go here; see section 3 below. */
    }

    /* Fires once at framework shutdown. Dispose subscriptions, release
     * resources, save any pending state. */
    protected override void OnShutdown()
    {
        Log.Info("Health module shutting down", LogCategory.Game);
    }
}

Save the script, return to the Editor, let the compile finish, then add the HealthModule component to a new direct child of the bootstrap GameObject (Scylla). The framework discovers it the next time the scene plays.

2. Logging with categories

Anyone who has shipped a game with a sea of logs knows the cost of skipping categories: by month two, half the output is irrelevant and the half that matters is buried. The static Log class routes messages through any number of ILogReceiver implementations (Unity console, file, custom). Methods exist for each log level (Trace, Debug, System, Info, Notice, Warning, Error, Fatal); each takes an optional LogCategory for runtime filtering. Trace and Debug are compiled out in release builds via conditional attributes, so leaving them in user code costs nothing at runtime.

using Scylla.Core;

/* The simplest call: level + message. Routed through every registered
 * ILogReceiver. The default receiver pair (Unity console + file) is set up
 * during ScyllaBootstrap.Awake. */
Log.Info("Game started");

/* Add a category for filtering. The Unity console shows the category as a
 * prefix; receivers can filter by category at runtime. */
Log.Info("Player spawned", LogCategory.Game);
Log.Info($"Loaded {assetCount} assets", LogCategory.Core);

/* Levels in increasing severity. Trace and Debug compile out in release. */
Log.Trace ("Per-frame trace info", LogCategory.Gameplay);
Log.Debug ("Detailed diagnostic", LogCategory.Gameplay);
Log.System("Framework state change", LogCategory.Lifecycle);  /* internal framework category */
Log.Info  ("Normal informational",   LogCategory.Game);
Log.Notice("Worth attention",        LogCategory.Network);
Log.Warning("Recoverable problem",   LogCategory.File);
Log.Error  ("Operation failed",      LogCategory.Core);
Log.Fatal  ("Unrecoverable error",   LogCategory.Lifecycle);

/* Visual separator: useful between logical phases (level load, mode change). */
Log.Delimiter(LogCategory.Game);

/* Runtime filter level. Trace and Debug below the FilterLevel are dropped
 * before reaching any receiver. */
Log.FilterLevel = LogLevel.Info;

/* Global enable / disable. */
Log.IsEnabled = false;

3. Publishing and subscribing to events

Sometimes a system needs to announce something to the rest of the game without knowing or caring who listens: damage was taken, a checkpoint was reached, a quest stage advanced. The event bus is the right shape for that. Define a class that inherits from ScyllaEvent, publish instances with ScyllaEvents.Publish<T>, and listen for them with ScyllaEvents.Listen<T>. The bus delivers the event to every subscriber synchronously in priority order; subscribers receive only events of the exact type they listened for.

using Scylla.Core;
using Scylla.Core.Events;

/* The event type. Inherit from ScyllaEvent and add typed payload fields.
 * The init-only properties make the event effectively immutable after
 * construction, which is the right shape for an event payload. */
public sealed class PlayerHealthChangedEvent : ScyllaEvent
{
    public int OldHealth { get; init; }
    public int NewHealth { get; init; }
}

The publisher constructs the event and hands it to the bus. Any number of subscribers can listen for the same type.

/* Inside the module that changes health. */
public void TakeDamage(int amount)
{
    var previous = _health;
    _health = Mathf.Max(0, _health - amount);

    /* Publish: the bus delivers to every subscriber of PlayerHealthChangedEvent
     * synchronously, in priority order. The static facade routes through the
     * singleton IScyllaEventBus (replaceable for tests via ScyllaEvents.SetBus). */
    ScyllaEvents.Publish(new PlayerHealthChangedEvent
    {
        OldHealth = previous,
        NewHealth = _health
    });
}

The subscriber takes an IDisposable token from Listen and disposes it during shutdown. Holding onto the token is required: the bus holds only weak references for cleanup, and disposing the token is the explicit “I am no longer listening” signal.

public sealed class HealthUIController : MonoBehaviour
{
    private ScyllaEventSubscription _subscription;

    private void Start()
    {
        /* Wait until the framework is up before subscribing. Listen here
         * after RunWhenReady or in a module's OnEnableModule. */
        ScyllaCore.Instance.RunWhenReady(() =>
        {
            /* Listen takes the subscriber object (for tracking), the typed
             * handler delegate, and an optional priority. The token must be
             * stored and disposed when the listener should stop receiving. */
            _subscription = ScyllaEvents.Listen<PlayerHealthChangedEvent>(
                subscriber: this,
                handler:    evt => UpdateHealthBar(evt.OldHealth, evt.NewHealth),
                priority:   ScyllaEventPriority.Medium /* default */
            );
        });
    }

    private void OnDestroy()
    {
        /* Dispose to unsubscribe. Disposing twice is safe (the token tracks
         * its own disposal state). Skipping this leaks the subscription until
         * the bus's periodic cleanup notices the destroyed subscriber. */
        _subscription?.Dispose();
    }

    private void UpdateHealthBar(int oldHealth, int newHealth)
    {
        Log.Info($"Health changed: {oldHealth} -> {newHealth}", LogCategory.Game);
    }
}

4. Cross-module access

Events cover the loose, broadcast-shaped conversations between systems, but plenty of cases still want a direct method call: the combat module calling into the health module to apply damage is the canonical example. Modules can hold references to other modules through GetRequiredModule<T>(moduleID) (throws if missing, the right shape for a hard dependency) and GetOptionalModule<T>(moduleID) (returns null, the right shape for a soft dependency). The helpers are protected on ScyllaModule so they are available only inside another module’s body. Outside a module, use ScyllaCore.Instance.GetModule<T>(moduleID) which behaves like the optional variant.

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.",
        dependencies: new List<ScyllaModuleDependency>
        {
            /* Hard dependency: framework validates at startup that Health is present. */
            new ScyllaModuleDependency(HealthModule.ModuleID, minVersion: "1.0.0")
        },
        initPriority: 110 /* runs after Health (priority 100) */
    );

    /* Cache the dependency reference once. The protected helpers are safe to
     * call inside OnInjectDependencies, OnInitialize, OnStart, and later. */
    private HealthModule _health;

    protected override void OnInjectDependencies(IScyllaModuleManager moduleManager)
    {
        /* GetRequiredModule throws if Health is missing - the failure is
         * caught by the framework's startup pass and reported with the
         * specific missing-dependency error. */
        _health = GetRequiredModule<HealthModule>(HealthModule.ModuleID);
    }

    public void DealDamage(int amount)
    {
        _health.TakeDamage(amount);
    }
}

Outside a module the entry point is the singleton.

ScyllaCore.Instance.RunWhenReady(() =>
{
    /* GetModule returns null when the module is not registered. */
    var health = ScyllaCore.Instance.GetModule<HealthModule>(HealthModule.ModuleID);
    if (health != null)
    {
        /* Use the module. */
    }
});

5. Working with time

Pause is one of those features that touches every gameplay system in the codebase, and the cheapest way to make it work cleanly is to read time from one central place from day one. ScyllaTime is the pause-aware time source for gameplay code. Reading DeltaTime instead of UnityEngine.Time.deltaTime routes the value through the framework’s pause and time-scale system automatically: pausing the framework freezes DeltaTime to 0 (so gameplay does not advance), changing the global time scale slows or speeds up DeltaTime proportionally, and per-system time-scale modifiers can apply on top. UnscaledDeltaTime is the unaffected counterpart for UI and tooling that should keep running during pause.

using Scylla.Core.Time;
using UnityEngine;

public sealed class HealthRegenerator : MonoBehaviour
{
    private void Update()
    {
        /* Pause-aware delta. Returns 0 when the framework is paused, scaled by
         * the active TimeScale and any active modifiers when running. */
        float dt = ScyllaTime.Instance.DeltaTime;

        /* Unscaled delta: ignores pause and time-scale. Use for UI animations
         * that should keep running even when gameplay is paused. */
        float realDt = ScyllaTime.Instance.UnscaledDeltaTime;

        /* Regenerate at 5 HP per gameplay second. Pauses correctly when the
         * framework is paused; speeds up under fast-forward; slows under
         * slow-motion. */
        _accum += 5f * dt;
        while (_accum >= 1f)
        {
            _health.Heal(1);
            _accum -= 1f;
        }
    }

    private int _hp;
    private float _accum;
    private HealthModule _health;
}

The pause and time-scale API covers the common cases.

/* Pause: gameplay deltas freeze, unscaled keeps running. */
ScyllaTime.Instance.Pause();
ScyllaTime.Instance.Resume();
ScyllaTime.Instance.TogglePause();

bool paused = ScyllaTime.Instance.IsPaused;

/* Time-scale presets. */
ScyllaTime.Instance.SetNormalSpeed();          /* 1.0 */
ScyllaTime.Instance.SetSlowMotion(scale: 0.3f); /* 0.3, default 0.5 when called without args */
ScyllaTime.Instance.SetFastForward(scale: 2f);  /* 2.0, default 2.0 when called without args */

/* Direct scale set. */
ScyllaTime.Instance.SetTimeScale(0.5f);

/* Smooth transition: ramp the scale over realSeconds with optional easing.
 * Returns a handle that fires onComplete when the transition finishes. */
var transition = ScyllaTime.Instance.TransitionTimeScale(
    targetScale: 0.3f,
    realSeconds: 0.5f,
    onComplete:  () => Log.Info("Slow motion engaged", LogCategory.Game)
);

/* Layered modifiers: push and pop time-scale modifiers for stacking effects.
 * Useful when multiple systems need to slow time independently. */
var slowAuraHandle = ScyllaTime.Instance.PushModifier(new TimeScaleModifier(
    sourceID: "SlowAura",
    scale:    0.5f
));

/* Effective scale: the product of every active modifier and the global scale. */
float effective = ScyllaTime.Instance.EffectiveTimeScale;

/* Remove the modifier when the effect ends. */
ScyllaTime.Instance.PopModifier(slowAuraHandle);

6. Configuration: tunable values

There is always a moment in a project’s life when a balance value needs to change without a rebuild: a QA tester wants to retry a level with MaxHealth = 200, a designer wants to test a faster regen rate, an end user wants to tweak a feel knob the developer never shipped a slider for. ScyllaConfiguration is the path for that. The configuration asset is a ScriptableObject (created via the Assets > Create menu) that holds project-level defaults; at runtime, a JSON file in the player’s Documents folder can override the values without touching the build. Define a configuration class, expose it as a [SerializeField] on the module, and override the two virtual methods that map between the asset’s fields and the JSON form.

using Scylla.Core;
using Scylla.Core.Config;
using Scylla.Core.Util.File;
using UnityEngine;

[CreateAssetMenu(menuName = "Scylla/Config/Health")]
public sealed class HealthConfiguration : ScyllaConfiguration
{
    [SerializeField] private int   _maxHealth      = 100;
    [SerializeField] private float _regenPerSecond = 5f;
    [SerializeField] private bool  _enableLowHealthScreen = true;

    public int   MaxHealth             => _maxHealth;
    public float RegenPerSecond        => _regenPerSecond;
    public bool  EnableLowHealthScreen => _enableLowHealthScreen;

    /* Override the base name used for the JSON file name on disk. The base
     * implementation returns the asset name; this override locks the
     * filename across renames of the asset. */
    public override string GetConfigFileBaseName() => "Health";

    /* Read the JSON file into the in-memory asset. Returns the number of
     * values that were actually applied. Override one TryGet call per
     * configurable field; defaults survive when a value is missing. */
    public override int ApplyConfigFileOverrides(ConfigFile configFile)
    {
        var applied = 0;

        if (configFile.TryGetInt("Tuning", "MaxHealth", out var max))
        {
            _maxHealth = max;
            applied++;
        }

        if (configFile.TryGetFloat("Tuning", "RegenPerSecond", out var regen))
        {
            _regenPerSecond = regen;
            applied++;
        }

        if (configFile.TryGetBool("UI", "LowHealthScreen", out var lowHP))
        {
            _enableLowHealthScreen = lowHP;
            applied++;
        }

        return applied;
    }

    /* Emit the asset's current values as a JSON file. The default
     * ExportToConfigFile in ScyllaConfiguration returns null; override to
     * support the "Export Config File" Inspector button. */
    public override ConfigFile ExportToConfigFile()
    {
        var file = new ConfigFile();
        file.SetInt   ("Tuning", "MaxHealth",       _maxHealth);
        file.SetFloat ("Tuning", "RegenPerSecond",  _regenPerSecond);
        file.SetBool  ("UI",     "LowHealthScreen", _enableLowHealthScreen);
        return file;
    }
}

Reference the configuration from the module via [SerializeField] (or by name through the configuration registry). The framework applies any matching JSON file at startup, so the values reaching the module’s OnInitialize are already the final, override-applied values.

public sealed class HealthModule : ScyllaModule
{
    public const string ModuleID = "Health";

    [SerializeField] private HealthConfiguration _config;

    public override ScyllaModuleInfo Info => new ScyllaModuleInfo(
        id: ModuleID, name: "Health", version: "1.0.0",
        description: "Tracks player health.", dependencies: null, initPriority: 100
    );

    protected override void OnInitialize()
    {
        /* The configuration is already applied. _config.MaxHealth reflects
         * any JSON override that the player or QA tester provided. */
        _health = _config.MaxHealth;
        Log.Info($"Health module ready: max={_config.MaxHealth}", LogCategory.Game);
    }

    private int _health;
}

The JSON file lives at ~/Documents/{ProductName}/Config/Health.json (Windows / macOS) and uses the group / property structure defined by the Set* calls in ExportToConfigFile.

{
  "Tuning": {
    "MaxHealth": 200,
    "RegenPerSecond": 10.0
  },
  "UI": {
    "LowHealthScreen": false
  }
}

7. Hooking into framework readiness

A lot of subtle startup bugs come from gameplay scripts assuming the framework is up the moment their own Awake or Start runs. The initialization delay (default 1.0 second) intentionally happens after Start, so that assumption breaks. The safe pattern is ScyllaCore.Instance.RunWhenReady(callback): the framework invokes the callback immediately if it is already ready, or queues it for the readiness event if not. The optional priority parameter controls firing order among multiple ready-handlers.

using UnityEngine;
using Scylla.Core;
using Scylla.Core.Events;

public sealed class GameStartup : MonoBehaviour
{
    private void Start()
    {
        /* Medium priority is the default. */
        ScyllaCore.Instance.RunWhenReady(OnFrameworkReady);

        /* High priority: fires before Medium and Low handlers. */
        ScyllaCore.Instance.RunWhenReady(InitializeCriticalSystems, ScyllaEventPriority.High);

        /* Low priority: fires after every other handler. Useful for UI that
         * should appear once all systems have warmed up. */
        ScyllaCore.Instance.RunWhenReady(FinalizeUI, ScyllaEventPriority.Low);
    }

    private void OnFrameworkReady()
    {
        /* The framework is fully up. Every module is in the Enabled state.
         * Cross-module queries via ScyllaCore.Instance.GetModule<T> are safe. */
        Log.Info("Scylla is up", LogCategory.Game);
    }

    private void InitializeCriticalSystems() { /* ... */ }
    private void FinalizeUI()               { /* ... */ }
}

API Sketch

The surface touched by the patterns in this tour spans several namespaces. The grouping below mirrors how user code typically reaches into the framework: module base, event bus facade, time singleton, log facade, configuration base.

namespace Scylla.Core
{
    public sealed class ScyllaCore
    {
        public static ScyllaCore Instance { get; }
        public bool IsInitialized       { get; }
        public T    GetModule<T>(string moduleID) where T : class, IScyllaModule;
        public void RunWhenReady(Action onReady, ScyllaEventPriority priority = ScyllaEventPriority.Medium);
    }

    public abstract class ScyllaModule : MonoBehaviour, IScyllaModule
    {
        public abstract ScyllaModuleInfo Info { get; }
        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 T GetRequiredModule<T>(string moduleID) where T : class, IScyllaModule;
        protected T GetOptionalModule<T>(string moduleID) where T : class, IScyllaModule;
    }

    public sealed class ScyllaModuleInfo
    {
        public ScyllaModuleInfo(string id, string name, string version,
            string description = "", List<ScyllaModuleDependency> dependencies = null,
            int initPriority = 100);
    }
}

namespace Scylla.Core.Events
{
    public abstract class ScyllaEvent { /* base for typed events */ }

    public static class ScyllaEvents
    {
        public static IScyllaEventBus Bus { get; }
        public static void SetBus(IScyllaEventBus bus);
        public static void                       Publish<TEvent>(TEvent @event) where TEvent : ScyllaEvent;
        public static ScyllaEventSubscription    Listen<TEvent>(object subscriber, Action<TEvent> handler,
            ScyllaEventPriority priority = ScyllaEventPriority.Medium) where TEvent : ScyllaEvent;
    }

    public sealed class ScyllaEventSubscription : IDisposable
    {
        public void Dispose();
    }

    public enum ScyllaEventPriority { /* High, Medium, Low, plus integer variants */ }
}

namespace Scylla.Core.Time
{
    public sealed class ScyllaTime
    {
        public static ScyllaTime Instance { get; }

        /* Pause-aware time. */
        public float DeltaTime              { get; }  /* scaled, 0 when paused */
        public float UnscaledDeltaTime      { get; }  /* unaffected by pause / scale */
        public float FixedDeltaTime         { get; }
        public float TimeSinceStartup       { get; }
        public float UnscaledTimeSinceStartup { get; }
        public float RealTimeSinceStartup   { get; }
        public int   FrameCount             { get; }

        public bool  IsPaused          { get; }
        public float TimeScale         { get; set; }
        public float EffectiveTimeScale { get; }

        /* Pause / resume. */
        public void Pause();
        public void Pause(PauseMode mode);
        public void Resume();
        public void TogglePause();

        /* Time-scale presets and transitions. */
        public void                       SetNormalSpeed();
        public void                       SetSlowMotion (float scale = -1f);
        public void                       SetFastForward(float scale = -1f);
        public void                       SetTimeScale  (float scale);
        public SmoothTimeScaleTransition  TransitionTimeScale(float targetScale, float realSeconds,
            Action onComplete = null, Func<float, float> easing = null);

        /* Layered modifiers. */
        public TimeScaleModifierHandle PushModifier(TimeScaleModifier modifier);
        public bool                    PopModifier(TimeScaleModifierHandle handle);
        public int                     RemoveModifiersBySourceID(string sourceID);
        public void                    ClearModifiers();
        public IReadOnlyList<TimeScaleModifier> GetActiveModifiers();
    }
}

namespace Scylla.Core.Debug
{
    public static class Log
    {
        public static bool     IsEnabled    { get; set; }
        public static LogLevel FilterLevel  { get; set; }

        public static void Trace   (object data, LogCategory category = LogCategory.None);
        public static void Debug   (object data, LogCategory category = LogCategory.None);
        public static void System  (object data, LogCategory category = LogCategory.None);
        public static void Info    (object data, LogCategory category = LogCategory.None);
        public static void Notice  (object data, LogCategory category = LogCategory.None);
        public static void Warning (object data, LogCategory category = LogCategory.None);
        public static void Error   (object data, LogCategory category = LogCategory.None);
        public static void Fatal   (object data, LogCategory category = LogCategory.None);
        public static void Delimiter(LogCategory category = LogCategory.None);

        public static void RegisterLogReceiver  (ILogReceiver logReceiver);
        public static void UnregisterLogReceiver(ILogReceiver logReceiver);
    }

    public enum LogLevel    { Trace, Debug, System, Info, Notice, Warning, Error, Fatal }
    public enum LogCategory { Analytics, Camera, Character, Command, Console, Core,
                              Data, Editor, Event, File, Game, Gameplay, Graphics,
                              Input, Lifecycle, Module, Network, None, Renderer, /* ... */ }
}

namespace Scylla.Core.Config
{
    public abstract class ScyllaConfiguration : ScriptableObject
    {
        public virtual string GetConfigFileName();
        public virtual string GetConfigFileBaseName();
        public virtual bool   IsConfigFileEnabledForCurrentBuild();
        public virtual int    ApplyConfigFileOverrides(ConfigFile configFile); /* override */
        public virtual ConfigFile ExportToConfigFile();                        /* override */
    }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Where to next

Best Practices

  • Lock in a module’s Info early. The ID, Name, and Version are part of the module’s contract. Renaming them later forces a migration of any save data or configuration that referenced them. Pick deliberately at module-creation time and treat the chosen values as a public API.
  • Cache module references in OnInjectDependencies, not in Update. Repeated GetRequiredModule<T> lookups in a hot path add up; the framework lookup is cheap but not free, and the validation work it does is wasted when the reference is known to be valid.
  • Always store the Listen return value. Discarding the subscription token makes the listener effectively permanent. Assign to a private field whose lifetime matches the listener’s lifetime; dispose in OnShutdown (for modules) or OnDestroy (for MonoBehaviours that subscribed in Start).
  • Prefer ScyllaTime.Instance.DeltaTime over UnityEngine.Time.deltaTime for gameplay code. Pause-aware time is the right default; reserve UnityEngine.Time for UI animations and tooling that should ignore pause.
  • Use specific LogCategory values from the first log call. Adding categories retroactively is a chore; picking the right category up front makes runtime filtering instant.
  • Trace and Debug calls are free in release builds. Both compile out via conditional attributes, so leaving them in user code does not cost anything in shipped builds. Use them liberally for diagnostic output.
  • Define event payload fields as init-only properties. The init modifier produces effectively-immutable event payloads, which is the right shape because event handlers run synchronously and should not mutate the event mid-dispatch.
  • Pick event priorities deliberately. High for handlers that other handlers depend on (state mutators); Medium for general listeners; Low for handlers that should run last (analytics, UI). Mixing priorities at random produces order-dependent bugs.
  • Use OnInjectDependencies for cross-module references and OnInitialize for self-contained setup. Splitting the work this way keeps the lifecycle phases distinct and makes it obvious which references are guaranteed to be valid at each step.
  • Wire Pause to game pause events and Resume to unpause events. Without this, gameplay time keeps running while the rest of the simulation is “paused”, producing “the UI is paused but the timer is not” bugs.
  • Export the configuration file once, then version it. Click Export Config File on the configuration asset’s Inspector, commit the resulting JSON to version control, and treat that file as the seed for any future tuning. The file is also what ships in the user-overrides location for player-editable tuning.
  • Use RunWhenReady for one-shot startup, IsInitialized polling for per-frame gates. Both patterns are appropriate in different contexts; mixing them (waiting for ready via a poll) wastes frames.

Pitfalls