Skip to main content

Interaction System

The interaction system provides a framework for creating context-sensitive interactions with entities (vehicles, peds, objects, players) and world positions. It supports 3D floating markers, proximity-based detection, and a cascading menu UI rendered via DUI.

Overview

The interaction system provides:

  • Entity targeting with raycasting and proximity scanning
  • 3D floating markers positioned at specific bones/points on entities
  • Filtering by entity type, model hash, and custom predicates
  • Cascading menu UI with categories, submenus, and keyboard/mouse controls
  • DUI rendering for in-world menu display
  • Extensible registration API for modules to add their own interactions

Architecture

┌─────────────────────────────────────────────────────────────┐
│ Client Runtime │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ InteractionService │ │
│ │ - Entity scanning (raycast + proximity) │ │
│ │ - Registration management │ │
│ │ - 3D marker rendering │ │
│ │ - DUI lifecycle management │ │
│ │ - Input handling (keyboard/mouse) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DUI Browser │ │
│ │ - Interaction menu page (React) │ │
│ │ - Cascading menu display │ │
│ │ - Selection highlighting │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Basic Usage

Registering Interactions

import { InteractionService } from "@modules/features/interaction/interaction.service";
import { inject } from "@core/di";
 
export class MyFeatureService {
	private interactionService = inject(InteractionService);
	private registration: IDisposable | null = null;
 
	@ClientOnly
	initClientSide(): void {
		this.registration = this.interactionService.register({
			id: "my-feature:vehicle",
			targetTypes: ["vehicle"],
			options: [
				{ id: "lock", label: "Lock", description: "Lock the vehicle", icon: "lock", priority: 1 },
				{ id: "unlock", label: "Unlock", description: "Unlock the vehicle", icon: "unlock", priority: 2 },
			],
			handler: (target, option) => {
				console.log(`Action: ${option.id} on vehicle ${target.handle}`);
			},
		});
	}
 
	@ClientOnly
	cleanupClientSide(): void {
		this.registration?.dispose();
	}
}

Registration Options

PropertyTypeRequiredDescription
idstringUnique identifier for this registration
targetTypesInteractionEntityTypeValue[]Entity types: "player", "ped", "vehicle", "object", "world"
optionsInteractionOption[]Menu options to display
handlerInteractionHandlerCallback when an option is selected
modelsnumber[]Filter by model hash
predicate(target) => booleanCustom filter function
pointsInteractionPoint[]Specific interaction points (bones/offsets)
maxDistancenumberOverride default detection distance
keyPromptstringCustom key prompt text

Interaction Points

Interaction points define specific locations on entities where markers appear. Useful for vehicles with multiple interaction zones (doors, trunk, hood).

Basic Points

service.register({
    id: "vehicle:doors",
    targetTypes: ["vehicle"],
    points: [
        { id: "driver_door", name: "Driver Door", bone: "handle_dside_f" },
        { id: "passenger_door", name: "Passenger Door", bone: "handle_pside_f" },
    ],
    options: [...],
    handler: (target, option) => {
        // target.pointId contains which point was interacted with
        console.log(`Door: ${target.pointId}, Action: ${option.id}`);
    },
});

Points with Offset

points: [
	{
		id: "trunk",
		name: "Trunk",
		bone: "boot",
		offset: { x: 0, y: 0, z: 0.2 }, // Offset from bone position
	},
];

Conditional Points with Check Function

Not all vehicles have rear doors. Use the check function to conditionally show points:

// Helper to check if a vehicle has a specific bone
function hasBone(boneName: string): (entityHandle: number) => boolean {
    return (entityHandle: number) => {
        const boneIndex = GetEntityBoneIndexByName(entityHandle as HEntity, boneName);
        return boneIndex !== -1;
    };
}
 
service.register({
    id: "vehicle:doors",
    targetTypes: ["vehicle"],
    points: [
        { id: "driver_door", name: "Driver Door", bone: "handle_dside_f", check: hasBone("handle_dside_f") },
        { id: "passenger_door", name: "Passenger Door", bone: "handle_pside_f", check: hasBone("handle_pside_f") },
        { id: "rear_left", name: "Rear Left", bone: "handle_dside_r", check: hasBone("handle_dside_r") },
        { id: "rear_right", name: "Rear Right", bone: "handle_pside_r", check: hasBone("handle_pside_r") },
    ],
    options: [...],
    handler: (target, option) => { ... },
});

