Reference · Updated 2552.07.31.08.07
Aim Target & Reticle — Restructure Plan
Replaces the per-tick Blueprint reticle driver with a single C++ aim-target query that the fire path and the HUD share. Goals, in priority order: simplify, make it reliable, make i
Replaces the per-tick Blueprint reticle driver with a single C++ aim-target query that the fire path and the HUD share. Goals, in priority order: simplify, make it reliable, make it debuggable.
Companion reading: BugTracker.md BUG-026 / BUG-027 / BUG-028, Docs/UISystem.md, Docs/SidearmMode.md §13.
#1. Why — this is the fourth recurrence, not bad luck
| When | Bug | Cause |
|---|---|---|
| 2026-06-15 | (footgun, SidearmMode.md §13) | reticle reference went stale after a sidearm swap |
| 2026-06-26 | BUG-026 | detection traced ECC_Pawn; capsule collision diverges on clients after respawn |
| 2026-07-01 | BUG-027 | HUD tick drove a cached reticle destroyed by SwapReticle |
| 2026-07-28 | BUG-028 | red reticle dead on a client; still open |
Four failures, one shape: the thing that decides "am I aiming at an enemy" and the thing that displays it are connected by hand, per frame, in Blueprint, and every swap or respawn breaks the connection. BUG-027's own write-up already proposed this refactor. We're doing it now.
#2. What's wrong with the current design, precisely
- Two sources of truth for "what am I aiming at." The fire path traces its own ray; the HUD traces a
second one from WBP_SL_HUDWidget's Event Tick. They must agree by hand — nothing enforces it, and the crosshair can promise a hit the bullet won't deliver.
- Gameplay logic lives in a UI tick. Trace setup, validity checks, visibility, and spread all run in a
Blueprint Event Tick. Not testable, not greppable, and it rots whenever the graph is edited.
- State is pushed every frame, not on change.
OnTargetDetectedis called ~60×/s with the current answer.
Anything that writes the widget is immediately overwritten, which is why the BUG-028 poke test read as a dead widget — the driver reasserted false every tick. Per-frame reassertion hides faults.
- Widget identity churns.
SwapReticledestroys and recreates the reticle on every equip *and every
sidearm draw/holster*. Any reference held across that is stale, and a freshly created reticle starts blank until the next change.
- Cosmetics depend on replicated collision state. BUG-026's lesson: a check that depends on
SetCollisionEnabled (not replicated) diverges between host and clients after death/respawn.
#3. Target architecture
#One query, shared with the fire path
The fire path and the reticle call the same helper, so the crosshair cannot disagree with where a shot lands. BuildAimRay lives on USLWeaponsComponent (it depends on weapon state); resolution and publication live on the perception component (see "Local-only, and structurally so" below).
/** Everything the game knows about what the local player is currently aiming at. */
USTRUCT(BlueprintType)
struct FSLAimTarget
{
ESLAimTarget Kind = ESLAimTarget::None; // None / Hostile / Friendly / Interactable
TWeakObjectPtr<AActor> Actor;
FVector ImpactPoint = FVector::ZeroVector;
float Distance = 0.f;
};
/** Builds the ray the SHOT would use. One definition, used by both fire and the reticle. */
FSLAimRay BuildAimRay() const;
/** Resolves the ray to a target. Pure classification is separated out so it can be unit-tested. */
FSLAimTarget ResolveAimTarget() const;
static ESLAimTarget ClassifyHit(const FHitResult& Hit, const AActor* Instigator);
Enum, not bool. Team play and interactables are coming; a bool would have to be replaced the moment either lands, and OnTargetDetected(bool) is already the wrong shape.
#Publish on change
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FSLOnAimTargetChanged, ESLAimTarget, Kind);
UPROPERTY(BlueprintAssignable) FSLOnAimTargetChanged OnAimTargetChanged;
Broadcast only when the value changes. This deletes an entire bug class: nothing reasserts state every frame, so a stale or wrong value is visible instead of being papered over, and a widget that misses an update stays wrong loudly.
#No Blueprint tick
Blueprint's only job becomes visuals: an event in, a tint out. The Event Tick graph in WBP_SL_HUDWidget goes away — visibility-on-equip, spread, and target detection all move to USLHUDWidget::NativeTick (or delegate bindings) in C++.
#Local-only, and structurally so
The query lives on USLPlayerPerceptionComponent, attached to ASLPlayerCharacter — not to ASLCharacterBase, and not to the weapons component. AI pawns fight with weapons but have no HUD, so they simply never have this component. That makes "only the local player does this" a fact about the class hierarchy rather than an IsLocallyControlled() check somebody has to remember — the kind of guard that rots.
It is cosmetic; there is no reason for it to exist on the server or on simulated proxies. This removes the "works on host, broken on client" family by construction — there is no second path to diverge from.
BuildAimRay() stays on USLWeaponsComponent. The ray depends on weapon state (muzzle, spread, trace distance), and keeping one definition there is what guarantees the reticle and the bullet use the same ray. The perception component consumes that ray; it never invents its own.
Scope rule — what belongs in this component, and what doesn't. Perception owns things nobody owns yet because they must be discovered by scanning the world: aim target now, radar contacts later (same throttle, same debug tooling, same local-only lifetime). It does not own state another system already holds — dash charges stay on USLMobilityComponent, which already owns and broadcasts them, and the HUD binds there directly. The HUD binds to whoever owns the state; perception exists only for what has to be found.
#Deterministic collision
Generalising BUG-026: cosmetics must not depend on state set differently per machine. Collision profiles are set once in the constructor; death/ragdoll changes ride replicated state (the Dead tag / an OnRep), never a bare SetCollisionEnabled on one machine.
#Reticles are cached, never destroyed
SwapReticle stops destroying widgets. Reticles are created on first use and kept forever, in a cache keyed by reticle class; swapping collapses the current one and un-collapses the next. Nothing to configure up front, nothing to forget when a weapon is added, and no widget identity ever churns — which removes the mechanism behind BUG-027 and the 2026-06-15 footgun outright.
Two details this depends on:
Collapsed, notHidden. A hidden widget still participates in layout; a collapsed one is skipped
entirely — not arranged, not painted, not ticked. That is what makes keeping them all alive genuinely free.
- Every cached reticle stays bound to
OnAimTargetChanged. Inactive ones keep receiving updates, so a
reticle is already correct the instant it is un-collapsed. This is why no "push state on create" rule is needed: nothing is ever recreated, so there is no gap to cover. Cost is a few enum assignments, only when the value actually changes.
⚠ Deviation as built (2026-07-29). The implementation does not keep inactive reticles bound. The HUD
tracks the current aim kind, spread and hidden state itself and re-applies all three on activation —
in SwapReticle, on both the create path and the cache-hit path.
Why the change: the delegates fire on the HUD, not on each reticle, so "keep them all bound" would mean
the HUD fanning every update out to every cached widget — calling Blueprint events on collapsed widgets that
nobody can see. Re-applying once at the moment of activation is less work and has one place to get wrong
instead of N.
The trade is that a cached reticle holds stale state while collapsed. That is invisible by definition, but it
does mean the re-apply on activation is load-bearing — drop it and swapping back to a weapon shows a
crosshair frozen from minutes ago. Verified in PIE: aimed at an enemy (red), swapped away, moved off target,
swapped back — correctly blue, not frozen red.
#4. Debuggability (a first-class goal, not polish)
sl.AimTarget.Debug 1— draw the ray, the impact point, the resolved actor name and the classification
on screen. BUG-028 cost two sessions largely because there was no way to see what the trace was doing.
sl.AimTarget.Rate— the query doesn't need to run at frame rate. Default ~30 Hz, cvar-tunable, so the
cost is visible and adjustable.
- Every failure mode should be observable: with debug on, "the trace hits nothing", "hits the wrong actor",
and "hits the right actor but the widget is blue" are three visibly different pictures.
#5. Tests (fits the existing headless suite)
ClassifyHit— pure, no world: character → Hostile; self → None; corpse/pickup/geometry → None.
- Change-only broadcast — same value twice fires once.
- Content validation — every reticle Blueprint implements the new event;
WBP_SL_HUDWidgethas no Event Tick
trace nodes left (guards against the old pattern creeping back).
#6. Phases
## ✅ ALL SIX PHASES COMPLETE — 2026-07-29
Implemented and verified in 3-player PIE. Full headless suite 17/17. The Blueprint Event Tick in
WBP_SL_HUDWidget is empty, which was the point.
The plan's central bet paid off, and not in the way it expected. §8 warned that the refactor might
inherit BUG-028 "in nicer clothes" if the cause was a collision problem. It wasn't. Phase 2's debug tooling
eliminated the trace, the channel, respawned proxies and the classification in a single PIE run — four
suspects that had survived two sessions of reasoning — and then a same-frame sample showed every C++ path
returning the correct answer while the widget stayed blue. That isolated the fault to Blueprint *before the
graph was ever opened*.
The actual cause:WBP_SL_HUDWidget's Event Tick was one long chain of validatedGETs on cached per-pawn
references with every Is Not Valid pin unwired. A respawn invalidated one, the chain died at that gate,
and everything downstream — including the reticle colour — stopped silently and permanently. No error, no
log, nothing to grep for. Full RCA: BUG-028 RESOLUTION in BugTracker.md.
What made the difference was instrumentation, not insight. Three of the theories formed while debugging
this were wrong (wrong camera inputs, wrong component instance, a stuck Equipping tag). Each died to a
measurement in minutes. Build the debug view first.
Bugs found by building this, beyond the one it targeted:
- BUG-029 — ragdoll never cleared on observing clients; the host had fallen out of the world, not gone
invisible. Almost certainly the true cause of BUG-026 as well.
-BuildAimRayhardcodedbPrimary=true— secondary fire got the primary's range. Found in Phase 3 only
because unifying forced a line-by-line comparison of the two rays.
- Firing with no player controller traced from the world origin, silently, forever.
- Loose gameplay tags inflating on clients (InventoryLoaded,ReadyToFinishLoadout) — found by
sl.DumpTags, still open by choice so the stuck-weapon repro survives.
Each phase compiles, runs, and is independently testable. Debug tooling lands in Phase 2, deliberately early: BUG-028 is still undiagnosed, and the fastest way to diagnose it is to be able to see the trace.
Phase 1 ✅ — Shared ray + classification (no wiring). ESLAimTarget, FSLAimTarget, USLWeaponsComponent::BuildAimRay(), ClassifyHit(), unit tests for classification. Nothing consumes it yet. Gate: headless tests pass; nothing in game behaves differently.
Phase 2 ✅ — USLPlayerPerceptionComponent + debug tooling. Component on ASLPlayerCharacter, consuming BuildAimRay, resolving at a throttled rate, publishing OnAimTargetChanged. sl.AimTarget.Debug draws the ray, impact, resolved actor and classification; sl.AimTarget.Rate tunes the query rate. The old Blueprint path still runs untouched. Gate: diagnose BUG-028 with the debug draw, on host and on client. Do not proceed until the actual cause of the current failure is known — see §8.
Phase 3 ✅ — Fire path adopts BuildAimRay. The shot uses the shared ray. No behaviour change intended. Gate: single-player fire unchanged; 2-player fire unchanged (FireAbilityNetworkTesting.md Tests 1–3).
Phase 4 ✅ — HUD consumes the delegate. USLHUDWidget binds OnAimTargetChanged and drives the reticle from C++. Delete the Red Reticle Check from the Blueprint tick. Gate: red reticle works on host and client — the BUG-028 reproduction.
Phase 5 ✅ — Spread + visibility move to C++. Remaining Event Tick logic moves to NativeTick. The Blueprint tick graph ends up empty — that is the point: leaving one node in it preserves the exact habit this refactor exists to kill. Gate: spread and equip-hide behave as before.
Phase 6 ✅ — Reticle cache. Lazy-create and keep; collapse/un-collapse instead of destroy/recreate; all cached reticles stay bound. Gate: reticle instance count stays flat across many sidearm toggles and weapon swaps. Measured with sl.DumpWidgets Reticle (not the obj list this plan originally named): 6 -> 10 across many swaps, every instance _C_0, the growth being two new CLASSES appearing once each. Exactly one non-collapsed reticle per machine.
#7. What we keep
ECC_WeaponTrace, notECC_Pawn— trace the channel you act on (BUG-026).
- Require
ASLCharacterBase, not any pawn (BUG-026).
- The corpse-targetability decision from BUG-026: fix in the collision layer if it ever matters, keep
classification dumb.
#8. Honest scope note
This refactor does not, by itself, fix BUG-028. If Check for Enemy Target currently returns false because of a collision problem on client proxies, the new code inherits that bug in nicer clothes. The plan improves the odds two ways: Phase 1's debug draw makes the actual cause visible in seconds, and §3's determinism rule is aimed squarely at that class of fault. Diagnose BUG-028 with the Phase 1 debug tooling before assuming the restructure cured it.
#9. Decisions made (2026-07-28)
- Dedicated
USLPlayerPerceptionComponentonASLPlayerCharacter, not the weapons component.
Beepers' reasoning, which is better than the original recommendation: AI enemies use weapons but have no HUD, so putting HUD-feeding queries on the shared weapons component means AI pays for them and the "local player only" rule survives as a runtime check instead of a structural fact. A player-only component makes it structural. Radar will join it; dash charges will not (they already have an owner).
- Reticles stay separate widgets per weapon, cached and never destroyed. Better than the originally
proposed single-host-with-swappable-style: it keeps full per-weapon visual freedom and removes the churn, with no content restructuring. See §3.
- Spread moves to C++ in this pass (Phase 5), so no Blueprint tick graph survives to rot or be copied.