Character Init Local/Remote Mechanism and CharacterDef Guard Impact¶
Historical analysis, superseded by lifecycle gates. The pawn Blueprint initialization hooks described below have been removed. Current subsystem notifications are
OnLocalCharacterInitGateOpened/OnRemoteCharacterInitGateOpened; they indicate open prerequisites, not completed initialization.
Scope¶
This documents the current ATankPlayerCharacter local/remote init mechanism (UponLocalCharacterInitialized / UponRemoteCharacterInitialized) and evaluates what changes if we add a CharacterDef readiness guard before firing those events.
Where the mechanism lives¶
- Declarations:
Source/Metal_terra/Public/Character/TankPlayerCharacter.hUponLocalCharacterInitialized()(BlueprintImplementableEvent)UponRemoteCharacterInitialized()(BlueprintImplementableEvent)
- Runtime logic:
Source/Metal_terra/Private/Character/TankPlayerCharacter.cppHandleClientInit()- event fire points:
- local:
UponLocalCharacterInitialized() - remote:
UponRemoteCharacterInitialized()
Current mechanism¶
OnRep_PlayerState()callsHandleClientInit().OnRep_Controller()callsHandleClientInit()for client/listen/standalone modes.BeginPlay()callsHandleClientInit()if init was deferred before begin play.HandleClientInit()decides:- local controlled pawn -> fire local init
- non-local pawn -> wait until local pawn marked ready, then fire remote init
- A per-world queue (
GClientInitQueues) holds remote pawns until local init has fired.
Existing guards (today)¶
bClientInitFiredidempotence guard (skip duplicate fire).- Requires
GetPlayerState(). - Requires
GetWorld(). - Local client defers until
HasActorBegunPlay()(client-only check). - Remote defers until:
- local character is marked ready in queue, and
- remote has begun play.
HandleClientUnpossessed()resets queue-related state.
Important missing guard¶
There is currently no guard that requires TankPlayerState->GetCharacterDef() to be non-null before firing UponLocalCharacterInitialized() / UponRemoteCharacterInitialized().
This is the direct reason we observed BP-side null access in UponLocalCharacterInitialized when CharacterDef replication lags behind OnRep_PlayerState and begin-play timing.
If we add a CharacterDef guard, what changes?¶
Proposed guard idea:
- In HandleClientInit(), before firing local/remote event, require:
- TankPlayerState != nullptr
- TankPlayerState->GetCharacterDef() != nullptr
- If null, do not fire yet; keep pending and retry later.
Flow-by-flow impact analysis¶
1) Standalone flow¶
- Expected behavior:
- Usually still works because CharacterDef is often set before local init.
- Risk:
- If CharacterDef is not set at first
HandleClientInit()call, there is no replication-driven wake-up like network clients. - Potential stall if retry trigger depends only on replication delegates.
- Conclusion:
- Can break if wake-up strategy is weak.
2) Listen server host local pawn flow¶
- Expected behavior:
- Usually okay when server already has CharacterDef before
PossessedBylocal init path. - Risk:
- Same stall class as standalone if CharacterDef arrives late and no guaranteed wake-up.
- Extra nuance: some code paths call
SetCharacterDefSource(CharacterDef)without broadcast, so waiting only onOnCharacterDefChangedis unsafe. - Conclusion:
- Can break without explicit retry or an always-fired readiness signal.
3) Client joined listen server flow (autonomous proxy on client)¶
- Expected behavior:
- This is where guard helps most by preventing early BP init with null CharacterDef.
- Risk:
- If waiting depends only on
OnCharacterDefChanged, there are edge cases where that delegate can be suppressed by predicted-selection logic. - Conclusion:
- Good improvement, but do not rely on a single delegate for wake-up.
4) Client joined dedicated server flow¶
- Expected behavior:
- Also a strong improvement; prevents early init against not-yet-replicated CharacterDef during DS join/bootstrap timing.
- Risk:
- If bootstrap/selection fails and CharacterDef remains null, init can stall forever unless fallback policy exists.
- Conclusion:
- Safe with timeout/fallback and robust re-entry triggers.
Why OnCharacterDefChanged alone is not enough¶
ATankPlayerState::SetCharacterDefSource(..., bBroadcastChange=false)can update CharacterDef with no broadcast.- Replication-side
OnCharacterDefChangedmay be conditionally suppressed by predicted-selection reconciliation. - Therefore, a guard waiting only on that delegate can deadlock.
Recommended safe approach¶
If adding CharacterDef guard, use multi-source wake-up:
1. Keep existing HandleClientInit() entry points (OnRep_PlayerState, OnRep_Controller, BeginPlay deferred path).
2. Add a lightweight retry timer (for example next-tick or short interval) while init is pending and CharacterDef is null.
3. Optionally also listen for OnCharacterDefChanged, but do not depend on it as sole trigger.
4. Add timeout + warning path (avoid infinite stall), with explicit behavior:
- either fire init without CharacterDef after timeout, or
- hard fail with clear log and user-facing error path.
5. Consider making CharacterDef updates that are intended to unblock init emit a consistent readiness signal.
Suggested validation matrix after guard addition¶
- Standalone start -> local init fires once, no stall.
- Listen host -> local init fires once, no stall.
- Client join listen host -> no BP null access from
GetCharacterDef, init eventually fires. - Client join DS -> no BP null access from
GetCharacterDef, init eventually fires. - Failure path (CharacterDef missing/invalid) -> explicit timeout/warning behavior is observed, not silent stall.