Skip to content

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.h
    • UponLocalCharacterInitialized() (BlueprintImplementableEvent)
    • UponRemoteCharacterInitialized() (BlueprintImplementableEvent)
  • Runtime logic:
  • Source/Metal_terra/Private/Character/TankPlayerCharacter.cpp
    • HandleClientInit()
    • event fire points:
    • local: UponLocalCharacterInitialized()
    • remote: UponRemoteCharacterInitialized()

Current mechanism

  1. OnRep_PlayerState() calls HandleClientInit().
  2. OnRep_Controller() calls HandleClientInit() for client/listen/standalone modes.
  3. BeginPlay() calls HandleClientInit() if init was deferred before begin play.
  4. HandleClientInit() decides:
  5. local controlled pawn -> fire local init
  6. non-local pawn -> wait until local pawn marked ready, then fire remote init
  7. A per-world queue (GClientInitQueues) holds remote pawns until local init has fired.

Existing guards (today)

  • bClientInitFired idempotence 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 PossessedBy local 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 on OnCharacterDefChanged is 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 OnCharacterDefChanged may be conditionally suppressed by predicted-selection reconciliation.
  • Therefore, a guard waiting only on that delegate can deadlock.

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.