Skip to content

Cross Plugin Communication

PlatanoGames edited this page Feb 23, 2026 · 3 revisions

Cross-Plugin Communication -- The Three-Bus Model

The Problem

PGX uses a star topology: every plugin depends only on the central L1 Core, never on another L2 plugin. This is essential for modularity -- any plugin can be added or removed without breaking others.

But systems absolutely need to talk to each other at runtime:

  • The loading screen needs PSO shader compilation progress to show a combined bar.
  • Audio needs to respond to game state changes (mute during pause, duck during dialogue, switch music on level transition).
  • Save needs to know when the game flow transitions so it can trigger auto-save.
  • UI ceremonies need to fire audio cues without importing the audio module.
  • Event-driven gameplay (item pickups, ability triggers, interaction responses) needs a resolution mechanism that does not hardcode which system responds.

In a naive architecture, you solve this with direct dependencies. Audio imports GameFlow. Save imports GameFlow. Loading imports PSO. Within a month, you have a dependency web that makes every plugin removal a cascading build failure.

PGX solves this with three complementary communication buses, all living in L1 Core where every plugin can reach them.


Architecture Overview

+-----------------------------------------------------------------------+
|                           L1 CORE                                     |
|                                                                       |
|  +-------------------+  +--------------------+  +------------------+  |
|  |   MESSAGE BUS     |  |   EVENT HANDLER    |  |  DATA REGISTRY   |  |
|  |   (1:N notify)    |  |   (1:1 execute)    |  |  (N:1 store)     |  |
|  |                   |  |                    |  |                  |  |
|  |  Pub/sub channels |  |  Tag -> Handler    |  |  Tag -> Asset    |  |
|  |  Typed payloads   |  |  DataTable-driven  |  |  O(1) lookup     |  |
|  |  History buffer   |  |  Lifecycle mgmt    |  |  Category index  |  |
|  |  Partial matching |  |  Telemetry + bbox  |  |  Async loading   |  |
|  +-------------------+  +--------------------+  +------------------+  |
|                                                                       |
+-----------------------------------------------------------------------+
         ^                         ^                        ^
         |                         |                        |
    +---------+  +---------+  +---------+  +---------+  +---------+
    |  Save   |  |  Audio  |  | Loading |  |   PSO   |  |GameFlow |
    +---------+  +---------+  +---------+  +---------+  +---------+
         L2 plugins -- no direct references to each other

Each bus serves a different communication pattern. Using the wrong bus for a given task creates unnecessary complexity. Using the right one keeps things clean.


Bus 1: Message Bus (1:N Notification)

When to Use

One system has information that zero or more other systems might care about. The publisher does not know (or care) who is listening. Classic pub/sub.

Examples:

  • "A level transition just started" -- Loading, Audio, and UI all react differently.
  • "Player health changed to 30%" -- HUD updates, audio plays low-health heartbeat, camera adds vignette. The health system broadcasts once; three systems respond.
  • "Settings changed" -- volume sliders, resolution, language. Multiple systems apply the new values independently.

How It Works

Publisher                    Message Bus                    Listeners
   |                            |                              |
   |  Broadcast(Channel, Data)  |                              |
   |--------------------------->|                              |
   |                            |  Channel: "PGX.Audio.Music"  |
   |                            |  Payload: {TrackTag, Fade}   |
   |                            |                              |
   |                            |----> Listener A (Audio)      |
   |                            |----> Listener B (UI)         |
   |                            |----> Listener C (Analytics)  |
   |                            |                              |
   |                            |  (nobody on this channel?    |
   |                            |   message is just recorded   |
   |                            |   in history buffer)         |

Channels are GameplayTags. Any tag in the PGX.Message.Channel.* hierarchy (or any custom tag) can be a channel. This means channels are extensible without code changes -- create a new tag, and you have a new channel.

Payloads are typed structs. The publisher broadcasts a specific struct type, and listeners declare what type they expect. Type safety is enforced at registration time, not at broadcast time -- a listener that registered for struct type A will not receive a broadcast of struct type B on the same channel.

History is a circular buffer per channel. Recent messages are retained so that inspector panels and debug tools can show what was broadcast. The buffer size is configurable per project.

Partial tag matching is an opt-in feature. A listener registered on PGX.Audio with partial matching enabled will receive broadcasts on PGX.Audio.Music, PGX.Audio.SFX, and any other child tag. This is useful for aggregate monitoring (e.g., an inspector that watches all audio events).

Blueprint Integration

