terminalCode Example

Reusable World-Space Interaction Prompt Framework

calendar_today May 10, 2026 schedule ~2 min read person rater193 verified 50

A small, compartmentalized interaction framework can be built from three pieces:

This keeps content-specific behavior in subclasses while leaving detection, prompt positioning, and input handling in one reusable controller. In networked games, keep the controller local-only with IsProxy guards, then let each interactable decide whether its work should be host-authoritative, owner-only, or broadcast cosmetic behavior.
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?