Skip to main content

Welcome to a Grasp lesson

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

Lesson 27

Calculating Mood Scores with Weighted Needs in Rust

Recommended resources

Hello! Welcome back to our life simulation game project.

In the previous lesson, we brought our characters to life by giving them a set of fundamental needsβ€”hunger, energy, social, and funβ€”that decay over time. While this is a crucial first step, managing four separate values to understand a character's state can be cumbersome. We need a simpler, high-level indicator of their overall well-being.

This is where today's lesson comes in. Our goal is to define a mood score in Rust computed as a weighted function of current need levels. This single mood value will serve as a quick summary of a character's happiness, making it easier for us (and our future AI systems) to make decisions. It will also be the perfect value to display in the UI as a "mood meter."

Mood Gauge Visual Concept
A mood gauge like this one is a common UI element in games. It translates multiple complex stats into a single, easy-to-understand visual, representing the character's overall emotional state.

1. From Needs to Mood: The Concept

The idea of combining various needs into a single "happiness" score is a cornerstone of the life simulation genre. Perhaps the most famous example is The Sims, where the overall mood is a direct result of the character's underlying needs (or "motives").

To understand this better, let's watch a brief segment from the Game Maker's Toolkit video on the AI of The Sims.

The Genius AI Behind The Sims

This clip from Game Maker's Toolkit explains how a Sim's individual needs are combined to determine their 'overall level of happiness.' Pay attention to how this single metric is derived from multiple underlying motives.

Watch from 01:26 to 01:51.

As the video explains, mood is essentially an aggregation of needs. The simplest way to calculate this would be a direct average: (hunger + energy + social + fun) / 4. However, this approach has a major flaw: it assumes all needs are equally important.

Is being a little bored really as bad as being on the verge of starvation? Of course not. A robust mood system must account for the different priorities of each need. This is where a weighted function comes in.

A weighted function allows us to assign a specific "importance" or weight to each need. Needs with a higher weight will have a much larger impact on the final mood score. For our simulation, physiological needs like hunger and energy should logically have higher weights than social or fun.


2. Designing the Mood Calculation

Our mood score will be a floating-point number between 0.0 (absolutely miserable) and 1.0 (perfectly happy). We'll calculate it using a weighted average of the current need levels.

The formula for a weighted average is:

Let's apply this to our four needs:

By choosing our weights carefully, we can ensure that a drop in hunger has a much more significant impact on mood than an equivalent drop in fun.


3. Implementing the Mood Score in Rust

Time to translate this theory into code. Open your Rust project and let's get started.

Step 1: Define Need Weights

First, let's define our weights as constants. This makes them easy to find and tweak later as we balance the game. For simplicity, we'll make them sum to 1.0, which simplifies our weighted average formula (the denominator becomes 1).

In your lib.rs or character.rs file, add these constants near the top:

// Weights for each need's contribution to mood. Must sum to 1.0.
const HUNGER_WEIGHT: f32 = 0.4;
const ENERGY_WEIGHT: f32 = 0.3;
const SOCIAL_WEIGHT: f32 = 0.15;
const FUN_WEIGHT: f32 = 0.15;

Here, we've decided that Hunger and Energy together account for 70% of a character's mood, which feels appropriate for a life simulation.

Step 2: Update the Character Struct

Our character needs a place to store their calculated mood. Let's add a mood field to the Character struct. We'll also update the new() function to initialize it. A new character should start with a perfect mood.

// In your Character struct definition



#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Character {
    pub name: String,
    // ... other fields
    pub needs: Needs,
    pub mood: f32, // Add this line
}

// In your impl Character block
impl Character {
    pub fn new(name: String /*...other params...*/) -> Self {
        Character {
            name,
            // ...
            needs: Needs::new(),
            mood: 1.0, // Initialize mood to full
        }
    }
    // ... other methods will go here
}

Step 3: Create the update_mood Method

Now for the core logic. We'll create a new method for the Character struct that calculates the mood based on its current need levels and our defined weights.

Add this method inside your impl Character block:

// In impl Character
pub fn update_mood(&mut self) {
    let weighted_sum = self.needs.hunger * HUNGER_WEIGHT
                     + self.needs.energy * ENERGY_WEIGHT
                     + self.needs.social * SOCIAL_WEIGHT
                     + self.needs.fun * FUN_WEIGHT;

    // Since our weights sum to 1.0, we don't need to divide.
    // If they didn't, we would divide by (HUNGER_WEIGHT + ...).
    self.mood = weighted_sum;

    // Clamp the value just in case of any floating point inaccuracies.
    self.mood = self.mood.clamp(0.0, 1.0);
}

This function is clean and directly implements our weighted average formula.

Step 4: Integrate into the Game Loop

