Skip to main content

Welcome to a Grasp lesson

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

Lesson 9

Unity-Rust Command Queue for Serialized JSON

Recommended resources

Hello! Welcome back to our project.

In the last lesson, we built the fundamental bridge for passing structured data and strings between Unity and Rust. We learned the critical #[repr(C)] and [StructLayout] attributes for memory compatibility and mastered the create/free pattern for safely managing memory across the FFI boundary.

While creating specific FFI functions for each data type was a great start, it's not a scalable architecture for a complex game. Imagine creating hundreds of FFI functions like UpdateHunger, StartConversation, BuyObject, etc. It would be brittle and a nightmare to maintain.

Today, we'll implement a much more powerful and flexible solution that is central to our game's design. We will address the learning outcome: Implement a robust command queue for sending serialized JSON commands from Unity to Rust. Instead of calling specific functions, Unity will serialize a "command" object into a JSON string and send it to a single endpoint in Rust. The Rust engine will then deserialize this command and act on it. This pattern is not only cleaner but is also essential for our goal of a deeply moddable game, as new commands can be added without changing the core FFI bridge.

Rust FFI with Multiple Languages via safer_ffi
This image visualizes the Foreign Function Interface (FFI) we are building. Today, we make that connection more robust by channeling all communication through a single, data-driven command queue.

1. Serialization and the Power of Serde

At the heart of our command queue is serialization: the process of converting a data structure (like a Rust struct or a C# class) into a format that can be easily stored or transmitted. We'll use the ubiquitous JSON format, which represents data as human-readable text.

In the Rust ecosystem, the definitive library for this is Serde (SERialize/DEserialize). It's a framework that allows you to make your data structures serializable with minimal effort.

Rust serialization - RustStack.Org

To get a solid understanding of Serde and how it works with JSON, please read the introductory sections of the 'Rust serialization' article from RustStack.Org. This will explain the core concepts we're about to implement.

Please read the sections titled 'Introduce', 'Serde', and 'JSON'. Pay close attention to the roles of the Serialize and Deserialize traits and the serde_json crate.

As the article describes, Serde's magic comes from its Serialize and Deserialize traits, which we can automatically implement for our structs using a derive macro. Let's set this up.

Step 1: Add Serde to Your Project

Open your Rust project's Cargo.toml file and add serde and serde_json to your dependencies. We'll include Serde's "derive" feature to enable the macros.

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Your Cargo.toml should now look something like this:

[package]
name = "life_sim_backend"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

2. Defining the Commands

Now, let's define the structure of the commands we want to send. A Rust enum is perfect for this, as it can represent one of several possible variants. We'll start with two simple commands: one to log a message and another to simulate a character moving.

Step 2: Create the Command Enum in Rust

In src/lib.rs, add the following code. We'll create a Vector3Data struct that is FFI-safe and can be used by both Rust and C#.

use serde::{Serialize, Deserialize};

// A simple, C-compatible Vector3 struct.



#[repr(C)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub struct Vector3Data {
    pub x: f32,
    pub y: f32,
    pub z: f32,
}

// Our main command enum.



#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "command_type")] // This tells Serde to use a field "command_type" to identify the variant.
pub enum GameCommand {
    LogMessage { msg: String },
    SetCharacterPosition { character_id: u32, position: Vector3Data },
}

Let's break this down:

  • We're using #[derive(Serialize, Deserialize)] on both of our data structures. This is Serde's derive macro doing its work, automatically generating the code to convert these structures to and from formats like JSON.
  • #[serde(tag = "command_type")] is a powerful Serde attribute. It formats the JSON in a way that is much easier for other languages to parse.
    • A LogMessage command will be serialized to JSON like this:
      {"command_type":"LogMessage","msg":"Hello from Unity!"}
    • A SetCharacterPosition command will look like this:
      {"command_type":"SetCharacterPosition","character_id":0,"position":{"x":10.0,"y":0.0,"z":5.0}}
  • The Debug derive is helpful for printing the structs to the console for debugging purposes.

3. The Rust Command Processor

Next, we'll create the single FFI function that will act as the entry point for all commands from Unity. This function will take a JSON string, deserialize it into our GameCommand enum, and then use a match statement to execute the appropriate logic.

Step 3: Implement process_command

Add the following function to your src/lib.rs. This combines what we learned about string handling in the last lesson with our new Serde-powered GameCommand enum.

use std::ffi::CStr;
use std::os::raw::c_char;

// ... (your structs from before) ...




#[no_mangle]
pub extern "C" fn process_command(command_json: *const c_char) {
    // 1. Safely convert the C string to a Rust string slice.
    let command_str = unsafe {
        if command_json.is_null() {
            println!("[Rust Error] Received a null pointer for command.");
            return;
        }
        CStr::from_ptr(command_json).to_str().unwrap_or_else(|e| {
            println!("[Rust Error] Failed to read command string: {}", e);
            ""
        })
    };

    if command_str.is_empty() {
        return;
    }

    // 2. Deserialize the JSON string into our GameCommand enum.
    let command: Result<GameCommand, _> = serde_json::from_str(command_str);

    // 3. Match on the command variant and execute logic.
    match command {
        Ok(GameCommand::LogMessage { msg }) => {
            println!("[Rust Command] LogMessage: {}", 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.
        }
        Err(e) => {
            println!("[Rust Error] Failed to deserialize command: {}", e);
            println!("[Rust Error] Received JSON: {}", command_str);
        }
    }
}

// Make sure to also keep your string-freeing function from the previous lesson.



#[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 it.
        let _ = CStr::from_ptr(s);
    }
}

