Tutorial 2026-02-26

Creating Interactive NPC Dialogue Systems for FiveM

TDYSKY

TDYSKY

Founder & Lead Developer at Agency Scripts

Why NPC Dialogue Matters

NPC dialogue systems transform static peds into living characters that give your server personality and guide players through activities. Without dialogue, NPCs are little more than interaction targets that open menus. With a proper dialogue system, they become quest givers, storytellers, shop owners with attitude, and informants who react differently based on the player's reputation or job. A dialogue system creates opportunities for server-specific lore, branching storylines, and player choices that affect future interactions. Think about how much more engaging it is when a player approaches a mechanic NPC who greets them by name, comments on the condition of their car, and offers different services based on whether they are a returning customer or a first-time visitor. This level of immersion keeps players invested in your server and gives them reasons to explore beyond the standard grind loops.

Dialogue Tree Data Structure

The foundation of any dialogue system is the data structure that defines conversation flow. A dialogue tree consists of nodes, where each node contains the NPC's text and a list of player response options that link to other nodes. This tree structure supports branching conversations, conditional paths based on player state, and loops that return to earlier points in the conversation. Design your dialogue nodes to be data-driven so server staff can create new conversations without touching Lua code. Here is a practical dialogue tree format:

Config.Dialogues = {
    ['mechanic_greeting'] = {
        npcName = 'Tony the Mechanic',
        nodes = {
            ['start'] = {
                text = "Hey there! Car giving you trouble, or are you just here for a tune-up?",
                animation = 'WORLD_HUMAN_WELDING',
                options = {
                    {
                        label = "I need repairs",
                        next = 'repairs',
                        condition = function(player)
                            return IsPlayerInVehicle(player)
                        end
                    },
                    {
                        label = "What services do you offer?",
                        next = 'services'
                    },
                    {
                        label = "Just browsing, thanks",
                        next = 'goodbye'
                    },
                }
            },
            ['repairs'] = {
                text = "Let me take a look... Yeah, your engine's seen better days. I can fix it up for $500. What do you say?",
                options = {
                    { label = "Fix it up", next = 'repair_accept', action = 'repair_vehicle' },
                    { label = "Too expensive", next = 'haggle' },
                    { label = "Never mind", next = 'goodbye' },
                }
            },
            ['haggle'] = {
                text = "Look, parts aren't cheap. But since you seem like a decent person, I can do $350. Final offer.",
                options = {
                    { label = "Deal!", next = 'repair_accept', action = 'repair_vehicle_discount' },
                    { label = "I'll pass", next = 'goodbye' },
                }
            },
            ['services'] = {
                text = "I do repairs, custom paint jobs, performance tuning, and tire changes. What catches your eye?",
                options = {
                    { label = "Tell me about tuning", next = 'tuning_info' },
                    { label = "Back to start", next = 'start' },
                }
            },
            ['repair_accept'] = {
                text = "Alright, give me a minute... Done! She's running smooth now. Take care of her out there.",
                options = {
                    { label = "Thanks, Tony!", next = 'end' },
                }
            },
            ['goodbye'] = {
                text = "No worries. Come back anytime you need help with your ride!",
                options = {
                    { label = "See you around", next = 'end' },
                }
            },
        }
    },
}

The condition function on options lets you dynamically show or hide choices based on the player's current state. A repair option only appears when the player arrived in a vehicle, quest-related options only show when the player has reached the right stage, and VIP options can be restricted to players with certain permissions. The action field triggers server-side functions when a specific option is selected, connecting dialogue choices to gameplay outcomes.

Spawning and Managing NPCs

Dialogue NPCs need to be spawned reliably, placed at consistent locations, and kept immune to the chaos that GTA's world can throw at them. When spawning a dialogue NPC, you need to request the model, create the ped, set it as a mission entity so GTA's cleanup systems do not delete it, freeze its position so it does not wander away, and make it invincible so players cannot kill your quest giver. Use a centralized NPC manager that spawns peds when players are nearby and despawns them when no players are in range to save memory on busy servers:

local spawnedNPCs = {}

function SpawnDialogueNPC(npcId, config)
    if spawnedNPCs[npcId] then return end

    local model = GetHashKey(config.model)
    RequestModel(model)
    while not HasModelLoaded(model) do Wait(10) end

    local ped = CreatePed(0, model, config.coords.x, config.coords.y,
        config.coords.z, config.heading, false, true)

    SetEntityInvincible(ped, true)
    SetBlockingOfNonTemporaryEvents(ped, true)
    FreezeEntityPosition(ped, true)
    SetPedFleeAttributes(ped, 0, false)
    SetPedCombatAttributes(ped, 46, true)
    SetPedCanRagdoll(ped, false)
    SetEntityAsMissionEntity(ped, true, true)
    SetModelAsNoLongerNeeded(model)

    -- Play idle animation if configured
    if config.scenario then
        TaskStartScenarioInPlace(ped, config.scenario, 0, true)
    end

    spawnedNPCs[npcId] = { entity = ped, config = config }
    return ped
