Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 10

Rust and Unity: Command Processing and JSON Serialization

Recommended resources

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:

  1. Rust: Allocates the string and converts it into a C-compatible pointer, explicitly giving up ownership.
  2. C#: Receives the pointer, uses the data, and then...
  3. 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.

Rust Console Output: Serialized JSON Example
This image illustrates the final step in our Rust function: serializing a data structure (our `CommandResponse`) into a JSON string that is ready to be sent back across the FFI boundary to Unity.

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:

  1. The JSON for the SetCharacterPosition command being sent.
  2. The Success response received from Rust, confirming the action.
  3. The invalid JSON being sent.
  4. The Error response 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.PtrToStringUTF8 reads the data from the pointer, and a try...finally block 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.