Evolving NPC Dialogue using Ollama and Genetic Algorithms in C++

Author: JJustis | Published: 2026-03-04 04:52:27
๐Ÿงฌ GENETIC AI ๐ŸŽฎ C++ GAME DEV
โฑ๏ธ 20 MIN READ

Evolving NPC Dialogue:
Ollama + Genetic Algorithms in C++

Imagine an NPC whose dialogue evolves over timeโ€”learning which words resonate with players, adapting its personality based on interaction frequencies, and using genetic algorithms to optimize prompts for Ollama. This guide shows you how to build a C++ system where dialogue choices are driven by keyword frequencies that evolve through generations, creating truly adaptive game characters.

๐Ÿงฌ Why Combine Ollama with Genetic Algorithms?

Traditional NPC dialogue is staticโ€”branched trees or pre-written responses. Ollama brings dynamic, LLM-powered conversations, but even LLMs can become repetitive. By adding a genetic algorithm that evolves prompt structures based on keyword frequencies, you create dialogue that:

  • Adapts to player behavior โ€“ Words players respond to become more frequent.
  • Evolves over game time โ€“ NPC personalities shift based on interaction history.
  • Maintains coherence โ€“ Genetic optimization ensures prompts stay within character.

Recent research in genetic prompt optimization shows that evolving prompts can significantly improve the performance of smaller, open-weight models for game tasks like procedural content generation [1][2].

๐Ÿ”ง Prerequisites

  • Ollama installed โ€“ curl -fsSL https://ollama.com/install.sh | sh
  • A model pulled โ€“ e.g., ollama pull llama3.2:3b (lightning fast) or mistral:7b for richer dialogue
  • C++17 compiler โ€“ with libcurl and nlohmann/json (for HTTP requests)
  • Basic understanding โ€“ of genetic algorithms (selection, crossover, mutation)

๐Ÿ—๏ธ System Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Game Engine   โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚  Dialogue Agent โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚    Ollama API   โ”‚
โ”‚  (C++ / UE/Unityโ”‚     โ”‚ (C++ core logic)โ”‚     โ”‚  (local model)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                               โ–ผ
                        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                        โ”‚   Genetic       โ”‚
                        โ”‚   Optimizer     โ”‚
                        โ”‚ (Keyword Frequencies)โ”‚
                        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The player's interaction history feeds into a genetic algorithm that evolves a keyword frequency vector. This vector is injected into the prompt sent to Ollama, influencing the NPC's dialogue style. After each interaction, the fitness of that generation's keywords is evaluated (e.g., player engagement time, sentiment), and the population evolves.

1๏ธโƒฃ Building the Ollama HTTP Client in C++

First, we need a client that sends prompts to Ollama's API. Ollama runs a local server at http://localhost:11434 [3]. Here's a minimal implementation using libcurl:

#include <iostream>
#include <string>
#include <curl/curl.h>
#include <nlohmann/json.hpp>

using json = nlohmann::json;

// Callback for writing response data
size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* output) {
    size_t totalSize = size * nmemb;
    output->append((char*)contents, totalSize);
    return totalSize;
}

class OllamaClient {
public:
    OllamaClient(const std::string& model = "llama3.2:3b") : model_(model) {}

    std::string Generate(const std::string& prompt) {
        CURL* curl = curl_easy_init();
        if (!curl) return "Error: Could not initialize CURL";

        std::string response;
        struct curl_slist* headers = nullptr;
        headers = curl_slist_append(headers, "Content-Type: application/json");

        // Construct JSON payload
        json payload = {
            {"model", model_},
            {"prompt", prompt},
            {"stream", false},
            {"options", {
                {"temperature", 0.7},
                {"max_tokens", 150}
            }}
        };

        std::string payloadStr = payload.dump();

        curl_easy_setopt(curl, CURLOPT_URL, "http://localhost:11434/api/generate");
        curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payloadStr.c_str());
        curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
        curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
        curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);

        CURLcode res = curl_easy_perform(curl);
        if (res != CURLE_OK) {
            std::cerr << "curl_easy_perform() failed: " << curl_easy_strerror(res) << std::endl;
            return "Error: API request failed";
        }

        curl_easy_cleanup(curl);
        curl_slist_free_all(headers);

        // Parse response
        try {
            json responseJson = json::parse(response);
            return responseJson["response"];
        } catch (...) {
            return "Error: Could not parse response";
        }
    }

private:
    std::string model_;
};

2๏ธโƒฃ Genetic Algorithm: Keyword Frequency Evolution

We'll represent an individual (a "prompt genome") as a vector of keyword frequencies. Each keyword maps to a weight (0.0โ€“1.0) that influences how often it appears in prompts [4]. The genetic algorithm evolves these weights across generations.

Chromosome Representation

struct DialogueGenome {
    std::vector<float> keywordWeights;  // One weight per keyword in vocabulary
    float fitness;
    