The message bus exposes two Blueprint workflows:

  1. Simple broadcast -- a single node that sends a base message with a sender reference. Covers 90% of Blueprint use cases where the receiver just needs to know "who sent what on which channel."

  2. Typed async listener -- a latent node that suspends the Blueprint graph until a message arrives on the specified channel. The output pins include the channel tag, the sender reference, and a typed payload pin that morphs based on the selected struct type. When the struct type is left empty, it receives all messages on the channel regardless of payload type.


Bus 2: Event Handler (1:1 Action Execution)

When to Use

A specific event should trigger a specific behavior, but you want to configure which behavior through data rather than code. The key distinction from the message bus: the caller expects exactly one handler to execute, not a fan-out to N listeners.

Examples:

  • "Player picked up item X" -- resolve which handler processes this pickup based on item category (consumable, weapon, quest item). Different handlers, different logic.
  • "Auto-save trigger" -- a single handler runs the save sequence, possibly checking conditions first (is the player in a safe zone? is another save in progress?).
  • "UI ceremony: show victory screen" -- one handler orchestrates the specific animation sequence, audio cue, and input blocking for this ceremony.

How It Works

+-------------------+     +------------------------+     +-------------------+
|   Game Code       |     |    Event Handler Bus   |     |    Handler        |
|                   |     |                        |     |   (resolved)      |
| Execute(EventTag, |---->|  1. Lookup EventTag    |     |                   |
|   Context,        |     |     in DataTable rows  |     |                   |
|   Payload)        |     |                        |     |                   |
|                   |     |  2. Found? Resolve     |---->| CanExecute(Ctx)?  |
|                   |     |     handler class      |     |   |               |
|                   |     |                        |     |   +--yes--> Execute(Ctx)
|                   |     |  3. Lifecycle:         |     |   |               |
|                   |     |     Singleton? reuse   |     |   +--no---> skip  |
|                   |     |     Cached? LRU check  |     |                   |
|                   |     |     Ephemeral? new     |     |                   |
|                   |     |                        |     |                   |
|                   |     |  4. Record telemetry   |     |                   |
|                   |     |  5. Fire delegates     |     |                   |
+-------------------+     +------------------------+     +-------------------+

DataTable-driven resolution: The mapping from event tag to handler class is defined in UE5 DataTables, not in code. A designer can add, remove, or swap handlers by editing a spreadsheet-like table in the editor. No recompilation needed.

Each DataTable row contains:

Field Purpose
Event Tag The GameplayTag that triggers this handler
Handler Class Which class to instantiate (soft reference)
Lifecycle Singleton, Cached, or Ephemeral
Category Tag Grouping for telemetry and budget tracking
Enabled Toggle without removing the row
Expected Payload Optional type hint for editor validation

Three lifecycle modes control instance management:

Singleton:    One instance for the entire session. Never destroyed.
              Use for: global state managers, persistent services.

Cached:       Reused across calls, evicted by LRU when the cache
              reaches its configured maximum.
              Use for: frequently-fired handlers with setup cost.

Ephemeral:    New instance per execution, destroyed immediately after.
              Use for: stateless one-shot behaviors.

Condition checking: Handlers can implement a CanExecute() check that runs before the main Execute(). This enables data-driven preconditions -- "only execute if the player is not in a loading screen" or "only execute if no save is in progress." Condition handlers are themselves resolved through the same tag system.

Recursive dispatch: A handler can call ExecuteSubHandler() to trigger another handler from within its own execution. A configurable depth limit (default: 8) prevents infinite chains.

Telemetry and blackbox: Every execution is recorded -- tag, duration, success or failure, error message. A circular buffer (configurable size, default 256) keeps the most recent records for debugging. Per-tag statistics (execution count, average time, failure rate) are tracked continuously.

The Bridge Pattern

Built-in handlers (auto-save, config flush, audio ceremonies) need to communicate with L2 systems. They cannot import L2 modules directly -- that would violate star topology. Instead, they use the message bus:

EventHandler resolves "AutoSave" handler
  |
  +-> Handler.Execute()
        |
        +-> BroadcastMessage("PGX.Message.System.AutoSave", payload)
              |
              +-> Save system listener picks up the message
                    |
                    +-> Save system triggers auto-save sequence

This is the "Message Bridge" pattern. The EventHandler bus resolves what to do. The Message bus delivers the instruction to who does it. Neither bus knows about the other's consumers.


Bus 3: Data Registry (Persistent Data Store)

When to Use

Systems need to discover, query, and load assets that other systems created. The Registry is not about runtime events -- it is about finding and accessing shared data.

