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.

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
LogMessagecommand will be serialized to JSON like this:
{"command_type":"LogMessage","msg":"Hello from Unity!"} - A
SetCharacterPositioncommand will look like this:
{"command_type":"SetCharacterPosition","character_id":0,"position":{"x":10.0,"y":0.0,"z":5.0}}
- A
- The
Debugderive 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 forJsonUtilityto be able to process the struct.- Because
JsonUtilityis simpler than Serde and doesn't handle polymorphism well, we create a singleGameCommandDatastruct that includes thecommand_typetag and all fields from all possible command variants. When we serialize aLogMessagecommand, thecharacter_idandpositionfields will just have default values, but Serde on the Rust side is smart enough to ignore them when it seescommand_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:
- In your Rust project directory, run
cargo build. - Copy the compiled library (
.dll,.so, or.dylib) fromtarget/debug/into your Unity project'sAssets/Pluginsfolder, overwriting the old one. - Return to Unity. It should automatically reload the plugin.
- 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
SerializeandDeserializetraits. - JSON is a flexible, human-readable data format ideal for our FFI bridge.
- Using
#[serde(tag = "...")]on a Rustenumcreates a JSON structure that is easy to produce from other languages. - Unity's
JsonUtilitycan 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.