    DialogueGenome(size_t vocabSize) : keywordWeights(vocabSize), fitness(0.0f) {
        // Random initialization (0.0 to 1.0)
        for (auto& w : keywordWeights) {
            w = static_cast<float>(rand()) / RAND_MAX;
        }
    }
};

Vocabulary Definition

Choose keywords relevant to your NPC's personality. For example, a gruff warrior might have:

std::vector<std::string> warriorKeywords = {
    "honor", "battle", "glory", "strength", "sword", 
    "enemy", "victory", "blood", "brave", "fight"
};

Prompt Construction from Weights

The genome's weights are used to bias the prompt. Higher-weight keywords are more likely to appear in the system prompt [5]:

std::string BuildPrompt(const DialogueGenome& genome, 
                         const std::vector<std::string>& keywords,
                         const std::string& playerMessage) {
    std::string prompt = "You are a warrior NPC. Emphasize these words with given frequencies:\n";
    
    // Select keywords above threshold (e.g., weight > 0.6)
    for (size_t i = 0; i < keywords.size(); ++i) {
        if (genome.keywordWeights[i] > 0.6f) {
            prompt += "- " + keywords[i] + ": frequency " + 
                      std::to_string(genome.keywordWeights[i]) + "\n";
        }
    }
    
    prompt += "\nKeep responses under 50 words.\n";
    prompt += "Player: " + playerMessage + "\n";
    prompt += "Warrior:";
    
    return prompt;
}

3๏ธโƒฃ Fitness: Measuring Dialogue Success

Fitness determines which genomes survive. For dialogue, we can combine multiple metrics [6]:

  • Player engagement โ€“ Time spent in conversation (longer = better).
  • Sentiment score โ€“ Positive player responses (using simple keyword matching).
  • Keyword diversity โ€“ How many of the targeted keywords appeared in NPC responses.
struct InteractionMetrics {
    float engagementTime;     // seconds
    float playerSentiment;    // 0.0 (negative) to 1.0 (positive)
    float keywordHitRate;     // % of high-weight keywords used
    float responseCoherence;  // 0.0โ€“1.0 (placeholder)
};

float CalculateFitness(const InteractionMetrics& metrics) {
    // Weighted sum
    return (metrics.engagementTime * 0.4f +
            metrics.playerSentiment * 0.3f +
            metrics.keywordHitRate * 0.2f +
            metrics.responseCoherence * 0.1f);
}

4๏ธโƒฃ The Evolution Engine

Now we combine everything into a genetic algorithm that runs every N interactions [7]:

class DialogueEvolution {
public:
    DialogueEvolution(size_t populationSize, size_t vocabSize)
        : populationSize_(populationSize), vocabSize_(vocabSize) {
        // Initialize population
        for (size_t i = 0; i < populationSize; ++i) {
            population_.emplace_back(vocabSize);
        }
    }

    // Select best genomes (tournament selection)
    std::vector<DialogueGenome> SelectParents(int numParents) {
        std::vector<DialogueGenome> parents;
        for (int i = 0; i < numParents; ++i) {
            int idx1 = rand() % populationSize_;
            int idx2 = rand() % populationSize_;
            parents.push_back(population_[idx1].fitness > population_[idx2].fitness ? 
                              population_[idx1] : population_[idx2]);
        }
        return parents;
    }

    // Crossover: blend weights from two parents
    DialogueGenome Crossover(const DialogueGenome& a, const DialogueGenome& b) {
        DialogueGenome child(vocabSize_);
        for (size_t i = 0; i < vocabSize_; ++i) {
            // Uniform crossover
            child.keywordWeights[i] = (rand() % 2) ? a.keywordWeights[i] : b.keywordWeights[i];
        }
        return child;
    }

    // Mutate: randomly adjust weights
    void Mutate(DialogueGenome& genome, float mutationRate = 0.1f) {
        for (auto& w : genome.keywordWeights) {
            if ((float)rand() / RAND_MAX < mutationRate) {
                // Add small random change (gaussian-like)
                w += ((float)rand() / RAND_MAX - 0.5f) * 0.2f;
                w = std::max(0.0f, std::min(1.0f, w)); // Clamp
            }
        }
    }

    // Main evolution step
    void Evolve() {
        // Sort by fitness (descending)
        std::sort(population_.begin(), population_.end(),
                  [](const DialogueGenome& a, const DialogueGenome& b) {
                      return a.fitness > b.fitness;
                  });

        // Keep top 20% as elites
        std::vector<DialogueGenome> newPopulation;
        int eliteCount = populationSize_ * 0.2;
        for (int i = 0; i < eliteCount; ++i) {
            newPopulation.push_back(population_[i]);
        }

        // Fill rest with offspring
        while (newPopulation.size() < populationSize_) {
            auto parents = SelectParents(2);
            auto child = Crossover(parents[0], parents[1]);
            Mutate(child);
            newPopulation.push_back(child);
        }

        population_ = std::move(newPopulation);
    }

private:
    size_t populationSize_;
    size_t vocabSize_;
    std::vector<DialogueGenome> population_;
};

