Welcome back!
In our last lesson, we established a robust, one-way communication channel from Unity to Rust using a JSON-based command queue. Unity can now send various commands to a single, clean FFI endpoint. However, for a dynamic game, communication needs to be a two-way street. Unity needs to know if an action succeeded, what the new state of the world is, or if an error occurred.
Today, we will complete the communication loop. This lesson addresses the learning outcome: Process commands in Rust and return serialized JSON responses back to Unity. We will modify our Rust engine to process the incoming commands and send back a serialized JSON response, which Unity will then deserialize and act upon. This pattern is fundamental to building a responsive and interactive simulation.
1. From One-Way to Two-Way Communication
Our current process_command function in Rust takes a command but returns nothing. We're going to change it so that after processing a command, it constructs a response object, serializes it to a JSON string, and returns that string to Unity.
First, let's define what our responses will look like. Just as we created a GameCommand enum, we'll create a CommandResponse enum in Rust. This provides a structured way to handle different outcomes, like success or failure.
Step 1: Define CommandResponse in Rust
In your src/lib.rs file, add the following enum. We'll again use Serde to make it easily serializable.
use serde::{Serialize, Deserialize};
// Add this alongside your other struct/enum definitions.
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "ResponseType")] // Use a distinct tag for responses.
pub enum CommandResponse {
Success { msg: String },
Error { error: String },
}
This simple enum allows our Rust core to report either a successful operation with a confirmation message or a failure with an error description.
2. The Challenge: Returning Dynamic Data from Rust
Returning a string from Rust to C# is more complex than passing one in. When Rust creates a String, it manages that memory. If we just pass a pointer to that memory over to C#, Rust's ownership system might deallocate it while C# is still using it, causing a crash.
The correct pattern is:
- Rust: Allocates the string and converts it into a C-compatible pointer, explicitly giving up ownership.
- C#: Receives the pointer, uses the data, and then...
- C#: Calls another Rust function (
free_string) to tell Rust it's safe to reclaim the memory.
This is the most critical concept of this lesson. It ensures that memory allocated in one language is properly deallocated by the same language's allocator, preventing leaks and crashes.
How we replace most of our HPC# code with Rust code - Unity Engine
The forum post 'How we replace most of our HPC# code with Rust code' provides an excellent, in-depth explanation of this exact problem. It explores why you can't just return a Rust String and demonstrates the correct patterns for passing string data back to C#.
Read the section that begins with the question, 'βHow about just return a rust String back?β'. Pay close attention to the evolution of the examples, from the one that causes a memory error to the one that leaks memory, and finally to the introduction of a deallocation function. This directly explains the 'why' behind the pattern we are implementing.
3. Implementing the Response Logic
Now, let's modify process_command to generate and return a response. We'll use serde_json::to_string to serialize our CommandResponse enum, and then CString::into_raw to get a pointer we can safely send to C#.
Step 2: Update process_command in Rust
Replace your existing process_command function in src/lib.rs with this updated version. Make sure you have the necessary use statements at the top of your file.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::ptr; // We'll use this for returning null pointers
// ... your GameCommand, Vector3Data, and CommandResponse enums/structs ...
#[no_mangle]
pub extern "C" fn process_command(command_json: *const c_char) -> *mut c_char {
// 1. Safely convert the C string to a Rust string slice.
let command_str = unsafe {
if command_json.is_null() {
// If we receive a null pointer, we can't proceed.
// We'll construct an error response.
let err_response = CommandResponse::Error { error: "Received null command pointer from Unity.".to_string() };
let response_json = serde_json::to_string(&err_response).unwrap();
return CString::new(response_json).unwrap().into_raw();
}
CStr::from_ptr(command_json).to_str().unwrap_or("")
};
if command_str.is_empty() {
// Handle empty string case
let err_response = CommandResponse::Error { error: "Received empty command string from Unity.".to_string() };
let response_json = serde_json::to_string(&err_response).unwrap();
return CString::new(response_json).unwrap().into_raw();
}
// 2. Deserialize the JSON and generate a response.
let command: Result<GameCommand, _> = serde_json::from_str(command_str);
let response = match command {
Ok(GameCommand::LogMessage { msg }) => {
println!("[Rust Command] LogMessage: {}", msg);
CommandResponse::Success { msg: format!("Log command processed: '{}'", msg) }
}
Ok(GameCommand::SetCharacterPosition { character_id, position }) => {
println!("[Rust Command] SetCharacterPosition for char {}: ({}, {}, {})", character_id, position.x, position.y, position.z);
// In a real game, you would update your game state here.
CommandResponse::Success { msg: format!("Character {} moved successfully.", character_id) }
}
Err(e) => {
let error_msg = format!("Failed to deserialize command: {}", e);
println!("[Rust Error] {}", error_msg);
CommandResponse::Error { error: error_msg }
}
};
// 3. Serialize the response to a JSON string.
let response_str = serde_json::to_string(&response).unwrap_or_else(|e| {
// This is a fallback in case serializing the response itself fails.
let fallback_err = CommandResponse::Error { error: format!("FATAL: Failed to serialize response: {}", e) };
serde_json::to_string(&fallback_err).unwrap()
});
// 4. Convert the Rust String to a C-compatible string pointer and return it.
let c_str_response = CString::new(response_str).unwrap();
c_str_response.into_raw()
}
// Your free_string function from the last lesson is now essential!
// Ensure it is still in your lib.rs file.
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
unsafe {
if s.is_null() { return; }
// Retake ownership of the pointer so Rust can deallocate the memory.
let _ = CString::from_raw(s);
}
}
With this change, our process_command function now completes the request-response cycle. It takes a command, produces a result, and sends that result back to the caller.

