Guide UE 5.7VersionedPart of project → StillCooking_Tools

A tickable UObject — how to build one, what to keep in mind

How to build a base class for a ticking UObject in a UE project: the design decisions that matter, with the complete class available in StillCooking_Tools.

Table of contents

A UObject does not tick. It has no PrimaryActorTick, it belongs to no tick group, and the engine has no reason to visit it every frame. The standard answer is multiple inheritance: UObject plus FTickableGameObject. The catch is that inheritance alone does not give you a tick, only registration. Everything that determines whether, when, and for how long the object actually ticks is still yours to set up.

What FTickableGameObject is

FTickableGameObject is a plain C++ class from Tickable.h that you inherit from alongside UObject. Its constructor adds this to a pending queue (Tickable.cpp:133-145), and the object only moves into the real array at the next tick pass (Tickable.cpp:67-95). The destructor removes it from that array (Tickable.cpp:147-152), and the engine walks the array once per frame (Tickable.cpp:167-208). This is a separate track running alongside the FTickFunction machinery that actors and components use. It is not part of it.

The upside is that the object does not have to be in a world, does not have to be spawned, and has no transform. The price is that it also gets none of what the other track gives actors: no tick groups, no AddTickPrerequisiteActor, no TickInterval. The entire class declaration (Tickable.h:134-210) does not contain a single one of them. You either give up all three or build them by hand.

For an object tied to a world, the call sits in UWorld::Tick (LevelTick.cpp:1792), after TG_PostPhysics (LevelTick.cpp:1749) but before TG_PostUpdateWork (LevelTick.cpp:1848). An actor ticking in TG_PostUpdateWork sees the state this tick has already produced. An object with no world lands somewhere else: it ticks only after the loop over all worlds. The engine says so outright in the comment on the method (Tickable.h:183), and the call itself sits in GameEngine.cpp:1947.

When to reach for this, and when not to

Does this have to be an actor? If the object needs a transform, collision, replication, placement in a level, or tick groups, the answer is “actor” and the rest of this post does not apply. None of those can be bolted onto FTickableGameObject.

Is the logic per-frame or event-driven? If it comes down to “in three seconds” or “every half second” and nothing in between, that is FTimerManager, not a tick. Polling state sixty times a second to respond once is a cost with no return.

Should the object live exactly as long as the world? If so, the right answer is almost always UTickableWorldSubsystem: a ready, tested lifecycle, without a single line of what I describe below. Your own base class earns its place when you need multiple instances (a subsystem is one instance per class per owner by definition), or when a designer is meant to create subclasses in Blueprint, which a subsystem cannot do.

Insight
UTickableWorldSubsystem is the best possible source on this: it is Epic’s own implementation of the pattern. The whole class fits in WorldSubsystem.cpp:97-160.

Decision one: when you register

The default answer: in the constructor. Inheritance is enough. The base constructor runs on its own, the object lands in the registry, no extra code.

And that is exactly what you must not do. The constructor documentation says so outright:

If this is something like a UObject that could be created on a different thread (like for async loading), construct with a Never tick type and enable tick later.

— Tickable.h:147 (UE 5.7)

The base constructor in full:

// UE 5.7 — Tickable.cpp:133-145
FTickableGameObject::FTickableGameObject(ETickableTickType StartingTickType)
{
    if (StartingTickType != ETickableTickType::Never)
    {
        // It is only safe to create tickable game objects on the game thread, as otherwise there is a race condition between object initialize and the game thread tick
        // If you hit this ensure, change the constructor to use FTickableGameObject(ETickableTickType::Never) and call SetTickableTickType after initialization
        ensure(IsInGameThread());

        // Queue for creation, this can get called very early in startup
        FTickableStatics& Statics = GetStatics();
        Statics.QueueTickableObjectForAdd(this, StartingTickType);
    }
}

