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_aat 0.2 secondsfootstep_bat 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.
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:
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.
AfterScripts hook is the intended delivery boundary, not proof that every host integration is complete.The implementation uses Gamma's entity channel:
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:
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