Skip to main content

Welcome to a Grasp lesson

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

Lesson 29

Implementing Need-Fulfilling Interactions in Rust

Recommended resources

Hello! Welcome back to our course on building a life simulation game.

In our last lesson, we put time itself under our control by building a GameClock. Our characters' needs now decay at a rate we can manage with pause, normal, and fast-forward speeds. However, this has created a new, inevitable problem: without any way to replenish their needs, every character is on a one-way trip to misery.

Today, we'll fix that. We will give our characters the ability to perform actions that restore their needs. This is the other half of the core simulation loop that will eventually drive all autonomous behavior.

Our learning outcome for this lesson is to create four need-fulfilling interactions (e.g., eat, sleep) that modify need values in the Rust backend. We will define what these interactions are, implement their effects on a character's state, and write tests to prove they work as expected.

1. The Core Simulation Loop: Needs and Actions

At its heart, a life simulation game is a cycle:

  1. A character's needs decay over time.
  2. Low needs create a motivation to act.
  3. The character performs an action to fulfill the need.
  4. The cycle repeats.

This simple loop is the foundation for creating believable, autonomous characters. To get a feel for this in practice, let's watch a short clip from a video about a simple evolution simulator.

I Made an Evolution Simulator (with silly little guys)

The video "I Made an Evolution Simulator" by Icoso shows this core loop in its most basic form. Notice how the creatures' internal needs (hunger, thirst) directly drive their external actions (searching for food/water).

Watch from 00:22 to 01:08. Pay attention to the direct cause-and-effect relationship: low need leads to a specific, need-fulfilling behavior.

Just like the "goblets" in the video, our characters need a set of actions they can perform to counteract their decaying needs. Our goal today is to build the backend logic for these actions.


2. Modeling Interactions in Rust

Before we write code, let's decide how to represent an "interaction." An interaction like "Eat a meal" can be complex, involving multiple steps: walk to the fridge, get ingredients, cook, sit down, and finally, eat.

Atomic vs. Complex Actions
This image shows the difference between simple 'Atomic Actions' and 'Complex Actions' composed of multiple steps. For now, we will treat our need-fulfilling interactions as atomic from the backend's perspective.

For this lesson, we will model our interactions as atomic. When a character performs the Eat interaction, their hunger will be replenished instantly in the backend. Later in the course, we'll connect these backend commands to animations and object interactions in Unity, but the core state change happens here in Rust.

The simplest and most robust way to represent a fixed set of actions in Rust is with an enum. Let's define one for the four needs we've implemented: hunger, energy, social, and fun.

In your Rust project, add the following enum:

// You can add this near your other struct/enum definitions in lib.rs



#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Interaction {
    Eat,
    Sleep,
    Socialize,
    PlayGame,
}
  • #[derive(Debug, Clone, Copy, PartialEq)] allows this enum to be easily printed for debugging, copied, and compared, which will be useful for our tests and future AI logic.

3. Implementing Need-Fulfilling Logic

Now that we have a way to represent what a character can do, we need to implement the effect of doing it. We will add a method to our Character struct that takes an Interaction and modifies the character's Needs accordingly.

Open the file containing your Character struct and add this impl block:

// In the file with your Character struct
impl Character {
    /// Applies the effects of a given interaction to the character's needs.
    pub fn perform_interaction(&mut self, interaction: Interaction) {
        match interaction {
            Interaction::Eat => {
                // Eating restores a large amount of hunger.
                self.needs.hunger = (self.needs.hunger + 0.7).min(1.0);
            }
            Interaction::Sleep => {
                // Sleeping restores a large amount of energy.
                self.needs.energy = (self.needs.energy + 0.8).min(1.0);
            }
            Interaction::Socialize => {
                // Socializing restores social need and a little fun.
                self.needs.social = (self.needs.social + 0.6).min(1.0);
                self.needs.fun = (self.needs.fun + 0.2).min(1.0);
            }
            Interaction::PlayGame => {
                // Playing a game restores a lot of fun, but slightly drains energy.
                self.needs.fun = (self.needs.fun + 0.7).min(1.0);
                self.needs.energy = (self.needs.energy - 0.1).max(0.0);
            }
        }
        
        // After any interaction, it's a good idea to update the mood.
        self.update_mood();
    }
}

