Hello! Welcome back to the course.
In the last lesson, we built the core logic in Rust for generating dynamic, character-driven dialogue. We can now construct a detailed prompt based on a character's personality and situation, send it to a local LLM, and get a unique line of dialogue back. The entire process, for now, lives inside our Rust backend and prints to the console.
Today, we'll bridge the gap between our Rust engine and our Unity frontend. This lesson focuses on achieving a crucial goal for our game: integrating and displaying the generated dialogue text within the existing conversation UI. We'll take the text generated in Rust, send it across the FFI boundary, and make it appear in the Unity conversation window.
By the end of this lesson, you will have a complete, end-to-end pipeline: from a player's interaction choice in Unity to the AI's generated response appearing on screen.
1. The Asynchronous FFI Challenge
Our generate_dialogue function in Rust is async. This is great for performance because it doesn't block the program while waiting for the LLM. However, we cannot directly export an async function through a C-compatible FFI. Calling a blocking function from Unity would freeze the entire game until the LLM responds, which is unacceptable.
The solution is a callback mechanism. Our C# code will pass a function pointer to Rust. Rust will then start the async task in a separate thread and immediately return, keeping Unity responsive. Once the task is complete and the dialogue is ready, Rust will use the provided function pointer to call back into C# with the result.
This is the standard pattern for handling long-running operations between a game engine and a backend library.
2. Part 1: Modifying the Rust FFI for Callbacks
Let's adapt our Rust code to support this callback pattern. We'll need two FFI functions: one to request the dialogue and another to free the memory of the string we send back to C#.
First, add the libc crate to your Cargo.toml, which we'll use for C-compatible types.
[dependencies]
# ... other dependencies
libc = "0.2"
Now, let's define the FFI layer in your main.rs.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use libc; // Import libc
// 1. Define the signature of the C# callback function
// It takes a pointer to a C-style string (char*).
type DialogueCallback = extern "C" fn(*const c_char);
// 2. Create an FFI function to free the string memory from C#
#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
// Retake ownership of the CString to deallocate it
let _ = CString::from_raw(ptr);
}
}
// 3. Create the main FFI function to request dialogue
#[no_mangle]
pub extern "C" fn generate_dialogue_ffi(
system_prompt: *const c_char,
user_prompt: *const c_char,
callback: DialogueCallback,
) {
// Safely convert C strings to Rust strings
let system_prompt_str = unsafe { CStr::from_ptr(system_prompt) }.to_string_lossy().into_owned();
let user_prompt_str = unsafe { CStr::from_ptr(user_prompt) }.to_string_lossy().into_owned();
// Spawn a Tokio task to run our async function without blocking
tokio::spawn(async move {
match generate_dialogue(&system_prompt_str, &user_prompt_str).await {
Ok(dialogue) => {
// Convert the Rust String to a C-compatible string
let c_dialogue = CString::new(dialogue).unwrap();
// Pass the pointer to the C# callback
callback(c_dialogue.as_ptr());
// Leak the memory so C# can control it
std::mem::forget(c_dialogue);
}
Err(e) => {
eprintln!("Error generating dialogue: {}", e);
// In case of error, send an error message back
let c_error = CString::new(format!("Error: {}", e)).unwrap();
callback(c_error.as_ptr());
std::mem::forget(c_error);
}
}
});
}
Key changes here:
DialogueCallback: We define the type for a function pointer that C# will give us.generate_dialogue_ffi: This is our new, C-compatible entry point. It takes the prompts and the callback.tokio::spawn: This is crucial. It runs ourasync generate_dialoguefunction in the background, sogenerate_dialogue_ffireturns immediately.CStringandstd::mem::forget: We convert the resultingStringinto aCString.std::mem::forgettells Rust's memory manager to "forget" about this string, preventing it from being deallocated. We are handing over ownership and responsibility to the C# side.free_string: This function allows C# to hand ownership back to Rust so the memory can be properly reclaimed, preventing memory leaks.
Rebuild your Rust library (cargo build --release) and copy the resulting .dll (Windows), .so (Linux), or .dylib (macOS) file back into your Unity project's Assets/Plugins folder.
3. Part 2: Creating a C# Service for FFI Communication
In Unity, it's good practice to centralize all FFI communication into a single static class. This keeps your game logic clean from the complexities of P/Invoke.
The pattern we'll use is inspired by common Unity LLM integrations. The article "How to Use LLMs in Unity" demonstrates a similar C# architecture for handling asynchronous replies from an LLM.
Let's look at a practical example of a C# script designed to interact with an LLM in Unity. This article provides an excellent reference for the client-side code we're about to write.
Please review the 'Simple interaction' section. Focus on the SimpleInteraction C# script. Notice how it uses _ = llm.Chat(message, SetAIText, AIReplyComplete). This callback-based approach is exactly what we are implementing. The SetAIText function is our equivalent of the callback that will update the UI.
Now, create a new C# script named LLMService.cs in your Unity project.
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using AOT; // Necessary for MonoPInvokeCallback
public static class LLMService
{
private const string RustLib = "your_rust_lib_name"; // CHANGE THIS to your library's name
// 1. Define the delegate for the callback, matching Rust's `DialogueCallback`
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void DialogueCallback(IntPtr response);
// 2. Import the Rust functions
[DllImport(RustLib, CallingConvention = Cdecl)]
private static extern void generate_dialogue_ffi(string systemPrompt, string userPrompt, DialogueCallback callback);
[DllImport(RustLib, CallingConvention = Cdecl)]
private static extern void free_string(IntPtr ptr);
// 3. Create a static event to notify the UI of new dialogue
public static event Action<string> OnDialogueReceived;
// 4. The static callback method that Rust will call
[MonoPInvokeCallback(typeof(DialogueCallback))]
private static void ReceiveDialogue(IntPtr responsePtr)
{
if (responsePtr == IntPtr.Zero) return;
try
{
// Marshal the C string to a C# string
string dialogue = Marshal.PtrToStringAnsi(responsePtr);
// Raise the event for UI listeners
// We need a way to invoke this on the main thread, as Rust may call back from another thread.
UnityMainThreadDispatcher.Instance().Enqueue(() =>
{
OnDialogueReceived?.Invoke(dialogue);
});
}
finally
{
// Free the memory that Rust allocated
free_string(responsePtr);
}
}
// 5. Public method for the game to call
public static void RequestDialogue(string systemPrompt, string userPrompt)
{
generate_dialogue_ffi(systemPrompt, userPrompt, ReceiveDialogue);
}
}
A Note on Threading: The Rust callback might execute on a background thread. Unity's UI can only be modified from the main thread. To solve this, you need a simple "main thread dispatcher". If you don't have one, here is a minimal implementation. Create a script UnityMainThreadDispatcher.cs and add it to a persistent GameObject in your scene (like a "GameManager").
// UnityMainThreadDispatcher.cs
using System;
using System.Collections.Generic;
using UnityEngine;
public class UnityMainThreadDispatcher : MonoBehaviour
{
private static readonly Queue<Action> _executionQueue = new Queue<Action>();
private static UnityMainThreadDispatcher _instance = null;
public static UnityMainThreadDispatcher Instance()
{
if (!_instance)
{
throw new Exception("UnityMainThreadDispatcher could not find an instance in the scene. Make sure one is present.");
}
return _instance;
}
void Awake()
{
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(this.gameObject);
}
}
void Update()
{
lock (_executionQueue)
{
while (_executionQueue.Count > 0)
{
_executionQueue.Dequeue().Invoke();
}
}
}
public void Enqueue(Action action)
{
lock (_executionQueue)
{
_executionQueue.Enqueue(action);
}
}
}
4. Part 3: Updating the Unity Conversation UI
Now we need a script that listens for the OnDialogueReceived event and updates our UI. Your conversation UI from Module 8 should have a text component to display dialogue. We'll use TextMeshPro, as it's the standard for professional-looking text in Unity.
This video provides an excellent guide on how to script TextMeshPro elements. The concepts are directly applicable here.
A Beginner Guide to TEXTMESHPRO (Unity UI Tutorial)
To display the dialogue, we need to programmatically change the text of a UI element. This video from SpeedTutor is a great tutorial on controlling TextMeshPro components via C# script.
Watch the section from 5:28 to 7:15. It demonstrates exactly what we need: how to get a reference to a TextMeshProUGUI component and change its .text property from a script.
Create a ConversationUIController.cs script and attach it to the parent canvas or panel of your conversation UI.
using UnityEngine;
using TMPro; // Add this namespace for TextMeshPro
public class ConversationUIController : MonoBehaviour
{
// Assign this in the Unity Inspector
public TextMeshProUGUI dialogueText;
public TextMeshProUGUI speakerNameText; // Optional: for speaker's name
void OnEnable()
{
// Subscribe to the event when the object is enabled
LLMService.OnDialogueReceived += DisplayDialogue;
}
void OnDisable()
{
// Unsubscribe to prevent errors
LLMService.OnDialogueReceived -= DisplayDialogue;
}
private void DisplayDialogue(string message)
{
if (dialogueText != null)
{
// Update the text component with the message from Rust
dialogueText.text = message;
}
}
// You can add a method to clear the text
public void ClearDialogue()
{
if (dialogueText != null) dialogueText.text = "";
}
}
In the Unity Editor, select the GameObject with this script and drag your TextMeshProUGUI component into the dialogueText field in the Inspector.
5. Putting It All Together
We have all the pieces. Let's create a final script to trigger the process. Imagine this is part of your interaction handler that runs when the player selects a conversation topic.
Create a temporary script DialogueTester.cs and add it to any object in your scene.
using UnityEngine;
public class DialogueTester : MonoBehaviour
{
void Update()
{
// For testing, let's trigger it with a key press
if (Input.GetKeyDown(KeyCode.T))
{
Debug.Log("Requesting dialogue from Rust...");
// These prompts would come from your game logic
string systemPrompt = "You are a cheerful shopkeeper named Bob. You are speaking to a customer.";
string userPrompt = "You are performing the interaction 'Greet Customer'. Generate a single, short line of dialogue (less than 15 words). Your response must ONLY be the dialogue text itself.";
// Call our service to kick off the process
LLMService.RequestDialogue(systemPrompt, userPrompt);
}
}
}
Now, run your Unity scene. Make sure your Ollama server is running in the background. Press the 'T' key. You should see your debug log, and after a short delay, the dialogue generated by the LLM will appear in your TextMeshPro UI element!

Conclusion
Excellent work! You have successfully built a non-blocking, asynchronous pipeline to generate dialogue in Rust and display it in Unity. This is a foundational piece of our dynamic simulation.
Key Takeaways:
- Asynchronous FFI: The callback pattern is the standard way to handle long-running Rust tasks (like API calls) from Unity without freezing the game.
- Memory Management: When passing strings from Rust to C#, Rust must relinquish control (
mem::forget), and C# must be responsible for telling Rust when to free the memory (free_string). - Service-Oriented Architecture: Centralizing FFI calls in a static C# service (
LLMService) keeps your code clean and manageable. - Event-Driven UI: Using C# events (
OnDialogueReceived) to decouple the FFI logic from the UI logic is a robust and scalable design pattern.
Preview of the Next Lesson:
Our characters can now talk, but they have no memory of what they've said. To make conversations feel more natural and coherent, we need to give them a short-term memory. In the next lesson, we will "Refine prompts to include recent conversation history to provide more coherent dialogue." This will involve expanding our FFI to handle a list of past messages, making our characters' conversations flow much more realistically.