The Scylla Event eXchange (SEX) is your project-wide publish/subscribe layer, giving any module or gameplay system a way to talk to the rest of the game without holding direct references to it.
Summary
The Scylla Event eXchange (SEX) is the project-wide publish/subscribe layer that lets modules and gameplay code communicate without holding direct references to each other. You hand a typed event instance to the bus; the bus delivers it synchronously to every live subscriber whose handler matches the event’s runtime type, in priority order, on the calling thread. Subscribing returns an IDisposable token; disposing the token removes the listener. The bus uses weak references for subscribers, so destroyed Unity objects and garbage-collected C# objects are reaped automatically on the next publish.
Two surfaces cover the common cases. ScyllaEvents is the static facade: ScyllaEvents.Publish<T>(event) and ScyllaEvents.Listen<T>(subscriber, handler, priority). Underneath, IScyllaEventBus is the interface (ScyllaEventBus is the default implementation). The bus is wired up by ScyllaBootstrap.InitializeEventSystem during the framework’s Awake; ScyllaEvents.SetBus lets tests substitute a fake or a stub. Custom event types inherit from ScyllaEvent and carry their payload as init-only properties or readonly fields, which makes the event effectively immutable after construction (the right shape because handlers run synchronously and should not mutate the event mid-dispatch).
Five priority levels control firing order: Essential (always fires, never skipped by cancellation), High, Medium (the default), Low, and Cleanup (always fires last, never skipped). Within a single priority level, handlers fire in subscription order. A handler can call StopPropagation() on the event to skip the remaining High/Medium/Low handlers, but Essential and Cleanup handlers always run regardless. The IsCancelled flag exposes whether cancellation has been requested so downstream handlers can branch on it without being skipped themselves.
This matters because cross-cutting concerns pile up as a game grows. A save system wants to flush after every LevelLoadedEvent, an analytics layer wants to log every PlayerDamagedEvent, an audio module wants to play a sound on WeaponSwapEvent. Wiring each of those directly forces the publisher to know about every consumer, which couples unrelated systems and leaves a tangle of references that only gets harder to change. The bus inverts the relationship: publishers describe what happened, and subscribers decide whether and how to react. Tier-1 and tier-2 modules cannot reference each other directly (see Tiered Modules), so the bus is also how they coordinate without breaking the tier discipline.
Use the event bus for:
- Cross-module communication that should not couple the publisher to the subscriber (combat publishes
PlayerDamagedEvent; analytics, audio, UI, and save all listen independently). - Same-tier module coordination, where the dependency rule forbids direct references.
- Framework lifecycle hooks (
ScyllaFrameworkInitializedEvent,ScyllaFrameworkShutdownEvent) that your code subscribes to for one-shot startup or shutdown work. - Optional or conditional consumers where the publisher should not know whether any consumers exist (the bus delivers to zero subscribers cleanly).
- Cancellable workflows where one consumer can short-circuit further handlers (a damage-mitigation buff cancels the rest of the damage chain).
- Editor and developer tooling that wants to react to runtime state changes without modifying the gameplay code.
Features
- Strongly-typed publish/subscribe.
Publish<T>(event)dispatches to every handler whose subscribed type matchesTor its base classes;Listen<T>(subscriber, handler, priority)registers a typed callback. No string topic, no boxing. ScyllaEventsstatic facade. Project-wide entry point for the common case.Publish<T>,Listen<T>,Bus, plusSetBus(IScyllaEventBus)for test substitution.IScyllaEventBusfor substitution. The interface lets you swap in a fake or a stub for tests;ScyllaEventBusis the default implementation wired up by the bootstrap.- Five priority levels.
Essential(always fires),High,Medium(default),Low,Cleanup(always fires last). Within a level, handlers fire in subscription order. The two outer levels run regardless of cancellation. - Cooperative cancellation. A handler can call
StopPropagation()to skip the remainingHigh/Medium/Lowhandlers.EssentialandCleanupstill run. Downstream handlers can readIsCancelledto branch on the request without being skipped. - Synchronous, ordered dispatch on the calling thread. Publishers and subscribers run in the same thread context; ordering is deterministic per priority. No background queue, no thread hop, no surprise reentrancy.
- Weak-reference subscriber tracking. Destroyed Unity objects and garbage-collected C# objects are reaped automatically on the next publish. No manual unsubscribe step when the subscriber’s lifetime ends.
IDisposablesubscription tokens.Listenreturns a token; disposing unsubscribes. Disposing twice is safe; disposing during dispatch applies to the next publish.- Immutable event shape recommended. Custom events inherit from
ScyllaEventand carry payload asinit-only properties orreadonlyfields. Handlers cannot mutate the event mid-dispatch, so order-dependent bugs are impossible. - Exception isolation. A handler that throws does not stop other handlers from running.
ScyllaEventConfiguration.ExceptionIsolation(defaulttrue) routes the exception through the log without aborting dispatch. - Subscriber caps with warnings.
SubscriberWarningThreshold(default 100) logs a warning when an event type’s subscriber count crosses the threshold;SubscriberLimit(default 1000) rejects further subscriptions to detect leak-style patterns early. - Tier-rule compliance. Same-tier modules cannot reference each other directly; the bus is the framework’s sanctioned way for them to coordinate. Cross-tier downward coordination also benefits from the bus when the publisher should not know its subscribers.
Concepts
A few terms appear repeatedly in the recipes below. An event is a class that inherits from ScyllaEvent and carries the payload as fields or properties. The bus is the object that routes events to subscribers; the default implementation is ScyllaEventBus. The facade is the static entry point (ScyllaEvents) most code uses instead of touching the bus directly. A subscription is the IDisposable token returned by Listen; disposing it unsubscribes. Priority picks the firing order among multiple subscribers to the same event type. Cancellation is the cooperative signal that a handler has decided no further High/Medium/Low handlers should run for this dispatch.
| Concept | Type / class | Notes |
|---|---|---|
ScyllaEvents | static facade | Project-wide entry point. Publish<T>(event), Listen<T>(subscriber, handler, priority), Bus (the underlying IScyllaEventBus), SetBus(IScyllaEventBus) (test hook). |
IScyllaEventBus | interface | The contract for any bus implementation. Implemented by ScyllaEventBus (default). Custom implementations replace the default via ScyllaEvents.SetBus for testing or specialized dispatch. |
ScyllaEventBus | sealed class | Default bus. Holds weak references to subscribers, dispatches in priority order, enforces subscriber caps from ScyllaEventConfiguration, isolates exceptions. |
ScyllaEvent | abstract class | Base for every event type. Properties: IsCancelled (set by StopPropagation). Custom events inherit and add typed payload fields (typically init-only properties). |
ScyllaEventSubscription | sealed IDisposable | Returned by Listen. Disposing removes the listener. Disposing twice is safe. Disposing during dispatch does not affect the current dispatch but applies to the next publish. |
ScyllaEventPriority | enum | Five values: Essential = 0, High = 100, Medium = 200 (default), Low = 300, Cleanup = 999. Lower-priority numeric values fire first. |
ScyllaEventConfiguration | ScriptableObject | Three tunables: SubscriberWarningThreshold (default 100), SubscriberLimit (default 1000), ExceptionIsolation (default true). Set via the Inspector or via JSON config-file overrides. |
The five priority levels are organized so that framework-level work (logging, metrics, save) can run at Essential or Cleanup, gameplay code runs at Medium, and “after-the-fact” listeners (analytics, UI updates that depend on settled state) run at Low. Cancellation skips the middle three priorities but never the outer two, which keeps essential side effects (telemetry, audit logs) always running while letting gameplay logic short-circuit normally.
Recipes
These recipes are self-contained answers to single problems. Each one stands alone, so jump to whichever matches what you are building right now. If you are new to the event system, the first recipe defines the event type that the following ones publish and subscribe, so it is a natural starting point.
Define a custom event type
Problem. You want to signal that something happened in your game – a player took damage, a level finished loading, a status effect was applied – and you need other systems to be able to respond to that signal without you having to know which systems exist. The first step is to define the event type itself.
Solution. Custom events inherit from ScyllaEvent and carry their payload as fields or properties. The recommended shape is init-only properties (or readonly fields) so the event is effectively immutable after construction. Mutability invites a handler to mutate the event mid-dispatch, which produces order-dependent bugs because later handlers see different values than earlier ones.
using Scylla.Core.Events;
using UnityEngine;
/* Sealed class so subclassing does not accidentally split the dispatch
* target type. init-only properties make the event read-only post-construction. */
public sealed class PlayerDamagedEvent : ScyllaEvent
{
public int Amount { get; init; }
public Vector3 Source { get; init; }
public DamageType Type { get; init; }
}
/* A status-effect event with a default value. Subscribers see Duration = 5.0
* unless the publisher explicitly overrides it. */
public sealed class StatusAppliedEvent : ScyllaEvent
{
public string Name { get; init; }
public float Duration { get; init; } = 5.0f;
}
/* A void event (no payload). Useful for signal-only notifications. The base
* class still provides IsCancelled / StopPropagation. */
public sealed class LevelLoadedEvent : ScyllaEvent { }
Subscribe to an event
Problem. Your combat module publishes PlayerDamagedEvent, and you want your UI, your audio system, and your analytics layer to each respond independently without the combat module knowing they exist. You need to register a typed handler that fires every time that event is published.
Solution. Subscribe via the static facade ScyllaEvents.Listen<T>. The method takes the subscriber object (used for weak-reference tracking and auto-cleanup), the typed handler delegate, and an optional priority. It returns a ScyllaEventSubscription token that you must store; disposing the token removes the listener. Subscribing inside a module’s OnInitialize or OnEnableModule is the canonical pattern, with disposal in OnShutdown or OnDisableModule.
using Scylla.Core;
using Scylla.Core.Events;
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: null, initPriority: 110
);
/* Token storage: the bus drops the listener when this is disposed. Lifetime
* must match the listener's: store on the owning module / component, dispose
* in the corresponding teardown hook. */
private ScyllaEventSubscription _damageSub;
private ScyllaEventSubscription _levelLoadedSub;
protected override void OnInitialize()
{
/* Subscribe to PlayerDamagedEvent. The subscriber argument (this) is
* what the bus weak-references for auto-cleanup; pass the owning
* object, not a captured-closure target. */
_damageSub = ScyllaEvents.Listen<PlayerDamagedEvent>(
subscriber: this,
handler: OnPlayerDamaged,
priority: ScyllaEventPriority.Medium /* default */
);
/* Priority can be passed as either the enum or a custom integer.
* Lower numeric values fire first. */
_levelLoadedSub = ScyllaEvents.Listen<LevelLoadedEvent>(
subscriber: this,
handler: evt => Log.Info("Level loaded", LogCategory.Game),
priority: ScyllaEventPriority.High
);
}
protected override void OnShutdown()
{
/* Dispose to remove the listeners. Disposing twice is safe; the token
* tracks its own disposal state. Skipping this works (the bus auto-
* cleans on the next publish because the module reference becomes
* dead) but is sloppy; explicit disposal is the right pattern. */
_damageSub?.Dispose();
_levelLoadedSub?.Dispose();
}
private void OnPlayerDamaged(PlayerDamagedEvent evt)
{
Log.Info($"Player took {evt.Amount} from {evt.Source}", LogCategory.Game);
}
}
Publish an event
Problem. Something has happened in your gameplay code – the player took a hit, a door opened, a quest flag flipped – and you want every interested system to hear about it without you having to track who those systems are.
Solution. Call ScyllaEvents.Publish<T>. The bus delivers the event synchronously to every live subscriber whose handler type matches the event’s runtime type, in priority order, on the calling thread. The call returns after every handler has run; there is no async mode. If no subscribers exist for the event type, the call is a clean no-op.
public void DealDamage(int amount, Vector3 from, DamageType type)
{
/* The publisher constructs the event and hands it to the bus. The init
* properties make the event immutable; handlers cannot mutate the
* payload mid-dispatch (which is the right shape). */
ScyllaEvents.Publish(new PlayerDamagedEvent
{
Amount = amount,
Source = from,
Type = type
});
}
/* The same call signature for void events. The bus drops the event reference
* after dispatch; the event instance is short-lived. */
public void NotifyLevelLoaded()
{
ScyllaEvents.Publish(new LevelLoadedEvent());
}
/* Publishing with zero subscribers: the call returns without doing anything.
* The bus does not log a warning or throw. */
ScyllaEvents.Publish(new EventWithNoListeners());
Control firing order with priorities
Problem. You have multiple systems listening for the same event, and you need them to fire in a specific order. Your telemetry system must always log the event before any gameplay handler can cancel it. Your post-dispatch reconciliation should always run last. Your combat resolution needs to land between those two extremes.
Solution. The five-level priority system controls firing order. Lower numeric values fire first: Essential = 0 first, then High = 100, Medium = 200 (the default), Low = 300, and Cleanup = 999 last. Within the same priority, handlers fire in subscription order (first subscriber fires first). Reserve Essential for framework-level handlers that must always run (logging, telemetry); reserve Cleanup for last-pass work (audit logs, post-dispatch state reconciliation); use Medium for almost everything else.
/* Standard medium-priority subscriber. The right default for gameplay code. */
ScyllaEvents.Listen<PlayerDamagedEvent>(this, OnDamageMedium, ScyllaEventPriority.Medium);
/* Higher priority: fires before Medium handlers. Useful when one handler
* mutates state that Medium handlers depend on. */
ScyllaEvents.Listen<PlayerDamagedEvent>(this, OnDamageHigh, ScyllaEventPriority.High);
/* Lower priority: fires after Medium handlers. The right slot for "after the
* dust settles" listeners (analytics, UI updates that depend on final state). */
ScyllaEvents.Listen<PlayerDamagedEvent>(this, OnDamageLow, ScyllaEventPriority.Low);
/* Essential priority: always runs, even after StopPropagation. Use for
* framework-level handlers that must observe every event (logging, metrics,
* audit trails). */
ScyllaEvents.Listen<PlayerDamagedEvent>(this, OnDamageEssential, ScyllaEventPriority.Essential);
/* Cleanup priority: always runs last. Use for post-dispatch reconciliation
* (clearing transient state, releasing per-event allocations). */
ScyllaEvents.Listen<PlayerDamagedEvent>(this, OnDamageCleanup, ScyllaEventPriority.Cleanup);
Cancel an event mid-propagation
Problem. You are building a damage pipeline where a handler earlier in the chain can absorb a hit entirely – an invulnerability shield, a parry window, a blocking stance. When the mitigating handler fires, you want to stop the remaining gameplay handlers from running, while still letting your telemetry and audit handlers observe that a cancelled hit occurred.
Solution. A handler calls StopPropagation() on the event to signal that further High/Medium/Low handlers should be skipped. The flag is cooperative: the bus checks it between handlers. Essential and Cleanup handlers always run regardless. Downstream handlers that are still in the always-run range (like a Cleanup telemetry handler) can read event.IsCancelled to distinguish “absorbed” from “fully delivered”.
public sealed class DamageMitigationModule : ScyllaModule
{
/* Subscribe at High priority so this runs before gameplay damage handlers. */
protected override void OnInitialize()
{
ScyllaEvents.Listen<PlayerDamagedEvent>(
subscriber: this,
handler: OnPlayerDamaged,
priority: ScyllaEventPriority.High
);
}
private void OnPlayerDamaged(PlayerDamagedEvent evt)
{
if (_invulnerable)
{
/* StopPropagation prevents High/Medium/Low handlers further in
* the dispatch order from running. Essential and Cleanup handlers
* still run. The event itself can still be inspected; mutation
* is not allowed (init-only properties). */
evt.StopPropagation();
Log.Info("Damage absorbed by invulnerability shield.", LogCategory.Game);
}
}
}
/* A downstream handler can still observe IsCancelled when its own
* priority level is being skipped, IF it is in the always-run range
* (Essential or Cleanup). */
public sealed class TelemetryModule : ScyllaModule
{
protected override void OnInitialize()
{
ScyllaEvents.Listen<PlayerDamagedEvent>(
subscriber: this,
handler: OnDamageTelemetry,
priority: ScyllaEventPriority.Cleanup
);
}
private void OnDamageTelemetry(PlayerDamagedEvent evt)
{
/* Cleanup always runs, even after StopPropagation. Inspect IsCancelled
* to distinguish "absorbed by mitigation" from "fully delivered". */
var fate = evt.IsCancelled ? "mitigated" : "delivered";
SendTelemetry($"damage_{fate}", evt.Amount);
}
}
Manage subscription token lifetimes
Problem. Anyone who has shipped an event-driven system has had at least one leaked subscription that fired long after the subscriber was supposed to be gone – a UI health bar still updating for a player who left the scene, a handler crashing on the third scene reload because the MonoBehaviour was destroyed. The ScyllaEventSubscription token returned by Listen is the only mechanism for removing a listener, and managing it correctly is the difference between a clean module shutdown and a slow leak.
Solution. Store the token on a field whose lifetime matches the listener’s, and dispose it in the matching teardown hook. The three patterns below cover the common cases: a single subscription on a module, an auto-cleanup shortcut for short-lived handlers, and a list pattern when a component subscribes to many events at once.
/* Standard pattern: store the token on a field, dispose in OnShutdown / OnDestroy. */
public sealed class HealthBarController : MonoBehaviour
{
private ScyllaEventSubscription _sub;
private void Start()
{
ScyllaCore.Instance.RunWhenReady(() =>
{
_sub = ScyllaEvents.Listen<PlayerDamagedEvent>(
subscriber: this,
handler: OnDamage,
priority: ScyllaEventPriority.Medium
);
});
}
private void OnDestroy()
{
/* Explicit dispose: deterministic, runs on Unity's main thread,
* removes the listener immediately. Better than relying on the
* bus's per-publish cleanup. */
_sub?.Dispose();
}
private void OnDamage(PlayerDamagedEvent evt) { /* ... */ }
}
/* Auto-cleanup pattern: the bus reaps listeners whose subscriber is dead.
* Works even when the token is never stored, but defers cleanup until the
* next publish. Acceptable for short-lived handlers; not recommended as
* a primary pattern. */
public sealed class TransientHandler : MonoBehaviour
{
private void Start()
{
/* No token stored. The handler fires until the GameObject is
* destroyed and the bus's next-publish cleanup notices. */
ScyllaEvents.Listen<LevelLoadedEvent>(this, evt => DoOneTimeSetup());
}
private void DoOneTimeSetup() { /* ... */ }
}
/* Multi-subscription pattern: store all tokens in a list, dispose them
* en masse during teardown. */
public sealed class MultiListenerModule : ScyllaModule
{
private readonly List<ScyllaEventSubscription> _subs = new();
protected override void OnInitialize()
{
_subs.Add(ScyllaEvents.Listen<PlayerDamagedEvent>(this, OnDamage));
_subs.Add(ScyllaEvents.Listen<LevelLoadedEvent>(this, OnLevelLoaded));
_subs.Add(ScyllaEvents.Listen<StatusAppliedEvent>(this, OnStatusApplied));
}
protected override void OnShutdown()
{
foreach (var sub in _subs)
{
sub?.Dispose();
}
_subs.Clear();
}
private void OnDamage(PlayerDamagedEvent evt) { /* ... */ }
private void OnLevelLoaded(LevelLoadedEvent evt) { /* ... */ }
private void OnStatusApplied(StatusAppliedEvent evt) { /* ... */ }
}
Auto-cleanup and the weak-reference contract
Problem. You want to understand exactly when the bus removes a dead subscriber’s handler, because your tests are showing unexpected handler counts and you need to know when cleanup actually happens.
Solution. The bus holds weak references to subscribers, not strong references. When a subscriber object becomes unreachable (a MonoBehaviour whose GameObject was destroyed, a pure C# object with no other references), the bus’s next-publish iteration detects the dead reference and removes the subscription automatically. This means defensive OnDestroy unsubscription is not strictly required, but explicit disposal is still the recommended pattern because it is deterministic and avoids the one-extra-fire window.
/* The bus's auto-cleanup is triggered by Publish, not by a periodic timer.
* Until the next Publish call, a dead subscriber's handler still occupies
* a slot in the bus's listener list (just not callable). */
/* Scenario 1: GameObject destroyed mid-frame, no Publish between destruction
* and next frame. The next Publish finds the dead reference, skips the
* handler invocation, and removes the listener. No exception thrown. */
/* Scenario 2: GameObject destroyed mid-frame, immediate Publish. The bus
* detects the dead reference on this very Publish, skips the handler, and
* removes the listener. Still no exception. */
/* Scenario 3: Long gap between destruction and next Publish. The listener
* sits in the bus's list (occupying memory) until the next Publish. The
* subscriber count appears one higher than the live count would suggest
* until cleanup runs. */
/* The auto-cleanup behaviour means tests that publish into a bus with stale
* subscribers can still verify "no handler ran", because the bus skips dead
* handlers before invocation. */
Replace the bus for testing
Problem. You are writing a unit test for a module that publishes events, and you want to verify that DealDamage actually publishes a PlayerDamagedEvent without wiring up a real subscriber. You need a controllable bus that records every publish call.
Solution. You can swap out the bus by calling ScyllaEvents.SetBus with your own IScyllaEventBus implementation. The default bus is wired up by ScyllaBootstrap.InitializeEventSystem during Awake; tests replace it before the code under test runs, and restore it in TearDown.
using NUnit.Framework;
using Scylla.Core.Events;
[TestFixture]
public sealed class CombatTests
{
private IScyllaEventBus _originalBus;
private FakeEventBus _fakeBus;
[SetUp]
public void SetUp()
{
/* Snapshot the existing bus so it can be restored after the test. */
_originalBus = ScyllaEvents.Bus;
/* Install a fake bus that records every Publish call. */
_fakeBus = new FakeEventBus();
ScyllaEvents.SetBus(_fakeBus);
}
[TearDown]
public void TearDown()
{
/* Restore the real bus so the next fixture is not affected. */
ScyllaEvents.SetBus(_originalBus);
}
[Test]
public void DealingDamagePublishesEvent()
{
var combat = new CombatModule();
combat.DealDamage(amount: 10, from: Vector3.zero, type: DamageType.Fire);
Assert.That(_fakeBus.PublishedEvents, Has.Count.EqualTo(1));
Assert.That(_fakeBus.PublishedEvents[0], Is.InstanceOf<PlayerDamagedEvent>());
}
}
/* A simple fake that records every published event and skips Listen entirely.
* The framework provides no such fake in production code; this is a test
* helper you author per project. */
public sealed class FakeEventBus : IScyllaEventBus
{
public readonly List<ScyllaEvent> PublishedEvents = new();
public void Publish<TEvent>(TEvent @event) where TEvent : ScyllaEvent
{
PublishedEvents.Add(@event);
}
public ScyllaEventSubscription Listen<TEvent>(object subscriber, Action<TEvent> handler,
ScyllaEventPriority priority = ScyllaEventPriority.Medium) where TEvent : ScyllaEvent
{
return null; /* fake does not invoke handlers */
}
}
Tune the bus configuration
Problem. You are scaling up your game and a particular event type is accumulating a lot of listeners, or you are hitting the default subscriber cap in a legitimate large-scale architecture. You also want to decide whether handler exceptions should abort the dispatch chain in production, or be isolated so one bad handler does not break the others.
Solution. ScyllaEventConfiguration exposes three tunables you can set via the Inspector on the asset bound to ScyllaBootstrap.EventConfiguration, or via JSON config-file overrides (see Configuration).
/* The configuration asset is bound to ScyllaBootstrap.EventConfiguration in
* the Inspector, or falls back to Resources/Config/Scylla Event Configuration. */
[CreateAssetMenu(menuName = "Scylla/Configurations/Event Configuration")]
public sealed class CustomEventConfig : ScyllaEventConfiguration
{
/* Inherited fields are tuned via the Inspector. Override the virtual
* methods to customize JSON load/save shape if a custom format is needed. */
}
/* Read the current values at runtime via the configuration asset. */
var config = ScyllaBootstrap.Instance.EventConfiguration;
int warningAt = config.SubscriberWarningThreshold; /* default 100 */
int hardCap = config.SubscriberLimit; /* default 1000 */
bool isolate = config.ExceptionIsolation; /* default true */
/* The properties are also exposed on the default ScyllaEventBus implementation
* (initialized from the configuration at bootstrap time). Adjusting them on
* the bus at runtime is allowed but rare; the configuration asset is the
* canonical source. */
if (ScyllaEvents.Bus is ScyllaEventBus defaultBus)
{
defaultBus.SubscriberWarningThreshold = 200;
defaultBus.SubscriberLimit = 2000;
defaultBus.ExceptionIsolation = false; /* propagate handler exceptions */
}
API Sketch
The public surface is split across the static facade, the interface, the default bus implementation, the event base class, the subscription token, the priority enum, and the configuration asset. Most code touches ScyllaEvents.Publish, ScyllaEvents.Listen, and ScyllaEvent only.
namespace Scylla.Core.Events
{
/* Static facade: project-wide entry point. */
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;
}
/* Bus interface. Custom implementations replace the default via SetBus. */
public interface IScyllaEventBus
{
void Publish<TEvent>(TEvent @event) where TEvent : ScyllaEvent;
ScyllaEventSubscription Listen<TEvent>(object subscriber, Action<TEvent> handler,
ScyllaEventPriority priority = ScyllaEventPriority.Medium) where TEvent : ScyllaEvent;
}
/* Default bus implementation. Weak-reference subscribers, priority-ordered
* dispatch, exception isolation, configurable caps. */
public sealed class ScyllaEventBus : IScyllaEventBus
{
/* Live-tunable knobs. Initialized from ScyllaEventConfiguration. */
public int SubscriberWarningThreshold { get; set; }
public int SubscriberLimit { get; set; }
public bool ExceptionIsolation { get; set; }
public void Publish<TEvent>(TEvent @event) where TEvent : ScyllaEvent;
public ScyllaEventSubscription Listen<TEvent>(object subscriber, Action<TEvent> handler,
ScyllaEventPriority priority = ScyllaEventPriority.Medium) where TEvent : ScyllaEvent;
}
/* Base class for every event type. */
public abstract class ScyllaEvent
{
public bool IsCancelled { get; } /* set by StopPropagation, read by handlers */
public void StopPropagation();
}
/* Subscription token returned by Listen. Dispose to unsubscribe. */
public sealed class ScyllaEventSubscription : IDisposable
{
public void Dispose();
}
/* Priority enum. Numeric values: Essential = 0, High = 100, Medium = 200,
* Low = 300, Cleanup = 999. */
public enum ScyllaEventPriority
{
Essential = 0,
High = 100,
Medium = 200,
Low = 300,
Cleanup = 999
}
}
namespace Scylla.Core.Config
{
public sealed class ScyllaEventConfiguration : ScyllaConfiguration
{
public const int MIN_SUBSCRIBER_WARNING_THRESHOLD = 10;
public const int MAX_SUBSCRIBER_WARNING_THRESHOLD = 10000;
public const int DEFAULT_SUBSCRIBER_WARNING_THRESHOLD = 100;
public const int MIN_SUBSCRIBER_LIMIT = 50;
public const int MAX_SUBSCRIBER_LIMIT = 100000;
public const int DEFAULT_SUBSCRIBER_LIMIT = 1000;
public const bool DEFAULT_EXCEPTION_ISOLATION = true;
public int SubscriberWarningThreshold { get; }
public int SubscriberLimit { get; }
public bool ExceptionIsolation { get; }
}
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Configuration reference
ScyllaEventConfiguration exposes three settings. Set them in the Inspector on the asset bound to ScyllaBootstrap.EventConfiguration, or via JSON config-file overrides (the [Subscriber Limits] and [Exception Handling] groups, see Configuration).
| Property | Type | Default | Range | Effect |
|---|---|---|---|---|
SubscriberWarningThreshold | int | 100 | 10..10000 | Per-event-type subscriber count at which the bus logs a warning. Informational only; does not block further subscriptions. Raise for events with many legitimate listeners. |
SubscriberLimit | int | 1000 | 50..100000 | Hard cap on subscribers per event type. Exceeding it raises an exception on the next Listen call. Set high enough that real workloads do not trip it. |
ExceptionIsolation | bool | true | bool | When true, the bus catches handler exceptions so one bad handler does not break the dispatch chain. The exception is still logged. Set to false to propagate exceptions. |
The defaults are conservative; most projects will never need to tune them. The warning threshold is useful for catching designs where a single event type accumulates hundreds of listeners (usually a sign the event should be split into more specific types, or that listeners are not being disposed correctly). The hard limit is a safety net against runaway subscription bugs.
Best Practices
- Use the static
ScyllaEventsfacade for everything. It is the recommended entry point. TouchIScyllaEventBusorScyllaEventBusdirectly only when implementing a custom bus or replacing the default for tests. - Make event types sealed with
init-only properties. Sealed prevents subclassing (which would split dispatch targets);init-only properties make the payload effectively immutable after construction, which prevents handlers from mutating the event mid-dispatch. - Name events with the noun first and the past participle.
PlayerDamagedEvent,LevelLoadedEvent,WeaponSwappedEvent. The dispatch shape becomes obvious at every call site. - Always store the
Listenreturn value. Discarding the token leaves the listener effectively permanent. Assign to a field whose lifetime matches the listener’s lifetime; dispose inOnShutdown(modules) orOnDestroy(MonoBehaviours that subscribed inStart). - Pick priorities deliberately.
Mediumfor application code;Highfor handlers that other handlers depend on;Lowfor listeners that should see settled state;EssentialandCleanuponly for framework-level code that must always run. - Use
StopPropagationcooperatively, not as a flow-control hammer. Cancellation is for short-circuiting a dispatch (mitigation absorbs damage, validation rejects an action). It is not a substitute for branching inside a handler. - Subscribe in
OnInitializeorOnEnableModule, dispose inOnShutdownorOnDisableModule. The bracket pattern keeps subscription lifetimes obvious; the disposal hook matches the subscribe hook one-to-one. - Use the bus for cross-cutting events, not for hot paths. Per-frame ticks, render notifications, and physics events fire too often for the bus’s priority sort and weak-reference checks. Use direct delegates or a Unity component reference instead. The bus is for low-frequency signals (damage, level load, item pickup).
- Subscribe to framework lifecycle events from project setup code, not from modules. Modules already have lifecycle hooks (
OnInitialize,OnShutdown); subscribing toScyllaFrameworkInitializedEventfrom inside a module duplicates the hook. Reserve the framework events for top-level project code (analytics init, save-system reload). - Replace the bus in tests via
ScyllaEvents.SetBus. A fake bus that records every publish is simpler to assert against than a real bus with mocked subscribers. Restore the original bus inTearDownto avoid leaking the fake into the next fixture. - Tune
SubscriberLimitonly when the workload demands it. The default1000per event type is generous. Approaching the limit usually indicates a design issue (events accumulating listeners that are never disposed); fix the lifecycle before raising the cap. - Leave
ExceptionIsolation = truein production. A single broken handler should not abort the entire dispatch chain. Set tofalseonly in tests where a failing handler should fail the test loudly.
Pitfalls
Related
- Architecture Overview
- Tiered Modules
- Module System
- Scene Manager
- Getting Started
- API reference:
Scylla.Core.Events.ScyllaEvents - API reference:
Scylla.Core.Events.ScyllaEventBus - API reference:
Scylla.Core.Events.IScyllaEventBus - API reference:
Scylla.Core.Events.ScyllaEvent - API reference:
Scylla.Core.Events.ScyllaEventPriority - API reference:
Scylla.Core.Events.ScyllaEventSubscription - API reference:
Scylla.Core.Config.ScyllaEventConfiguration