end

The combination of SetBlockingOfNonTemporaryEvents and FreezeEntityPosition ensures that ambient events like nearby explosions, police chases, or aggressive players do not cause your NPC to flee, fight back, or ragdoll. Without these protections, players could encounter a quest giver lying on the ground twitching after being hit by a passing car, completely breaking the immersion you worked to create.

Cinematic Camera System

Camera work during dialogue conversations elevates the experience from a menu interaction to a cinematic moment. When a dialogue begins, create a camera that focuses on the NPC's face with a slight offset, using depth of field to blur the background and draw attention to the conversation. Switch between camera angles as the conversation progresses, cutting to the player when they make a choice and back to the NPC when they respond. GTA's native camera system gives you full control over position, rotation, field of view, and depth of field:

local dialogueCam = nil

function StartDialogueCamera(npcPed)
    local npcCoords = GetEntityCoords(npcPed)
    local npcHeading = GetEntityHeading(npcPed)
    local playerPed = PlayerPedId()

    -- Calculate camera position offset from NPC face
    local angleRad = math.rad(npcHeading + 160)
    local camX = npcCoords.x + (math.sin(angleRad) * 1.5)
    local camY = npcCoords.y + (math.cos(angleRad) * 1.5)
    local camZ = npcCoords.z + 0.6

    dialogueCam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
    SetCamCoord(dialogueCam, camX, camY, camZ)
    PointCamAtPedBone(dialogueCam, npcPed, 31086, 0.0, 0.0, 0.1, true)  -- Head bone

    -- Depth of field for cinematic look
    SetCamNearDof(dialogueCam, 0.5)
    SetCamFarDof(dialogueCam, 3.5)
    SetCamDofStrength(dialogueCam, 0.6)
    SetCamUseShallowDofMode(dialogueCam, true)

    SetCamFov(dialogueCam, 40.0)  -- Tighter shot
    SetCamActive(dialogueCam, true)
    RenderScriptCams(true, true, 800, true, false)

    -- Disable player controls during dialogue
    SetPlayerControl(PlayerId(), false, 0)

    -- Make player face NPC
    TaskTurnPedToFaceEntity(playerPed, npcPed, 1000)
end

function StopDialogueCamera()
    if dialogueCam then
        RenderScriptCams(false, true, 500, true, false)
        DestroyCam(dialogueCam, true)
        dialogueCam = nil
        SetPlayerControl(PlayerId(), true, 0)
    end
end

The PointCamAtPedBone native is particularly powerful because it locks the camera focus on the NPC's head regardless of any slight animation movements, keeping the framing consistent throughout the conversation. The transition durations in RenderScriptCams create smooth camera fades rather than jarring cuts, and you should experiment with values between 500ms and 1000ms to find the right feel for your server's pace.

Subtitle Display System

A subtitle system presents the NPC's dialogue text in a visually appealing way at the bottom of the screen, mimicking how story-driven games display conversation. Rather than dumping the entire text block at once, implement a typewriter effect that reveals characters one at a time, creating the illusion that the NPC is actively speaking. Use NUI for the subtitle display because it gives you full CSS control over fonts, colors, animations, and positioning. Send each dialogue node's text to the NUI frame when it becomes active, along with the NPC's name and any emotion tags that should affect the display style:

// Subtitle display JavaScript (html/subtitles.js)
let typewriterTimeout = null;

window.addEventListener('message', (event) => {
    const data = event.data;

    if (data.action === 'showDialogue') {
        clearTimeout(typewriterTimeout);
        const container = document.getElementById('subtitle-container');
        const nameEl = document.getElementById('npc-name');
        const textEl = document.getElementById('dialogue-text');
        const optionsEl = document.getElementById('dialogue-options');

        container.style.display = 'block';
        nameEl.textContent = data.npcName;
        textEl.textContent = '';
        optionsEl.innerHTML = '';

        // Typewriter effect
        let charIndex = 0;
        const fullText = data.text;

        function typeNext() {
            if (charIndex < fullText.length) {
                textEl.textContent += fullText[charIndex];
                charIndex++;
                typewriterTimeout = setTimeout(typeNext, 30);
            } else {
                // Show options after text completes
                showOptions(data.options);
            }
        }
        typeNext();
    }

    if (data.action === 'hideDialogue') {
        document.getElementById('subtitle-container').style.display = 'none';
    }
});

