Files
ue-toolchain/plugins/ue-design-skills/skills/ue-gameplay-messaging/SKILL.md
T
MagentaDolphin ecd87ac96d feat(skills): ship ue-design-skills bundle, licensing and delivery gate
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/
directory. Content moved out of the LyraResearch archive and depersonalised:
addresses stay in the archive, recipes ship.

- plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the
  six required fields; catalog.json as the harness-neutral source of truth and
  .claude-plugin/ as one adapter over it.
- _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a
  declared file cannot silently miss the line rules.
- ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split
  licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata).
- LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as
  the material the licence decision grew from.

Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their
fixtures with a clean baseline and 2 root files reaching the line rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:48:55 +07:00

15 KiB

name, description
name description
ue-gameplay-messaging Design, implement or review a typed tag-addressed pub/sub message bus in Unreal Engine: subsystem scope and lifetime, struct type checks, exact versus hierarchical channel matching, listener handles and ownership, parity between the C++ and Blueprint APIs, the local-versus-network boundary, and migration from direct dependencies. Use when decoupling gameplay from UI, feedback, telemetry or processors, or when debugging duplicate, stale, mismatched or wrongly replicated messages.

UE gameplay messaging

The invariant:

Messages announce facts inside one process. They do not own state, ordering, persistence, authority or replication.

Measured patterns from a reference product: patterns. Seventeen detection recipes for silent bus failures: failure modes.

Read the failure modes before adopting a bus implementation. Each entry names the mechanism, why it stays silent, why the obvious check misses it, and a command you can run against your own tree.

Related skills: ue-gameplay-tag-governance, ue-ui-architecture, ue-gas-architecture, ue-multiplayer-authority, ue-data-driven-architecture.

Method, not architecture — how to check any claim in this bundle before repeating it: ue-evidence-discipline.


1. Choose the bus scope explicitly

A bus implemented as a GameInstance subsystem:

  • is shared by every world belonging to that GameInstance;
  • survives map travel;
  • is destroyed at GameInstance teardown;
  • is local to one process;
  • does not replicate.

That scope suits:

  • gameplay event → HUD and feedback;
  • local processors and telemetry;
  • decoupling feature plugins from the base module;
  • cross-system notifications where no receiver owns the sender.

It is unsuitable as the source of truth for:

  • inventory, team or score state;
  • guaranteed ordered workflows;
  • cross-process communication;
  • persistence;
  • anything an RPC should carry.

The state-versus-event test

Ask: "if a listener subscribes one second late, must it know the current value?"

  • yes → replicated property or state component, plus a change notification;
  • no → a transient message may be appropriate.

"An elimination occurred" is an event. "The current team score" is state. A bus that is asked to answer the second question will appear to work for every listener that happened to be subscribed early, which is every listener during development.


2. Storage model and handle identity

map: channel tag -> channel listener list

channel listener list
├─ monotonically increasing handle ID (local to THIS channel)
└─ listeners[]
   ├─ callback
   ├─ weak expected struct type
   ├─ had-valid-type flag
   ├─ handle ID
   └─ exact / partial match

Registration returns a handle carrying the subsystem, the channel, and the channel-local ID. The handle is the ownership receipt. The caller, or an RAII wrapper, retains it and unregisters when its lifecycle ends.

The handle identity rule

If IDs are allocated per channel, then every internal removal must use both the registration channel and the ID. Never search another channel by ID alone.

This is not a stylistic point. A hierarchical broadcast walks several channels in one call; code written inside that walk has two plausible variables in scope — the channel the message was sent on, and the channel currently being visited — and they are the same value only on the first iteration. Recipe: MB-01.


3. Typed broadcast

Template the C++ API and resolve the struct type at compile time on both sides. Store the expected type at registration.

On broadcast, the payload is deliverable when:

broadcast struct IsChildOf listener expected struct

which permits a listener registered for a base struct to receive derived payloads, and makes exact equality a special case.

Type mismatch policy

  • log the channel, the sent struct and the expected struct;
  • skip that listener;
  • never reinterpret incompatible bytes;
  • never abort the rest of the broadcast;
  • add development-only assertions and tests.

A path that disables the type check entirely — registering with a null expected type — must be private and documented. If it is reachable from Blueprint through an empty type pin, it is not private. Recipe: MB-03.

Payload lifetime

