Skip to content

Message System

PlatanoGames edited this page Feb 26, 2026 · 3 revisions

Message System v1.0 -- Typed Pub/Sub for Decoupled Architecture

The only legal way for plugins to talk to each other. By design.


The Problem

PGX enforces a strict architectural rule: no plugin may depend on any other plugin at runtime. All plugins depend on the core, and nothing else. This is the star topology -- one hub, many spokes, no spoke-to-spoke connections.

                  +-------+
          +------>| Core  |<------+
          |       +-------+       |
          |           ^           |
          |           |           |
      +------+   +------+   +------+
      | Save |   | Audio|   | Load |   ... (21 plugins)
      +------+   +------+   +------+
          ^           ^           ^
          |           |           |
          X-----------X-----------X
                No direct connections

This topology guarantees that plugins can be added, removed, or replaced independently. A project that does not need the Audio system can exclude it without breaking anything else. A project that replaces the Save system with a custom implementation does not invalidate any other plugin's code.

But systems still need to communicate. Real examples from a shipping game framework:

  • The Save system needs to know when game state changes so it can mark the save as dirty.
  • The Audio system needs to know when a loading screen appears so it can mute gameplay audio.
  • The Event Handler needs to trigger auto-save, which lives in the Save system.
  • Game code needs to broadcast custom events that multiple systems react to.

Without a communication mechanism, the star topology becomes an isolation ward. Every system operates in its own bubble, and cross-system behavior requires the game code to manually orchestrate everything.

The naive solution -- a global event bus with string-based channels and untyped payloads -- works until the first production debugging session. When every message is void* and every channel is a string that nobody validates, the debugging experience is "something fired an event with data that might be what I expected, or might be garbage from a typo in the channel name."


How PGX Solves It

The Message System provides a typed pub/sub bus that is the sole permitted mechanism for cross-plugin runtime communication. It lives in the core module, available to all plugins.

The design is based on Epic's Lyra messaging pattern -- the same architecture that ships in Lyra for cross-system communication. PGX extends it with message history, channel statistics, partial tag matching, and configuration via Data Asset.


Architecture

  Publisher                        Message Bus                      Listeners
  (any code)                   (GameInstance scope)                (any code)

  +-----------+                +-------------------+             +-----------+
  | System A  |---broadcast--->| Channel: X        |---notify--->| System B  |
  |           |    payload T   |   Listener 1 (T)  |             |           |
  +-----------+                |   Listener 2 (T)  |             +-----------+
                               |   Listener 3 (any)|
  +-----------+                +-------------------+             +-----------+
  | System C  |---broadcast--->| Channel: Y        |---notify--->| System D  |
  |           |    payload U   |   Listener 1 (U)  |             |           |
  +-----------+                +-------------------+             +-----------+
                               |                   |
                               | History Buffer    |
                               | Channel Stats     |
                               +-------------------+

The flow:

  1. A publisher broadcasts a typed payload on a GameplayTag channel.
  2. The bus iterates all listeners registered on that channel.
  3. Each listener receives the channel tag and the typed payload.
  4. The broadcast is recorded in the history buffer.
  5. Channel statistics are updated.

Type safety:

In C++, broadcasts and listeners are template-typed. The compiler ensures that a listener registered for payload type T only compiles if T matches the broadcast. A listener for type "WeaponFiredPayload" will not accidentally receive a "DoorOpenedPayload."

In Blueprint, the system provides two broadcast mechanisms:

  • A simple broadcast that sends a basic message with a sender reference -- covers 90% of use cases where the listener only needs to know "who sent this."
  • A struct broadcast with wildcard pins that adapts to any payload struct type -- for the 10% of cases that need typed data.

An async Blueprint node provides the listener side: connect a payload type, and the output pins morph to match. When a matching message arrives, the node fires.


Capabilities

Channel-Based Communication

Channels are GameplayTags, not strings. This means:

  • Auto-complete in the editor. No typos.
  • Hierarchical organization. A listener on "Audio" receives messages from "Audio.Music" and "Audio.SFX" (when partial matching is enabled).
  • Validation at edit time. A tag that does not exist in the tag table is flagged immediately.

Partial Tag Matching

When enabled (default), a listener registered on a parent tag receives broadcasts on any child tag.

  Listener registered on: PGX.Message.Audio
       |
       +-- Receives: PGX.Message.Audio.MusicChanged       (child match)
       +-- Receives: PGX.Message.Audio.VolumeChanged       (child match)
       +-- Receives: PGX.Message.Audio                     (exact match)
       +-- Does NOT receive: PGX.Message.Save.Completed    (different branch)

This enables broad listeners ("tell me about any audio event") and specific listeners ("tell me only about music changes") on the same bus with no additional infrastructure.

Partial matching can be disabled per-project via the config Data Asset for projects that want strict exact-match semantics.

Message History

Every broadcast is recorded in a circular buffer with configurable capacity (default: 100 messages per channel). History records include:

  • Channel tag
  • Payload type name
  • Timestamp
  • Number of listeners notified