The base constructor runs before the body of the derived class constructor, before the archetype and Blueprint-class defaults are applied over the native ones, and before the owner has a chance to configure anything. Defaults are not applied until FObjectInitializer::PostConstructInit (UObjectGlobals.cpp:4239), the copy itself in UObjectGlobals.cpp:4350, and PostInitProperties runs later still (UObjectGlobals.cpp:4427) — all three after the C++ constructor chain. The next tick pass will call GetTickableTickType(), and then Tick(), on a half-configured object. On top of that, the CDO registers along with the instances, because the FTickableGameObject constructor does not check IsTemplate() or anything of the sort (Tickable.cpp:133-145).

The correct answer is to construct with an explicit “do not tick” and enable ticking later:

UTickableObject::UTickableObject()
    : FTickableGameObject(ETickableTickType::Never)
{
    // Deliberately empty. Registering for tick here is exactly what Tickable.h forbids.
}

Decision two: when you unregister

The default answer: in the destructor. And again it is too late. A UObject destructor runs long after the object stopped being useful, and in the meantime the engine is free to tick it.

The right place is an explicit method called by the owner, plus a hard gate in BeginDestroy() as the last line of defense. The tick queries themselves are gates too, each in its own place:

ETickableTickType UTickableObject::GetTickableTickType() const
{
    // Never for the CDO and before Initialize: the object stays out of the tickable
    // array entirely instead of sitting in it and being polled every frame.
    return (IsTemplate() || !bInitialized) ? ETickableTickType::Never : ETickableTickType::Conditional;
}

bool UTickableObject::IsTickable() const
{
    return bInitialized && bTickEnabled && CachedWorld.IsValid() && IsValidChecked(this);
}

The split between these two methods is deliberate. IsTemplate() belongs in GetTickableTickType, not in IsTickable: that way the CDO never enters the array at all, instead of sitting in it and answering “no” every frame. IsTickable is left for the conditions that genuinely change over the object’s lifetime.

Note

Implement DisableTick() as SetTickableTickType(ETickableTickType::Never), a real removal from the array, rather than as a flag read in IsTickable. It comes at one cost worth remembering: re-enabling takes effect from the next tick pass, because SetTickableTickType adds the object to a pending queue (Tickable.cpp:59-63) that the engine drains at the start of the following pass (Tickable.cpp:67-95). An object enabled from inside Tick() does not tick a second time in the same frame.

// UE 5.7 — Tickable.cpp:59-63, indentation reduced (branch for an object not yet in the array)
else
{
    // Add to the pending list (which could override previous request), this will apply it next frame
    NewTickableObjects.Add(InTickable, NewTickType);
}

Decision three: which world you belong to

The default answer: none. Left unoverridden, GetTickableGameObjectWorld() returns nullptr (Tickable.h:187-190), and the object ticks in the global pass that follows all the worlds — knowing nothing about pause, nothing about PIE, nothing about the moment its world stops existing.

Overriding that method is cheap and handles all three at once:

UWorld* UTickableObject::GetTickableGameObjectWorld() const
{
    return CachedWorld.Get();
}

I resolve the world once, in Initialize(), and hold it in a TWeakObjectPtr<UWorld>. Plain GetWorld() is enough: the default UObject::GetWorld() implementation walks the Outer chain (Obj.cpp:1145-1150), so an object created with a sensible Outer finds its world with no help at all.

Here is the trap I ran into. Once the world is gone, CachedWorld.Get() starts returning nullptr. You would expect that to be the end of the tick. The opposite is true. The gate in the engine reads GetTickableGameObjectWorld() == World (Tickable.cpp:189), and TickObjects is also called with World == nullptr, precisely for objects with no world (GameEngine.cpp:1947). The comparison nullptr == nullptr passes. The object does not stop ticking. It quietly migrates from its own world’s tick to the global engine pass and keeps going.

The whole condition in the engine loop:

// UE 5.7 — Tickable.cpp:186-189 (indentation reduced)
// If it is tickable and in this world
if (TickableObject->IsAllowedToTick()
    && ((TickableEntry.TickType == ETickableTickType::Always) || TickableObject->IsTickable())
    && (TickableObject->GetTickableGameObjectWorld() == World))

Closing this takes two things at once: the CachedWorld.IsValid() condition in IsTickable(), and a subscription to world cleanup, so that the object shuts itself down instead of just no longer being polled.

