BP Authoring — Events & Triggers
Write custom CharacterSFX Events and Triggers in Blueprint — heartbeat case study, production-grade lifecycle, and the seven gotchas that come with the territory
BP Authoring — CharacterSFX Events & Triggers
Extend the CharacterSFX system with no C++ — a pure designer / scripter workflow. This guide walks the Blueprint authoring path end-to-end via a real case study (a low-health heartbeat loop with full production lifecycle: idempotent spawn, fade-in, fade-out, destroy, no stacking) and catalogs every gotcha we hit shipping it.
Prereq: Read Character SFX first for the three-layer architecture (Config / Event / Trigger) and the shipped C++ subclasses (
Loop,OneShot,FoleyEvent,LocomotionState,Attribute).
The Two Class Trees
Both base classes are Abstract, Blueprintable, EditInlineNew — meaning you can subclass either of them in Blueprint and your subclass shows up in the DataAsset's class-picker dropdown alongside the shipped C++ implementations.
UAgenticCharacterSFXEvent ← WHAT plays
├─ UAgenticCharacterSFXEvent_Loop "Loop (Breathing)" (C++)
├─ UAgenticCharacterSFXEvent_OneShot "One-Shot (Grunt/Effort)" (C++)
└─ <your BP subclasses>
UAgenticCharacterSFXTrigger ← WHEN to raise tags
├─ UAgenticCharacterSFXTrigger_FoleyEvent "Foley Event (Breathing)" (C++)
├─ UAgenticCharacterSFXTrigger_LocomotionState "Locomotion State" (C++)
├─ UAgenticCharacterSFXTrigger_Attribute "Attribute Threshold" (C++)
└─ <your BP subclasses>Triggers raise EventTagToRaise → the foley component looks up EventConfigs[tag] → runs every Event in its array. Triggers carry no sound; Events carry no condition. They compose freely.
Case study: heartbeat loop, BP-only
What we'll build:
| Asset | Purpose |
|---|---|
BPT_HeartbeatTrigger (BP) | Timer-driven trigger — toggles a tag every 3s |
BPE_HeartbeatPulse (BP) | Looping AudioComponent with proper spawn / fade-out / destroy lifecycle |
SW_Heartbeat_Loop_60bpm | Looping heartbeat audio (any source) |
CharacterSFX.Breathing.Heartbeat | New gameplay tag |
DA_CharacterSFX_* wiring | One EventConfig entry + one EventTrigger entry |
Why this is the right test case: it exercises every hard-won gotcha. Timer-based triggers hit the world-resolution bug. Idempotent loop spawning hits the no-stacking pattern. Fade-out + destroy hits the cleanup chain. If your BP heartbeat works in PIE without stacking or leaking, you've validated the whole BP authoring path.
Already shipped. The plugin includes a working
BPT_HeartbeatTrigger,BPE_HeartbeatPulse, andSW_Heartbeat_Loop_60bpminContent/Audio/CharacterSFX/Heartbeat/plusContent/Audio/Foley/Triggers/.DA_CharacterSFX_Defaultis pre-wired with the EventConfig and anAttribute ThresholdEventTrigger gating Heartbeat at Health < 100. Open them in the editor as a reference.
Step 1 — Source the audio
Looping heartbeat sound, ~3–5s, lub-dub rhythm, dry / close-mic, mono, seamless loopable.
Use any source — your DAW, a Fab pack, ElevenLabs SFX, the wave the plugin ships. Save as .wav or .mp3 somewhere under your project's Content tree, e.g. Content/Audio/CharacterSFX/Heartbeat/SourceWavs/heartbeat_loop_60bpm.wav.
Import to UE: right-click in Content Browser → Import → pick the file → it lands as a USoundWave.
Critical setting: open the SoundWave → Details panel → set Looping = true. Without this, the AudioComponent won't loop and you'll get one play-then-silence per trigger fire.
Step 2 — Register the gameplay tag
The CharacterSFX system routes through gameplay tags. New tags must be registered in DT_FoleyTags before you can wire them up.
The shipped CharacterSFX.Breathing.Heartbeat tag is already in the plugin's DT_FoleyTags. To add your own custom tag, either:
- Project Settings UI: Project Settings → Project → GameplayTags → click
Add New Gameplay Tag→ enterCharacterSFX.Breathing.MyTag+ a comment → save. The tag becomes selectable in the picker immediately. - CSV table: edit your project's tag CSV, then in the editor reimport the DataTable (right-click → Reimport) so the registry picks it up. CSV-on-disk alone is not enough — the registry reads the in-memory DataTable, not the CSV.
Verify the tag picker on a FGameplayTag field shows your new tag under the CharacterSFX.Breathing.* hierarchy before continuing.
Step 3 — Build the BP Event (BPE_HeartbeatPulse)
The Event handles what plays when the tag fires. Production-grade lifecycle requires:
- Idempotent ExecuteEvent — re-triggering while already playing must NOT stack
- Tracked AudioComponent — so we can stop/destroy it later
- FadeOut + Destroy on StopEvent — clean shutdown, no leaks
- Optional fade-in — smooth start
3.1 Create the BP class
Content Browser → right-click → Blueprint Class → click All Classes at the bottom → search AgenticCharacterSFXEvent → name it BPE_HeartbeatPulse. Save under Content/Audio/CharacterSFX/Heartbeat/.
3.2 Add variables
| Variable | Type | Default | Notes |
|---|---|---|---|
Sound | USoundBase* | (set per-instance) | The looping audio asset. Instance Editable. |
FadeInDuration | float | 0.15 | Seconds. Instance Editable. |
FadeOutDuration | float | 0.5 | Seconds. Instance Editable. |
ActiveAudioComponent | UAudioComponent* | (none) | Runtime tracking. Transient = true (runtime-only state, not serialized). Instance Editable = false. |
3.3 Override Execute Event
In the My Blueprint panel → Functions section → Override dropdown → pick Execute Event. (Or right-click in EventGraph → search "Execute Event" → drop the override.) Inputs: Component, EventTag, Context.
Graph (idempotent spawn-and-fade-in):
Event Execute Event (Component, EventTag, Context)
│ then
▼
[Get ActiveAudioComponent] → [IsValid?]
│
▼ Branch on Condition
├─ true (already playing) ──→ no-op (idempotent)
└─ false:
│
▼
[Spawn Sound 2D]
• WorldContextObject = Component (foley component, has world)
• Sound = (Get Sound)
• VolumeMultiplier = 1.0 (NOT 0.0 — see Gotcha 2)
• bAutoDestroy = false (we control destroy ourselves)
→ ReturnValue (UAudioComponent)
│
▼
[Set ActiveAudioComponent = ReturnValue]3.4 Override Stop Event
Override dropdown → pick Stop Event. Inputs: Component, EventTag.
Graph (fade-out → delay → destroy → null out):
Event Stop Event (Component, EventTag)
│ then
▼
[Get ActiveAudioComponent] → [IsValid?]
│
▼ Branch
├─ false (nothing playing) ──→ no-op
└─ true:
│
▼
[AudioComponent.FadeOut]
• Target = ActiveAudioComponent
• FadeOutDuration = (Get FadeOutDuration)
• FadeVolumeLevel = 0.0
│
▼
[Delay (latent)]
• WorldContextObject = Component
• Duration = (Get FadeOutDuration)
│ Completed
▼
[Get ActiveAudioComponent] → [IsValid?]
│ (re-check — Stop may have been called twice)
▼ Branch
├─ false → skip
└─ true:
│
▼
[DestroyComponent]
• Target = ActiveAudioComponent (wire to .self pin — see Gotcha 4)
│
▼
[Set ActiveAudioComponent = null]3.5 Don't override IsLooping
Parent default returns false. For the toggle pattern (each ExecuteEvent ↔ StopEvent pair owns its own AudioComponent), the foley component's internal loop tracking isn't used — your BPE owns lifecycle directly. Leave IsLooping unmodified.
3.6 Compile
Click Compile in the BP editor toolbar. Zero errors, zero warnings. Save.
Step 4 — Build the BP Trigger (BPT_HeartbeatTrigger)
The Trigger handles when to raise the tag. For this demo, it's a fixed 3-second timer that toggles. In a real game you'd use the shipped Attribute trigger (Health < 25%) instead — see the recipe at the bottom of this page. The custom BP trigger here is the proof that the BP authoring path works for arbitrary conditions.
4.1 Create the BP class
Content Browser → Blueprint Class → All Classes → search AgenticCharacterSFXTrigger → name BPT_HeartbeatTrigger. Save under Content/Audio/Foley/Triggers/.
4.2 Add a variable
| Variable | Type | Notes |
|---|---|---|
TimerHandle | FTimerHandle (struct) | Stored so we can clear the timer on Shutdown. |
4.3 Add a function: FireBeat
This is the timer callback. It toggles bIsActive (a parent property) between fire and stop.
My Blueprint panel → Functions section → + Add Function → name FireBeat (no inputs, no outputs).
Graph:
FireBeat
│
▼
[Get bIsActive (parent)] → [Branch]
├─ true → [On Condition No Longer Met] (parent BP-callable, stops the tag)
└─ false → [On Condition Met (Magnitude=-1)] (parent BP-callable, raises the tag)On Condition Met (Magnitude = -1) uses the trigger's Magnitude property as the default. Pass an explicit value to override per-fire.
4.4 Override On Initialize and On Shutdown
In the EventGraph, drop:
Event On Initialize(fires when the foley component begins play; passesInOwningPawn+InComponent)Event On Shutdown(fires on EndPlay)
On Initialize graph:
Event On Initialize
│ then
▼
[Set Timer by Function Name]
• Object = Self (the trigger BP)
• FunctionName = "FireBeat"
• Time = 3.0 (seconds)
• bLooping = true
→ ReturnValue (FTimerHandle)
│
▼
[Set TimerHandle] ← stash for Shutdown cleanupOn Shutdown graph:
Event On Shutdown
│ then
▼
[Get TimerHandle]
│
▼
[Clear and Invalidate Timer Handle]
• WorldContextObject = (Get OwningComponent) ← parent property, has world
• Handle = (Get TimerHandle)
Set Timer by Function Nameresolves world from the Trigger'sGetWorld()override. Triggers live inside a DataAsset's outer chain (no world) — without the C++ override on the Trigger base class, the timer would fail with "No world was found for object". The base class ships this override and your BP just works. See Gotcha 1 below.
4.5 Compile
Compile + Save. Zero errors, zero warnings.
Step 5 — Wire into the DataAsset
Open your DA_CharacterSFX_* (or duplicate DA_CharacterSFX_Default).
5.1 Add EventConfig (the WHAT)
Click + on EventConfigs:
EventTag=CharacterSFX.Breathing.HeartbeatEventsarray → click + → in the class picker, scroll to findBPE_HeartbeatPulse_C→ pick it- Expand the new BPE instance → set
Sound=SW_Heartbeat_Loop_60bpm. Leave fade durations at defaults.
5.2 Add EventTrigger (the WHEN)
Click + on EventTriggers → in the class picker, find BPT_HeartbeatTrigger_C → pick it.
Set the inherited fields on the new instance:
EventTagToRaise=CharacterSFX.Breathing.HeartbeatbAutoStopWhenConditionFalse=true(auto-call StopEvent when the condition flips false)Magnitude=1.0
5.3 Save the DA, assign to your character
On your character's foley component, set CharacterSFXConfig = your DA.
Step 6 — Test in PIE
PIE for ~15 seconds, stand still or walk around.
What you should hear:
- Every 3 seconds, the heartbeat loop fades in (over
FadeInDuration), plays continuously for ~3s, fades out overFadeOutDuration = 0.5s, silent for ~2.5s, repeats. - Walking simultaneously triggers the C++
FoleyEventbreathing layer — both audio layers play in parallel because they use different tags.
What you should see in the log (filter LogAgenticFoley):
CharacterSFXTrigger::Initialize: class=BPT_HeartbeatTrigger_C owner=<CharBP> (isPawn=1) — invoking ReceiveInitialize
CharacterSFXTrigger::OnConditionMet: class=BPT_HeartbeatTrigger_C tag=CharacterSFX.Breathing.Heartbeat OwningComponent=AC_FoleyEvents
TriggerCharacterSFX: tag=CharacterSFX.Breathing.Heartbeat events=1
TriggerCharacterSFX: dispatching to event class=BPE_HeartbeatPulse_C
CharacterSFXTrigger::OnConditionNoLongerMet: class=BPT_HeartbeatTrigger_C tag=CharacterSFX.Breathing.Heartbeat bIsActive=1
[... cycle repeats every 3s ...]If you see Initialize but never OnConditionMet, your timer didn't fire — Gotcha 1. If OnConditionMet fires but no audio — Gotcha 2 or 6. If your BP variables show as "purged" at compile time — Gotcha 3.
The Seven Gotchas
The bug-class catalog. Every one of these bit us shipping BPT_HeartbeatTrigger + BPE_HeartbeatPulse for the first time. They're either fixed in the C++ base classes (1 and 7) or surfaced via diagnostic logs you can grep for — but BP authors should still understand them.
Gotcha 1: BP triggers' GetWorld() returns null without the override
Symptom (pre-fix):
LogScript: Warning: No world was found for object (DA:BPT_HeartbeatTrigger_C_1) passed in to UEngine::GetWorldFromContextObject().Every Set Timer by Function Name, Spawn Actor, or any other latent / world-context call from the BP fails silently. Trigger appears to do nothing.
Root cause: Trigger instances are stored inline inside the CharacterSFXConfig DataAsset's EventTriggers array. Their UObject outer chain is Trigger → DataAsset → Package — none of which have a world. Default UObject::GetWorld() walks the outer chain and returns nullptr.
Fix (already shipped in C++): the Trigger base class overrides GetWorld() to delegate to OwningComponent (a UActorComponent, which has a valid world). The fix is invisible to BP authors — your timers, latent calls, and Spawn Actor calls just work.
Same applies to BP Events (UAgenticCharacterSFXEvent). Events take Component as an ExecuteEvent parameter — pass Component as the WorldContextObject for any latent / world-context call inside the BP graph.
Gotcha 2: Spawn Sound 2D with VolumeMultiplier = 0.0 silences the AudioComponent
Symptom: BP graph runs end-to-end (debug prints fire), Spawn Sound 2D returns a valid UAudioComponent, FadeIn(0.15, 1.0) is called — but no audio plays.
Root cause: UGameplayStatics::SpawnSound2D calls SetVolumeMultiplier(VolumeMultiplier) before Play() internally. If VolumeMultiplier = 0, the AudioComponent is silenced before playback starts. Calling FadeIn afterward calls AdjustVolume to ramp toward the target, but on looping waves and certain audio submixes the component remains effectively silent.
Fix (recommended pattern):
- Spawn at
VolumeMultiplier = 1.0— sound plays immediately - For instant-on, that's all you need
- For a fade-in: spawn at full volume, then immediately call
AdjustVolume(FadeInDuration, 1.0, Linear)— the component is actually playing, so the volume ramp works
Gotcha 3: VariableGet / VariableSet need explicit self pin wiring (programmatic-authoring only)
Symptom (programmatic authoring only): BP compile shows errors:
[Compiler] Variable node Get bIsActive uses an invalid target.
It may depend on a node that is not connected to the execution chain, and got purged.The variable nodes are silently removed from the compiled BP at runtime.
Root cause: K2Node_VariableGet and K2Node_VariableSet have a hidden self input pin. When you drag a variable into the graph interactively in the editor, UE auto-defaults this pin to "Self Context". When you create the node programmatically (via the engine MCP tooling we use for tests), this default isn't applied — the compiler can't resolve the target and the node is purged.
Fix: if you're authoring purely in the editor, this never happens. If you're scripting BP construction, wire a Self node to every VariableGet / VariableSet's self pin.
Gotcha 4: K2_DestroyComponent's target is self, not Object
Symptom: BP compile error:
This blueprint (self) is not a ActorComponent, therefore 'Target' must have a connection.Root cause: the BP node for Destroy Component has TWO object input pins — self (the AudioComponent target — what gets destroyed) and Object (a legacy/unused parameter). Wiring to Object doesn't satisfy the compiler — it wants self (called Target in the editor's pretty-printed error).
Fix: wire ActiveAudioComponent → DestroyComponent.Target (which routes to the self pin). Leave Object unconnected.
Gotcha 5: Compile-clean ≠ wired-correctly
Symptom: BP compiles with zero errors and zero warnings. PIE: nothing happens.
Root cause: the standard BP compiler doesn't warn about disconnected entry-node then outputs. An override Event Execute Event with no body just falls through to the parent C++ default (which is empty for UAgenticCharacterSFXEvent). Author thinks the BP is doing something; it isn't.
Fix: before declaring a graph done, visually verify every override entry node has a wire on its then output. Diagnostic UE_LOG lines now print when ExecuteEvent_Implementation and StopEvent_Implementation fire — if you see the (BASE C++ default — BP did NOT override) variant in the log, your BP override isn't wired.
Gotcha 6: BP override Events must match parent signature exactly
Symptom: parent C++ default fires instead of your BP override. Log shows:
CharacterSFXEvent::ExecuteEvent_Implementation (BASE C++ default — BP did NOT override): class=BPE_HeartbeatPulse_CRoot cause: UE's BlueprintNativeEvent dispatch matches by function name AND signature. If your BP "override" was authored as a Custom Event with the same name (different parameter order, type, or count), UE doesn't see it as an override and the C++ default runs.
Fix: always use the editor's Override Function dropdown when adding the event node — it generates an Event node with the exact parent signature. Don't manually create a Custom Event named ExecuteEvent — that creates a new event, not an override.
Gotcha 7: Initialize was previously gated on a Pawn cast (now fixed)
Symptom (pre-fix): BP triggers attached to non-Pawn actors (vehicles, spectators, generic actors) silently never received On Initialize — no timer set, no listener bound, no audio.
Root cause: the old C++ Initialize only called ReceiveInitialize if the owner cast successfully to APawn. Non-Pawn owners → BP never gets the init hook.
Fix (shipped): Initialize now always calls ReceiveInitialize, passing nullptr for Pawn if the owner isn't a Pawn. BP authors who need a non-null Pawn can branch on IsValid(InOwningPawn) themselves.
Production-grade BPE pattern reference
The exact graph that ships in BPE_HeartbeatPulse. Use this as a template for any looping audio Event you author.
Variables
Sound: USoundBase*(instance editable)FadeInDuration: float = 0.15(instance editable)FadeOutDuration: float = 0.5(instance editable)ActiveAudioComponent: UAudioComponent*(transient, runtime-only)
ExecuteEvent graph
Event ExecuteEvent (Component, EventTag, Context)
├─ Get ActiveAudioComponent → IsValid → Branch
│ ├─ true → exit (idempotent — already playing)
│ └─ false → ↓
├─ Spawn Sound 2D
│ • WorldContextObject = Component
│ • Sound = (Get Sound)
│ • VolumeMultiplier = 1.0
│ • bAutoDestroy = false
│ → ReturnValue (UAudioComponent)
├─ Set ActiveAudioComponent = ReturnValue
└─ (optional) AdjustVolume on ReturnValue: Duration = FadeInDuration, Level = 1.0StopEvent graph
Event StopEvent (Component, EventTag)
├─ Get ActiveAudioComponent → IsValid → Branch
│ ├─ false → exit
│ └─ true → ↓
├─ FadeOut on ActiveAudioComponent
│ • FadeOutDuration = (Get FadeOutDuration)
│ • FadeVolumeLevel = 0.0
├─ Delay (Duration = FadeOutDuration, WorldContextObject = Component)
├─ Get ActiveAudioComponent → IsValid → Branch
│ ├─ false → exit (Stop called twice, or destroyed elsewhere)
│ └─ true → ↓
├─ DestroyComponent on ActiveAudioComponent (target wired to .self pin)
└─ Set ActiveAudioComponent = nullWhy this design
| Property | Mechanism |
|---|---|
| No stacking on rapid retrigger | Idempotency check on IsValid(ActiveAudioComponent) — if already playing, skip spawn |
| Smooth start | (Optional) AdjustVolume ramp from 0 to 1 |
| Smooth stop | FadeOut ramps to silence over FadeOutDuration before destroy |
| No audio component leaks | Explicit DestroyComponent after fade completes |
| Re-trigger during fade-out is graceful | New ExecuteEvent's IsValid sees the still-fading component as valid → skips. Previous component completes fade + destroys, future ExecuteEvent spawns fresh. |
bAutoDestroy = false | We control destroy timing — engine doesn't yank the component out from under us mid-fade |
| World resolution | Component (foley component) wired as WorldContextObject for Spawn Sound 2D and Delay — both resolve correctly |
Recipe — wire the C++ Attribute trigger to a heartbeat (no BP class needed)
The shipped Attribute Threshold trigger (UAgenticCharacterSFXTrigger_Attribute) handles every health-/stamina-/mana-driven character SFX. To gate the heartbeat on Health < 100, you don't need to author a BP trigger at all — drop a configured instance of the C++ class into EventTriggers directly. This is what DA_CharacterSFX_Default ships with.
From the editor
- Open
DA_CharacterSFX_*→EventTriggersarray → click + - Class picker → pick Attribute Threshold (
UAgenticCharacterSFXTrigger_Attribute) - Expand the new entry and set:
EventTagToRaise=CharacterSFX.Breathing.HeartbeatAttribute=Health(use the property picker — pick the AttributeSet class, then the field)MaxAttribute=MaxHealth(only used whenbUsePercentage = true)Comparison=LessThanThreshold=100(absolute HP)bUsePercentage=false(settrue+Threshold = 30if you want percent-based)Hysteresis=10(clears at Health > 110, prevents flicker at the boundary)bAutoStopWhenConditionFalse=true(heartbeat fades out when health regenerates)
- Save the DA. PIE → take damage below 100 → heartbeat starts; heal above 110 → it stops.
The editor's property picker handles FGameplayAttribute correctly. (If you ever populate the field via Python or another scripting path, note that you must populate the FProperty TFieldPath alongside the legacy AttributeName+AttributeOwner — otherwise Initialize silently bails at Attribute.IsValid().)
Use-case ladder
The same Attribute trigger drives every health-/stamina-/mana-driven character SFX. Stack as many as you need in one DataAsset:
| Situation | Attribute | Comparison | Threshold | Hysteresis | EventTagToRaise |
|---|---|---|---|---|---|
| Heartbeat (low HP panic) | Health | < | 100 (abs) or 30% | 10 / 5 | CharacterSFX.Breathing.Heartbeat |
| Injured breathing | Health | < | 25% | 5 | CharacterSFX.Breathing.Injured |
| Exhausted wheezing | Stamina | < | 10% | 3 | CharacterSFX.Breathing.Exhausted |
| Battle cry on rage spike | Rage | >= | 100 (abs) | 5 | CharacterSFX.BattleCry |
| Mana surge whisper | Mana | >= | 90% | 5 | CharacterSFX.ManaSurge |
| Bleed-out gurgle | Health | < | 5 (abs) | 1 | CharacterSFX.Breathing.Dying |
Each Attribute trigger raises a different tag; EventConfigs plays the matching Loop or OneShot. The pattern composes — no C++ touch.
Related
- Character SFX — the architecture overview, shipped C++ Events / Triggers, full Details panel walkthrough
- Multiplayer — CharacterSFX replicates through the same GAS GameplayCue pipeline
- Data Assets reference — full DataAsset field reference for the surface foley layer