This history is queryable at runtime and visible in the inspector. When debugging "did that message actually fire?", the answer is in the history buffer, not in a print statement.

Channel Statistics

The system tracks aggregate statistics:

  • Total broadcasts across all channels
  • Total listeners notified
  • Number of active channels
  • Number of active listeners
  • History buffer utilization

These statistics feed the inspector and are available via console commands.


Configuration

A single config Data Asset controls system behavior:

Setting Default Purpose
Max Message History 100 Messages retained per channel in the history buffer
Log Broadcasts false Log every broadcast at verbose level (useful for debugging, noisy in production)
Log Registrations false Log every listener register/unregister
Enable Partial Matching true Listeners on parent tags receive child tag broadcasts

Zero-config default: if no config DA exists, the system initializes with the defaults above. Partial matching on, 100-message history, no verbose logging.


Editor Tooling

Message Inspector

  +---------------------------------------------------------------+
  | PGX Message Inspector                                         |
  +---------------------------------------------------------------+
  |                                                               |
  | Active Channels:  7         Active Listeners:  23             |
  | Total Broadcasts: 1,847     History Buffer:    42%            |
  |                                                               |
  | Channel                    | Listeners | Last Broadcast       |
  | PGX.Message.Save.Dirty     | 3         | 0.4s ago            |
  | PGX.Message.Audio.Music    | 2         | 1.2s ago            |
  | PGX.Message.GameFlow.State | 5         | 3.7s ago            |
  | PGX.Message.Loading.Start  | 4         | 12.1s ago           |
  | ...                        |           |                     |
  |                                                               |
  | --- Message History (last 20) ---                             |
  | [14:32:01.847] Audio.Music     MusicStatePayload    (2 recv) |
  | [14:32:01.203] Save.Dirty      BasicMessage         (3 recv) |
  | [14:31:59.991] GameFlow.State  FlowStatePayload     (5 recv) |
  |                                                               |
  | [Broadcast Test] Channel: [________] [Send]                   |
  +---------------------------------------------------------------+

The inspector shows:

  • All active channels with listener counts
  • Recent message history with timestamps, types, and receiver counts
  • Aggregate statistics
  • A test broadcast button for debugging

Console Commands (4)

  pgx.message.status      Total channels, listeners, history size
  pgx.message.channels    All active channels with listener counts
  pgx.message.history     Recent history (optional channel filter)
  pgx.message.broadcast   Test-broadcast on a given channel

Integration Points

The Sole Cross-Plugin Communication Mechanism

This is not optional. The Message System is the ONLY way for L2 plugins to communicate with each other at runtime. This is enforced by architecture (no L2 module can have a build dependency on another L2 module) and by convention (code review rejects direct cross-plugin calls).

The practical pattern:

  Save System                     Audio System
  (L2 Plugin)                     (L2 Plugin)
       |                               ^
       |                               |
       v                               |
  [Broadcast on                   [Listens on
   PGX.Message.Save.Completed]     PGX.Message.Save.Completed]
       |                               ^
       |                               |
       +---------> Message Bus --------+
                   (L1 Core)

Neither system knows the other exists. Neither system has a build dependency on the other. If the Audio system is removed from the project, the Save system still broadcasts, and the messages go to zero listeners. No errors, no crashes, no dangling references.

With the Event Handler System

The Event Handler's built-in handlers use the Message System to communicate with L2 systems. The AutoSave handler does not call the Save subsystem directly -- it broadcasts a message that the Save system listens to. This preserves the star topology even for framework-internal behaviors.

With Game Code

The Message System is the primary mechanism for game-level pub/sub events. Custom game channels work identically to framework channels. A "Player.Respawned" channel, a "Quest.Completed" channel, a "Shop.Purchase" channel -- all use the same infrastructure.


Event Notifications (4 Delegates)

Event When It Fires
Message Broadcast Every broadcast (includes channel tag and payload type name)
Listener Registered A new listener subscribes to a channel
Listener Unregistered A listener unsubscribes from a channel
Message Broadcast (Native) Every broadcast (includes struct type and timestamp, for C++ monitoring)

Why This Matters

The Message System is the architectural keystone that makes star topology viable. Without it, decoupled plugins cannot communicate, and the architecture devolves into either monolithic coupling or manual orchestration in game code.

The Lyra-based design is not an accident. Epic faced the same problem in Lyra -- modular gameplay features that need to communicate without direct dependencies -- and solved it with a typed message bus on GameplayTag channels. PGX takes that proven pattern, extends it with history and statistics for production debugging, and makes it configurable via Data Asset.

For framework users, the Message System is invisible infrastructure. They broadcast when something happens, listen when they care about something, and never think about how the message gets from A to B. That transparency is the goal.

For framework developers, the Message System is the enforcement mechanism for architectural integrity. Every time two plugins need to talk, the answer is always the same: message bus. No exceptions. No special cases. One pattern, universally applied.

Clone this wiki locally