Examples:

  • Audio needs to find sound definitions registered by the game project.
  • Save needs to discover which save game classes exist for each context.
  • A weapon system needs to resolve item definitions by GameplayTag without knowing which plugin registered them.
  • An editor tool needs to list all Data Assets of a given type across the project.

How It Works

+-------------------+     +------------------------+     +-------------------+
|  System A         |     |    Data Registry       |     |  System B         |
|  (at init time)   |     |                        |     |  (at query time)  |
|                   |     |  Databases:            |     |                   |
| CreateDatabase(   |---->|    "Weapons" [10 items] |<----| ResolveAsset(     |
|   "Weapons",      |     |    "Audio"  [47 items] |     |   "Weapons",      |
|   WeaponDefClass) |     |    "Save"   [ 3 items] |     |   "Sword.Fire")   |
|                   |     |                        |     |                   |
| RegisterAsset(    |---->|  Primary Index:         |     | --> returns the   |
|   "Weapons",      |     |    ItemTag -> Entry     |     |    WeaponDef DA   |
|   SwordFireDA)    |     |    O(1) lookup          |     |    (loaded)       |
|                   |     |                        |     |                   |
|                   |     |  Category Index:        |     | FindByCategory(   |
|                   |     |    CategoryTag -> Items  |<----| "Weapons",       |
|                   |     |    O(1) bucket lookup   |     |   "Swords")       |
+-------------------+     +------------------------+     +-------------------+

Typed databases: Each database is created with an expected asset class. The registry validates that registered assets match the expected type. You cannot accidentally register an audio definition in the weapons database.

Dual indexing: Primary index provides O(1) lookup by item tag. Secondary index provides O(1) bucket access by category tag, then linear scan within the category. For a database of 500 items across 20 categories, a category query touches ~25 items instead of 500.

Auto-discovery: When a system creates its database, it can opt into automatic AssetRegistry scanning. The registry finds all assets of the specified class, extracts their metadata (tag, version, display name), and registers them without manual calls.

Lazy loading: The registry stores soft references by default. No asset is loaded into memory until explicitly requested. Three access patterns are available:

GetSoftReference()  --> Returns the path. No load. For UI display.
RequestLoad()       --> Async load with callback. For gameplay.
ResolveAsset()      --> Synchronous load. For init-time or editor.

Cache management: Once loaded, an asset is cached as a strong reference. The cache can be invalidated per-database to release memory. Invalidation does not unregister entries -- the registry still knows about the assets, but they would need to be loaded again on next access.

DataTable-first authoring (v2.0): For large datasets (hundreds of items), the registry supports a catalog pattern where entries are defined in DataTables. A Registry Definition asset groups multiple DataTables into a logical database with conflict resolution policies (first wins, last wins, or fail on conflict).


How the Three Buses Work Together: A Level Transition

Here is a concrete example showing all three buses cooperating during a level transition, which is one of the most complex runtime operations in a game.

STEP 1: Game code requests level change
        Game --> LevelFlow: "Go to Level_Forest"

STEP 2: LevelFlow broadcasts via MESSAGE BUS
        LevelFlow --> Message("PGX.LevelFlow.TransitionStarted", {TargetLevel})
             |
             +--> Loading listener: show loading screen
             +--> Audio listener: fade out current music, start loading ambience
             +--> Save listener: trigger checkpoint auto-save

STEP 3: Save auto-save triggers via EVENT HANDLER
        Save --> EventHandler: Execute("PGX.Event.AutoSave", context)
             |
             +--> AutoSave handler resolves from DataTable
             +--> Handler.CanExecute() checks "not already saving"
             +--> Handler.Execute() broadcasts Message("PGX.Message.System.AutoSave")
             +--> Save system listener performs actual save

STEP 4: Loading screen queries DATA REGISTRY for visual config
        Loading --> Registry: ResolveAsset("LoadingScreens", "Forest")
             |
             +--> Returns the Loading Profile DA for Forest level
             +--> Loading applies: background image, tips, progress bar style

STEP 5: PSO warm-up begins, broadcasts progress via MESSAGE BUS
        PSO --> Message("PGX.PSO.Progress", {Compiled: 47, Total: 120})
             |
             +--> Loading listener: update combined progress bar
                  (level load 60% + shader compile 39% = 50% combined)

STEP 6: Level load completes, LevelFlow broadcasts via MESSAGE BUS
        LevelFlow --> Message("PGX.LevelFlow.TransitionCompleted", {Level})
             |
             +--> Loading listener: begin fade-out sequence
             +--> Audio listener: cross-fade to Forest ambient + music
             +--> GameFlow listener: update state tags

