Skip to main content

Welcome to a Grasp lesson

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

Lesson 26

Implementing a Needs System in Rust

Hello! Welcome back to the course.

In the last few lessons, we've established a solid foundation in Unity. We have a world, we can spawn a character into it, and we've just implemented a point-and-click movement system so the player can direct them around the scene. Our character can move, but they have no inner life, no motivation. It's time to change that.

This lesson marks a shift back to our Rust backend. We will begin building the "brain" of our simulation by implementing one of its most critical components. Our goal is to implement a needs system in Rust with four needs (hunger, energy, social, fun) that decay over time. This system will become the primary driver for character behavior and the foundation for the autonomous AI we'll build later.

Let's start giving our characters some personality and purpose.

1. What is a Needs System?

In life simulation games, a needs system is a set of internal stats that represent a character's physical and mental state. These stats, or "needs," decrease over time, and the player (or the AI) must direct the character to perform actions to replenish them. Letting a need drop too low often results in negative consequences for the character's mood or health.

You've likely seen this in many games. The Sims is the classic example, where characters are driven by needs like Hunger, Hygiene, and Fun.

The Sims 1 Needs System UI
This UI from The Sims 1 is a classic representation of a needs system. The bars visually communicate the character's current state, creating goals for the player to fulfill.

This concept isn't limited to life sims. Survival games like Rust use similar systems for hunger, thirst, and health, creating a sense of urgency and resource management.

Rust Game UI: Character Needs and Inventory
In the game Rust, status indicators on the right side of the screen constantly remind the player of their character's needs, directly influencing their survival strategy.

For our game, we will start with four core needs:

  • Hunger: The need to eat.
  • Energy: The need to sleep or rest.
  • Social: The need for interaction with other characters.
  • Fun: The need for leisure and entertainment.

2. Implementing the Needs Struct in Rust

Let's get practical and write some Rust code. We'll start by defining a data structure to hold our four needs. A struct is perfect for this. We'll use f32 (32-bit floating-point numbers) for each need, representing their levels as a value from 0.0 (empty) to 1.0 (full).

Open your Rust project. In the file where you've defined your Character struct (e.g., lib.rs or a dedicated character.rs module), add the following code:

use serde::{Serialize, Deserialize};




#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Needs {
    pub hunger: f32,
    pub energy: f32,
    pub social: f32,
    pub fun: f32,
}

impl Needs {
    // A function to create a new Needs struct with default (full) values.
    pub fn new() -> Self {
        Needs {
            hunger: 1.0,
            energy: 1.0,
            social: 1.0,
            fun: 1.0,
        }
    }
}

A quick breakdown:

  • #[derive(Serialize, Deserialize, Debug, Clone)]: We're using serde's derive macros so we can easily convert this struct to and from JSON for saving, loading, and communicating with Unity. Debug allows us to print it for debugging, and Clone allows us to make copies.
  • pub: We make the struct and its fields public so they can be accessed from other parts of our code, including the Character struct we'll modify next.
  • impl Needs: This block is where we'll define functions (or "methods") that operate on the Needs struct. The new() function is a simple "constructor" that gives us a fresh set of needs, all at their maximum value.

Now, let's add this Needs struct to your main Character struct.

// Assuming you have a Character struct like this from previous lessons



#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Character {
    pub name: String,
    // ... other fields like age, personality, etc.
    pub needs: Needs,
}

impl Character {
    // You might have a constructor for Character already.
    // If so, modify it to initialize needs.
    pub fn new(name: String /*...other params...*/) -> Self {
        Character {
            name,
            // ...
            needs: Needs::new(), // Initialize with full needs
        }
    }
}

With this change, every character in our simulation now has their own set of needs.


3. Implementing Need Decay

Needs aren't static; their core gameplay function is to decay over time, creating motivation for action. We'll implement this with a decay function that reduces each need based on how much game time has passed.

First, let's define some constants for how quickly each need decays. Placing these at the top of your file is good practice. The values represent the amount of the need lost per second. You can tune these later to balance your game.

// Decay rates: units per second
const HUNGER_DECAY_RATE: f32 = 0.005;
const ENERGY_DECAY_RATE: f32 = 0.004;
const SOCIAL_DECAY_RATE: f32 = 0.003;
const FUN_DECAY_RATE: f32 = 0.0035;

Now, let's add the decay method to our impl Needs block. This function will take delta_time as an argument, which represents the time elapsed (in seconds) since the last update.

impl Needs {
    pub fn new() -> Self {
        // ... (as before)
    }

