Skip to main content

Welcome to a Grasp lesson

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

Lesson 30

Rust AI: Need-Based Action Selection

Recommended resources

Hello! Welcome back to our journey of building a life simulation game.

In the last lesson, we gave our characters the ability to restore their needs by performing interactions like Eat and Sleep. We built the what (the actions) and the how (the perform_interaction method). However, our characters still lack the intelligence to act on their own.

Today, we will build the why. We'll construct a simple autonomous AI in Rust, enabling our characters to look at their own needs and decide which action to take. This is a foundational step towards creating the living, breathing, and self-sufficient characters that are the heart of our game.

Our learning outcome is to build a simple autonomous AI in Rust that selects a need-fulfilling action based on the character's lowest need.

1. The Brains of the Operation: Utility AI

Before we write code, let's explore the theory behind our AI's decision-making. The system we are building is a simplified version of a powerful and popular technique in game development called Utility AI.

The core idea is simple:

  1. The AI looks at all possible actions.
  2. It assigns a "utility score" to each action based on how beneficial it would be right now.
  3. It picks the action with the highest score.

For a game like ours, "benefit" is directly tied to a character's needs. An action that satisfies a desperate need (like eating when starving) will have a very high utility score.

To see this concept in action in the game that inspired our project, let's watch a few clips.

The Genius AI Behind The Sims

This video from Game Maker's Toolkit, titled "The Genius AI Behind The Sims," breaks down how Sims make decisions. It's a perfect high-level overview of the system we're aiming for.

Watch the segments from 01:26-03:43, 04:08-05:23, and 06:30-07:25. As you watch, focus on: How objects "advertise" what they can do. How a Sim's current needs (motives) act as multipliers to weigh the value of these advertisements. The concept of unique "curves" for each need, making some more critical than others when low. The final decision-making process, which is the definition of Utility AI.

As you can see, the decision isn't just "I'm hungry." It's a sophisticated calculation where the AI weighs the advertised benefits of every object against its own internal state.

For our first implementation, we will use a very simple utility calculation: the score for an action will be directly inverse to the need it satisfies. In other words, the lower the need, the higher the utility. The AI will simply find its lowest need and pick the corresponding action.


2. A Blueprint for Utility AI in Rust

While our "lowest need" model is simple, it's helpful to see what a more advanced implementation looks like in Rust. This will give us a mental map of where we could take our AI in the future, with features like personality and context influencing decisions.

Utility AI (with Rust example) - How-To

The video "Utility AI (with Rust example)" by Code Blacksmith provides a more technical, code-focused walkthrough of the concept. You don't need to implement this now, but pay attention to the terminology, as it provides a powerful vocabulary for thinking about AI.

Watch from the beginning to 03:54 and from 04:30 to 08:42. Focus on understanding these key terms: Considerations: The raw data points the AI uses (e.g., current health, number of potions). For us, this is our character's need levels. Mapping Curves: Functions that convert a raw consideration value into a normalized score (0 to 1). This is how you implement the unique curves mentioned in the GMTK video. Evaluators: Logic that combines multiple scores into a single utility value for an action. Reasoner: The final function that compares all action scores and picks the best one.

Keep these concepts in mind. Our choose_action function will act as a simple reasoner. Our "utility score" is essentially 1.0 - need_level, a linear inverse mapping curve.


3. Implementing the "Lowest Need" AI

Now, let's translate this into code. We'll add a new method to our Character struct that encapsulates the decision-making logic.

Open the file containing your Character struct (lib.rs or similar) and add this new method inside the impl Character block.

use crate::Interaction; // Make sure Interaction is in scope

impl Character {
    // ... your existing new(), perform_interaction(), etc. methods ...

    /// Chooses a need-fulfilling action based on the character's lowest need.
    /// Returns `None` if no action is urgently needed.
    pub fn choose_action(&self) -> Option<Interaction> {
        // Define a threshold below which a need is considered low enough to act upon.
        const ACTION_THRESHOLD: f32 = 0.5;

        // Create a list of needs and their corresponding actions.
        let needs_and_actions = [
            (self.needs.hunger, Interaction::Eat),
            (self.needs.energy, Interaction::Sleep),
            (self.needs.social, Interaction::Socialize),
            (self.needs.fun, Interaction::PlayGame),
        ];

        // Find the need with the lowest value.
        // We use `min_by` with `partial_cmp` because we are comparing f32 values.
        let lowest = needs_and_actions
            .iter()
            .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));

        // Decide whether to act.
        if let Some((value, action)) = lowest {
            if *value < ACTION_THRESHOLD {
                return Some(*action); // Return the corresponding interaction.
            }
        }

        // If no need is below the threshold, do nothing.
        None
    }
}