The mood needs to be recalculated whenever the needs change. The perfect place for this is in our main update_simulation function from the last lesson, right after we call decay.

Modify your GameState's update function:

// In impl GameState
pub fn update_simulation(&mut self, delta_time: f32) {
    for character in &mut self.characters {
        character.needs.decay(delta_time);
        character.update_mood(); // Add this call
    }
}

Now, every time the simulation ticks forward, each character's needs will decay, and their mood will be immediately updated to reflect their new state.


4. Testing the Mood Calculation

As with our decay logic, we should write a unit test to verify our mood calculation works as expected. This ensures our logic is sound before we even think about displaying it in Unity.

Add the following test to your #[cfg(test)] mod tests block:




#[cfg(test)]
mod tests {
    use super::*;

    // ... your previous tests for decay ...




    #[test]
    fn mood_reflects_weighted_needs() {
        // 1. Setup
        let mut character = Character::new("Moody Blues".to_string());
        character.needs.hunger = 0.5; // 50%
        character.needs.energy = 1.0; // 100%
        character.needs.social = 1.0; // 100%
        character.needs.fun = 0.2;  // 20%

        // 2. Action
        character.update_mood();

        // 3. Assertion
        let expected_mood = (0.5 * HUNGER_WEIGHT)    // 0.5 * 0.4 = 0.2
                          + (1.0 * ENERGY_WEIGHT)    // 1.0 * 0.3 = 0.3
                          + (1.0 * SOCIAL_WEIGHT)    // 1.0 * 0.15 = 0.15
                          + (0.2 * FUN_WEIGHT);      // 0.2 * 0.15 = 0.03
                                                     // Total = 0.68
        
        println!("Calculated mood: {}, Expected mood: {}", character.mood, expected_mood);
        assert!((character.mood - expected_mood).abs() < 1e-6);
    }




    #[test]
    fn low_critical_need_tanks_mood() {
        // 1. Setup
        let mut char1 = Character::new("Hungry".to_string());
        char1.needs.hunger = 0.1; // Critical need is low
        char1.needs.fun = 1.0;

        let mut char2 = Character::new("Bored".to_string());
        char2.needs.hunger = 1.0;
        char2.needs.fun = 0.1; // Non-critical need is low

        // 2. Action
        char1.update_mood();
        char2.update_mood();

        // 3. Assertion
        println!("Hungry mood: {}", char1.mood);
        println!("Bored mood: {}", char2.mood);
        assert!(char1.mood < char2.mood, "Low hunger should impact mood more than low fun.");
    }
}

Run cargo test in your terminal. The first test verifies the math of our weighted average. The second test is a sanity check, confirming that a character with low hunger is in a worse mood than a character with low fun, demonstrating that our weighting scheme is working as intended.


5. Going Further: Non-Linear Mood Contribution

Our weighted average is a great start, but real-world emotions aren't always linear. The difference in happiness between 100% fun and 90% fun is negligible, but the difference between 10% and 0% can feel vast. Game designers often use curves to model this.

To learn more about how mathematical functions are used to create these curves in AI, the following video provides an excellent explanation.

How Utility AI Helps NPCs Decide What To Do Next | AI 101

This video from AI and Games dives into how Utility AI uses mathematical functions to score actions. The concepts of linear, quadratic, and other curves are directly applicable to modeling a more nuanced mood system.

Watch the section from 04:26 to 06:22. Focus on how simple math functions can create different response curves.

Inspired by this, you could experiment with a non-linear model. For example, instead of using the need level directly, you could use its square root (need.sqrt()) or a power function (need.powf(0.5)). This would cause the mood to drop more sharply as a need gets lower.

For example: let weighted_sum = self.needs.hunger.sqrt() * HUNGER_WEIGHT + ...

This is a more advanced topic for game balancing, but it's a powerful technique to keep in mind. For now, our linear weighted average is a solid and effective foundation.


Conclusion

Excellent work! You have now bridged the gap between raw needs and a character's overall emotional state. By creating a single, representative mood score, we've built a crucial piece of our simulation's core logic.

Key Takeaways:

  • A mood score is a single value that summarizes a character's overall well-being, derived from multiple underlying needs.
  • A weighted average is an effective way to calculate mood, allowing different needs to have different levels of importance.
  • The implementation in Rust involves defining constants for weights, adding a mood field to your Character struct, and creating an update_mood function.
  • This mood calculation should be called in the main game loop every time the needs are updated.
  • More advanced models can use non-linear functions (curves) to create more realistic emotional responses.

In the next lesson, we will introduce a proper time management system. We'll create a game clock in Rust with pause, normal, and fast-forward speeds that drives need decay, giving the player control over the flow of time in the simulation.