    // New method to handle decay
    pub fn decay(&mut self, delta_time: f32) {
        self.hunger -= HUNGER_DECAY_RATE * delta_time;
        self.energy -= ENERGY_DECAY_RATE * delta_time;
        self.social -= SOCIAL_DECAY_RATE * delta_time;
        self.fun -= FUN_DECAY_RATE * delta_time;

        // Clamp the values to ensure they stay between 0.0 and 1.0
        self.hunger = self.hunger.clamp(0.0, 1.0);
        self.energy = self.energy.clamp(0.0, 1.0);
        self.social = self.social.clamp(0.0, 1.0);
        self.fun = self.fun.clamp(0.0, 1.0);
    }
}

Explanation:

  • &mut self: This indicates that the method needs to modify the Needs instance it's called on.
  • delta_time: Multiplying the decay rate by delta_time makes the decay frame-rate independent. A character will lose the same amount of hunger over 10 seconds whether the game is running at 30 FPS or 120 FPS.
  • .clamp(0.0, 1.0): This is a very useful function that ensures a value stays within a specified range. It prevents our needs from dropping below 0 or going above 1, which is important for when we start replenishing them.

4. Simulating Time with a Game Tick

We now have the logic to decay needs, but we need a central place to call it from. In game development, this is typically done in an update loop or a "game tick" function that runs repeatedly.

Let's define a main GameState struct and an update_simulation function. This structure will become the heart of our Rust backend.

// This struct will eventually hold all simulation data.



#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameState {
    pub characters: Vec<Character>,
    // ... other world data will go here later
}

impl GameState {
    pub fn new() -> Self {
        GameState {
            characters: Vec::new(),
        }
    }

    pub fn update_simulation(&mut self, delta_time: f32) {
        for character in &mut self.characters {
            character.needs.decay(delta_time);
        }
    }
}

The update_simulation function is simple but powerful. It takes the delta_time, loops through every character currently in the game, and calls the decay method on their needs. In the future, this function will be called from Unity on every frame to keep the simulation state in sync with what the player sees.


5. Testing Your Logic

How can we be sure our decay logic works without connecting it to Unity yet? We can write a unit test. This is a great habit to get into, especially for backend logic that doesn't have an immediate visual component.

At the bottom of your Rust file, add the following test module:




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




    #[test]
    fn needs_decay_over_time() {
        // 1. Setup
        let mut character = Character::new("Testy McTestface".to_string());
        assert_eq!(character.needs.hunger, 1.0); // Check initial state

        let delta_time = 10.0; // Simulate 10 seconds passing

        // 2. Action
        character.needs.decay(delta_time);

        // 3. Assertion
        let expected_hunger = 1.0 - (HUNGER_DECAY_RATE * delta_time);
        // Use a small epsilon for float comparison
        assert!((character.needs.hunger - expected_hunger).abs() < 1e-6); 
        println!("Hunger after 10s: {}", character.needs.hunger);
    }




    #[test]
    fn needs_do_not_drop_below_zero() {
        // 1. Setup
        let mut character = Character::new("Bottom Out".to_string());
        character.needs.energy = 0.05; // Start with a low need

        let delta_time = 100.0; // Simulate a long time passing

        // 2. Action
        character.needs.decay(delta_time);

        // 3. Assertion
        assert_eq!(character.needs.energy, 0.0);
        println!("Energy after 100s: {}", character.needs.energy);
    }
}

You can run these tests from your terminal in your project's root directory using the command cargo test.

The first test, needs_decay_over_time, checks if a need decays by the correct amount over a 10-second interval. The second, needs_do_not_drop_below_zero, verifies that our clamp logic works and prevents a need from falling below 0.0.


Conclusion

Congratulations! You have successfully implemented the core of a needs system in Rust. This is a massive step towards creating believable, autonomous characters. While it's just numbers in the backend for now, this system lays the critical groundwork for everything from AI decision-making to player feedback.

Key Takeaways:

  • A Needs struct is an effective way to organize a character's internal stats.
  • Using f32 and a 0.0 to 1.0 range is a flexible standard for representing needs.
  • Logic for changing stats over time should be delta_time dependent to ensure it's not tied to the game's frame rate.
  • The .clamp() function is essential for keeping values within a valid range.
  • A central update_simulation function provides a single entry point for advancing the game state.
  • Unit tests are invaluable for verifying backend logic without needing a visual frontend.

In our next lesson, we will build directly on this foundation. We will define a mood score in Rust computed as a weighted function of current need levels. This will give us a single, high-level indicator of a character's well-being, which is crucial for both AI behavior and UI feedback.