Skip to main content

Welcome to a Grasp lesson

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

Lesson 18

Saving a Character Profile to JSON

Recommended resources

Hello! Welcome back to the course.

In the last lesson, we put the finishing touches on the visual side of our character creator, setting up a dynamic 3D preview with interactive rotation and professional lighting. Your character creator now looks and feels like a complete tool, allowing a player to define their character's identity and see it come to life.

This lesson focuses on the final, crucial step of character creation: saving the data. Our goal is to implement the logic that takes the validated character profile from your UI, processes it in the Rust backend, and saves it permanently to a JSON file. This action bridges the gap between the setup phase (character creation) and the start of actual gameplay.

We will accomplish this by:

  1. Serializing our Rust Character struct into a JSON string using the serde crate.
  2. Adding robust validation rules to our Rust data model using the validator crate.
  3. Writing the final, validated JSON data to a file using Rust's standard library.
  4. Exposing this entire process as a single function that Unity can call.

All of our work today will be in the Rust backend, building upon the data structures we've already established.

1. The Core Task: Serialization with serde

You've already used serde to deserialize JSON data sent from Unity into Rust structs. Now, we'll perform the reverse operation: serialization. This is the process of converting an in-memory Rust data structure, like our Character struct, into a text format like JSON.

To enable this, you need to ensure your Character struct, and any custom structs it contains (like the Hexaco personality model), are decorated with the #[derive(Serialize)] attribute.

// In your character data model file (e.g., src/character.rs)
use serde::{Serialize, Deserialize};




#[derive(Serialize, Deserialize, Debug)]
pub struct Hexaco {
    // ... fields ...
}




#[derive(Serialize, Deserialize, Debug)]
pub enum AgeStage {
    Child,
    Teen,
    Adult,
    Elder,
}




#[derive(Serialize, Deserialize, Debug)]
pub struct Character {
    pub name: String,
    pub age_stage: AgeStage,
    pub personality: Hexaco,
    pub traits: Vec<String>,
}

With the Serialize trait derived, we can use serde_json to turn an instance of Character into a JSON string. The following video provides a detailed guide on this process.

Hitchhiker's Guide to JSON Data in Rust πŸ¦€ Serialize and Deserialize with Serde πŸ—ΊοΈ Rust Tutorial

To understand how to convert your Rust structs into JSON text, we'll watch a detailed guide on the serde crate. This video will cover everything from annotating the struct to handling complex, nested data.

Please watch the following segments: Defining and Annotating a Struct (08:41 - 10:39): Focus on how #[derive(Serialize)] is added to a struct. Serializing to a JSON String (10:39 - 15:19): This is the core of our task. Pay attention to the use of serde_json::to_string and to_string_pretty, and how to handle the Result it returns. Handling Nested Structs (19:11 - 22:21): This is important as our character model contains nested structs (Hexaco). Notice that all nested structs must also derive Serialize.


2. Ensuring Data Integrity with validator

Before saving data to a file, it's critical to ensure its integrity. A character with no name or with personality scores outside the expected range could cause bugs later. We can enforce these rules using the validator crate, which provides a clean, declarative way to define validation logic directly on your structs.

First, add validator to your Cargo.toml file:

[dependencies]



# ... other dependencies
validator = { version = "0.16", features = ["derive"] }

Next, add the Validate derive macro and validation attributes to your structs. This works alongside serde.

// In your character data model file
use serde::{Serialize, Deserialize};
use validator::Validate;




#[derive(Serialize, Deserialize, Validate, Debug)]
pub struct Hexaco {



    #[validate(range(min = 0.0, max = 1.0))]
    pub honesty_humility: f32,



    #[validate(range(min = 0.0, max = 1.0))]
    pub emotionality: f32,



    #[validate(range(min = 0.0, max = 1.0))]
    pub extraversion: f32,



    #[validate(range(min = 0.0, max = 1.0))]
    pub agreeableness: f32,



    #[validate(range(min = 0.0, max = 1.0))]
    pub conscientiousness: f32,



    #[validate(range(min = 0.0, max = 1.0))]
    pub openness_to_experience: f32,
}




#[derive(Serialize, Deserialize, Validate, Debug)]
pub struct Character {



    #[validate(length(min = 1, message = "Character name cannot be empty."))]
    pub name: String,
    
    pub age_stage: AgeStage, // Enums don't typically need validation here




    #[validate] // This attribute tells validator to also validate the nested struct
    pub personality: Hexaco,

    // Ensure the character has at least one trait.



    #[validate(length(min = 1, message = "Character must have at least one trait."))]
    pub traits: Vec<String>,
}

To learn more about how to use the validator crate, its documentation provides an excellent guide.

validator - Rust

The validator crate's documentation provides a clear, concise example of how to apply validation rules directly to your structs. This will be our guide for ensuring our character data is correct before we save it.

