The Class Default Object, the full initialization pipeline from constructor to BeginPlay, what a constructor may and may not do, how Blueprint differs, and why destroyed does not mean null.
For every UClass, Unreal creates exactly one Class Default Object — a real, allocated instance built once when the class first loads. It's the template holding default property values: when you spawn a real instance, the engine uses the CDO's values as the starting point, then applies any per-instance overrides on top.
Your C++ constructor runs on the CDO first, at class-load time — before any gameplay instance exists, before the world exists, with no guarantee other actors are around. So the constructor is for setting defaults only.
BeginPlay. The constructor also runs per spawned instance, but conceptually its job is defaults, and it runs in that special no-world context.
Defaults set in the constructor land on the CDO:
AMyActor::AMyActor()
{
Health = 100.f;
bIsHostile = true;
}
Every future instance inherits these unless overridden.
EditDefaultsOnly field in the Details panel, edits the Blueprint-generated class's CDO — not any live instance.GetClass()->GetDefaultObject<AMyActor>() hands you the CDO, letting you read a class's defaults without spawning anything — for example reading a projectile's default damage off a ProjectileClass before you ever fire it. Handy for data-driven code."Constructor for defaults, BeginPlay for logic" is the conclusion. The pipeline between them is what explains it — and there are several useful hooks in the gap.
// Spawning an instance of a Blueprint class derived from your C++ class
1. C++ constructor chain // base -> derived. Native defaults,
// CreateDefaultSubobject. NO world.
2. Serialized / Blueprint values // designer-authored defaults applied
// to the instance, over the top
3. PostInitProperties() // first point those values are readable
4. Simple Construction Script // components added in the BP Components
// panel are created here
5. PostActorCreated() // spawned; PostLoad() if loaded instead
6. OnConstruction() // the Construction Script
7. Component registration // -> InitializeComponent() on each
// component that wants it
8. PostInitializeComponents() // all components exist and are initialized
9. BeginPlay() // world is live, gameplay has started
So a designer setting Health = 250 in a Blueprint's Class Defaults is invisible to this:
AMyActor::AMyActor()
{
Health = 100.f;
MaxHealth = Health * 2.f; // always 200. Never 500.
}
MaxHealth is computed from the native default, not the designer's value, because the designer's value hasn't arrived yet. PostInitProperties() is the first hook that can see it — that's what it's for.
| Hook | State when it runs | Use it for |
|---|---|---|
| Constructor | No world, no BP values, no BP components | Native defaults, default subobjects, tick settings |
| PostInitProperties | Property values fully applied; components not yet registered | Derived values computed from designer-set properties |
| OnConstruction | Properties and components available; runs in-editor too | Procedural setup and editor preview driven by designer properties |
| InitializeComponent | Per component, at registration, before the owner finishes | A component initializing itself |
| PostInitializeComponents | Every component exists and is initialized | Owner-side wiring that needs components — caching pointers, binding component delegates |
| BeginPlay | World live, gameplay started, other actors present | Everything else — spawning, timers, gameplay |
The two diverge in the middle:
PostActorCreated().PostLoad() instead. Its OnConstruction has typically already been re-run many times in the editor before the game ever starts.Both converge again at component registration, and BeginPlay fires for both when play begins.
CreateDefaultSubobject<T>() for native componentsSetRootComponent() and attaching those subobjectsPrimaryActorTick.bCanEverTick / TickIntervalbReplicates = trueConstructorHelpers lookupsGetWorld() or rely on a worldThe constructor runs on the CDO at class-load time. There is no world at that point — GetWorld() returns null. Spawning requires a world to spawn into, so the call has nothing to work with.
It's worth being precise about the failure, because it's easy to write a constructor that appears to work: the CDO is constructed once at load, so a spawn attempt there fails or asserts long before any gameplay path runs it. And even if a world existed, you'd be spawning an actor per CDO construction — which is not what anyone means to do.
The constructor's results land on the CDO, and the CDO gets serialized. Randomizing a value there means the saved class defaults differ run to run, which produces nondeterministic cooks and spurious diffs on assets nobody edited. Per-instance randomization belongs in BeginPlay, or in OnConstruction with a seeded stream if it genuinely needs to be visible in the editor.
| You want to… | Do it in |
|---|---|
| Compute a value from a designer-set property | PostInitProperties |
| Cache a pointer to a component, or bind to one | PostInitializeComponents |
| Spawn an actor, start a timer, query the world | BeginPlay |
| Build geometry a designer should see while editing | OnConstruction (the Construction Script) |
| Randomize per instance | BeginPlay |
Attaching native components is the standard, recommended pattern, and it's the clearest illustration of "configure the object itself":
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = true;
RootMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("RootMesh"));
RootComponent = RootMesh;
Collider = CreateDefaultSubobject<USphereComponent>(TEXT("Collider"));
Collider->SetupAttachment(RootComponent);
}
CreateDefaultSubobject<T>() here, never NewObject<T>(). CreateDefaultSubobject registers the component with the CDO, which is what makes it editor-visible, serialized, and correctly inherited by Blueprint children. NewObject doesn't hook into any of that.TEXT("Name") argument is the component's internal name and must be unique per class.RootComponent and calling SetupAttachment() works here specifically because everything is still template/CDO form — not a live world object yet.BeginPlay or later — is a different, rarer tool: NewObject<T>() followed by RegisterComponent(). Reach for it only when the component shouldn't exist on every instance by default; it's more expensive than the constructor path.
ConstructorHelpers::FObjectFinder<T> and FClassFinder<T> grab an asset by path inside a constructor:
static ConstructorHelpers::FObjectFinder<UStaticMesh>
MeshAsset(TEXT("/Game/Meshes/SM_Rock.SM_Rock"));
if (MeshAsset.Succeeded())
{
MeshComponent->SetStaticMesh(MeshAsset.Object);
}
CreateDefaultSubobject carries the same restriction for the same reason.static keyword matters. It caches the resolved pointer after the first call, during CDO construction, so the expensive asset-registry and disk lookup doesn't re-run on every spawn. The rest of the constructor body still executes on every instantiation — only the lookup is cached.OnConstruction. Despite the name it is not a constructor — it runs much later in the pipeline, it runs repeatedly, and it runs in the editor.| C++ Constructor | Construction Script | |
|---|---|---|
| When it runs | Class load (CDO) and object creation, before properties are applied | Step 6 — after properties and components exist |
| How often | Once per object creation | Repeatedly — on spawn, and on every property edit or move in the editor |
| Sees Blueprint-set defaults | No | Yes |
| Sees BP-added components | No — native default subobjects only | Yes |
| World access | None | Yes, but it's an editor world as often as a game one |
| Can spawn actors | No | Technically yes — but don't, see below |
| Purpose | Native defaults and default subobjects | Procedural setup and editor preview driven by designer properties |
Dropping a component into a Blueprint's Components panel adds it to that class's Simple Construction Script, which runs at step 4 — after the native constructor chain has finished. A C++ constructor therefore cannot see, configure, or cache a pointer to it.
If your C++ needs a handle on a component a designer added, take it in PostInitializeComponents, where everything is guaranteed to exist.
BeginPlay.BeginPlay.Blueprint exposes two points in the pipeline directly: the Construction Script and Event BeginPlay. The intermediate hooks — PostInitProperties, InitializeComponent, PostInitializeComponents — are C++-only.
BlueprintImplementableEvent from it (Chapter 3). You decide which moments in the lifecycle are worth handing over.
Unreal runs a mark-and-sweep garbage collector over UObjects. The key idea to internalize:
Calling Destroy() doesn't wipe the object immediately — it sets an internal flag: Garbage in UE5, previously PendingKill in UE4. Memory and the pointer value persist until the GC's next pass reclaims them. In that window, a raw pointer is non-null but logically dead.
If it's a UPROPERTY in C++ or an exposed Blueprint variable, the GC nulls it for you — but on the GC's schedule, not the frame Destroy() was called.
Raw C++ pointers not wrapped in UPROPERTY get no such courtesy. They dangle, and dereferencing one is a hard crash rather than a clean null.
UPROPERTY is also what makes a pointer visible to the GC in the first place — which is why Chapter 7 treats an unwrapped raw pointer as a bug rather than an optimization.
| Check | Catches | Misses |
|---|---|---|
| != nullptr | Never-set or explicitly-nulled references | Destroyed but not yet collected |
| IsValid() | Both null and the Garbage / PendingKill flag | Nothing — this is the real "safe to use?" check |
IsValid() is what the Blueprint Is Valid node calls under the hood, with its separate valid and not-valid exec pins.
== on object references compares pointers, not aliveness:
Destroy() still returns true, because the pointer value hasn't changed — only the flag has.The variable does not become None the instant you destroy it.
IsValid() before use, especially in Tick.
TWeakObjectPtr is the C++ tool for a reference that reports its own death without waiting on GC nulling — it checks liveness against the object's internal serial number.