Use Opsive’s EventHandler when one gameplay system must react to a UCC character, item, or object change without holding a direct reference to the component that caused it. It is best for notifications with a stable name, target, and typed parameter contract; use a normal method call when the sender already owns the receiver and needs an immediate result.

Choose an event scope

EventHandler stores subscriptions in static tables and exposes static methods. You do not need to add an EventHandler component to every publisher.

Scope Call shape Use it when
Object-scoped Pass a target object first The event belongs to one character, item, camera, or impacted object. This is the normal UCC pattern.
Global Omit the target object Every listener should receive one project-wide signal regardless of scene object.

For an object-scoped event, the target is the publisher’s key, not necessarily the listener’s GameObject. UCC commonly uses the character GameObject for ability and inventory events, the Health GameObject for death events, and the impacted GameObject for impact events. Register, execute, and unregister with the same target instance.

Global events are easier to reach but harder to own. They remain registered across scene changes until explicitly unregistered or until the event tables are reset at Play Mode subsystem registration. Prefer an object-scoped event unless the signal is genuinely global.

Use one typed contract

The event name, target, parameter count, parameter types, and parameter order form one contract. Registration, execution, and unregistration must use that same contract.

These UCC 3.2.0 events illustrate the pattern:

Scenario Target Typed signature
A Health component dies The Health GameObject OnDeath(Vector3 position, Vector3 force, GameObject attacker)
A character Ability starts or stops The character GameObject OnCharacterAbilityActive(Ability ability, bool active)
A Character Item is equipped The character GameObject OnInventoryEquipItem(CharacterItem item, int slotID)
An item impact reaches an object The impacted GameObject OnObjectImpact(ImpactCallbackContext context)

Use the event names reference to find the target concept and expected parameters for built-in events. When an integration defines an additional event, verify its exact signature against the installed integration source.

Do not reuse one event name with unrelated signatures. Opsive Shared can optionally allow multiple signature types for some one-, two-, and three-parameter registrations, but the normal and clearer design is one name per typed contract.

Follow the listener lifecycle

Every subscription has three parts:

  1. Register when the listener becomes interested.
  2. Let the publisher execute the event on the agreed target.
  3. Unregister with the same target, name, generic types, and delegate before the listener stops being interested.

Use Awake and OnDestroy for a listener that should remain subscribed for its complete component lifetime. Use OnEnable and OnDisable when disabling or pooling the listener should pause the subscription. Do not combine both patterns for the same callback.

This pooled-friendly component listens for the Health event on its own GameObject:

using UnityEngine;
using EventHandler = Opsive.Shared.Events.EventHandler;

public sealed class DeathReporter : MonoBehaviour
{
    private void OnEnable()
    {
        EventHandler.RegisterEvent<Vector3, Vector3, GameObject>(
            gameObject, "OnDeath", OnDeath);
    }

    private void OnDisable()
    {
        EventHandler.UnregisterEvent<Vector3, Vector3, GameObject>(
            gameObject, "OnDeath", OnDeath);
    }

    private void OnDeath(
        Vector3 position,
        Vector3 force,
        GameObject attacker)
    {
        Debug.Log($"{name} died at {position}.", this);
    }
}

A listener does not need to be attached to the target. For example, a HUD can register against its assigned character GameObject and unregister from that same character before switching players.

Use a named method or cache the delegate. Creating a new lambda during unregistration does not reproduce the delegate that was registered, so the old callback remains.

Practical UCC subscriptions

The following registrations use source-verified Version 3 signatures:

using Opsive.UltimateCharacterController.Character.Abilities;
using Opsive.UltimateCharacterController.Items;
using Opsive.UltimateCharacterController.Items.Actions.Impact;
using EventHandler = Opsive.Shared.Events.EventHandler;

// Character lifecycle: target is the character GameObject.
EventHandler.RegisterEvent<Ability, bool>(
    character, "OnCharacterAbilityActive", OnAbilityActive);

// Inventory lifecycle: target is also the character GameObject.
EventHandler.RegisterEvent<CharacterItem, int>(
    character, "OnInventoryEquipItem", OnItemEquipped);

// Impact lifecycle: target is the GameObject that received the impact.
EventHandler.RegisterEvent<ImpactCallbackContext>(
    impactTarget, "OnObjectImpact", OnObjectImpact);

The matching callbacks are:

private void OnAbilityActive(Ability ability, bool active)
{
    // React only to the Ability types relevant to this listener.
}

private void OnItemEquipped(CharacterItem item, int slotID)
{
    // Refresh the UI or another dependent system for this slot.
}

private void OnObjectImpact(ImpactCallbackContext context)
{
    // Read the source-verified impact context needed by this object.
}

Mirror all three calls with UnregisterEvent when their owner is disabled, destroyed, or assigned a different character or target.