Please review the documentation, focusing on: The main Example: See how attributes like #[validate(length(min = 1))] and #[validate(range(min = 18, max = 20))] are used. Notice how the .validate() method is called and its Result is handled. Installation: Confirm how to add the dependency to your Cargo.toml file. You need the derive feature.


3. Persisting the Data: Writing to a File

With a validated Character instance, we can now serialize it and write it to a file. Rust's standard library (std::fs and std::io) provides all the tools we need for this.

The following article explains the final piece of the puzzle: writing our generated JSON string to a file.

Creating and Writing JSON Data in Rust | CodeSignal Learn

This article from CodeSignal Learn explains how to write string data to a file in Rust.

Read the section titled 'Writing JSON Data to a File'. It provides a minimal, clear example using File::create and write_all. This is the pattern we will follow.

While converting the struct to a string and then writing the string works perfectly, a more memory-efficient approach is to serialize the data directly to the file stream. This avoids creating a potentially large intermediate string. The serde_json::to_writer_pretty function is ideal for this.

Rust JSON Serialization/Deserialization with Serde
This code snippet demonstrates reading from and writing to JSON files. Notice the use of `serde_json::to_writer_pretty`. This function is more memory-efficient for writing large JSON files as it serializes the data directly into a file stream.

4. Putting It All Together: The save_character Function

Let's combine these steps into a single FFI-exposed function. This function will replace or augment your previous validation function. It will receive the character data from Unity, deserialize it, validate it, and if successful, save it to a file.

Here is a complete implementation for your FFI library file (e.g., src/lib.rs):

// In your FFI library file (e.g., src/lib.rs)
use std::ffi::{CStr, CString};
use std::fs::{self, File};
use std::io::BufWriter;
use std::os::raw::c_char;
use std::path::Path;

// Make sure your Character, AgeStage, and Hexaco structs are in scope
mod character;
use character::{Character};

// A helper function to manage returning strings to C#
fn to_c_string(s: String) -> *mut c_char {
    CString::new(s).unwrap().into_raw()
}




#[no_mangle]
pub extern "C" fn save_character(json_data: *const c_char) -> *mut c_char {
    // 1. Convert C string to Rust string
    let c_str = unsafe { CStr::from_ptr(json_data) };
    let json_str = match c_str.to_str() {
        Ok(s) => s,
        Err(_) => return to_c_string("Error: Invalid UTF-8 string.".to_string()),
    };

    // 2. Deserialize to a Character struct
    let character: Character = match serde_json::from_str(json_str) {
        Ok(c) => c,
        Err(e) => return to_c_string(format!("Error deserializing: {}", e)),
    };

    // 3. Validate the struct
    if let Err(e) = character.validate() {
        return to_c_string(format!("Validation Error: {}", e));
    }

    // 4. Create a file path
    // Ensure the 'characters' directory exists
    let dir = "characters";
    if !Path::new(dir).exists() {
        if let Err(e) = fs::create_dir(dir) {
            return to_c_string(format!("Could not create directory: {}", e));
        }
    }
    // Sanitize the name for use as a filename
    let filename = format!("{}.json", character.name.to_lowercase().replace(' ', "_"));
    let path = Path::new(dir).join(filename);

    // 5. Create the file and serialize the character data into it
    let file = match File::create(&path) {
        Ok(f) => f,
        Err(e) => return to_c_string(format!("Error creating file: {}", e)),
    };
    let writer = BufWriter::new(file);

    match serde_json::to_writer_pretty(writer, &character) {
        Ok(_) => to_c_string(format!("Success: Saved to {}", path.display())),
        Err(e) => to_c_string(format!("Error serializing to file: {}", e)),
    }
}

Now, from your Unity C# script, you can call this save_character function when the user clicks the "Save" button. The string returned from Rust will tell you if the operation was successful and where the file was saved.


Conclusion

Fantastic work! You have now closed the loop on the entire character creation process. Data flows from the Unity UI, is validated and processed by the robust Rust backend, and is finally persisted to disk as a clean, human-readable JSON file. This is a major milestone for the project.

Key Takeaways:

  • Serialization is the process of converting Rust structs to JSON, enabled by #[derive(Serialize)] and serde_json.
  • The validator crate allows you to enforce data integrity with declarative rules on your structs, preventing bad data from being saved.
  • Rust's standard library (std::fs, std::io) provides the tools to create directories and write files.
  • Using serde_json::to_writer_pretty is a memory-efficient way to save large data structures directly to a file.
  • A single FFI function can orchestrate the entire deserialization, validation, and file-saving process, providing a simple interface for Unity.

In the next module, we will finally enter the game world. The upcoming lesson will focus on setting up a game scene and, most excitingly, loading a character from a JSON file and spawning them in the world. All the foundational work you've done is about to pay off as we begin to see our simulation come to life.