Let's break this down:

  • pub fn perform_interaction(&mut self, interaction: Interaction): This public method allows external code to tell a character to perform an action. It takes &mut self because it needs to modify the character's internal state (their needs).
  • match interaction { ... }: A match statement is the perfect tool for handling different enum variants. It's clear, and the Rust compiler will ensure we've handled every possible Interaction.
  • self.needs.hunger = (self.needs.hunger + 0.7).min(1.0);: For each interaction, we add a certain amount to the relevant need. We then use .min(1.0) to clamp the value, ensuring it never goes above 1.0. Similarly, we use .max(0.0) to prevent a need from dropping below 0.0. This is crucial for keeping our data valid.
  • Notice that interactions can affect multiple needs. Socialize restores both social and fun, while PlayGame restores fun but costs a little energy. This allows for more interesting gameplay trade-offs.
  • self.update_mood(): After changing the needs, we immediately recalculate the character's mood to reflect their new state.

4. Testing the Interactions

We've written the logic, but how do we know it works? We don't need to boot up Unity and build a complex UI to find out. We canβ€”and shouldβ€”verify our backend logic with a unit test. This ensures our core simulation is solid before we add more layers on top.

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




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

    // ... your previous tests for clock, needs, etc. ...




    #[test]
    fn character_can_fulfill_needs_with_interactions() {
        // 1. Setup
        let mut character = Character::new("Testy".to_string());

        // 2. Test Eating
        character.needs.hunger = 0.2; // Set hunger to a low value
        character.perform_interaction(Interaction::Eat);
        assert!(character.needs.hunger > 0.8, "Eating should restore hunger.");

        // 3. Test Sleeping
        character.needs.energy = 0.1;
        character.perform_interaction(Interaction::Sleep);
        assert!(character.needs.energy > 0.8, "Sleeping should restore energy.");

        // 4. Test Socializing
        character.needs.social = 0.3;
        character.needs.fun = 0.5; // Note the starting fun value
        character.perform_interaction(Interaction::Socialize);
        assert!(character.needs.social > 0.8, "Socializing should restore social need.");
        assert!(character.needs.fun > 0.6, "Socializing should also restore some fun.");

        // 5. Test Playing a Game
        character.needs.fun = 0.1;
        character.needs.energy = 0.5; // Note the starting energy value
        character.perform_interaction(Interaction::PlayGame);
        assert!(character.needs.fun > 0.7, "Playing a game should restore fun.");
        assert!(character.needs.energy < 0.5, "Playing a game should drain some energy.");
    }
}

This test is very explicit:

  1. It creates a character.
  2. For each interaction, it artificially sets the relevant need(s) to a low value.
  3. It calls our new perform_interaction method.
  4. It asserts that the need values have changed in the way we expect.

Run cargo test from your terminal. The test should pass, giving you confidence that your need-fulfillment logic is correct.


Conclusion

Excellent work! You have now implemented the crucial counterpart to need decay. Our characters are no longer doomed to a slow decline; they now have the means to care for themselves.

Key Takeaways:

  • The core simulation loop consists of needs decaying and actions restoring them.
  • Enums are a type-safe and clear way to represent a finite set of actions or Interactions.
  • Interaction logic can be cleanly implemented in a method on the Character struct using a match statement.
  • Always clamp need values (e.g., between 0.0 and 1.0) to prevent invalid data states.
  • Unit tests are essential for verifying backend logic quickly and reliably, confirming that your interactions have the intended effects.

We have now created the what (the interactions) and the how (the perform_interaction method). In our next lesson, we will tackle the why. We'll build a simple autonomous AI in Rust that can look at a character's needs and decide which interaction to perform, taking our first major step towards creating intelligent, self-sufficient characters.