ProjectInteraction
September 17, 2026 ยท View on GitHub
Player interaction system for ALIS.
Purpose
- Detects interactable actors via proximity gather, line-of-sight gating, and deterministic aim/distance/priority scoring
- Broadcasts events via
IInteractionService - Features (Inventory, Dialogue) subscribe to events
- Decoupled via interfaces in ProjectCore
Architecture
ProjectCore (interfaces only)
-> IInteractionComponentInterface # PlayerController queries this
-> IInteractionService # Features subscribe to this
PlayerController (SinglePlayController, etc.)
-> Depends on ProjectCore
-> Owns IA_Interact input (E key)
-> Calls BeginInteractInput()/EndInteractInput() on pawn's interaction component
ProjectInteraction (this plugin)
-> FFeatureRegistry("Interaction") # Attaches component on GameMode init
-> UInteractionComponent # Implements IInteractionComponentInterface
-> FInteractionService # Implements IInteractionService
Features (Inventory, Dialogue, etc.)
-> Subscribe to IInteractionService::OnInteraction()
Flow (Server-Authoritative)
Player Input (IA_Interact in PlayerController)
|
v
PlayerController finds UInteractionComponent on Pawn
|
v
IInteractionComponentInterface::Execute_BeginInteractInput()
|
v
UInteractionComponent starts hold if the focused execution spec requires it
|
+-- Release early? --> cancel prompt/progress
|
+-- Hold completes? --> DispatchInteract(HoldTarget, HoldComponent)
| (held target captured at hold start)
|
+-- Otherwise --> TryInteract() -> DispatchInteract(FocusedActor, FocusedComponent)
|
+-- Has Authority? --> ExecuteInteraction_ServerAuth(Target, HitComponent)
|
+-- No Authority? --> Server_TryInteract(Target, HitComponent) RPC
|
v
ExecuteInteraction_ServerAuth(Target, HitComponent)
|
v
Server validates the client's focused (Actor, Component) - the same
target the local resolver picked and highlighted - and dispatches that
target. Server does NOT re-resolve from a different view: highlight and
interaction share a single source of truth on the client.
|
v
IInteractionService::OnInteraction.Broadcast(Target, Instigator)
|
+--> ProjectInventory (pickups, loot containers, doors)
+--> DialogueFeature (checks for DialogueComponent)
Single source of truth for highlight + interaction:
- The client's local targeting resolver picks
FocusedActorandFocusedComponent. That pick drives the highlight (custom depth on the primitive) AND is the exact target sent into interaction. TryInteract()readsFocusedActor/FocusedComponentand dispatches throughDispatchInteract(Target, HitComponent). On authority the call goes straight intoExecuteInteraction_ServerAuth(Target, HitComponent); remote clients send the pair viaServer_TryInteract(Target, Component).- The server does NOT re-resolve from its own view. Re-resolving was the
dresser regression: a single actor with 5 - 10 cm sibling drawer-slot
primitives, the client highlighted slot A, the server's eye view
(
GetActorEyesViewPoint, no FOV interp / camera lag, BaseEyeHeight offset) landed the cone on slot B and either opened the wrong drawer or rejected everything as out-of-cone. - Server-side
ExecuteInteraction_ServerAuthis now a validation gate, not a re-resolver. It checks: target non-null, distance from pawn withinInteractionRadius * 1.5(anti-cheat range bound), target is actually interactable (actor interface or capability components present). If validation passes, dispatch goes through actor interface or capability selector; the server then broadcastsOnInteraction. - Hold interactions capture
(HoldTargetActor, HoldTargetComponent)at hold start and dispatch that captured target on completion - the SOT for a hold is the target the player started the hold on, not whatever is focused at the instant the hold finishes. - Only the server broadcasts
OnInteraction- features handle server-side only. - Component is on the player's pawn so the RPC has a valid path.
Targeting
UInteractionComponent keeps orchestration, focus state, highlighting, hold
progress, and service broadcasts. Private helpers keep the selection logic
separate:
FInteractionTargetResolvergathers candidates withOverlapMultiByObjectTypeforWorldStatic,WorldDynamic,PhysicsBody, andPawn. It then filters to interactable actors or components, resolves the target point per primitive (see "TargetPoint resolution"), applies the aim-cone gate and visibility LOS gate, and picks a winner with a deterministic comparator chain.FInteractionCapabilitySelectormaps the winning actor/component to the correct interactable component, focus info, execution spec, and fallback component interaction.
Player rule: real ray collision wins. A bounds-only ray hit is a visual fallback. A cone fallback is last. Within a bucket the front-most (or most-centered) candidate wins. Priority is only a final tiebreak.
The comparator (IsBetterCandidate) is a 3-bucket deterministic chain. The
hit-kind enum is EInteractionViewRayHitKind { None, Bounds, Collision },
ranked Collision (2) > Bounds (1) > None (0):
- Higher hit-kind rank wins. A closer
Boundshit must NOT outrank a furtherCollisionhit - that was the backpack regression where a loose AABB clipped above a smaller collision-pierced item. - Among same-bucket
CollisionorBounds: smallerViewRayHitDistancewins (front-most along the ray). - Among same-bucket
None: higherAimDotwins (most centered in cone). - Tiebreak: smaller
Distance. - Tiebreak: higher
Priority(never overrides aim). - Final: deterministic
Actor->GetUniqueID().
TargetPoint resolution
For each primitive, ResolveTargetPoint picks the most honest "I'm visually
pointing at this" point and a ViewRayHitKind in three steps:
-
UPrimitiveComponent::LineTraceComponentagainst the candidate's collision. If the ray hits:ViewRayHitKind = Collision,ViewRayHitDistance = ComponentHit.Distance. Highest trust - real ray pierce against the physics shape. -
AABB ray intersection (
FMath::LineExtentBoxIntersection) against the primitive'sBounds. Catches meshes whose visible geometry has no collision (window glass on a frame-only collision body, decorative meshes, etc.):ViewRayHitKind = Bounds,ViewRayHitDistance = |HitPoint - ViewOrigin|. AABB is loose for thick or rotated meshes, so aBoundshit ranks belowCollisioneven when its hit distance is closer.Authoring rule: if a foreground interactable should reliably beat other collision-hit objects, author collision on the intended interactable surface. Bounds is only a visual fallback and intentionally ranks below real collision; it cannot compensate for missing collision on a hero surface (e.g. a foreground window with frame-only collision will lose to a background object with full-mesh collision under the same crosshair).
-
Closest point on collision to view origin. Final fallback when neither the ray nor the bounds intersect:
ViewRayHitKind = None. The candidate competes in the cone-fallback bucket onAimDot.
Hysteresis (FInteractionTargetResolver::ShouldKeepCurrentCandidate) only
smooths within a single bucket. Cross-bucket switches (different
ViewRayHitKind) are deliberate aim transitions and always switch. Within a
bucket:
Collision/Bounds: distance-based multiplicative threshold onViewRayHitDistance- the incumbent keeps focus if its hit distance is withinFocusSwitchHysteresisfraction of the challenger's. Distance is unbounded so a ratio scales correctly.None: angular leeway scaled to the cone half-angle. Convert bothAimDotvalues back to angles viaacos, then keep the incumbent only if its angle is withinFocusSwitchHysteresis * acos(MinAimDot)of the challenger's. With defaults that is0.10 * 31.8 deg ~= 3.18 degof stickiness. The earlier ratio threshold (NewWinner.AimDot * (1 - h)) was unusable here becauseAimDotlives in[MinAimDot, 1.0](a 0.15-wide range with defaults), so any ratio< 1kept the entire cone sticky and prevented legitimate aim switches.
Default targeting tunables:
| Property | Default | Meaning |
|---|---|---|
InteractionRadius | 200.0 | Candidate overlap sphere radius (~2 m: arm's reach plus floor-pickup margin). |
ShortCircuitRadius | 60.0 | Bypass LOS only for directly touched targets; aim still applies. |
MinAimDot | 0.85 | Minimum dot between view direction and target direction (cone half-angle ~31.8 deg). |
FocusSwitchHysteresis | 0.10 | Within a bucket, keep current focus unless a new candidate beats the discriminator (ViewRayHitDistance for pierced, AimDot for non-pierced) by more than 10 percent. Cross-bucket switches always switch. |
TraceIntervalSeconds | 0.15 | Passive local focus refresh cadence. Input refreshes immediately before interaction. |
Debug CVars:
alis.Interaction.Debug 1logs overlap, candidate, rejection, and winner counts.alis.Interaction.Draw 1draws only the overlap sphere and winner/miss ray.alis.Interaction.Draw 2also draws rejected candidate rays.
Timed interaction rules
- Interaction timing is described by
FInteractionExecutionSpec. - World interaction providers own search/open timing.
- Default searchable world storage uses a short hold (
1.0s). 0.0smeans instant interaction.- HUD prompt shows progress through the shared interaction prompt path.
- Releasing
Ecancels timed interaction; no extra cancel key is required. - If a nearby world-container session is already open,
Ecloses it.
Key Classes
| Class | Location | Purpose |
|---|---|---|
IInteractionComponentInterface | ProjectCore | Interface for component |
IInteractionService | ProjectCore | Event subscription interface |
UInteractionComponent | This plugin | Focus orchestration, highlight, implements interface |
FInteractionTargetResolver | This plugin private | Overlap gather, gates, scoring, debug draw |
FInteractionCapabilitySelector | This plugin private | Component selection, focus/spec resolution, fallback execution |
FInteractionService | This plugin | Broadcasts events |
Feature Initialization
Registered with FFeatureRegistry as "Interaction" feature:
- Module startup registers init function with FFeatureRegistry
- GameMode (ProjectSinglePlay) calls
InitializeFeature("Interaction", Context) - Init function attaches
UInteractionComponentto pawn
To enable in a mode, add "Interaction" to ModeConfig.FeatureNames
Highlight System
Focused actors are outlined via post-process material using Custom Depth.
Setup:
- Set
OutlineMaterialonUInteractionComponentto your PP material (e.g./ProjectMaterial/Effect/MI_Outline) bEnableHighlight = true(default)- Project Settings: Custom Depth-Stencil Pass = "Enabled"
- Material Settings: Blendable Location = "After Tonemapping" (required for camera PP)
How it works:
- Component adds PP material to owner's camera on BeginPlay
- On focus change, toggles
SetRenderCustomDepth()on target actor's primitives - PP material reads Custom Depth buffer to draw outline
Dependencies
ProjectCore- IInteractionComponentInterface, IInteractionService, ServiceLocatorProjectFeature- FFeatureRegistry for GameMode-driven initialization
Related docs
- Inventory/world-storage behavior SOT:
Plugins/Features/ProjectInventory/docs/design_vision.md - HUD composition root:
Plugins/UI/ProjectHUD/README.md
Decoupling
| Plugin | Depends On |
|---|---|
| ProjectCharacter | ProjectCore, ProjectGAS |
| ProjectInteraction | ProjectCore, ProjectFeature |
| Features | ProjectCore (+ ProjectGAS if using GAS) |
No direct dependencies between Character <-> Interaction <-> Features.
TODO
-
UInteractionComponent- trace detection,TryInteract()method -
IInteractionComponentInterface- decoupled interface in Core - Highlight/focus system (PP material + Custom Depth)
- FFeatureRegistry integration (GameMode-driven init)
-
Server_TryInteract(Target, HitComponent)- server-authoritative interaction (validates the client's focused target; does not re-resolve)