4. Handling the Response in Unity
Now let's update the Unity side to call this new function, receive the pointer, read the string, and, most importantly, free the memory.
Step 3: Create the C# Response Struct
In your FFIStructs.cs file, add a struct to represent the response data. Like our command struct, it needs to be a "fat" struct containing fields for all possible response types.
// In FFIStructs.cs
[System.Serializable]
public struct CommandResponseData
{
public string ResponseType;
// Fields for Success response
public string msg;
// Fields for Error response
public string error;
}
Step 4: Update the FFI Bridge and GameManager
First, modify RustBridge.cs to reflect the new signature of process_command and to include free_string. We use IntPtr to handle the raw pointer returned by Rust.
In RustBridge.cs:
using System;
using System.Runtime.InteropServices;
public static class RustBridge
{
private const string RustLib = "life_sim_backend";
[DllImport(RustLib, EntryPoint = "process_command")]
public static extern IntPtr ProcessCommand(string commandJson);
[DllImport(RustLib, EntryPoint = "free_string")]
public static extern void FreeString(IntPtr s);
}
Next, update your GameManager.cs to handle the full request-response flow, including the crucial try...finally block to guarantee memory deallocation.
In GameManager.cs:
using System;
using UnityEngine;
using System.Runtime.InteropServices;
public class GameManager : MonoBehaviour
{
void Start()
{
Debug.Log("GameManager starting...");
// --- Send a valid command ---
var moveCommand = new GameCommandData
{
command_type = "SetCharacterPosition",
character_id = 1,
position = new Vector3Data { x = 15.5f, y = 0f, z = 22.1f }
};
SendCommand(moveCommand);
// --- Send an invalid command to test error handling ---
string badJson = "{\"command_type\":\"FlyToMoon\",\"power_level\":9001}";
SendCommand(badJson);
}
void SendCommand(GameCommandData command)
{
string json = JsonUtility.ToJson(command);
SendCommand(json);
}
void SendCommand(string json)
{
Debug.Log($"Sending JSON: {json}");
IntPtr responsePtr = IntPtr.Zero;
try
{
// 1. Call Rust and get the response pointer
responsePtr = RustBridge.ProcessCommand(json);
// 2. Convert the pointer to a C# string
string responseJson = Marshal.PtrToStringUTF8(responsePtr);
Debug.Log($"Received Response: {responseJson}");
// 3. Deserialize the JSON response
var responseData = JsonUtility.FromJson<CommandResponseData>(responseJson);
// 4. Act on the response
if (responseData.ResponseType == "Success")
{
Debug.Log($"<color=green>Rust confirmed: {responseData.msg}</color>");
}
else
{
Debug.LogError($"<color=red>Rust returned an error: {responseData.error}</color>");
}
}
catch (Exception e)
{
Debug.LogError($"An exception occurred: {e.Message}");
}
finally
{
// 5. CRITICAL: Free the memory allocated by Rust
if (responsePtr != IntPtr.Zero)
{
RustBridge.FreeString(responsePtr);
}
}
}
}
Now, recompile your Rust library (cargo build), copy the new .dll to your Unity Plugins folder, and run the scene. In your Unity console, you should now see:
- The JSON for the
SetCharacterPositioncommand being sent. - The
Successresponse received from Rust, confirming the action. - The invalid JSON being sent.
- The
Errorresponse received from Rust, explaining that deserialization failed.
Conclusion
You have successfully established a complete, two-way communication channel between Unity and your Rust game engine! This request-response pattern using a serialized command queue is a powerful and scalable foundation for the rest of our game.
Key Takeaways:
- A full communication loop involves Unity sending a request (command) and Rust sending back a response.
- When returning a dynamically sized string from Rust to C#, Rust must allocate the memory and give C# a pointer, and C# must later call a Rust function to free that memory.
- The
CString::into_raw()function in Rust gives up ownership of the memory and provides a raw pointer for FFI. CString::from_raw()reclaims ownership, allowing Rust to deallocate the memory correctly.- In C#,
Marshal.PtrToStringUTF8reads the data from the pointer, and atry...finallyblock is essential to ensure the deallocation function is always called.
In our next lesson, we will build on this foundation to "Establish a comprehensive error handling strategy for FFI communication". We've already started by returning error messages, but we will make this system more robust and explore patterns for handling panics at the FFI boundary to prevent your entire game from crashing.