Hello! Welcome back to the course.
In our last lesson, you did an excellent job building the front-end of our 'buy mode'. You now have a dynamic, data-driven UI catalog that loads object information from a JSON file and displays it neatly in a scrollable grid. The foundation is solid, but the "Buy" buttons are still waiting for their logic.
Today, we'll connect that UI to our Rust engine to make purchases functional. This lesson's goal is to implement the purchase transaction, checking funds in Rust and adding the object to the world.
This involves a full round-trip communication loop: a user action in Unity will trigger a command, send it to the Rust backend for processing, and receive a result that updates the game state and provides feedback to the player.
1. From UI Click to a Backend Command
Our first step is to capture the button click in Unity and translate it into a structured command that our Rust engine can understand.
In the CatalogManager.cs script from the previous lesson, we instantiated the item prefabs but didn't attach any logic to their buttons. Let's fix that now. We'll add an onClick listener programmatically, which allows us to pass the specific data of the clicked item.
Modify the LoadAndDisplayObjects method in CatalogManager.cs. Inside the foreach loop, add the following lines to find the button and add a listener:
// In CatalogManager.cs, inside the foreach loop
// ... (code to find and populate text fields)
// Find the button component
Button buyButton = itemGO.transform.Find("BuyButton").GetComponent<Button>();
// Add a listener that calls a new method, passing the current object's data
if (buyButton != null)
{
// We need a local copy of 'obj' for the lambda to capture it correctly
PurchasableObject currentObject = obj;
buyButton.onClick.AddListener(() => OnPurchaseClicked(currentObject));
}
Now, add the OnPurchaseClicked method to the CatalogManager.cs script. This method will be responsible for constructing and sending the command to Rust. For now, it will just log the action.
// In CatalogManager.cs
void OnPurchaseClicked(PurchasableObject obj)
{
Debug.Log($"Attempting to purchase: {obj.name} (ID: {obj.id}) for ${obj.cost}");
// We will build and send the command to Rust from here.
}
Make sure the "Buy" button in your catalogItemPrefab is named "BuyButton". Run the scene and click a few buy buttons. You should see the corresponding log messages in the Unity console, confirming that the correct item data is being captured.
2. Architecting the Transaction Logic
Before writing the code, let's consider the architecture. A good game economy needs to be robust and flexible. We can draw inspiration from established patterns to guide our design.
[PDF] a Reference Architecture for Economy Mechanics in Digital Games
The research paper 'A Reference Architecture for Economy Mechanics in Digital Games' presents an architecture called the Unlimited Rulebook (URB). While academic, its core idea is highly practical and aligns perfectly with our goals. Let's look at the key concepts.
Read the sections 'Mechanics Model' (under III-A, Reference Model) and the first two paragraphs of 'Reference Architecture' (under III-C). Focus on the core principle: decoupling data (the game state, called the 'Record') from behavior (game logic, called 'Rules'). This is exactly what we're doing by keeping our game state in Rust structs and implementing logic in separate functions that operate on that state.
As the paper suggests, we'll treat our Rust GameState as the central Record of all data. The logic to process a purchase will be a Rule that reads from and writes to this record.
This means we need to update our Rust data structures to support the transaction:
- Load Object Definitions: The Rust backend needs to know about all purchasable objects and their properties (especially
cost). We'll load the sameobjects.jsonfile into aHashMapfor fast lookups. - Represent World Objects: When an object is purchased, it becomes an instance in the game world. We need a new struct, let's call it
WorldObject, to represent these instances. EachWorldObjectwill have a unique instance ID and a reference to its definition (theobject_id). - Update GameState: Our main
GameStatestruct will now hold the object definitions and aVec<WorldObject>to track all purchased objects.
In your Rust project, update your data model (e.g., in src/game_state.rs):
// In Rust, representing the data from objects.json
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PurchasableObjectDef {
pub id: String,
pub name: String,
pub cost: u32,
// Other fields like description, need_effects can be added here
}
// Represents an object instance in the game world
#[derive(Serialize, Deserialize, Debug)]
pub struct WorldObject {
pub instance_id: u32,
pub object_id: String, // Links to a PurchasableObjectDef
// We'll add position and rotation later
}
// In your main GameState struct
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Debug)]
pub struct GameState {
// ... existing fields like characters
pub characters: HashMap<u32, Character>,
// New fields for the economy
#[serde(skip)] // Don't save definitions to save file, they are loaded from config
pub object_definitions: HashMap<String, PurchasableObjectDef>,
pub world_objects: Vec<WorldObject>,
next_instance_id: u32,
}
impl GameState {
pub fn new() -> Self {
// When creating a new game state, also load object definitions
let object_defs = load_object_definitions().unwrap_or_else(|_| HashMap::new());
GameState {
characters: HashMap::new(),
object_definitions: object_defs,
world_objects: Vec::new(),
next_instance_id: 1,
}
}
// A helper function to generate unique IDs for new objects
fn get_next_instance_id(&mut self) -> u32 {
let id = self.next_instance_id;
self.next_instance_id += 1;
id
}
}
// Helper function to load the object definitions from JSON
// This would read 'objects.json' and deserialize it.
// We'll assume this function is implemented.
fn load_object_definitions() -> Result<HashMap<String, PurchasableObjectDef>, std::io::Error> {
// For now, let's create a dummy map. You would implement file reading here.
let mut defs = HashMap::new();
defs.insert("obj_bed_simple".to_string(), PurchasableObjectDef { id: "obj_bed_simple".to_string(), name: "Simple Bed".to_string(), cost: 350 });
defs.insert("obj_chair_dining".to_string(), PurchasableObjectDef { id: "obj_chair_dining".to_string(), name: "Dining Chair".to_string(), cost: 60 });
Ok(defs)
}
3. Implementing the Purchase Rule in Rust
Now for the core logic. We'll create a function that takes a purchase request, validates it, and updates the GameState.
First, let's define the command and response structures for communication. We'll use serde to handle the JSON serialization, a familiar tool in our project.