The bus should pass a pointer to the caller's stack object rather than copying. That is the right performance decision and it creates one hard rule: a listener may not retain the payload pointer after its callback returns. Copy what you need. Recipe: MB-04.


4. Exact versus hierarchical matching

Channels are tags, so a broadcast can walk from the sent channel up through its parents:

Event.Combat.Elimination
→ Event.Combat
→ Event

On the first tag every listener is called regardless of match type; on ancestors, only listeners that registered for partial matching.

Default to exact. It minimises accidental traffic and type ambiguity. Use partial matching for analytics, debug logging, and aggregators that genuinely want a subtree.

The hierarchy type gate

Every descendant channel observed through one parent must share a compatible base payload struct. Otherwise partial matching converts a semantic hierarchy into a stream of type errors. Document the payload contract at each parent tag — this is the same rule as parent-tag contracts in ue-gameplay-tag-governance, applied to payload types instead of semantics.

Cost

Walking parents costs one map lookup per tag depth on every broadcast, even when no partial listeners exist anywhere. If the listener array is copied per non-empty bucket for mutation safety, that copy includes a callable object per listener, which may allocate. On a high-frequency channel this is measurable. Recipe: MB-16.


5. Broadcast safely under mutation

Callbacks may unregister themselves or others. Copy the listener array before iterating, then invoke the copy — and then define the resulting semantics explicitly, because they are observable:

  • a listener removed during this broadcast may still receive this one message;
  • a listener added during this broadcast receives from the next one;
  • ordering is not guaranteed and must not be assumed.

Do not let registration order become an accidental priority contract. If removal uses a swap-with-last strategy, order changes as a side effect of unrelated unsubscribes. Recipe: MB-11.


6. Registration forms and ownership

Provide three forms: a raw callable, a UObject member bound through a weak reference, and a parameter struct for match type and future options.

The trap is that these three are not equally expressive. If the most convenient form — the UObject member overload — cannot express the match type, then the match type is effectively unavailable to the code that would most benefit from it, and a project can ship a fully implemented hierarchical matching feature that nothing uses. Recipe: MB-05.

Ownership patterns

Owner Retain Release
C++ component handle as a member EndPlay / deinit
subsystem handles in a container Deinitialize
feature action per activation context on deactivation
async operation inside the action object completion, cancel or destruction

A weak callback is not an unsubscription. It prevents a call into a dead object; the listener record and its callable stay in the map, and accumulate. Recipe: MB-08.

Teardown must not assert

An accessor that asserts when there is no world is the wrong tool inside EndPlay, NativeDestruct or any other teardown path, where the world may already be gone. Prefer unregistering through the handle, which resolves the subsystem weakly and no-ops when it is dead. Recipe: MB-09.


7. Blueprint parity

A wildcard struct pin lets Blueprint send arbitrary payloads while C++ receives a type plus bytes. An async listen node stores the channel, payload type and match type, registers on activation, copies the payload into typed output storage, and unregisters on cancel or destruction.

Keep the two APIs semantically identical

This is the rule most often broken, because the two paths are written at different times by different people:

  • if C++ accepts derived payloads, Blueprint must too;
  • if C++ logs a mismatch, Blueprint must log it as well.

A silent Blueprint skip produces "the node never fires", which every developer first investigates as a wrong tag. Pick one contract, document it, and test both APIs against the same matrix. Recipe: MB-02.


8. Messaging is not replication

No broadcast crosses the network. For networked facts the shape is always:

server-authoritative state change
→ replicated property / RPC / replicated fast array
→ receiving side broadcasts a LOCAL message
→ local UI, feedback and processors react

This keeps transport and local fan-out separate, and it is the only arrangement in which a late joiner is correct: the state replicates, and the message is merely a notification that it changed.

Never treat a client-local message as proof of a server-side fact. A message may request UI behaviour. It is not evidence of damage, score, ownership or team membership. Recipe: MB-15.


9. Payload design

A message struct is a compact immutable fact:

verb / channel context
instigator
target
magnitude
context tags
optional IDs or weak references

Rules:

  • prefer IDs and weak references over large object graphs;
  • carry enough context that a listener needs no callback into the sender;
  • do not embed mutable ownership;
  • initialise every field — a payload never passes through serialisation, so there are no free defaults;
  • version deliberately if the same struct is persisted or replicated elsewhere;
  • one parent channel hierarchy shares one base payload.

