One frame, one beat: latching musical time against Unreal's tick order
When every actor reads a live clock, effects that should hit together drift with tick order. Snapshot the beat once per frame and read that instead.
Continuity is an Unreal Engine 5.8 editor plugin from The Fantastic Planet that gives Unreal a musical transport. It follows an external clock, such as Ableton Link, MIDI clock or timecode, so that Sequencer, Niagara and other systems land on the beat.
This post is about one problem any tool like that has to solve. It applies to anything in Unreal that asks "what time is it?" from more than one place in a frame, whether or not music is involved.
The question every system asks
Picture a kick drum hit. On the downbeat you want a light to flash, a mesh to pulse, a Niagara burst to fire and a Blueprint to swap a material. Four systems, four places in your project, and each one needs to know the current beat.
The obvious approach is to give them all access to the clock and let each one read it when it ticks. That works in a quick test. It falls apart when you look closely, because those four systems do not tick at the same moment.
What Epic actually guarantees about tick order
Unreal ticks actors and components in tick groups. Epic's Actor Ticking page lists the gameplay groups in frame order: TG_PrePhysics at the beginning of the frame, then TG_DuringPhysics, TG_PostPhysics, and TG_PostUpdateWork. The key rule is this:
Each tick group will finish ticking every actor and component assigned to it before the next tick group begins.
That is a guarantee between groups. The page does not promise any order within a group. If you need one actor to tick after another, you ask for it explicitly with AddTickPrerequisiteActor or AddTickPrerequisiteComponent, which make the caller wait until the other actor or component has finished ticking.
There are also clocks outside the world tick. FTSTicker, described in the API reference as a "thread-safe ticker class" that "fires delegates after a delay", has a singleton from GetCoreTicker() that is used in Core and Launch. Code hung off it runs on its own schedule, not in step with your actors.
So in a single frame, your four systems might read the clock early in TG_PrePhysics, somewhere in the middle, late in TG_PostUpdateWork, and outside the world tick altogether.
Why a live clock gives different answers
An external musical clock keeps moving while the frame runs. If each system reads it live, each one gets the value at the moment it happened to tick.
At 120 BPM a beat is 500 ms. At 60 fps a frame is about 16.7 ms, so one frame is roughly 3% of a beat. The spread inside a frame is smaller than that, but it is not zero, and it matters in two ways:
- Threshold crossings split. If the downbeat lands during the frame, the systems that tick early see beat 3.999 and wait. The ones that tick late see 4.001 and fire. Your flash and your burst end up a frame apart.
- The result depends on tick order. Since order within a group is not guaranteed, which systems fall on which side of that line can change when you add an actor, reload a level, or change a tick group for another reason.
That second point is the real problem. It is not a fixed offset you can tune out. It is jitter, and it moves around.
The fix: one snapshot per frame
The fix is simple. Read the external clock once, at the start of the frame, and store the result. Every system reads that stored value for the rest of the frame. Nobody reads the live clock directly.
Now every system gets the same beat number in a given frame, whatever its tick group and wherever it sits in the order. A downbeat either happened this frame for everyone, or it did not happen yet for anyone. Tick order stops mattering for timing.
This is what Continuity does. Here is a generic sketch of the pattern. It is not Continuity's code, just the smallest version of the idea:
// Generic example: latch musical time once per world tick.
UCLASS()
class UBeatLatchSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override
{
Super::Initialize(Collection);
Handle = FWorldDelegates::OnWorldPreActorTick.AddUObject(
this, &UBeatLatchSubsystem::Latch);
}
virtual void Deinitialize() override
{
FWorldDelegates::OnWorldPreActorTick.Remove(Handle);
Super::Deinitialize();
}
// Everyone reads this. Nobody reads the live clock.
UFUNCTION(BlueprintPure, Category = "Beat")
double GetCurrentBeat() const { return CurrentBeat; }
private:
void Latch(UWorld* World, ELevelTick TickType, float DeltaSeconds)
{
if (World == GetWorld())
{
CurrentBeat = ReadExternalClockBeat(); // your clock source
}
}
double ReadExternalClockBeat() const;
double CurrentBeat = 0.0;
FDelegateHandle Handle;
};FWorldDelegates::OnWorldPreActorTick is documented as being "called before actors are ticked for each world", which is the right moment to take the snapshot. The check against GetWorld() matters because the delegate fires for every world, and the editor can have more than one.
In Blueprint the pattern is the same: actors call Get Current Beat from the subsystem instead of querying the clock themselves. For Niagara, push the latched value into a User Parameter. Epic's system settings reference notes that User Parameters "can only be set from outside the Niagara simulation, such as from Blueprint logic or C++ code". Use Set Niagara Variable (Float) in Blueprint, or UNiagaraComponent::SetVariableFloat in C++, and feed it the latched beat, never a fresh read.
The trade-off: GPU sims are one frame behind
Latching has a cost, and it is better to know it up front.
Game-thread systems read the value latched at the start of the current frame. Continuity's own documentation is explicit that GPU simulations end up reading a value latched one frame earlier. They run consistently one frame behind the game-thread systems.
That sounds worse than it is. A constant one-frame offset is predictable. You can measure it once and correct for it, for example by adding one frame's worth of beats to the value you send to a GPU system. Random tick-order jitter cannot be corrected like that, because it changes from frame to frame and from build to build.
The takeaway
If more than one system in your project asks what time it is, decide once per frame and make everyone use that answer. It costs one stored value and a getter. It removes a class of timing bugs that otherwise show up as "it was in sync yesterday."



