Engineering notes / Rust & game engines

An animation notify isn't just a marker

Building animation events across four Rust repositories in Pulsar Engine

Problem. Animation events must survive looping, time skips and instance-specific delivery. Implementation. Typed notifies, deterministic time-window evaluation and event routing across four Rust repositories. Verification. Serialization, timing and routing regressions were tested; four original PRs merged upstream.

An animation notify sounds simple enough. Put a marker somewhere on a timeline, then fire an event when playback reaches it.

That works until the animation loops. Or the frame time jumps across several markers. Or the event needs to reach one particular Blueprint instance rather than every object using the same animation.

I recently worked on Pulsar Engine issue #1337, which covers animation notifies, float/vector curves, and root-motion extraction. The initial work ended up spanning four repositories: Plugin_SKM, Pulsar-Native, Helio, and Plugin_Blueprints.

I built the foundational asset, evaluation, runtime-event, and Blueprint integration changes. tristanpoland, the upstream maintainer, then extended the editor functionality, refined parts of the integration, and handled the final upstream coordination.

All four original PRs have since been merged. I wanted to write down a few of the engineering decisions behind them, because the interesting part wasn't just adding new animation data. It was making that data behave predictably across an engine.

Getting the asset format right

The first piece was the .animseq format.

An animation sequence already had a clip and bone tracks. The new functionality needed somewhere to store notifies, float/vector curves, and root-motion settings without breaking existing sequences.

I added an AnimationFeatures structure alongside the clip. That kept the bone animation data separate from the additional channels and settings.

A notify carries a stable ID, a display name, a timestamp, a target, and optional typed fields.

The stable ID matters more than it might seem. An event handler shouldn't stop working just because someone renames footstep to left_footstep in the editor. The readable name can change while the underlying event identity remains the same.

Targets are also stored differently from live runtime references. A reusable asset can say "send this to self" or refer to a class variable, but it shouldn't serialize an actual Entity handle from a running world. That reference has to be resolved when the event fires.

The format also needed to be careful with existing files. Older v1/v2 sequences still load, while feature-bearing sequences use the appropriate newer format version. Version 3 covers notify targets and typed fields. An older reader should refuse data it cannot safely preserve instead of silently stripping it on save.

It's not the most visible part of an animation feature, but losing authored data because an editor didn't understand a field would be a fairly serious failure.

The awkward part: deciding when a notify fires

The obvious implementation is to check whether the current animation time has reached a notify timestamp.

Unfortunately, "current time" isn't enough.

Imagine a one-second looping clip with two notifies:

  • footstep_a at 0.2 seconds
  • footstep_b at 0.8 seconds

Now suppose playback advances from 0.75 to 1.25 seconds in one update.

Both events should fire, in this order: footstep_b at 0.8, then footstep_a at 1.2 in the next cycle.

FIGURE 01 / LOOPING NOTIFY CROSSINGS
Notify crossings across a one-second loop Two cycles from zero to two seconds. Notifies A at 0.2 and 1.2, B at 0.8 and 1.8. The interval from 0.75 to 1.25 crosses B at 0.8 first and A at 1.2 second. Seeking and pausing have no playback crossings. Cycle 0 · 0.0–1.0 s Cycle 1 · 1.0–2.0 s loop boundary 0.01.02.0 footstep_afootstep_b footstep_bfootstep_a Playback advance: 0.75 → 1.25 s · (from, to] 01 footstep_b @ 0.8 s 02 footstep_a @ 1.2 s Seek and Paused: no playback crossings Notify crossings over two animation cycles Cycle zero spans 0 to 1 second, then cycle one spans 1 to 2 seconds. The interval from 0.75 to 1.25 seconds crosses footstep B at 0.8 and footstep A at 1.2, in that order. Seek and Paused produce no crossings. Cycle 0 0.0–1.0 s footstep_a footstep_b 0.01.0 Cycle 1 1.0–2.0 s footstep_a footstep_b 1.02.0 Advance: 0.75 → 1.25 s · (from, to] 01 footstep_b @ 0.8 s 02 footstep_a @ 1.2 s Seek / Paused: no crossings
Only occurrences inside the unwrapped advance interval (0.75, 1.25] fire: footstep_b at 0.8 seconds, then footstep_a at 1.2 seconds. The one-second loop boundary does not reset the ordering.

Simply comparing the previous and current wrapped positions won't give the right result. The engine needs to know how much unwrapped playback time has passed.