5๏ธโƒฃ Putting It All Together in Your Game

Here's how the main game loop integrates with the evolution system:

class NPC {
public:
    NPC(const std::string& name, const std::vector<std::string>& keywords)
        : name_(name), keywords_(keywords), 
          currentGenomeIndex_(0), interactionCount_(0) {
        
        evolution_ = std::make_unique<DialogueEvolution>(20, keywords.size());
    }

    std::string Talk(const std::string& playerInput) {
        // Get current best genome
        auto& genome = evolution_->GetPopulation()[currentGenomeIndex_];
        
        // Build prompt with keyword weights
        std::string prompt = BuildPrompt(genome, keywords_, playerInput);
        
        // Query Ollama
        std::string response = ollama_.Generate(prompt);
        
        // Record interaction metrics (simplified)
        RecordInteraction(playerInput, response);
        
        interactionCount_++;
        
        // Evolve every 10 interactions
        if (interactionCount_ % 10 == 0) {
            // Evaluate fitness for all genomes based on recent interactions
            EvaluateAllFitness();
            evolution_->Evolve();
            // Switch to a new genome (maybe the best one)
            currentGenomeIndex_ = 0;
        }
        
        return response;
    }

private:
    void RecordInteraction(const std::string& playerMsg, const std::string& npcResponse) {
        // Calculate metrics (simplified)
        InteractionMetrics metrics;
        metrics.engagementTime = 5.0f; // Placeholder
        metrics.playerSentiment = CalculateSentiment(playerMsg);
        metrics.keywordHitRate = CountKeywordUsage(npcResponse);
        metrics.responseCoherence = 0.8f; // Placeholder
        
        // Store with current genome
        recentInteractions_.push_back({currentGenomeIndex_, metrics});
    }

    void EvaluateAllFitness() {
        // For each genome, average fitness from its interactions
        std::map<int, std::vector<InteractionMetrics>> genomeMetrics;
        for (const auto& record : recentInteractions_) {
            genomeMetrics[record.first].push_back(record.second);
        }
        
        auto& pop = evolution_->GetPopulation();
        for (size_t i = 0; i < pop.size(); ++i) {
            if (genomeMetrics.count(i)) {
                float totalFitness = 0;
                for (const auto& m : genomeMetrics[i]) {
                    totalFitness += CalculateFitness(m);
                }
                pop[i].fitness = totalFitness / genomeMetrics[i].size();
            }
        }
        
        recentInteractions_.clear();
    }

    std::string name_;
    std::vector<std::string> keywords_;
    std::unique_ptr<DialogueEvolution> evolution_;
    size_t currentGenomeIndex_;
    int interactionCount_;
    OllamaClient ollama_;
    
    struct InteractionRecord {
        int genomeIndex;
        InteractionMetrics metrics;
    };
    std::vector<InteractionRecord> recentInteractions_;
};

โšก Performance Considerations

  • Model size โ€“ For real-time dialogue, use smaller models like llama3.2:3b or phi3:mini [3].
  • Batch evolution โ€“ Run genetic updates during loading screens or between levels.
  • Caching โ€“ Cache frequent responses for common player inputs.
  • GPU acceleration โ€“ Ollama uses GPU if available; ensure your game leaves some GPU headroom.

๐Ÿš€ Taking It Further

  • Multi-objective evolution โ€“ Optimize for both engagement and story coherence.
  • Population sharing โ€“ Have NPCs share a gene pool, allowing dialogue styles to spread like memes.
  • Player-driven selection โ€“ Let players "breed" NPCs by favoring certain dialogue styles.
  • Integration with dualโ€‘CPU workstations โ€“ On machines like the one we built earlier, run larger models (e.g., mixtral:8x7b) for even richer dialogue.

๐Ÿ“š References & Further Reading

  1. Grabowski, J., et al. "Fit to Fling: Genetic Prompt Optimization for the LLMs4PCG Competition." IEEE Conference on Games, 2025. [1][2]
  2. Fredericks, E.M., DeVries, B. "(Genetically) Improving Novelty in Procedural Story Generation." arXiv:2103.06935, 2021. [4][8]
  3. Ollama Documentation โ€“ API Reference. ollama.com. [3]
  4. "Large Language Models as Optimizers." arXiv:2309.03409, 2023. [7]

By combining Ollama's local LLM capabilities with a genetic algorithm that evolves keyword frequencies, you create NPCs that truly adapt to your players. The dialogue evolves, personalities shift, and every conversation feels fresh. This approach turns static text into a living, breathing part of your game world.

Let your NPCs evolve.

๐Ÿงฌ genetic dialogue systems โ€” april 2026 โ€” c++20, ollama, and the future of npcs.