// In a file like src/commands.rs
// Command sent from Unity to Rust
#[derive(Serialize, Deserialize, Debug)]
pub struct PurchaseCommand {
pub character_id: u32,
pub object_id: String,
}
// Response sent from Rust to Unity
#[derive(Serialize, Deserialize, Debug)]
pub enum PurchaseResult {
Success { new_balance: u32, purchased_object: WorldObject },
Error(String),
}
Now, let's implement the handle_purchase_request function in your main library file (src/lib.rs or a dedicated module). This function embodies the "Rule" from the URB architecture.
// In your main logic file
pub fn handle_purchase_request(state: &mut GameState, command: PurchaseCommand) -> PurchaseResult {
// 1. Find the character
let character = match state.characters.get_mut(&command.character_id) {
Some(char) => char,
None => return PurchaseResult::Error("Character not found.".to_string()),
};
// 2. Find the object definition to get its cost
let object_def = match state.object_definitions.get(&command.object_id) {
Some(def) => def,
None => return PurchaseResult::Error("Object definition not found.".to_string()),
};
// 3. Check if the character has enough money
if character.wallet < object_def.cost {
return PurchaseResult::Error("Insufficient funds.".to_string());
}
// 4. If funds are sufficient, perform the transaction
character.wallet -= object_def.cost;
// 5. Create a new world object instance
let new_object = WorldObject {
instance_id: state.get_next_instance_id(),
object_id: command.object_id.clone(),
};
// 6. Add the new object to the game state
state.world_objects.push(new_object);
// We need to get the newly created object back out to return it.
// In a real scenario, you might want to return a clone.
let purchased_object_info = state.world_objects.last().unwrap(); // Safe to unwrap here
// 7. Return a success message
PurchaseResult::Success {
new_balance: character.wallet,
purchased_object: WorldObject { // Return a copy of the data
instance_id: purchased_object_info.instance_id,
object_id: purchased_object_info.object_id.clone(),
}
}
}
4. Exposing the Rule via FFI
With the logic implemented, we just need to expose it through our FFI layer so Unity can call it. This involves creating a C-compatible wrapper function that handles string conversion.
// In src/lib.rs
// Assume GAME_STATE is a mutable static holding our game state
// (This is a simplification; you'd use a proper state management pattern like Mutex)
static mut GAME_STATE: Option<GameState> = None;
// FFI-exposed function
#[no_mangle]
pub extern "C" fn process_purchase(command_json: *const c_char) -> *mut c_char {
let command_str = unsafe { CStr::from_ptr(command_json).to_str().unwrap() };
let command: PurchaseCommand = serde_json::from_str(command_str).unwrap();
let state = unsafe { GAME_STATE.as_mut().unwrap() };
let result = handle_purchase_request(state, command);
let response_json = serde_json::to_string(&result).unwrap();
CString::new(response_json).unwrap().into_raw()
}
// Don't forget a function to free the memory for the string we return
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
if s.is_null() { return }
unsafe {
let _ = CString::from_raw(s);
}
}
(Note: You will also need an init_game function to initialize the GAME_STATE static if you don't have one already.)
5. Completing the Loop in Unity
The final step is to call this new FFI function from Unity and process the result.
First, define the C# classes that mirror the Rust PurchaseCommand and PurchaseResult structures.
// In a script like "FFIModels.cs"
[System.Serializable]
public class PurchaseCommand
{
public uint character_id;
public string object_id;
}
[System.Serializable]
public class PurchaseResult
{
public SuccessResult Success;
public string Error;
}
[System.Serializable]
public class SuccessResult
{
public uint new_balance;
public WorldObject purchased_object;
}
[System.Serializable]
public class WorldObject
{
public uint instance_id;
public string object_id;
}
Next, add the P/Invoke declarations to your FFI wrapper class in C#.
// In your FFI wrapper class (e.g., "RustBridge.cs")
public class FFI
{
[DllImport("your_rust_library_name")]
public static extern string process_purchase(string commandJson);
[DllImport("your_rust_library_name")]
public static extern void free_string(IntPtr str);
}
Finally, update the OnPurchaseClicked method in CatalogManager.cs to perform the full transaction.
// In CatalogManager.cs
void OnPurchaseClicked(PurchasableObject obj)
{
Debug.Log($"Attempting to purchase: {obj.name} (ID: {obj.id}) for ${obj.cost}");
// 1. Create the command
var command = new PurchaseCommand
{
character_id = 1, // Assuming active character ID is 1 for now
object_id = obj.id
};
// 2. Serialize to JSON and call Rust
string commandJson = JsonUtility.ToJson(command);
string resultJson = FFI.process_purchase(commandJson);
// 3. Deserialize the result
PurchaseResult result = JsonUtility.FromJson<PurchaseResult>(resultJson);
// 4. Handle the result
if (result.Error != null)
{
Debug.LogError($"Purchase failed: {result.Error}");
// Here you would show a UI notification to the player
}
else if (result.Success != null)
{
Debug.Log($"Purchase successful! New balance: {result.Success.new_balance}. " +
$"Created object instance {result.Success.purchased_object.instance_id}.");
// Here you would update the wallet UI and close the catalog
}
// 5. Free the memory of the string returned by Rust
// Note: This step is tricky with Unity's JsonUtility and string marshaling.
// For simplicity in this lesson, we will skip freeing the string, but in a production
// environment, you would need a more robust approach where the FFI function
// returns a pointer (IntPtr) that you can pass to free_string.
}

Conclusion
Excellent work! You have now implemented a complete, end-to-end feature that crosses the boundary between your Unity frontend and Rust backend. A player can click a button in the UI, and this action triggers secure, authoritative logic in Rust that validates the transaction, modifies the core game state, and returns a result.
Key Takeaways:
- You defined a clear communication protocol using JSON-serialized command and result structs.
- You implemented a state-changing "Rule" in Rust that checks character funds and updates the game state upon a successful purchase.
- You successfully handled both success and failure scenarios, providing a path to give the player meaningful feedback.
- You reinforced your understanding of the FFI by exposing the new Rust function and calling it from C#.
Preview of the Next Lesson:
Our Rust GameState now knows that the player owns a new object. However, that object exists only as data. In the next lesson, we will tackle the next logical step: implementing a grid-based system for placing purchased objects on the current lot. This will involve reading the world_objects list and allowing the player to physically position their new items in the Unity scene.