Runtime and ownership cautions

  • Main thread: the Shared 2.0.0 implementation mutates ordinary static Dictionary and List instances without synchronization and invokes Unity-facing callbacks. Register, execute, and unregister on Unity’s main thread.
  • Duplicate registration: in the Editor, registering the same delegate twice logs a warning but still adds the second subscription. One unregistration removes one matching entry, so an unmatched duplicate can continue firing.
  • Pooling: a pooled listener should normally pair OnEnable with OnDisable. A pooled publisher does not automatically remove callbacks registered against its GameObject.
  • Destroyed targets: the table does not infer subscription ownership from Unity object destruction. Explicitly unregister listeners rather than relying on a scene unload or destroyed target.
  • Global listeners: a global registration has no object key to constrain it. Always give it an explicit owner and matching cleanup path.
  • Play Mode/domain reload: EventHandler.DomainReset() is marked for SubsystemRegistration and clears both object and global event tables. This also protects a new Play Mode session when domain reload is disabled, but it is not a replacement for normal scene and pooling cleanup.
  • Execution changes: the implementation accounts for listeners being removed while an event is executing. Avoid adding broader lifecycle work inside a callback unless the order is intentional.

The internal invokable wrappers are pooled, so EventHandler avoids creating a new wrapper for every execution. Treat this as a notification mechanism, not as permission to broadcast high-frequency data when a direct update path would be simpler.

Verify the event flow

  1. Add a temporary, uniquely worded Debug.Log to the callback.
  2. Trigger the gameplay action once, such as starting one Ability, equipping one Character Item, or applying one impact.
  3. Confirm the callback runs exactly once and that its parameters describe the expected object and state.
  4. Disable or unassign the listener and repeat the action. The callback should not run.
  5. Re-enable or reassign the listener and repeat once more. The callback should again run exactly once, not once per previous enable cycle.

For a custom event, first test the object-scoped form on one known GameObject before considering a global version.

Troubleshooting

Symptom Check Fix
The callback never runs Compare the target object, event name, generic types, type order, and registration timing with the publisher Use the publisher’s exact target and typed contract, and register before the gameplay action occurs
The Console reports an unexpected event type Check whether another listener registered the same target and name with a different generic signature Correct the signature or give the different contract a distinct event name
The callback runs twice Check repeated OnEnable, initialization, or character-assignment paths Pair every registration with one matching unregistration and remove the duplicate path
Unregistration has no effect Compare the target, name, generic arguments, and delegate instance Use the same named method or cached delegate and the same target that was registered
A callback fires after a scene change or reassignment Check global registrations and listeners that retained the old character or target Unregister before unloading, disabling, returning to a pool, or replacing the target
A pooled object stops receiving events Check whether it unregisters on disable but never re-registers on enable Pair OnEnable with OnDisable and verify both are reached once per pool cycle
A background task causes inconsistent behavior Check whether it calls EventHandler or Unity APIs off the main thread Marshal the result back to Unity’s main thread before registering, executing, or unregistering

API reference

Register, execute, and unregister

String literals are accepted because they convert to the Shared StringHash value used by the API. Object-scoped and global forms support zero through six typed parameters:

using EventHandler = Opsive.Shared.Events.EventHandler;

// Object-scoped, no parameters.
EventHandler.RegisterEvent(target, "OnProjectReady", OnProjectReady);
EventHandler.ExecuteEvent(target, "OnProjectReady");
EventHandler.UnregisterEvent(target, "OnProjectReady", OnProjectReady);

// Object-scoped, two parameters.
EventHandler.RegisterEvent<int, bool>(
    target, "OnProjectValueChanged", OnProjectValueChanged);
EventHandler.ExecuteEvent<int, bool>(
    target, "OnProjectValueChanged", 12, true);
EventHandler.UnregisterEvent<int, bool>(
    target, "OnProjectValueChanged", OnProjectValueChanged);

// Global, one parameter: omit the target from all three calls.
EventHandler.RegisterEvent<string>(
    "OnProjectAnnouncement", OnProjectAnnouncement);
EventHandler.ExecuteEvent<string>(
    "OnProjectAnnouncement", "Round started");
EventHandler.UnregisterEvent<string>(
    "OnProjectAnnouncement", OnProjectAnnouncement);

RegisterUnregisterEvent is a convenience method when one lifecycle method already receives a boolean registration state:

private void SetEventRegistration(bool register)
{
    EventHandler.RegisterUnregisterEvent<Ability, bool>(
        register,
        character,
        "OnCharacterAbilityActive",
        OnAbilityActive);
}

Cache a custom event name

For a project event used frequently, cache its StringHash and keep the parameter contract beside it:

using Opsive.Shared.Runtime.Utility;
using EventHandler = Opsive.Shared.Events.EventHandler;

private static readonly StringHash s_OnAlertLevelChanged =
    new StringHash("OnAlertLevelChanged");

private void PublishAlertLevel(int level)
{
    EventHandler.ExecuteEvent<int>(
        gameObject, s_OnAlertLevelChanged, level);
}

The listener must register and unregister with RegisterEvent<int> and UnregisterEvent<int> using that same target and event name.