The asset implementation models this explicitly:

Playback interval modelRust
pub enum PlaybackInterval {
    Advance { from: f64, to: f64, looping: bool },
    Seek,
    Paused,
}

That distinction also keeps scrubbing separate from normal playback. Moving the playhead around in the editor shouldn't accidentally trigger gameplay events.

There were several other cases worth handling: multiple loops in one update, markers at the exact beginning or end of a clip, several notifies sharing a timestamp, and floating-point rounding at loop boundaries.

The crossing rule excludes the departure time and includes the arrival time. At a loop boundary, the departing cycle's end comes before the next cycle's zero-time event. Equal-time events retain their authored order.

One particularly subtle problem was calculating timestamps around loop boundaries. Different arithmetic paths could produce slightly different floating-point representations of what should be the same boundary. Both evaluation paths now use canonical occurrence timestamps, with regression tests covering split-query equivalence.

For repeated playback queries, NotifyIndex keeps a validated, time-sorted snapshot of the notifies. Its lookup cost is designed around the time window being crossed rather than repeatedly scanning every marker in the clip. The implementation documents O(log n + crossings) per touched cycle, with special handling for rare timestamp collisions.

I also added a crossing limit so an unreasonable playback interval cannot create an unbounded result list. Importantly, only actual accepted crossings count against that limit.

These details are easy to overlook when the feature is described as "fire an event at a timestamp." They make a difference once the system is used by real gameplay code.

Sending an event to the right object

The next question was where the notification should go.

Pulsar already uses Gamma and EventHub for its event infrastructure. tristanpoland's design guidance made one requirement especially clear: animation notifies should use the existing instance-directed event model, not introduce a separate dispatcher or broadcast to an entire class.

An animation running on one character shouldn't wake up handlers on every other character using that sequence.

FIGURE 02 / RUNTIME EVENT DELIVERY
Pulsar animation notify runtime delivery pipeline Six stages connected by arrows: animation sequence asset, skeletal_assets, Helio playback and indexed crossings, Gamma EventHub directed entity channel, AfterScripts flush, and a subscribed Blueprint instance. The editor-side discovery path is separate. 01.animseq assetStable ID, time, target ruleand typed payload fields 02Plugin_SKM / skeletal_assetsVersioned format anddeterministic crossings 03Helio / SkeletalMeshComponentPlayback cursor andindexed notify windows 04Gamma / session EventHubRegistered descriptor;Channel::Entity(target.bits()) 05AfterScripts flushDeferred event deliveryin the session bus 06Blueprint / script receiverOnly the subscribedentity instance handles it Pulsar animation notify runtime delivery pipeline A vertical six-stage event path from a versioned animation sequence through asset evaluation, Helio playback, Gamma EventHub entity targeting, AfterScripts flush, and the receiving Blueprint entity instance. Editor discovery is separate. 01.animseq assetStable ID, time, target ruleand typed payload fields 02Plugin_SKM / skeletal_assetsVersioned format anddeterministic crossings 03Helio / SkeletalMeshComponentPlayback cursor andindexed notify windows 04Gamma / session EventHubRegistered descriptor;Channel::Entity(target.bits()) 05AfterScripts flushDeferred event deliveryin the session bus 06Blueprint / script receiverOnly the subscribedentity instance handles it
Separate editor path: saved .animseq assets → event catalog with per-file diagnostics → Blueprint palette refresh. The editor catalog is not the runtime event bus.
Runtime notify publications use Gamma's session EventHub and a directed entity channel. Events go to the subscribed target instance, not every instance sharing the animation. The pre-AfterScripts hook is the intended delivery boundary, not proof that every host integration is complete.

The implementation uses Gamma's entity channel:

Directed event publication (simplified)Rust
hub.publish_dyn(
    entity_channel(target.bits()),
    event,
)?;

This is a simplified excerpt of the directed-publication path; the actual implementation also handles target resolution, payload construction, pending publications, and error handling.

The channel identifies the receiving entity. Subscribers registered for that entity receive the event; unrelated instances don't need to inspect it and decide whether it's theirs.

Pulsar-Native also needed changes to component event contexts. A live component callback now has access to both its owner and its emitting component instance. The existing owner-directed compatibility route remains available, while the instance-directed path gives systems more precise routing.

For authored notifies, the runtime registers a Gamma event descriptor with an identity based on the sequence ID and notify ID:

Stable event descriptor nameText
animseq::<sequence-id>.<notify-id>