Wait, CString::from_raw is what you used last time to deallocate. You are sharp! That is correct. However, CStr::from_ptr followed by letting the resulting CStr go out of scope does not deallocate the memory. The previous lesson's CString::from_raw is the correct way to deallocate a string that Rust's allocator created via CString::into_raw. The free_string function above has been corrected to use CString::from_raw to reflect that, but since C# is allocating the string for process_command, C# is responsible for freeing it, and Rust just needs to read it. Let's correct free_string:

use std::ffi::CString; // Make sure this is imported

// ...




#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
    unsafe {
        if s.is_null() { return; }
        let _ = CString::from_raw(s);
    }
}

My apologies for that confusion. The process_command function takes a string allocated by C#, so Rust only reads it. The free_string function is for strings allocated by Rust (which we'll use in the next lesson) and correctly uses CString::from_raw to deallocate.


4. Sending Commands from Unity

Now for the other side of the bridge. We need to create C# data structures that can be serialized into the JSON format that our Rust code now expects. Unity's built-in JsonUtility is sufficient for this task.

Step 4: Create Matching C# Data Structures

In your Unity project, let's update the FFIStructs.cs file (or create it if you haven't).

using System.Runtime.InteropServices;

// This must match the Rust Vector3Data struct.
[System.Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct Vector3Data
{
    public float x;
    public float y;
    public float z;
}

// A "fat" struct to represent our commands for JsonUtility.
// It contains the command_type tag and fields for ALL possible commands.
[System.Serializable]
public struct GameCommandData
{
    public string command_type;

    // Fields for LogMessage
    public string msg;

    // Fields for SetCharacterPosition
    public uint character_id;
    public Vector3Data position;
}
  • [System.Serializable] is required for JsonUtility to be able to process the struct.
  • Because JsonUtility is simpler than Serde and doesn't handle polymorphism well, we create a single GameCommandData struct that includes the command_type tag and all fields from all possible command variants. When we serialize a LogMessage command, the character_id and position fields will just have default values, but Serde on the Rust side is smart enough to ignore them when it sees command_type: "LogMessage".

Step 5: Send Commands from the GameManager

Finally, let's wire it all up. We'll update our RustBridge and call it from GameManager.

In RustBridge.cs:
Add the import for our new FFI function.

[DllImport(RustLib, EntryPoint = "process_command")]
public static extern void ProcessCommand(string commandJson);

In GameManager.cs:
Modify your Start method to create and send a couple of commands.

using UnityEngine;

public class GameManager : MonoBehaviour
{
    void Start()
    {
        Debug.Log("GameManager starting...");

        // --- Send a LogMessage command ---
        var logCommand = new GameCommandData
        {
            command_type = "LogMessage",
            msg = "Hello from Unity via JSON!"
        };
        string logJson = JsonUtility.ToJson(logCommand);
        Debug.Log($"Sending JSON: {logJson}");
        RustBridge.ProcessCommand(logJson);

        // --- Send a SetCharacterPosition command ---
        var moveCommand = new GameCommandData
        {
            command_type = "SetCharacterPosition",
            character_id = 1,
            position = new Vector3Data { x = 15.5f, y = 0f, z = 22.1f }
        };
        string moveJson = JsonUtility.ToJson(moveCommand);
        Debug.Log($"Sending JSON: {moveJson}");
        RustBridge.ProcessCommand(moveJson);
    }
}

Now, follow these steps:

  1. In your Rust project directory, run cargo build.
  2. Copy the compiled library (.dll, .so, or .dylib) from target/debug/ into your Unity project's Assets/Plugins folder, overwriting the old one.
  3. Return to Unity. It should automatically reload the plugin.
  4. Press Play.

Check your Unity console. You should see the JSON strings being prepared and logged. Then, check the console output from your Rust process (either the terminal you launched Unity from or the Editor Log file). You should see the output from our Rust println! macros:

[Rust Command] LogMessage: Hello from Unity via JSON!
[Rust Command] SetCharacterPosition for char 1: (15.5, 0, 22.1)

Success! You have now implemented a flexible, data-driven command queue.


Conclusion

This is a major architectural milestone. By moving from direct function calls to a serialized command queue, we have decoupled our game engine's core logic from its frontend representation. This single process_command endpoint is now the main artery for communication from Unity to Rust.

Key Takeaways:

  • A command queue is a scalable pattern for sending instructions from a client (Unity) to a server (our Rust backend).
  • Serde is the standard Rust library for serialization and deserialization, using the Serialize and Deserialize traits.
  • JSON is a flexible, human-readable data format ideal for our FFI bridge.
  • Using #[serde(tag = "...")] on a Rust enum creates a JSON structure that is easy to produce from other languages.
  • Unity's JsonUtility can be used to serialize C# structs into the required JSON format.

In the next lesson, we'll complete the communication loop. We'll tackle processing commands in Rust and returning serialized JSON responses back to Unity. This will allow the Rust engine to send data back to the frontendβ€”for example, confirming an action was completed or sending updated character stats to be displayed in the UI.