Do not send commands disguised as events

bad:   message "please give the player a weapon"
good:  service call performs the grant; message "weapon equipped"

A command has exactly one accountable handler and a failure result. An event has zero or many listeners and no failure path at all. Sending a command as an event means nobody is responsible for it happening, and nobody notices when it does not. Recipe: MB-14.


10. Channel naming and the string contract

Name channels so bus traffic is distinguishable from other tags:

<System>.<Domain>.Message[.<Detail>]

The word in the middle separates channels from the rest of the vocabulary and gives a natural place for a partial-match subscription.

The channel string is the public contract, and the compiler does not check it. If publisher and subscribers each declare their own file-local tag constant for the same string, the engine deduplicates by string and everything works — until one of them is edited. Put shared channels in one header owned by the publisher. Recipe: MB-10.


11. Migration recipe

When decoupling a direct dependency:

  1. Name the fact in the past tense.
  2. Define its payload struct and channel tag.
  3. Keep authoritative mutation in the existing owner.
  4. Broadcast only after the mutation succeeds.
  5. Move independent side effects to listeners, one at a time.
  6. Retain and release every handle.
  7. Test zero, one and multiple listeners.
  8. Verify no late subscriber needs the state it missed.

Good first migrations: combat event to HUD feed and audio; inventory delta to a notification widget; phase transition to local presentation.

Do not migrate tight request/response code merely to avoid a function reference.


12. Review checklist

  • Message is an event, not durable state and not a command.
  • Subsystem scope matches the required lifetime.
  • Payload is a struct with a documented contract and initialised fields.
  • Parent channels have a compatible payload hierarchy.
  • Exact matching is the default; partial matching is justified per site.
  • Registration returns a handle and the caller retains it.
  • UObject callbacks bind weakly.
  • Unregistration happens on every lifecycle exit, including teardown paths.
  • Teardown does not call an asserting accessor.
  • Broadcast tolerates mutation from inside a callback.
  • C++ and Blueprint type semantics are identical and both log mismatches.
  • No listener ordering is assumed without an explicit contract.
  • Replication and RPC transport are separate from local fan-out.
  • Shared channel tags live in one owned header.

13. Tests

Type matrix

  • exact same struct;
  • derived broadcast to base listener;
  • base broadcast to derived listener — rejected;
  • unrelated struct — logged and skipped;
  • expired expected struct type;
  • C++ and Blueprint produce the same result for every row above.

Channel matrix

  • exact listener, exact broadcast;
  • parent with partial matching receives a child broadcast;
  • parent with exact matching does not;
  • sibling does not;
  • a nested parent chain invokes each intended listener exactly once.

Lifecycle

  • unregister before broadcast;
  • unregister from inside a callback;
  • owner destroyed without an explicit unregister;
  • async node cancelled;
  • map travel with a GameInstance-scoped bus — listener count returns to baseline;
  • feature activation and deactivation cycle, twice;
  • duplicate registration is either documented or detected.

Network boundary

  • a server-side broadcast is invisible to clients;
  • replicated transport triggers exactly one client-local rebroadcast;
  • a listen-server process does not process both the server and client path.

14. When not to add a bus

Use a direct interface, service or delegate when:

  • exactly one receiver must handle the call;
  • the caller needs a return value or a failure reason;
  • ordering is part of correctness;
  • sender and receiver share ownership and lifetime;
  • the state must be queryable after the event.

A bus reduces coupling only when the fact genuinely has independent, optional observers. Used anywhere else it hides control flow and weakens correctness — it converts a compile-time dependency into a runtime one that nothing verifies.


Provenance

The findings behind the failure modes come from a line-by-line source audit of the gameplay message router plugin shipped with Epic's Lyra Starter Game on Unreal Engine 5.6, together with its use in that project. The plugin's runtime module is roughly a thousand lines and was read in full, which is why several claims here are negative results ("there is no networking in this module") stated with confidence rather than hedged.

Source addresses stay in the research archive that produced this skill; what ships is the detection recipe. Each entry carries a stable identifier (MB-01, MB-02, …) that resolves back to the audited location in that archive.

Evidence boundary

The measured material refers to one plugin at one engine version in one workspace. Blueprint graphs were not read — they are binary — so every statement about Blueprint usage in that project is bounded by what C++ could show, and is marked as open rather than concluded. Re-run the recipes against your own tree before trusting any specific claim.