Spawn & Transition Ordering¶
Summary: Player spawning is gated behind level streaming.
UTransitionStateManager(on the replicated GameState) runs a phased transition and holds the loading screen until every player actually has a pawn;UPlayerSpawnManager(on the GameMode) queues spawns while levels stream and refuses to spawn into an empty world. The two coordinate through theOnReadyToSpawndelegate and a placement poll so the screen never drops onto an unpossessed camera. A separate ordering hazard — possession running before provider init — is handled by a two-sided latch for ability-kit grants.
Table of Contents¶
- Why This Architecture
- Transition Lifecycle
- The Spawn Window
- Loading-Screen Settle-Hold
- Stale-Node Discard
- The World-Map Gate
- Honest Spawn Refusal
- Possession-vs-Init Ordering
- Per-Controller Death Respawn
- Symptoms and Causes
- Source References
- Related Systems
- Recent Changes
Why This Architecture¶
A player pawn must never appear before the level it stands on. In a streaming world the destination level arrives asynchronously, after the engine's normal spawn point in the login flow. If the engine spawns a default pawn on schedule, the player falls through an empty persistent map — a black void — until something rescues them. Every piece below exists to keep the pawn, the camera, and the level in lockstep across all clients.
Ownership¶
| Concern | Owner | Why |
|---|---|---|
| Transition phase, freeze/unfreeze, loading screen | UTransitionStateManager on AEternalGameState |
Replicated; drives all clients via reliable multicasts |
| Spawn queuing, spawn-point resolution, pawn creation | UPlayerSpawnManager on AEternalGameMode |
Server-only; the GameMode owns restart |
| Death respawn of a single controller | AEternalPlayer (server RPC) |
Per-controller, deliberately outside the transition flow |
| Travel decision (which node, when) | AEternalGameMode |
Coordinates persistence load with world-map travel |
The state manager lives on the GameState because transition state must replicate to every client. The
spawn manager lives on the GameMode because only the server spawns and the GameMode owns the restart
path. RestartPlayerAtPlayerStart is the initial/queued spawn path only — it lives inside
UPlayerSpawnManager::SpawnPlayerPawn. A death respawn does not go through it; see
Per-Controller Death Respawn.
Transition Lifecycle¶
StartTransition freezes characters, shows the screen, and walks a set of phases. Phases are server-only
state (CurrentPhase); clients never see them — they react to multicasts and replicated flags instead.
ETransitionPhase (server-only)
None
| StartTransition()
v
Freezing Multicast_FreezeCharacters + Multicast_ShowTransition
|
v
Loading level/environment streaming; InWorldTeleport fires
| OnRequestDynamicSublevelLoad for feature subsystems
v
WaitingForClients each client reports ready via Server_ReportTransitionReady
| -> OnClientReady; when all ready -> CompleteTransition()
v
Completing <-- THE SPAWN WINDOW (see below)
| OnReadyToSpawn.Broadcast(); hold screen until placed
v
None OnTransitionComplete + Multicast_EndTransition (unfreeze, hide)
Freezing happens on every machine: GravityScale does not replicate, so each client zeroes it
locally or the frozen pawn falls. Input is disabled only where the pawn is locally controlled.
A new travel arriving mid-transition supersedes the in-flight one — client tracking resets but the screen stays up, so the player never sees a flicker between two destinations.
The Spawn Window¶
Spawning happens in the Completing phase, not before. CompleteTransition broadcasts
OnReadyToSpawn while characters are still frozen, so pawns are placed before anyone can move.
The subtlety: ready callbacks that normally trigger spawns (dungeon ready, fixed-level streaming
complete) fire during Completing because dungeon rooms can stream after client readiness. Those
callbacks must process spawns during Completing rather than deferring to an OnReadyToSpawn broadcast
that has already happened. So IsTransitionInProgress() deliberately treats Completing as spawnable:
// UPlayerSpawnManager::IsTransitionInProgress
return TSM->IsTransitioning() && TSM->GetTransitionPhase() != ETransitionPhase::Completing;
Everywhere else in the transition, QueueSpawnRequest queues instead of spawning immediately —
OnReadyToSpawn owns the actual spawn. Only in Completing does a late-arriving ready callback get to
run a spawn directly.
Loading-Screen Settle-Hold¶
Client readiness only proves that levels streamed, not that pawns exist. A refused restart
re-queues on the next ready callback; dungeon rooms stream after readiness. Dropping the screen here
shows the void with the camera still unpossessed. So CompleteTransition does not end the transition —
it starts a poll:
CompleteTransition()
OnReadyToSpawn.Broadcast() // spawn attempt (frozen pawns)
SpawnSettleDeadline = now + SpawnSettleTimeout
TryFinishTransition()
|
+-- ArePlayersPlaced()? ---- YES ---> finish: OnTransitionComplete + unfreeze + hide
| (no pending spawns AND every PC has a pawn)
|
NO
|
+-- IsLoadInFlight()? ---- YES ---> push deadline forward (a slow load is progress)
|
+-- now < deadline? ---- YES ---> re-poll in 0.25s (SpawnSettleTimerHandle)
|
NO (deadline passed, nothing loading)
|
v
finish anyway, logging an unplaced-player warning
ArePlayersPlaced() is the exit condition: no pending spawn requests and every player controller
has a pawn. IsLoadInFlight() (world-map streaming in progress, or an active dungeon whose rooms are
not ready) keeps extending the deadline so a cold-editor stream that takes minutes is not mistaken for
a wedge. SpawnSettleTimeout (EditDefaultsOnly, default 15s) bounds only the genuinely wedged case:
nothing loading, players still unplaced. The poll itself only observes placement — spawning stays
owned by the ready callbacks and the spawn manager's retry timer.
Stale-Node Discard¶
On a relaunch (a saved character, or a read-only preset) the world-map node in GameInstance state is
stale until persistence restores it. But possession happens first, and the possession-time
InitialSpawn request captures that stale node. Because EnsureSpawnsQueuedForAllPlayers skips
players who already have a queued request, the stale request would win and spawn the player at the
wrong node.
TravelToCurrentNode resolves the real target only after persistence settles (the GameMode waits on
PersistenceComponent::WillAutoLoad / OnLoadCompleted before travelling). Immediately before
handing off to WorldMapSubsystem::TravelToNode, it discards the stale requests:
// AEternalGameMode::TravelToCurrentNode, after resolving TargetNodeID
if (SpawnManager)
{
SpawnManager->DiscardPendingInitialSpawns();
}
DiscardPendingInitialSpawns drops only InitialSpawn requests. The travel-completion callbacks then
re-queue every pawnless player against the freshly resolved node. A player who already received a
match-start default pawn is covered too — the re-queue produces a LevelTransition (teleport) request
for them rather than a second spawn.
The World-Map Gate¶
The engine's login flow tries to spawn a default pawn on its own schedule, before the destination
level streams in. SpawnDefaultPawnFor / SpawnDefaultPawnAtTransform are overridden to return null
when IsLevelReadyForSpawn says no — that is the first line of defence.
IsLevelReadyForSpawn returns false in the one case that matters most for boot: a world map governs
the session (WorldMapSubsystem::HasWorldMap()), no dungeon is active, and nothing is streamed yet. The
boot travel simply has not delivered a level. Reporting ready here would let the engine drop the
match-start default pawn into the empty persistent map — the black void, falling until the post-travel
teleport rescues it. Refusing keeps the request queued until streaming completes.
The other branches: an active dungeon is ready only when its rooms are ready; a requested world-map node is ready only when the currently streamed node matches. With no world-map or dungeon subsystem managing levels at all (standalone test maps), it falls through to ready so PIE test maps still work.
Honest Spawn Refusal¶
Even after the gate, RestartPlayerAtPlayerStart can be silently refused downstream (the same
level-ready check inside SpawnDefaultPawnFor). SpawnPlayerPawn does not assume success — it checks
whether a pawn actually resulted and, if not, logs and returns for re-queue rather than pretending the
spawn happened:
GM->RestartPlayerAtPlayerStart(Player, SpawnPoint);
if (!Player->GetPawn())
{
// refused by the level-ready gate — a later ready callback re-queues via
// EnsureSpawnsQueuedForAllPlayers; do not proceed as if we have a pawn
return;
}
This honesty is what makes the settle-hold correct: ArePlayersPlaced() stays false while a refused
restart is outstanding, so the screen keeps holding instead of dropping on a pawnless controller.
ProcessPendingSpawns similarly re-queues when no spawn point is found yet (spawn-point actors may not
be registered) and schedules a 0.1s retry.
Possession-vs-Init Ordering¶
A second, unrelated ordering hazard lives on the ability side: possession can precede provider init
and even BeginPlay. The PIE PlayPreset relaunch spawns and possesses the pawn before the
controller's BeginPlay runs, so AEternalCharacter::InitAbilityActorInfo (called from
PossessedBy) runs while PersistenceComponent's preset provider has not yet resolved ActivePreset.
At that moment the composed ability kits cannot be granted — the preset is not known.
The fix is a two-sided latch so whichever side resolves last performs the grant exactly once:
PossessedBy -> InitAbilityActorInfo (server) PersistenceComponent::InitializeProvider
GiveComposedKits(Controller, ASC) (runs from the component's BeginPlay)
preset not resolved yet? -> no-op preset now resolved; possession already
bStartupKitsGranted = true happened (bStartupKitsGranted)? ->
GiveComposedKits(PC, ASC)
\ /
\ /
v v
GiveComposedKits self-latches on bComposedKitsGranted
-> the grant runs on exactly one side, never both
- On a dedicated-server join,
BeginPlayruns before possession — the ASC is not initialized yet, soInitializeProvider'sbStartupKitsGrantedcheck is false and it skips;InitAbilityActorInfoperforms the grant later. - On a PIE PlayPreset relaunch, possession runs first with a null preset —
InitAbilityActorInfono-ops the composed grant;InitializeProviderperforms it once the preset resolves.
GiveComposedKits latches on bComposedKitsGranted after the grants run (a preset with zero kits
still latches — nothing is left to grant), which is what makes the two entry points collapse to a
single grant. Both bStartupKitsGranted and bComposedKitsGranted live on the ASC, which lives on the
(persistent) PlayerState, so a pawn respawn re-running InitAbilityActorInfo does not re-grant.
The same persistence cuts the other way on death: death state does not launder away with the pawn.
UEternalAbilitySystemComponent::ResetForRespawn() is the single place that removes State.Dead and
strips finite-duration effects — the DoT that killed the player is still active on the PlayerState ASC
and would resume ticking on the fresh pawn the moment the tag lifts. Infinite effects (equipment
modifiers, kit passives, auras) are deliberately left alone: they belong to the character, not the life.
See Ability Kits for the kit-grant flow in full.
Per-Controller Death Respawn¶
Restart used to be a session-wide ServerTravel("?Restart") — it reloaded the map for every player,
and an aborted travel in a packaged build exits to desktop with no error at all. Restart is now a
per-controller pawn respawn on AEternalPlayer::Server_RestartLevel, gated on the caller actually
holding State.Dead so a client cannot spam it. Inventory lives on the controller and equipment on the
PlayerState, so both survive a plain pawn swap; nothing needs a travel.
Server_RestartLevel (server, gated on State.Dead)
ResetForRespawn() clear State.Dead + strip finite-duration effects
DeadPawn = GetPawn()
UnPossess()
GameMode->RestartPlayer(this)
|
+-- got a pawn? ---- NO ---> RequestPlayerSpawn(this,
| SpawnManager->GetLateJoinContext(),
| SpawnManager->GetCurrentSpawnNodeID())
v
DeadPawn->Destroy() the dead pawn goes last
The fallback closes a real ordering hazard: SpawnDefaultPawnFor returns null while the level-ready
gate is closed (see The World-Map Gate), and RestartPlayer has no retry of its
own — the player would simply stay pawnless. Queuing through the spawn manager hands the request to the
one component that does retry.
Ordering notes:
ResetForRespawn()runs beforeUnPossess, so the respawned pawn is possessed by an ASC already cleared of death state.- The dead pawn is destroyed last, after the new one exists — destroying it first would leave the controller pawnless across the spawn attempt.
- This is the only
RequestPlayerSpawncaller outside the transition flow, and it deliberately bypasses the transition and its settle-hold: no level is changing, so there is nothing to hold a loading screen for.
Symptoms and Causes¶
| Symptom | Cause | Where it is handled |
|---|---|---|
| Black void on boot, pawn falling | Default pawn spawned into the empty persistent map before travel delivered a level | IsLevelReadyForSpawn returns false when a world map governs the session but nothing is streamed; SpawnDefaultPawnFor returns null |
| Camera stuck unpossessed after a transition | Loading screen dropped while a spawn was still in flight (refused restart / dungeon rooms still streaming) | Settle-hold: TryFinishTransition polls ArePlayersPlaced() until placed or SpawnSettleTimeout, extended by IsLoadInFlight() |
| Player spawns at the wrong (previous) node after relaunch | Possession-time InitialSpawn captured the stale pre-persistence world-map node |
DiscardPendingInitialSpawns before travel; travel-completion callbacks re-queue against the resolved node |
| Spawn silently "succeeds" but no pawn exists | RestartPlayerAtPlayerStart refused by the level-ready gate but treated as success |
SpawnPlayerPawn checks GetPawn() and returns for re-queue instead of proceeding |
| Abilities missing after a preset relaunch | Composed kits skipped because possession ran before the preset provider resolved ActivePreset |
Two-sided latch: PersistenceComponent::InitializeProvider performs the grant InitAbilityActorInfo skipped |
| Double ability grants | Both init sides granting, or a respawn re-granting | GiveComposedKits self-latches on bComposedKitsGranted; bStartupKitsGranted gates the per-session grant on the persistent ASC |
| Respawned player takes no damage; Restart stays spammable | State.Dead latched on the persistent PlayerState ASC — it never cleared with the pawn |
ResetForRespawn() removes the tag; it is the only place that does |
| A DoT keeps ticking after respawn | Finite-duration effects live on the PlayerState ASC and outlive the pawn they killed | ResetForRespawn() strips finite-duration effects; infinite ones (gear, kit passives, auras) survive by design |
| HUD and input dead after respawn | OnUnPossess tears down HUD/input, and death respawn is the first mid-session repossession |
AEternalPlayer::AcknowledgePossession rebinds the pawn-bound UI on the local side of every possession |
Source References¶
Transition State Manager¶
UTransitionStateManager::StartTransition/CompleteTransition/TryFinishTransition—Source/ProjectEternal/Private/GameMode/Components/TransitionStateManager.cppUTransitionStateManager::ArePlayersPlaced/IsLoadInFlight—Source/ProjectEternal/Private/GameMode/Components/TransitionStateManager.cppSpawnSettleTimeout(EditDefaultsOnly) —Source/ProjectEternal/Public/GameMode/Components/TransitionStateManager.hETransitionPhase—Source/ProjectEternal/Public/UI/ViewModels/Transition/TransitionTypes.h
Player Spawn Manager¶
UPlayerSpawnManager::QueueSpawnRequest/ProcessPendingSpawns/OnReadyToSpawn—Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cppUPlayerSpawnManager::IsLevelReadyForSpawn(world-map gate) —Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cppUPlayerSpawnManager::SpawnPlayerPawn(honest refusal) —Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cppUPlayerSpawnManager::DiscardPendingInitialSpawns—Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cppUPlayerSpawnManager::IsTransitionInProgress(Completing is spawnable) —Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cpp
Game Mode¶
AEternalGameMode::TravelToCurrentNode/HandleStartingNewPlayer—Source/ProjectEternal/Private/GameMode/EternalGameMode.cppAEternalGameMode::SpawnDefaultPawnFor/SpawnDefaultPawnAtTransform—Source/ProjectEternal/Private/GameMode/EternalGameMode.cpp
Death Respawn¶
AEternalPlayer::Server_RestartLevel—Source/ProjectEternal/Private/Character/EternalPlayer.cppAEternalPlayer::AcknowledgePossession/OnUnPossess(HUD + input rebind) —Source/ProjectEternal/Private/Character/EternalPlayer.cppUEternalAbilitySystemComponent::ResetForRespawn—Source/ProjectEternal/Private/AbilitySystem/EternalAbilitySystemComponent.cpp
Kit Grant Ordering¶
AEternalCharacter::InitAbilityActorInfo—Source/ProjectEternal/Private/Character/EternalCharacter.cppUPersistenceComponent::InitializeProvider—Source/ProjectEternal/Private/Persistence/Components/PersistenceComponent.cppUEternalAbilitySystemLibrary::GiveComposedKits(self-latch) —Source/ProjectEternal/Private/Utils/EternalAbilitySystemLibrary.cpp
Related Systems¶
- Replication Overview — property replication and multicasts
- Server Authority — the server-only mutation model these components follow
- Ability Kits — composed kit grant flow and the two-sided latch
- Persistence — auto-load timing that gates initial travel
Recent Changes¶
| Date | Change | Reason |
|---|---|---|
| 2026-07-07 | Initial documentation | Capture the spawn/possession/transition ordering contract, the settle-hold, and the kit-grant latch |
| 2026-08-06 | Documented per-controller death respawn; corrected the restart-path ownership note | Restart moved off session-wide ServerTravel (an aborted travel exits a packaged build to desktop); RestartPlayerAtPlayerStart is now the initial/queued spawn path only, and respawn must explicitly reset death state on the persistent ASC |