Common Vehicle Bones

Bone NameDescription
handle_dside_fDriver door handle
handle_pside_fPassenger door handle
handle_dside_rRear left door handle
handle_pside_rRear right door handle
bootTrunk/boot
bonnetHood/bonnet
petrolcapFuel cap
wheel_lfLeft front wheel
wheel_rfRight front wheel
wheel_lrLeft rear wheel
wheel_rrRight rear wheel

Filtering

Filter by Model Hash

Only show interactions for specific vehicle/ped/object models:

service.register({
    id: "police:vehicle",
    targetTypes: ["vehicle"],
    models: [
        GetHashKey("police") as number,
        GetHashKey("police2") as number,
        GetHashKey("police3") as number,
    ],
    options: [
        { id: "mdt", label: "MDT Computer", description: "Access police computer", icon: "search" },
        { id: "sirens", label: "Toggle Sirens", description: "Turn sirens on/off", icon: "open" },
    ],
    handler: (target, option) => { ... },
});

Filter by Predicate

Use custom logic to determine if interactions should show:

service.register({
    id: "damaged:vehicle",
    targetTypes: ["vehicle"],
    predicate: (target) => {
        const health = GetVehicleEngineHealth(target.handle as HVehicle);
        return health < 500; // Only show for damaged vehicles
    },
    options: [
        { id: "repair", label: "Emergency Repair", icon: "repair" },
        { id: "tow", label: "Call Tow Truck", icon: "talk" },
    ],
    handler: (target, option) => { ... },
});

Combine Model and Predicate Filters

service.register({
    id: "police:trunk",
    targetTypes: ["vehicle"],
    models: [GetHashKey("police") as number],
    predicate: (target) => !IsVehicleDoorDamaged(target.handle as HVehicle, 5),
    points: [{ id: "trunk", bone: "boot", check: hasBone("boot") }],
    options: [...],
    handler: (target, option) => { ... },
});

Nested Menus (Categories)

Create cascading submenus for organized interactions:

service.register({
	id: "vehicle:trunk",
	targetTypes: ["vehicle"],
	points: [{ id: "trunk", name: "Trunk", bone: "boot" }],
	options: [
		{ id: "open", label: "Open Trunk", description: "Open the trunk", icon: "open", priority: 1 },
		{ id: "close", label: "Close Trunk", description: "Close the trunk", icon: "close", priority: 2 },
		{
			id: "storage",
			label: "Access Storage",
			description: "Manage trunk inventory",
			icon: "trunk",
			priority: 3,
			children: [
				{ id: "store", label: "Store Item", description: "Put an item in the trunk", icon: "trunk" },
				{ id: "retrieve", label: "Retrieve Item", description: "Take an item from storage", icon: "search" },
			],
		},
	],
	handler: (target, option) => {
		// Handler receives the selected option (including nested ones)
		console.log(`Action: ${option.id}`);
	},
});

Interaction Options

Option Properties

PropertyTypeRequiredDescription
idstringUnique identifier for this option
labelstringDisplay text
descriptionstringTooltip/subtitle text
iconstringIcon identifier
prioritynumberSort order (lower = higher priority)
variant"default" | "danger"Visual style
childrenInteractionOption[]Nested submenu options

Option Variants

options: [
	{ id: "talk", label: "Talk", variant: "default" },
	{ id: "rob", label: "Rob", variant: "danger" }, // Red/warning style
];

Handler Callback

The handler receives the target information and selected option:

handler: (target: InteractionTarget, option: InteractionOption) => {
	// target.handle - Entity handle
	// target.type - Entity type ("vehicle", "ped", etc.)
	// target.coords - World coordinates
	// target.model - Model hash
	// target.pointId - Which interaction point was used (optional)
 
	console.log(`Entity: ${target.handle}, Point: ${target.pointId}, Action: ${option.id}`);
};

InteractionTarget Properties

PropertyTypeDescription
handlenumberEntity handle
typeInteractionEntityTypeValueEntity type
coords{ x, y, z }World position
modelnumberModel hash
pointIdstring | undefinedInteraction point ID if using points

Controls

When the interaction menu is open, the following controls are available:

InputAction
E / INPUT_CONTEXTOpen menu / Confirm selection
/ Navigate menu items
Left MouseConfirm selection
Right MouseGo back / Close submenu
EscapeClose menu

Configuration

The interaction system has configurable parameters in config.ts:

