Hello! Welcome back.
In our last lesson, you successfully built an end-to-end pipeline to generate a single line of dialogue in Rust and display it in Unity. This was a huge step, but our characters currently have the memory of a goldfish—each interaction is a fresh start, with no recollection of what was just said.
Today, we'll give our characters a short-term memory. This lesson directly addresses the learning outcome: refine prompts to include recent conversation history to provide more coherent dialogue. We will modify our system to track the back-and-forth of a conversation, sending this history to the LLM with each new message. This will transform our disjointed dialogue into a flowing, logical conversation.
By the end of this lesson, you'll be able to have a multi-turn conversation with an AI character that remembers what you've discussed.
1. The Key to Coherent Conversation: Context
An LLM, by itself, is stateless. To make a conversation coherent, we must provide the context of previous turns in every new request. There are a couple of ways to do this with Ollama, but the most common and flexible method is to use the /api/chat endpoint, which is specifically designed for conversational AI.
Unlike the /api/generate endpoint we used previously, /api/chat accepts a list of messages, where each message has a role (system, user, or assistant) and content.

To understand the Chat API better, please watch the first couple of minutes of this video.
Using the Chat Endpoint in the Ollama API
This video from Matt Williams clearly explains the purpose of Ollama's Chat API and how it uses a messages array to maintain conversation history.
Watch from the beginning to 2:31. Focus on the distinction between the 'generate' and 'chat' endpoints and the structure of the messages array with roles.
Our plan is to adopt this chat-based approach. We will make our Rust backend stateful, meaning it will store the conversation history and manage the context for us.
2. Upgrading the Rust Backend for Chat
We need to make three key changes in our Rust code:
- Define new structs to match the
/api/chatJSON structure. - Create a shared, thread-safe place to store the conversation history.
- Write a new function to call the
/api/chatendpoint with the history.
This article provides a fantastic, complete example of a Rust chatbot using the same techniques we'll be implementing. It's a great reference for the code we're about to write.
Building a simple AI chatbot using Rust and Ollama
The article 'Building a simple AI chatbot using Rust and Ollama' by zupzup is an excellent practical guide. We will use its implementation as a foundation.
Please read the article from the start down to the 'Testing' section. Pay close attention to: How the messages: Vec<Message> is created and updated after each user input and LLM response. The code that sends the messages vector to the /api/chat endpoint. The loop that collects the streamed response and adds it back to the history with the 'assistant' role.
Step 2.1: Defining the Chat Data Structures
First, let's add the necessary structs to your main.rs. These will use serde to serialize into the JSON format the /api/chat endpoint expects.
use serde::{Serialize, Deserialize};
// Represents a single message in the conversation history
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Message {
role: String,
content: String,
}
// The request body for the /api/chat endpoint
#[derive(Serialize, Debug)]
struct ChatRequest {
model: String,
messages: Vec<Message>,
stream: bool,
}
// Part of the streaming response from the /api/chat endpoint
#[derive(Deserialize, Debug)]
struct ChatResponse {
message: Message,
done: bool,
}
This structure is also detailed in another excellent tutorial, which you can refer to if you need more context on the struct definitions. The section "Mapping Ollama’s API to Rust structs" in the article "Building Doit Part 2" (resource ID LINK) provides a well-commented breakdown.
Step 2.2: Managing Conversation State
To store the history, we need a global, thread-safe variable. We'll use a Mutex to ensure that only one thread can access the history at a time, wrapped in a lazy_static macro to ensure it's initialized safely.
Add lazy_static and tokio::sync::Mutex to your Cargo.toml if they aren't there already.
[dependencies]
# ...
lazy_static = "1.4.0"
tokio = { version = "1", features = ["full"] }
# ...
Now, define the static history variable in main.rs.
use lazy_static::lazy_static;
use tokio::sync::Mutex;
use std::collections::Vec;
lazy_static! {
// A globally accessible, thread-safe vector to store our conversation
static ref CONVERSATION_HISTORY: Mutex<Vec<Message>> = Mutex::new(Vec::new());
}
Step 2.3: The New Chat Generation Logic
Let's adapt our old generate_dialogue function to work with the new Chat API. This new async function will handle the logic of sending the history and processing the streamed response.
async fn generate_chat_response() -> Result<String, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let history = CONVERSATION_HISTORY.lock().await; // Lock the history for reading
let request_body = ChatRequest {
model: "llama3".to_string(), // Or your preferred model
messages: history.clone(), // Send a clone of the current history
stream: true,
};
// We're done reading, so we can drop the lock manually before the network call
// This is good practice to avoid holding locks longer than necessary.
drop(history);
let mut res = client
.post("http://localhost:11434/api/chat")
.json(&request_body)
.send()
.await?;
let mut full_response = String::new();
while let Some(chunk) = res.chunk().await? {
// We need to handle multiple JSON objects in a single chunk
let chunk_str = String::from_utf8_lossy(&chunk);
for line in chunk_str.lines() {
if line.trim().is_empty() { continue; }
if let Ok(response_part) = serde_json::from_str::<ChatResponse>(line) {
full_response.push_str(&response_part.message.content);
if response_part.done {
break;
}
}
}
}
Ok(full_response)
}
3. Creating a Stateful FFI for Chat
With the core Rust logic in place, let's expose it to Unity through a new, stateful FFI. Instead of one function, we'll have two: one to start/reset the conversation and one to send a message.
Add these new FFI functions to your main.rs.
// FFI function to initialize or reset the conversation with a system prompt
#[no_mangle]
pub extern "C" fn init_conversation_ffi(system_prompt: *const c_char) {
let prompt = unsafe { CStr::from_ptr(system_prompt) }.to_string_lossy().into_owned();
tokio::spawn(async move {
let mut history = CONVERSATION_HISTORY.lock().await;
history.clear();
history.push(Message {
role: "system".to_string(),
content: prompt,
});
});
}
// FFI function to send a user message and get an AI response
#[no_mangle]
pub extern "C" fn send_user_message_ffi(user_prompt: *const c_char, callback: DialogueCallback) {
let prompt = unsafe { CStr::from_ptr(user_prompt) }.to_string_lossy().into_owned();
tokio::spawn(async move {
// Add the user's message to the history
{ // Scoped lock
let mut history = CONVERSATION_HISTORY.lock().await;
history.push(Message {
role: "user".to_string(),
content: prompt,
});
} // Lock is released here
// Generate the response based on the updated history
match generate_chat_response().await {
Ok(dialogue) => {
// Add the AI's response to the history
{ // Scoped lock
let mut history = CONVERSATION_HISTORY.lock().await;
history.push(Message {
role: "assistant".to_string(),
content: dialogue.clone(),
});
} // Lock is released here
// Send the response back to C#
let c_dialogue = CString::new(dialogue).unwrap();
callback(c_dialogue.as_ptr());
std::mem::forget(c_dialogue);
}
Err(e) => {
let error_message = format!("Error: {}", e);
eprintln!("{}", error_message);
let c_error = CString::new(error_message).unwrap();
callback(c_error.as_ptr());
std::mem::forget(c_error);
}
}
});
}
Remember to recompile your Rust library (cargo build --release) and copy the updated file to your Unity project's Assets/Plugins folder.
4. Updating Unity to Have a Conversation
Now, let's hook up the C# side to our new FFI.
Step 4.1: Updating LLMService.cs
Open your LLMService.cs script and add the new function imports.
// Add these to your DllImport section
[DllImport(RustLib, CallingConvention = Cdecl)]
private static extern void init_conversation_ffi(string systemPrompt);
[DllImport(RustLib, CallingConvention = Cdecl)]
private static extern void send_user_message_ffi(string userPrompt, DialogueCallback callback);
// Add these new public methods for the game to call
public static void InitConversation(string systemPrompt)
{
init_conversation_ffi(systemPrompt);
}
public static void SendUserMessage(string userPrompt)
{
// The callback (ReceiveDialogue) is the same one we used in the previous lesson
send_user_message_ffi(userPrompt, ReceiveDialogue);
}
Step 4.2: Updating ConversationUIController.cs
To see the conversation history, we'll modify the UI controller to append new messages instead of replacing the text.
// In ConversationUIController.cs
private void DisplayDialogue(string message)
{
if (dialogueText != null)
{
// Append the new message, along with a role and new lines for readability
dialogueText.text += $"AI: {message}\n\n";
}
}
// Add a public method to add the player's message to the log
public void DisplayPlayerMessage(string message)
{
if (dialogueText != null)
{
dialogueText.text += $"Player: {message}\n";
}
}
Step 4.3: Testing the Conversation Flow
Finally, let's modify the DialogueTester.cs to test a multi-turn conversation. Instead of a text input field, we'll use different keys to send pre-written messages.
using UnityEngine;
public class DialogueTester : MonoBehaviour
{
// Reference to the UI controller to show player's message
public ConversationUIController uiController;
private int messageIndex = 0;
private readonly string[] playerMessages = {
"Why is the sky blue?",
"And what about during sunset?"
};
void Start()
{
Debug.Log("Initializing conversation...");
string systemPrompt = "You are a helpful science teacher. Your answers should be concise and easy to understand for a 10-year-old.";
LLMService.InitConversation(systemPrompt);
}
void Update()
{
// Use a single key to step through the conversation
if (Input.GetKeyDown(KeyCode.T))
{
if (messageIndex < playerMessages.Length)
{
string message = playerMessages[messageIndex];
Debug.Log($"Sending message: {message}");
// Display the player's message in the UI
if (uiController != null)
{
uiController.DisplayPlayerMessage(message);
}
// Send the message to the Rust backend
LLMService.SendUserMessage(message);
messageIndex++;
}
else
{
Debug.Log("End of test conversation.");
}
}
}
}
Assign your ConversationUIController to the public field on the DialogueTester script in the Unity Inspector.
Now, run the scene.
- Press
T. The first question, "Why is the sky blue?", will be sent. The AI should give a simple explanation. - Press
Tagain. The second question, "And what about during sunset?", is sent. Because the Rust backend remembers the first question, the LLM will understand that "what about" refers to the color of the sky. It should give a relevant answer about sunsets, demonstrating that it has conversational memory.
Conclusion
Fantastic work! You have successfully upgraded our dialogue system from a simple one-shot generator to a stateful conversational agent. Our characters can now remember the context of a conversation, leading to much more believable and coherent interactions.
Key Takeaways:
- Chat API: Ollama's
/api/chatendpoint, with itsmessagesarray, is the standard for building conversational experiences. - Stateful Backend: By managing the conversation history (
Vec<Message>) in Rust, we create a clean and powerful interface for Unity, keeping the core game logic in our Rust engine. - Thread-Safe State: Using tools like
Mutexis essential for safely managing shared data in a multi-threaded environment, which is standard when dealing with asynchronous FFI calls.
Preview of the Next Lesson:
We have now established the core foundation of our dynamic social simulation! We'll now pivot from character interactions to world interactions. In our next module, we will begin implementing the game's economy and a "buy mode," allowing players to purchase objects and furnish their homes.