Hello! Welcome to Module 10.
In the last module, we built a sophisticated, stateful conversation system, enabling characters to have coherent, multi-turn dialogues. This was a major step in bringing our characters' personalities to life. Now, we'll shift our focus from social simulation to the economic simulation that underpins the game world.
This lesson kicks off our work on the game's economy by addressing a fundamental requirement: implementing a currency system (wallet) in the Rust backend for each character. We will design and build a robust Wallet for our characters, which will be the foundation for everything from earning a salary and paying bills to buying furniture in the "buy mode" we'll develop later.
By the end of this lesson, you will have created a secure and functional currency system within your Rust engine, fully integrated into your existing character data model.
1. Designing a Safe Wallet: The Power of Encapsulation
Before we write any code, let's think about the design. A character's wallet needs to store their money, but we also need to control how that money is changed. We can't have a situation where a bug accidentally sets a character's balance to a negative number, or where money is added without a proper transaction.
This is a perfect scenario to apply the principle of encapsulation. We will hide the wallet's internal balance from the outside world and only expose a set of public functions (methods) to interact with it, like deposit and withdraw. This ensures all changes to the balance happen in a controlled, predictable way.
This concept is fundamental to building reliable software. The following article uses a great vending machine analogy and then applies the principle to a BankAccount struct, which is almost identical to the Wallet we're about to build.
Making Your Structs Private but Usable
This article, 'Making Your Structs Private but Usable', explains why encapsulation is crucial and how to implement it in Rust by keeping struct fields private.
Read the article from the beginning down to 'Common Pitfalls and How to Avoid Them'. Pay close attention to: The 'Why Encapsulation Matters' section and its analogy. The difference between the 'Bad Approach' (public fields) and the 'Better Approach' (private fields with public methods). How the BankAccount example uses methods like deposit and withdraw to protect its balance field.
This next resource provides another concrete example of an encapsulated BankAccount within a Rust module. It reinforces the pattern of a private balance field with public deposit, withdraw, and get_balance methods.
Introduction to Modules and Encapsulation | CodeSignal Learn
This guide from CodeSignal Learn walks through creating a bank module and implementing encapsulation for a BankAccount struct.
Focus on the sections 'Implementing Encapsulation' and 'The Importance of Encapsulation'. Notice how the withdraw method includes logic to prevent the balance from going negative.
From these resources, our design for the Wallet is clear:
- A
Walletstruct. - A private field inside,
balance: f64. - A public
new()function to create a wallet with a starting balance. - A public
deposit()method to add money. - A public
withdraw()method to remove money, which fails if funds are insufficient. - A public
balance()method to check the current balance.
2. Implementing Behavior with impl
To attach these functions (like deposit and withdraw) to our Wallet struct, we use an impl (implementation) block in Rust.
Methods inside an impl block that need to modify the struct's data, like deposit, must declare that they are taking a mutable reference to the instance. This is done by specifying the first parameter as &mut self. This is a key concept in Rust that ensures data safety.
This video provides an excellent, practical demonstration of creating methods that mutate a struct's state.
Implement Methods on Rust Structs π¦
In 'Implement Methods on Rust Structs', Trevor Sullivan clearly demonstrates how to define methods in an impl block and explains the crucial role of &mut self for methods that change the struct's data.
Watch from 6:29 to 12:31. This section shows how to create a paint method that mutates a Vehicle struct. Pay special attention to why &mut self is required and how it forces the variable instance to be declared with let mut. Then, watch the brief section from 26:01 to 27:08, where he directly relates this concept to game development by mentioning hit points on a Character structβa perfect parallel to our wallet's balance.
To reinforce the syntax, the following video also provides a solid walkthrough of creating structs and impl blocks.
Doug Milford's video 'Rust Structs, Traits and Impl' gives another clear look at the syntax for impl blocks, including the distinction between associated functions (like new) and methods that use self.
Watch from 9:11 to 15:24. This segment covers impl blocks, associated functions (using capitalized Self), and methods that mutate internal data (using lowercase &mut self).
3. Building the Wallet in Rust
Now, let's combine these concepts and write the code.
Step 3.1: Define the Wallet Struct and impl Block
In your Rust project (e.g., in src/main.rs or a new src/economy.rs module file), add the following code. We'll derive Debug to make it easy to print, and serde's Serialize and Deserialize for our future save/load system.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct Wallet {
// This field is private by default because we don't add `pub`.
balance: f64,
}
impl Wallet {
/// Creates a new Wallet with a specified starting balance.
/// Ensures the starting balance is not negative.
pub fn new(starting_balance: f64) -> Self {
Wallet {
balance: if starting_balance > 0.0 { starting_balance } else { 0.0 },
}
}
/// Returns the current balance.
pub fn balance(&self) -> f64 {
self.balance
}
/// Adds a specified amount to the balance.
/// The amount must be positive.
pub fn deposit(&mut self, amount: f64) {
if amount > 0.0 {
self.balance += amount;
}
}
/// Attempts to withdraw a specified amount from the balance.
/// Returns Ok(()) on success or an Err(&str) on failure.
pub fn withdraw(&mut self, amount: f64) -> Result<(), &'static str> {
if amount <= 0.0 {
return Err("Withdrawal amount must be positive.");
}
if self.balance < amount {
return Err("Insufficient funds.");
}
self.balance -= amount;
Ok(())
}
}
Notice a few key details here:
- The
balancefield is private because it lacks thepubkeyword. Code outside this module cannot writemy_wallet.balance = 1000.0;. - The
withdrawmethod returns aResult<(), &'static str>. This is idiomatic Rust for operations that can fail. It forces the calling code to handle both the success (Ok) and failure (Err) cases, preventing bugs.
Step 3.2: Integrate the Wallet into the Character Struct
Now, find the Character struct you created in Module 3. We'll add the Wallet as a new field.
Assuming your Character struct looks something like this, make the following additions:
// You'll need to use your new Wallet struct
// If it's in another file, you might write: mod economy; use economy::Wallet;
#[derive(Serialize, Deserialize, Debug)]
pub struct Character {
pub name: String,
// ... other fields like personality, traits, age ...
pub wallet: Wallet, // Add the wallet here
}
impl Character {
// Update your character constructor
pub fn new(name: String, /*...other params...*/, starting_money: f64) -> Self {
Character {
name,
// ... initialize other fields ...
wallet: Wallet::new(starting_money), // Create a new wallet for the character
}
}
}
Step 3.3: Test the Implementation
It's always a good idea to test new logic. You can add a temporary test function to main to see it in action.
fn main() {
// Create a new character with $500
let mut character = Character::new("Alex".to_string(), 500.0);
println!("Initial state: {:?}", character);
// Deposit money
character.wallet.deposit(150.50);
println!("After deposit: balance is ${:.2}", character.wallet.balance());
// Try to withdraw more money than available
match character.wallet.withdraw(1000.0) {
Ok(_) => println!("Withdrawal successful."),
Err(e) => println!("Withdrawal failed: {}", e),
}
println!("After failed withdrawal: balance is ${:.2}", character.wallet.balance());
// Successfully withdraw money
match character.wallet.withdraw(200.0) {
Ok(_) => println!("Withdrawal successful."),
Err(e) => println!("Withdrawal failed: {}", e),
}
println!("Final state: {:?}", character);
}
When you run this with cargo run, you should see the balance change correctly and the error message for the failed withdrawal, proving your encapsulated system works as intended.
Conclusion
Excellent work! You have successfully implemented a core economic feature for our game. By creating an encapsulated Wallet system, you've not only given characters a way to manage money but also built it in a safe, robust, and maintainable way that will prevent future bugs.
Key Takeaways:
- Encapsulation in Rust: We made our
Walletrobust by keeping itsbalancefield private and exposing public methods (deposit,withdraw,balance) for controlled interactions. - Mutating Methods: Methods that change a struct's internal state, like
depositandwithdraw, require a mutable reference to the instance (&mut self). - Error Handling with
Result: UsingResultfor operations that can fail (likewithdraw) is a core Rust pattern that forces developers to handle errors gracefully. - Data Model Integration: The
Walletis now a fundamental part of ourCharacterdata model, ready to be used by other game systems.
Preview of the Next Lesson:
Now that our characters have wallets, they need a way to get money and a reason to spend it. In the next lesson, we will implement the other side of this coin: a basic income source (like a daily stipend) and recurring bills. This will put our new Wallet methods to immediate use and make the simulation feel more alive.