export const config = {
	/** Maximum distance for entity detection */
	maxDistance: 2.5,
 
	/** How often to scan for entities (ms) */
	scanInterval: 100,
 
	/** Raycast flags */
	raycastFlags: 1 | 2 | 4 | 8 | 16,
};

Complete Example

Here's a complete example of a module that registers interactions:

import { registerModule, type ModuleHandle } from "@core/module";
import { InteractionService } from "@modules/features/interaction/interaction.service";
import { createLogger } from "@core/logging";
import { IS_CLIENT } from "@core/runtime";
import type { IDisposable } from "@core/disposable";
 
const log = createLogger("my-interactions");
 
let interactionService: InteractionService | null = null;
const registrations: IDisposable[] = [];
 
// Helper to check if entity has a bone
function hasBone(boneName: string): (entityHandle: number) => boolean {
	return (entityHandle: number) => {
		const boneIndex = GetEntityBoneIndexByName(entityHandle as HEntity, boneName);
		return boneIndex !== -1;
	};
}
 
export default registerModule({
	name: "my-interactions",
	dependencies: ["interaction"],
	services: [],
 
	onStart(module: ModuleHandle) {
		interactionService = module.getService(InteractionService) ?? null;
		if (!interactionService) return;
 
		if (IS_CLIENT) {
			registerInteractions(interactionService);
		}
	},
 
	onStop() {
		// Cleanup all registrations
		for (const reg of registrations) {
			reg.dispose();
		}
		registrations.length = 0;
		interactionService = null;
	},
});
 
function registerInteractions(service: InteractionService): void {
	// Vehicle doors
	registrations.push(
		service.register({
			id: "my:vehicle:doors",
			targetTypes: ["vehicle"],
			points: [
				{ id: "driver", name: "Driver Door", bone: "handle_dside_f", check: hasBone("handle_dside_f") },
				{ id: "passenger", name: "Passenger Door", bone: "handle_pside_f", check: hasBone("handle_pside_f") },
			],
			options: [
				{ id: "enter", label: "Enter", description: "Get in the vehicle", icon: "enter", priority: 1 },
				{ id: "lock", label: "Lock", description: "Lock the door", icon: "lock", priority: 2 },
			],
			handler: (target, option) => {
				log.info(`Door interaction`, { door: target.pointId, action: option.id });
			},
		}),
	);
 
	// NPC interactions
	registrations.push(
		service.register({
			id: "my:npc",
			targetTypes: ["ped"],
			options: [
				{ id: "talk", label: "Talk", description: "Start a conversation", icon: "talk", priority: 1 },
				{
					id: "trade",
					label: "Trade",
					icon: "trade",
					priority: 2,
					children: [
						{ id: "buy", label: "Buy Items", icon: "trade" },
						{ id: "sell", label: "Sell Items", icon: "trade" },
					],
				},
			],
			handler: (target, option) => {
				log.info(`NPC interaction`, { ped: target.handle, action: option.id });
			},
		}),
	);
}

Types Reference

InteractionEntityTypeValue

type InteractionEntityTypeValue = "player" | "ped" | "vehicle" | "object" | "world";

InteractionPoint

interface InteractionPoint {
	id: string;
	name: string;
	bone?: string;
	offset?: { x: number; y: number; z: number };
	maxDistance?: number;
	check?: (entityHandle: number) => boolean;
}

InteractionOption

interface InteractionOption {
	id: string;
	label: string;
	description?: string;
	icon?: string;
	priority?: number;
	variant?: "default" | "danger";
	children?: InteractionOption[];
}

InteractionTarget

interface InteractionTarget {
	handle: number;
	type: InteractionEntityTypeValue;
	coords: { x: number; y: number; z: number };
	model: number;
	pointId?: string;
}

InteractionRegistration

interface InteractionRegistration {
	id: string;
	targetTypes: InteractionEntityTypeValue[];
	models?: number[];
	predicate?: (target: InteractionTarget) => boolean;
	options: InteractionOption[];
	handler: (target: InteractionTarget, option: InteractionOption) => void;
	maxDistance?: number;
	points?: InteractionPoint[];
	keyPrompt?: string;
}
tip

Always dispose of interaction registrations in your module's onStop() to prevent memory leaks and orphaned handlers.

note

The interaction system uses DUI (Dynamic UI) for rendering menus in 3D space. The menu appears near the focused interaction point and follows standard GTA5 input conventions.