void UTickableObject::HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources)
{
    if (World == CachedWorld.Get())
    {
        Shutdown();
    }
}

The delegate is FWorldDelegates::OnWorldCleanup, hooked up in Initialize() and removed in Shutdown(). Unregistering from inside your own broadcast is safe: Unreal’s multicast delegates defer compacting the list until the call finishes (MulticastDelegateBase.h:380-390).

Decision four: who cleans up

The default answer: nobody. A UObject with no UPROPERTY reference is collected at the next GC, and that is the correct behavior. It just has to be handled deliberately, instead of discovered halfway through a session when the object is already gone.

The reflex is AddToRoot(). That is not lifetime management; it is turning GC off for this object, with a manual RemoveFromRoot() as the only way out. The object survives a map change and everything else.

A sensible contract is simpler and puts both obligations on the owner: the object lives in a UPROPERTY(TObjectPtr<>), and Shutdown() runs before that reference is dropped.

Warning

Teardown does not belong in BeginDestroy(). It is tempting to hook it there — it looks like a fair way to close the lifecycle, one that saves a forgetful owner. It blows up Blueprint subclasses.

On the GC path the object already has the Unreachable flag set (GarbageCollection.cpp:5264, before ConditionalBeginDestroy is even called in GarbageCollection.cpp:6155), and a BlueprintNativeEvent overridden in Blueprint dispatches through UObject::ProcessEvent, which opens with checkf(!IsUnreachable(), ...) (ScriptCore.cpp:2015-2020).

The result: the first Blueprint subclass to implement the teardown event crashes the editor during an ordinary garbage collection. In a Shipping build the check is gone, so instead of a crash you get a silently skipped teardown — behavior that differs by build configuration, which is harder still to catch.

So BeginDestroy() does exactly as much as it has to and not an ounce more: it removes the delegate (a plain Remove, no dispatch into Blueprint), kills the tick, and reports that the owner never called Shutdown().

void UTickableObject::BeginDestroy()
{
    FWorldDelegates::OnWorldCleanup.Remove(WorldCleanupHandle);
    WorldCleanupHandle.Reset();

    SetTickableTickType(ETickableTickType::Never);
    bTickEnabled = false;

    ensureAlwaysMsgf(!bInitialized,
        TEXT("%s: destroyed while still initialized - the owner never called Shutdown()."), *GetName());

    Super::BeginDestroy();
}

ensureAlwaysMsgf, not ensureMsgf: a plain ensure fires once per callsite per process. The bEnsureHasExecuted flag is keyed by a hash of __FILE__ and __LINE__, and the Always variant skips that filter (AssertionMacros.h:440-448). The first leaked object would silence the diagnostic for every one after it.

Epic does the same in UTickableWorldSubsystem::BeginDestroy (WorldSubsystem.cpp:154-159): it does not call Deinitialize from there — and now the reason is clear. Two differences from the listing above are deliberate: Epic leaves a plain ensureMsgf there (WorldSubsystem.cpp:158) and calls Super::BeginDestroy() first (WorldSubsystem.cpp:156).

The whole class

The complete implementation ships in StillCooking_Tools as USCTickableObject — a free, MIT-licensed plugin distributed as C++ source (GitHub), module StillCookingCore, header Objects/SCTickableObject.h. It is the class from this post, developed further: on top of the four decisions above it adds an explicit tick intent that Initialize() applies, the bTickWhenPaused and bTickInEditor gates, and a protected engine-facing interface with IsTickable() marked final.

Checklist

Five things to check in your own class:

  1. The constructor calls FTickableGameObject(ETickableTickType::Never) and does nothing else tick-related.
  2. GetTickableTickType() returns Never for IsTemplate() and for the pre-initialization state, so the CDO never enters the array.
  3. GetTickableGameObjectWorld() returns a real world, and IsTickable() checks whether that world is still alive.
  4. There is an explicit shutdown method called by the owner, plus a world-cleanup subscription for the case where the world goes first.
  5. BeginDestroy() kills the tick and reports the problem, but does not run subclass teardown.

Related pages