The display name can still be something readable like "Run / Footstep." The binding identity doesn't depend on that label.

Typed notify fields are mapped into Gamma's event schema, along with runtime context such as the source entity, playback time, clip time, and cycle.

The events are published through the session's existing EventHub. The Helio publication function is designed to run immediately before the AfterScripts flush, after scripts release the world borrow. This is the intended host integration point; merging the library PRs alone does not establish that every runtime host has wired the hook. Script callbacks follow the normal deferred scripting path rather than executing within the publisher's component borrow.

The advantage is that animation events behave like other engine events. They don't require their own subscription system, and they can use the existing plugin and scripting infrastructure.

Connecting it to Blueprints

Getting an event onto the bus is only half the job. Somebody also needs to be able to bind to it.

The Blueprint integration reads the animation event catalog and exposes suitable events through the existing event palette and compiler paths.

There are two important details here.

First, the event keeps its fully qualified identity when moving through the compiler. Otherwise, two events with similar names from different animation assets could become ambiguous.

Second, the palette needs to discover events from saved assets without depending on the game session currently running. The catalog can report valid events and per-file diagnostics together. One broken .animseq file doesn't have to make every other animation event disappear.

Conflicting descriptor identities are quarantined rather than letting whichever file happens to be scanned first win.

The palette also refreshes when relevant animation assets change.

tristanpoland subsequently refined this integration so animation events are scoped through the appropriate component-reference path in Blueprints. He also added the timeline's notify lane and editing interactions in Plugin_SKM.

That collaboration mattered. My initial work provided the underlying systems; the maintainer's follow-up changes connected them more closely to the editor and existing engine conventions.

Curves and root motion are separate problems

Issue #1337 covered more than discrete events.

I added typed float and vector curves with validated, sorted keys and step/linear interpolation. These remain sampled animation data rather than being turned into a stream of event notifications. That distinction is useful: a notify represents something happening at a point in time, while a curve represents a value evaluated over time.

Root motion has a different concern again.

The extraction code calculates a rigid transform delta from an authored source bone over a playback interval. It supports selected translation axes, optional rotation, and composition across looping intervals.

But extracting movement is not the same as applying it to an entity.

Extraction, optional residual-pose removal, and applying actor movement are separate operations. A runtime that applies the extracted movement must also account for that movement in the rendered pose, or the character can effectively move twice.

I preferred keeping that responsibility explicit instead of claiming that root-motion extraction alone completed the entire character-movement pipeline.

There are limits to the current implementation, too. The asset-level crossing evaluator handles reverse intervals, but the live skeletal animation player currently uses forward playback. That's not the same as saying reverse runtime playback is finished.

Four repositories, one feature

The final contribution was split across four merged PRs:

  • Plugin_SKM #8: animation assets, notify evaluation, curves, root-motion extraction, and subsequent editor integration
  • Pulsar-Native #1466: instance-aware component event infrastructure and engine integration prerequisites
  • Helio #363: runtime notify playback, descriptor registration, and event catalog
  • Plugin_Blueprints #37: animation-event discovery and Blueprint integration

The original commits established the feature's asset and event foundations. tristanpoland followed up with editor controls, component-reference scoping, API exposure, and submodule integration.

The dependency ordering was important. Each repository had its own build context, and an API that compiled in an integrated local workspace wasn't automatically available to another repository using an older pinned engine revision.

Tests covered serialization, notify ordering and loop boundaries, event routing, catalog failures, and Blueprint compilation. Some integration checks used the combined local host, and those weren't a substitute for every possible separately loaded DLL or platform test.

I wouldn't describe the entire animation roadmap as finished just because these PRs merged. Issue #1337 remains the broader tracking point.

What I took away

The part I found most worthwhile was dealing with the boundaries between systems.

An authored event starts as data in a file. It becomes a runtime occurrence, acquires a target and typed payload, passes through Gamma, and eventually reaches a particular script instance. Each stage has its own rules, and bugs tend to appear where two stages disagree.

The same applies to repository boundaries. It's one thing to make several crates compile together locally. It's another to get the right versions and APIs into their upstream repositories in an order that other contributors can build and review.

Having tristanpoland extend and integrate the foundational work was a useful part of that process. The finished code is collaborative, and that's how I want to describe it.

A notify marker is still a small thing on a timeline. Getting it reliably from that timeline to the right game object is where the engineering starts.

← Back to the Pulsar Engine contribution