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) ormistral:7bfor 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:3borphi3: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
- Grabowski, J., et al. "Fit to Fling: Genetic Prompt Optimization for the LLMs4PCG Competition." IEEE Conference on Games, 2025. [1][2]
- Fredericks, E.M., DeVries, B. "(Genetically) Improving Novelty in Procedural Story Generation." arXiv:2103.06935, 2021. [4][8]
- Ollama Documentation โ API Reference. ollama.com. [3]
- "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.