Hello! Welcome to the fourth lesson in our course.
In our last lesson, we tackled Rust's ownership and borrowing system. You learned how these core rules guarantee memory safety and applied them to build a practical inventory system, successfully transferring an Item between two Inventory instances. This gave us a solid foundation in how Rust manages data.
Today, we're going to build on that foundation by learning how to make our data persistent. Our learning outcome is to serialize and deserialize complex nested data structures to JSON using the serde crate.
This might sound abstract, but it's one of the most practical skills you'll use. Serialization is the process of converting our Rust structs into a format that can be stored in a file or sent over a network, like JSON. Deserialization is the reverse process: taking that JSON data and turning it back into live Rust structs. For our game, this is the key to everything from saving a player's progress to defining character traits, items, and world layouts in data files.
Let's get started by introducing the tool that makes this process incredibly simple in Rust: serde.

1. What is Serde and How to Set It Up
Serde (pronounced ser-day) is a framework for SERializing and DEserializing Rust data structures. It's a generic framework, meaning it can work with many different data formats. We'll be using it with JSON, so we need two specific crates:
serde: The core library.serde_json: The library that tellsserdehow to handle the JSON format.
Let's watch a short video that explains the role of these crates and how to add them to our project.
Hitchhiker's Guide to JSON Data in Rust 🦀 Serialize and Deserialize with Serde 🗺️ Rust Tutorial
This video, 'Hitchhiker's Guide to JSON Data in Rust' by Trevor Sullivan, gives an excellent overview of what serde is for and how to configure it.
Please watch the following two segments: Introduction (0:13 - 2:10): This explains the purpose of serialization and deserialization and introduces the serde and serde_json crates. Project Setup (2:46 - 8:30): This walks you through adding the necessary dependencies to your Cargo.toml file. You can follow along with our game_engine_core project.
To summarize the setup process for our project, open your Cargo.toml file and add the following lines under the [dependencies] section:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
The features = ["derive"] part is crucial. It enables a powerful feature of serde that lets us automatically generate the serialization code using an attribute, which we'll see next.
2. Serializing Your First Struct
Let's start by making our existing data structures from the last lesson serializable. We'll continue working in src/main.rs.
First, find your Item and Inventory structs. To make them work with serde, you simply need to add Serialize and Deserialize to the derive attribute macro. We'll also keep Debug for easy printing.
// Add Serialize and Deserialize here
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Item {
name: String,
}
// And here as well
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Inventory {
items: Vec<Item>,
}
Note: We add serde:: before Serialize and Deserialize to be explicit. You could also add use serde::{Serialize, Deserialize}; at the top of your file.
That's it! serde now knows how to convert these structs to and from JSON. Let's prove it.
Replace your main function with the following code to create an inventory and serialize it.
fn main() {
let mut player_inventory = Inventory { items: Vec::new() };
player_inventory.items.push(Item { name: String::from("Health Potion") });
player_inventory.items.push(Item { name: String::from("Iron Sword") });
// Serialize the inventory to a JSON string.
let json_data = serde_json::to_string(&player_inventory).unwrap();
println!("Serialized (compact):\n{}", json_data);
// Serialize to a "pretty" formatted JSON string for readability.
let json_data_pretty = serde_json::to_string_pretty(&player_inventory).unwrap();
println!("\nSerialized (pretty):\n{}", json_data_pretty);
}
When you run this with cargo run, you will see the output:
Serialized (compact):
{"items":[{"name":"Health Potion"},{"name":"Iron Sword"}]}
Serialized (pretty):
{
"items": [
{
"name": "Health Potion"
},
{
"name": "Iron Sword"
}
]
}
Notice how serde automatically handled the Vec<Item> and created a JSON array of objects. This ability to handle nested structures is what makes it so powerful.
3. Handling Complex Nested Data
Our game will have much more complex data than just an inventory. A character will have a name, stats, an inventory, and maybe even more. Let's model this to see how serde handles deeper levels of nesting.
First, let's create a more complex data model. We'll define a CharacterState struct that holds personal info and an Inventory. To make it more interesting, let's add a capacity field to the Inventory and a value to our Item.
Update your structs and main function like this:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Item {
name: String,
value: u32, // Added a value field
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Inventory {
items: Vec<Item>,
capacity: u32, // Added a capacity field
}
// A new top-level struct for our character's state
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct CharacterState {
name: String,
level: u32,
inventory: Inventory, // Nested Inventory struct
}
fn main() {
// Create a fully populated CharacterState instance
let character = CharacterState {
name: String::from("Aris"),
level: 5,
inventory: Inventory {
capacity: 20,
items: vec![
Item { name: String::from("Health Potion"), value: 50 },
Item { name: String::from("Iron Sword"), value: 120 },
Item { name: String::from("Map"), value: 5 },
],
},
};
// Serialize it to a pretty JSON string
let json_data_pretty = serde_json::to_string_pretty(&character).unwrap();
println!("Serialized Character State:\n{}", json_data_pretty);
}
When you run this, you'll see the complete, nested JSON representation of your CharacterState:
Serialized Character State:
{
"name": "Aris",
"level": 5,
"inventory": {
"items": [
{
"name": "Health Potion",
"value": 50
},
{
"name": "Iron Sword",
"value": 120
},
{
"name": "Map",
"value": 5
}
],
"capacity": 20
}
}
As you can see, serde navigated the entire structure—from CharacterState to Inventory to the Vec of Items—without any extra configuration. This is the power of the derive macro.
4. Deserialization: From JSON Back to Rust
Now for the other half of the process. Let's take that JSON string and use it to reconstruct our CharacterState struct in memory. This is what will happen when we load a saved game.
We use the serde_json::from_str() function for this.
Serde Tutorial: JSON Serialization and Deserialization in Rust
The following article, 'Serde Tutorial: JSON Serialization and Deserialization in Rust', provides clear, self-contained code examples for both serialization and deserialization. We'll focus on the deserialization and nested structure sections.
Please review the following two sections for concise examples: Basic Deserialization: From JSON to Rust: Focus on the code block showing serde_json::from_str(json_data). Notice the type annotation let user: User. Handling Nested Structures: This section shows an Employee struct with a nested Company and Address. It demonstrates that deserializing nested structures is just as straightforward as serializing them, as long as all structs derive Deserialize.
Let's apply this to our CharacterState. We'll take the JSON string we just created and feed it back into serde.
Modify your main function:
fn main() {
// Our JSON data, as a raw string literal
let json_data = r#"
{
"name": "Aris",
"level": 5,
"inventory": {
"items": [
{
"name": "Health Potion",
"value": 50
},
{
"name": "Iron Sword",
"value": 120
},
{
"name": "Map",
"value": 5
}
],
"capacity": 20
}
}
"#;
// Deserialize the string back into a CharacterState struct
// We must tell Rust the type we expect to get back.
let deserialized_character: CharacterState = serde_json::from_str(json_data).unwrap();
// Print the struct to prove it worked
println!("Deserialized Character State:\n{:#?}", deserialized_character);
// We can now access its data like any other struct
println!("\nWelcome back, level {} {}!", deserialized_character.level, deserialized_character.name);
println!("First item in inventory: {}", deserialized_character.inventory.items[0].name);
}
When you run this, you'll see the Debug output of the reconstructed struct, followed by messages showing that we can access its fields. You have successfully completed a full "round-trip": Rust Struct -> JSON String -> Rust Struct.
5. Customizing with Attributes
A common scenario in game development is that your data files (JSON) might use a different naming convention (like camelCase) than your Rust code (snake_case). Serde handles this easily with another attribute.
Let's say we want our JSON to look like this:
{
"characterName": "Aris",
"characterLevel": 5,
/* ... */
}
We can tell serde to rename fields automatically.
Hitchhiker's Guide to JSON Data in Rust 🦀 Serialize and Deserialize with Serde 🗺️ Rust Tutorial
This final video clip demonstrates how to use serde attributes to customize the output, which is a very common and useful technique.
Watch the segment from 15:31 to 18:26. It explains how to use #[serde(rename_all = "...")] to change the casing of all fields in a struct automatically.
To achieve this, we add #[serde(rename_all = "camelCase")] to our struct definitions.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")] // Add this line
struct Item {
name: String,
value: u32,
}
// ... and so on for Inventory and CharacterState
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Inventory {
items: Vec<Item>,
capacity: u32,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CharacterState {
name: String,
level: u32,
inventory: Inventory,
}
If you re-run the serialization code from step 3 with these attributes, your JSON output will now use camelCase for all fields, while your Rust code remains idiomatic snake_case. Deserialization will also work correctly with camel-cased JSON input.
Conclusion
Excellent work! You've just mastered a critical component for our game engine. With serde, we can now easily save and load complex game states, a feature we'll implement fully in a later module.
Key Takeaways:
- Serialization converts Rust structs to formats like JSON, and deserialization converts them back.
- The
serdeandserde_jsoncrates provide the tools for this in Rust. - The
#[derive(Serialize, Deserialize)]attribute is the key to making your structs work withserde. serdeseamlessly handles nested structs and collections likeVec, making it perfect for complex game data.- The main functions are
serde_json::to_string()(orto_string_pretty()) for serializing andserde_json::from_str()for deserializing. - Attributes like
#[serde(rename_all = "...")]provide powerful and easy customization.
This lesson concludes the most foundational concepts of Rust we need for our backend. You've learned about basic data structures, the ownership system, and now serialization.
In our next lesson, we will take a significant step towards connecting our Rust engine to the Unity frontend. We will build our Rust code as a sharable library and expose its functions using a C-compatible Foreign Function Interface (FFI). This will create the bridge that allows C# in Unity to call our Rust code.