Let's break down this function:

  • const ACTION_THRESHOLD: f32 = 0.5;: This is important. Without a threshold, a character with all needs at 99% would still pick an action to top up the "lowest" one. This constant prevents the AI from acting unless a need has dropped to a reasonably low level.
  • needs_and_actions: We create a small array of tuples. Each tuple pairs a need's value with the Interaction that fulfills it. This structure makes it easy to find the minimum value and its associated action together.
  • .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(...)): This is the core of the logic.
    • iter() creates an iterator over our array.
    • min_by finds the minimum element using a custom comparison function (a closure).
    • a.0.partial_cmp(&b.0) compares the first element of our tuples (the f32 need value). partial_cmp is used for floats because they have special non-numeric values (like NaN) that make them not fully ordered. It returns an Option<Ordering>.
    • .unwrap_or(...) handles the None case from partial_cmp, though it's unlikely to happen with our data.
  • if let Some((value, action)) = lowest: If min_by successfully finds a minimum element...
  • if *value < ACTION_THRESHOLD: ...we check if its value is below our threshold. If it is, we return Some(*action).
  • None: If no need is below the threshold, we return None, signifying the character is content and doesn't need to do anything.

4. Structuring AI Logic

As our game grows, we'll have different AI behaviors. The Rust Roguelike tutorial offers excellent patterns for organizing this complexity. It suggests moving AI logic into its own module and using a "state machine" or "goal-oriented" approach.

Better AI - Roguelike Tutorial - In Rust

Let's read a short section from the "Better AI - Roguelike Tutorial - In Rust" to see how we might structure our AI code for future expansion.

Read the section titled "Making AIs appear to think". Focus on the idea of giving NPCs goals and using a state machine to represent what they are trying to do. Our character's current state could be 'Hungry', and their goal is to perform the 'Eat' action.

Our choose_action function is the first step in this direction. It determines the character's current "goal" (e.g., to eat). In future lessons, we will build systems that execute these goals, like moving to a fridge or a bed.


5. Testing the Decision-Maker

As always, we must test our new logic. A unit test will confirm that our AI correctly identifies the lowest need and chooses the right action.

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




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

    // ... your previous tests ...




    #[test]
    fn ai_chooses_action_for_lowest_need() {
        let mut character = Character::new("AI Test".to_string());

        // Test 1: Hunger is the lowest need
        character.needs = Needs {
            hunger: 0.1,
            energy: 0.6,
            social: 0.7,
            fun: 0.8,
        };
        assert_eq!(character.choose_action(), Some(Interaction::Eat));

        // Test 2: Energy is the lowest need
        character.needs = Needs {
            hunger: 0.9,
            energy: 0.2,
            social: 0.7,
            fun: 0.8,
        };
        assert_eq!(character.choose_action(), Some(Interaction::Sleep));
        
        // Test 3: Social is the lowest need
        character.needs = Needs {
            hunger: 0.9,
            energy: 0.6,
            social: 0.3,
            fun: 0.8,
        };
        assert_eq!(character.choose_action(), Some(Interaction::Socialize));

        // Test 4: Fun is the lowest need
        character.needs = Needs {
            hunger: 0.9,
            energy: 0.6,
            social: 0.7,
            fun: 0.4,
        };
        assert_eq!(character.choose_action(), Some(Interaction::PlayGame));

        // Test 5: No need is below the threshold
        character.needs = Needs {
            hunger: 0.9,
            energy: 0.6,
            social: 0.7,
            fun: 0.8,
        };
        assert_eq!(character.choose_action(), None);
    }
}

Run cargo test. This test suite explicitly sets the character's needs to various states and asserts that choose_action() returns the expected Interaction or None. Passing this test gives us high confidence in our AI's core logic.


Conclusion

Fantastic work! You have now implemented the "brain" for our characters. They can now evaluate their internal state and make a rational decision about what to do next. We've taken a significant step from a simple data model to a dynamic, autonomous agent.

Key Takeaways:

  • Utility AI is a powerful pattern where actions are scored based on their current benefit, and the highest-scoring action is chosen.
  • Our simple "lowest need" model is a foundational form of Utility AI.
  • The iter().min_by() method is an effective tool for finding the minimum element in a collection based on a custom comparison, which is perfect for our use case.
  • Using an action threshold is critical to prevent the AI from behaving erratically when all needs are relatively high.
  • Thinking about AI in terms of goals and states (as shown in the Roguelike tutorial) provides a robust mental model for adding complexity later.

In the previous lesson, we built the character's "hands" (perform_interaction). Today, we built the "brain" (choose_action). In our next lesson, we'll build the "eyes" by exposing need and mood values to Unity and displaying them as updating UI progress bars, finally making our backend simulation visible to the player.