terminalCode Example
Reusable World-Space Interaction Prompt Framework
A small, compartmentalized interaction framework can be built from three pieces:
- Interactable: the base component placed on objects the player can use.
- PlayerInteractionController: the player-side raycast scanner that finds interactables, moves the prompt, and calls OnInteract.
- InteractionPrompt: a lightweight world-space prompt controller that can be backed by a WorldPanel/PanelComponent prefab.
CSHARP
using Sandbox;
public class Interactable : Component
{
[Property] public string PromptText { get; set; } = "Interact";
[Property] public Vector3 PromptOffset { get; set; } = Vector3.Up * 48f;
public virtual bool CanInteract( PlayerController player )
{
return player != null;
}
public virtual void OnInteract( PlayerController player )
{
// Override in subclasses. Use [Rpc.Host], [Rpc.Owner], or [Rpc.Broadcast]
// on the override when the interaction needs a specific network target.
}
}
public sealed class PlayerInteractionController : Component
{
[Property] public GameObject PromptPrefab { get; set; }
[Property] public float InteractionDistance { get; set; } = 400f;
[Property] public string UseAction { get; set; } = "Use";
private GameObject promptObject;
private InteractionPrompt prompt;
protected override void OnUpdate()
{
if ( IsProxy )
return;
EnsurePrompt();
var camera = Scene.Camera;
if ( !camera.IsValid() )
{
HidePrompt();
return;
}
var start = camera.WorldPosition;
var end = start + (camera.WorldRotation.Forward * InteractionDistance);
var trace = Scene.Trace.Ray( start, end )
.UseHitboxes( true )
.IgnoreGameObject( GameObject )
.Run();
var interactable = trace.Hit ? trace.Collider.GetComponent<Interactable>() : null;
var player = GetComponent<PlayerController>();
if ( interactable == null || !interactable.CanInteract( player ) )
{
HidePrompt();
return;
}
prompt?.Show( interactable );
if ( Input.Pressed( UseAction ) )
{
interactable.OnInteract( player );
}
}
private void EnsurePrompt()
{
if ( promptObject.IsValid() )
return;
if ( !PromptPrefab.IsValid() )
return;
promptObject = PromptPrefab.Clone();
prompt = promptObject.Components.Get<InteractionPrompt>( FindMode.EverythingInSelfAndDescendants );
HidePrompt();
}
private void HidePrompt()
{
prompt?.Hide();
}
}
public sealed class InteractionPrompt : Component
{
[Property] public GameObject Visual { get; set; }
[Property] public string CurrentText { get; private set; } = string.Empty;
protected override void OnUpdate()
{
FaceCamera();
}
public void Show( Interactable interactable )
{
if ( interactable == null )
{
Hide();
return;
}
GameObject.Enabled = true;
GameObject.WorldPosition = interactable.GameObject.WorldPosition + interactable.PromptOffset;
SetPromptText( interactable.PromptText );
FaceCamera();
}
public void Hide()
{
GameObject.Enabled = false;
}
public void SetPromptText( string text )
{
text ??= string.Empty;
if ( CurrentText == text )
return;
CurrentText = text;
// If this component is paired with a PanelComponent/Razor UI, call a method
// on that UI component here and then StateHasChanged() from the panel.
}
private void FaceCamera()
{
var camera = Scene.Camera;
if ( !camera.IsValid() )
return;
var target = Visual.IsValid() ? Visual : GameObject;
target.WorldRotation = camera.WorldRotation * Rotation.FromYaw( 180f );
}
}
public sealed class DoorInteractable : Interactable
{
[Property] public GameObject DoorVisual { get; set; }
protected override void OnStart()
{
PromptText = "Open Door";
}
public override void OnInteract( PlayerController player )
{
if ( player == null || player.IsProxy )
return;
if ( DoorVisual.IsValid() )
{
DoorVisual.Enabled = false;
}
}
}Implementation notes:
- The player controller owns detection and input, so individual interactables do not need to trace every frame.
- The prompt is cloned once and reused, which avoids creating/destroying UI objects while the player looks around.
- PromptOffset keeps prompt placement configurable per object in the editor.
- CanInteract is useful for locked, disabled, unaffordable, or inventory-full states.
- OnInteract stays virtual so crates, doors, pickups, NPCs, shops, and debug objects can share the same detection system while keeping their behavior separate.
- For multiplayer, avoid mutating another player's local data from a broadcast call. Use the interacting PlayerController to find the player's data, and use the appropriate RPC target for authority-sensitive changes.
Was this helpful?