function showOptions(options) {
    const optionsEl = document.getElementById('dialogue-options');
    options.forEach((opt, index) => {
        const btn = document.createElement('button');
        btn.className = 'dialogue-option';
        btn.innerHTML = `${index + 1} ${opt.label}`;
        btn.onclick = () => {
            fetch(`https://${GetParentResourceName()}/selectOption`, {
                method: 'POST',
                body: JSON.stringify({ index: index })
            });
        };
        optionsEl.appendChild(btn);
    });
}

Style the subtitle container with a semi-transparent dark background, rounded corners, and a subtle gradient border that matches your server's theme. Position it at the bottom center of the screen with enough padding so it does not overlap with the minimap or other HUD elements. Add keyboard shortcuts so players can press number keys to select options quickly without clicking, which feels more natural during conversation flow.

Quest System Integration

Dialogue systems become truly powerful when connected to a quest framework. The dialogue tree's action field on response options provides the hook point where quest logic executes. When a player accepts a mission through dialogue, the action handler should create a quest entry in the player's quest log, set up any required waypoints or objectives, and track progress through subsequent dialogue interactions. Store quest progress in the database per player so they can disconnect and resume where they left off. Design quests as state machines where each state corresponds to a dialogue node and a set of objectives that must be completed before the next dialogue becomes available:

-- Server-side quest actions triggered by dialogue choices
local QuestActions = {
    ['accept_delivery_job'] = function(src, npcId)
        local Player = QBCore.Functions.GetPlayer(src)
        local citizenid = Player.PlayerData.citizenid

        -- Create quest entry
        MySQL.insert(
            'INSERT INTO player_quests (citizenid, quest_id, stage, started_at) VALUES (?, ?, ?, NOW())',
            {citizenid, 'tony_delivery_1', 'pickup'}
        )

        -- Set waypoint for pickup location
        TriggerClientEvent('quest:client:setWaypoint', src, {
            coords = vector3(482.5, -1311.2, 29.2),
            blipSprite = 501,
            blipColor = 5,
            label = 'Package Pickup'
        })

        TriggerClientEvent('QBCore:Notify', src, 'Quest started: Special Delivery', 'success')
    end,

    ['complete_delivery'] = function(src, npcId)
        local Player = QBCore.Functions.GetPlayer(src)
        local citizenid = Player.PlayerData.citizenid

        MySQL.update(
            'UPDATE player_quests SET stage = ?, completed_at = NOW() WHERE citizenid = ? AND quest_id = ?',
            {'completed', citizenid, 'tony_delivery_1'}
        )

        Player.Functions.AddMoney('cash', 1500, 'quest-delivery-reward')
        TriggerClientEvent('QBCore:Notify', src, 'Quest complete! Reward: $1,500', 'success')
    end,
}

Use the quest stage to dynamically modify which dialogue nodes are available. When a player returns to Tony after completing the delivery, the dialogue system checks the quest stage and presents a completion dialogue with reward instead of the initial greeting. This creates a natural conversation flow where NPCs acknowledge the player's progress and react accordingly, making the world feel responsive and alive.

NPC Animations and Expressions

Static NPCs that stand perfectly still while talking feel robotic and break immersion. Add animation support to your dialogue system so NPCs gesture, emote, and react during conversations. GTA V has a massive library of animation dictionaries covering gestures, facial expressions, and body language that you can trigger at specific points in the dialogue. Assign animations at the node level so each dialogue line can have its own accompanying gesture. When the NPC delivers good news, play a cheerful hand wave animation. When they discuss something serious, use a stern crossed-arms pose. For idle moments between player responses, loop a thinking or waiting animation. You can also use facial animation natives like SetFacialIdleAnimOverride to change the NPC's resting expression to match the mood of the conversation, making them appear happy, angry, scared, or confused. Combine body and facial animations for the most convincing performances, and always test animations in-game because some animation dictionaries look different on different ped models, and what works on a male ped might clip or look awkward on a female ped.

Performance and Best Practices

A dialogue system that spawns dozens of NPCs across the map needs careful performance management. Only spawn NPCs when players are within rendering distance, typically 50-100 meters, and despawn them when no players are nearby. Use a single thread to manage all NPC spawn distances rather than creating separate threads per NPC, as this dramatically reduces overhead on the client side. Cache dialogue tree data at resource start rather than reading files during conversations. Keep your dialogue camera transitions smooth but do not create and destroy cameras excessively because camera operations have measurable performance cost. When multiple players interact with the same NPC simultaneously, each player should get their own dialogue instance that runs independently, meaning the dialogue state must be stored per-player rather than on the NPC entity. Clean up all dialogue resources when the player disconnects or walks away mid-conversation, destroying the camera, releasing the NUI focus, and restoring player controls. Test your dialogue system with the worst-case scenario in mind: the player's game crashes mid-dialogue, or they alt-F4 while a camera is active. Your system should detect these cases and clean up gracefully to prevent lingering cameras or locked controls on reconnect.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.