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
| Property | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique identifier for this registration |
targetTypes | InteractionEntityTypeValue[] | ✅ | Entity types: "player", "ped", "vehicle", "object", "world" |
options | InteractionOption[] | ✅ | Menu options to display |
handler | InteractionHandler | ✅ | Callback when an option is selected |
models | number[] | Filter by model hash | |
predicate | (target) => boolean | Custom filter function | |
points | InteractionPoint[] | Specific interaction points (bones/offsets) | |
maxDistance | number | Override default detection distance | |
keyPrompt | string | Custom 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 Name | Description |
|---|---|
handle_dside_f | Driver door handle |
handle_pside_f | Passenger door handle |
handle_dside_r | Rear left door handle |
handle_pside_r | Rear right door handle |
boot | Trunk/boot |
bonnet | Hood/bonnet |
petrolcap | Fuel cap |
wheel_lf | Left front wheel |
wheel_rf | Right front wheel |
wheel_lr | Left rear wheel |
wheel_rr | Right 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
| Property | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique identifier for this option |
label | string | ✅ | Display text |
description | string | Tooltip/subtitle text | |
icon | string | Icon identifier | |
priority | number | Sort order (lower = higher priority) | |
variant | "default" | "danger" | Visual style | |
children | InteractionOption[] | 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
| Property | Type | Description |
|---|---|---|
handle | number | Entity handle |
type | InteractionEntityTypeValue | Entity type |
coords | { x, y, z } | World position |
model | number | Model hash |
pointId | string | undefined | Interaction point ID if using points |
Controls
When the interaction menu is open, the following controls are available:
| Input | Action |
|---|---|
E / INPUT_CONTEXT | Open menu / Confirm selection |
↑ / ↓ | Navigate menu items |
Left Mouse | Confirm selection |
Right Mouse | Go back / Close submenu |
Escape | Close 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;
}Always dispose of interaction registrations in your module's onStop() to prevent memory leaks and orphaned handlers.
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.