Skip to content

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 the OnReadyToSpawn delegate 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

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, BeginPlay runs before possession — the ASC is not initialized yet, so InitializeProvider's bStartupKitsGranted check is false and it skips; InitAbilityActorInfo performs the grant later.
  • On a PIE PlayPreset relaunch, possession runs first with a null preset — InitAbilityActorInfo no-ops the composed grant; InitializeProvider performs 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 before UnPossess, 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 RequestPlayerSpawn caller 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 / TryFinishTransitionSource/ProjectEternal/Private/GameMode/Components/TransitionStateManager.cpp
  • UTransitionStateManager::ArePlayersPlaced / IsLoadInFlightSource/ProjectEternal/Private/GameMode/Components/TransitionStateManager.cpp
  • SpawnSettleTimeout (EditDefaultsOnly) — Source/ProjectEternal/Public/GameMode/Components/TransitionStateManager.h
  • ETransitionPhaseSource/ProjectEternal/Public/UI/ViewModels/Transition/TransitionTypes.h

Player Spawn Manager

  • UPlayerSpawnManager::QueueSpawnRequest / ProcessPendingSpawns / OnReadyToSpawnSource/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cpp
  • UPlayerSpawnManager::IsLevelReadyForSpawn (world-map gate) — Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cpp
  • UPlayerSpawnManager::SpawnPlayerPawn (honest refusal) — Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cpp
  • UPlayerSpawnManager::DiscardPendingInitialSpawnsSource/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cpp
  • UPlayerSpawnManager::IsTransitionInProgress (Completing is spawnable) — Source/ProjectEternal/Private/GameMode/Components/PlayerSpawnManager.cpp

Game Mode

  • AEternalGameMode::TravelToCurrentNode / HandleStartingNewPlayerSource/ProjectEternal/Private/GameMode/EternalGameMode.cpp
  • AEternalGameMode::SpawnDefaultPawnFor / SpawnDefaultPawnAtTransformSource/ProjectEternal/Private/GameMode/EternalGameMode.cpp

Death Respawn

  • AEternalPlayer::Server_RestartLevelSource/ProjectEternal/Private/Character/EternalPlayer.cpp
  • AEternalPlayer::AcknowledgePossession / OnUnPossess (HUD + input rebind) — Source/ProjectEternal/Private/Character/EternalPlayer.cpp
  • UEternalAbilitySystemComponent::ResetForRespawnSource/ProjectEternal/Private/AbilitySystem/EternalAbilitySystemComponent.cpp

Kit Grant Ordering

  • AEternalCharacter::InitAbilityActorInfoSource/ProjectEternal/Private/Character/EternalCharacter.cpp
  • UPersistenceComponent::InitializeProviderSource/ProjectEternal/Private/Persistence/Components/PersistenceComponent.cpp
  • UEternalAbilitySystemLibrary::GiveComposedKits (self-latch) — Source/ProjectEternal/Private/Utils/EternalAbilitySystemLibrary.cpp


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