Notice:

  • No L2 plugin imports another L2 plugin.
  • Message bus handles all 1:N fan-out (steps 2, 5, 6).
  • Event handler resolves the specific auto-save behavior (step 3).
  • Data registry provides the loading screen configuration (step 4).
  • The handler in step 3 uses the message bridge to reach the save system.

Choosing the Right Bus

Question Bus
"I want to notify anyone who cares" Message
"I want exactly one handler to execute" EventHandler
"I want to find and load a data asset by tag" Data Registry
"The recipient should be configurable via DataTable" EventHandler
"Multiple systems should react independently" Message
"I need O(1) lookup of persistent data" Data Registry
"I need telemetry on how often an action runs" EventHandler
"I need to broadcast to channels I will define later" Message
"Designers should control which behavior runs, not coders" EventHandler

Tag Hierarchy as Namespace

All three buses use GameplayTags for addressing. The tag hierarchy acts as a namespace that prevents collisions and enables partial matching:

PGX.Message.Channel.*       Message bus channels (framework)
PGX.Message.System.*        Internal framework messages
PGX.Event.*                 Event handler triggers
PGX.Event.Condition.*       Pre-condition checks
PGX.Event.Audio.*           Audio ceremony events
PGX.Registry.Database.*     Database identifiers
PGX.Registry.Category.*     Category groupings
Game.Message.*              User-defined message channels (convention)
Game.Event.*                User-defined event tags (convention)

Tags are extensible without code changes. A designer adds a new row to a GameplayTag data file, and it becomes available as a channel, an event trigger, or a database key. The framework does not need to know about game-specific tags in advance.


Performance Characteristics

Operation Bus Cost
Broadcast to N listeners Message O(N)
Register/unregister listener Message O(1)
History query (last K messages) Message O(K)
Resolve handler by tag EventHandler O(1) hash
Execute handler EventHandler O(1) + work
Cache hit (Singleton/Cached) EventHandler O(1)
Lookup asset by tag Data Registry O(1) hash
Category query Data Registry O(bucket)
Auto-discovery scan Data Registry O(N assets)

None of these operations involve reflection, serialization, or network calls. They are in-process, in-memory operations suitable for game-thread use.


Key Takeaways

  1. Three buses, three patterns. Message for fan-out notification. EventHandler for data-driven behavior dispatch. Registry for persistent asset lookup. Each has a clear purpose; mixing them creates unnecessary coupling.

  2. GameplayTags everywhere. Channels, event triggers, database keys, and category labels are all tags. This gives extensibility without code changes and partial matching for aggregate monitoring.

  3. The bridge pattern preserves topology. Built-in handlers use Message broadcasts to reach L2 systems. The handler resolves what to do; the message delivers it to who does it. No L2-to-L2 runtime imports.

  4. Data-driven resolution. EventHandler mappings live in DataTables that designers edit. Adding a new game event is a data change, not a code change.

  5. Everything is observable. Message history, handler telemetry, blackbox records, registry statistics -- all three buses expose diagnostics through console commands and editor inspector panels.


Resumen en Espanol

PGX implementa tres buses de comunicacion complementarios en su capa L1 Core para resolver el problema de comunicacion inter-plugin sin romper la topologia estrella. El Message Bus es pub/sub 1:N con canales basados en GameplayTags, payloads tipados, historial circular, y matching parcial -- es el unico mecanismo permitido para comunicacion L2-a-L2 en runtime. El Event Handler es resolucion de comportamiento 1:1 basada en DataTables: un GameplayTag mapea a una clase handler con tres modos de lifecycle (Singleton, Cached, Ephemeral), condiciones pre-ejecucion, telemetria, y un blackbox recorder. El Data Registry es un almacen central de assets indexado por GameplayTag con lookup O(1), indice secundario por categoria, carga lazy via soft references, y auto-discovery por AssetRegistry. Los handlers built-in usan el patron "Message Bridge" para comunicarse con sistemas L2 sin importarlos directamente: el EventHandler resuelve que hacer, el Message Bus entrega la instruccion a quien lo hace. Los tres buses usan GameplayTags como namespace extensible sin cambios de codigo. El ejemplo de transicion de nivel muestra los tres buses cooperando: Message notifica inicio/fin, EventHandler resuelve el auto-save, y Registry proporciona la configuracion visual de la pantalla de carga.

Clone this wiki locally