Skip to main content

Welcome to a Grasp lesson

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

Lesson 51

Measuring Character Chemistry: Interaction Success Rates

Recommended resources

Hello! Welcome to the next lesson in our journey to build a life simulation game.

In our last session, you built a system to filter available conversation interactions based on character traits and moods. That was a crucial step in making characters act according to their personalities. Today, we're going to determine the outcome of those actions.

The learning outcome for this lesson is to calculate interaction success rates using a compatibility score derived from both characters' personalities. This will make your game's social dynamics feel more nuanced and realistic. Some characters will naturally click, while others will struggle to get along, and this will directly affect whether their social interactions succeed or fail.

1. The Concept of Character Compatibility

Why do some conversations flow effortlessly while others are painfully awkward? In life simulation games, this is often modeled using a "compatibility" or "chemistry" score. A high score means interactions are more likely to succeed and relationships progress faster. A low score means the opposite.

One of the best-known examples of this is the "Chemistry" system from The Sims 2: Nightlife.

Chemistry | The Sims Wiki

To understand the design pattern we're about to implement, let's look at how a classic life sim handled this. The Sims Wiki provides an excellent breakdown of the Chemistry system.

Please read the following sections from the article: The first two paragraphs of the main body (starts with 'Until the introduction of chemistry...'). Focus on how chemistry affects success rates. The section 'Calculating chemistry' and the table that follows. Note how an 'attraction score' is used to determine the chemistry level. The subsection 'Personality'. This is the most relevant part. Observe how it calculates a bonus or penalty based on the difference in personality points between two Sims. We will adapt this core idea—calculating a score based on personality differences—for our own HEXACO-based system.

The key insight here is that we can translate complex personality interactions into a single, computable score that influences gameplay mechanics.


2. Designing a Compatibility Score from HEXACO Traits

In our game, character personality is defined by the HEXACO model, which we've already implemented as a Rust struct with six floating-point values (from 0.0 to 1.0):

  • Honesty-Humility
  • Emotionality
  • Xtraversion
  • Agreeableness
  • Conscientiousness
  • Openness to Experience
HEXACO Personality Model Overview
A visual recap of the six HEXACO dimensions. Each represents a spectrum that we can use to compare characters.

Our goal is to create a function, calculate_compatibility(char_a, char_b), that returns a numerical score representing how well two characters' personalities mesh. A simple and effective way to measure the "distance" between two points in a multi-dimensional space is the Euclidean distance. Given your computer science background, you'll recognize this as a standard metric for similarity.

If we have two personality vectors, and , the squared Euclidean distance is the sum of the squared differences for each dimension:

A smaller distance implies greater similarity. We can then invert and normalize this value to get a compatibility score from 0.0 (maximum dissimilarity) to 1.0 (identical personalities).

Adding Nuance with Weights

However, not all traits are equally important for compatibility. A large gap in Honesty-Humility might be a deal-breaker, while a difference in Openness to Experience could be less significant. We can model this by introducing weights to our calculation.

The weighted Euclidean distance allows us to amplify or dampen the effect of each trait's difference:

Here, are the weights we assign to each trait. For example, we might decide that Honesty-Humility and Agreeableness are the most critical traits for getting along.

This approach is powerful because it's both mathematically sound and highly tunable. By adjusting the weights, you can fine-tune the social dynamics of your game.


3. Implementing the Compatibility Calculation in Rust

Let's translate this into Rust code. First, we'll define the weights as constants. These are great candidates for a configuration file later, but for now, we'll hardcode them.

// In a new module, e.g., `src/social.rs` or similar

// Weights for each HEXACO trait's contribution to compatibility.
// Higher values mean differences in this trait have a larger negative impact.
const HONESTY_HUMILITY_WEIGHT: f32 = 2.0;
const EMOTIONALITY_WEIGHT: f32 = 1.0;
const EXTRAVERSION_WEIGHT: f32 = 0.75;
const AGREEABLENESS_WEIGHT: f32 = 1.75;
const CONSCIENTIOUSNESS_WEIGHT: f32 = 0.5;
const OPENNESS_WEIGHT: f32 = 0.25;

const HEXACO_WEIGHTS: [f32; 6] = [
    HONESTY_HUMILITY_WEIGHT,
    EMOTIONALITY_WEIGHT,
    EXTRAVERSION_WEIGHT,
    AGREEABLENESS_WEIGHT,
    CONSCIENTIOUSNESS_WEIGHT,
    OPENNESS_WEIGHT,
];

// The maximum possible weighted squared distance, used for normalization.
// This occurs when one personality is all 0.0s and the other is all 1.0s.
// The difference for each trait is 1.0, so the squared difference is 1.0.
// The max distance is simply the sum of the weights.
const MAX_WEIGHTED_DISTANCE: f32 = HONESTY_HUMILITY_WEIGHT
    + EMOTIONALITY_WEIGHT
    + EXTRAVERSION_WEIGHT
    + AGREEABLENESS_WEIGHT
    + CONSCIENTIOUSNESS_WEIGHT
    + OPENNESS_WEIGHT;

Now, we can write the function to calculate the compatibility score. It will take the two characters' HEXACO profiles, compute the weighted distance, and normalize it to a [0.0, 1.0] range.

// In the same file as the constants

/// Calculates a compatibility score between two HEXACO personality profiles.
///
/// The score is based on the weighted Euclidean distance between the two personality vectors.
///
/// # Arguments
/// * `hexaco_a` - The 6-dimensional personality vector for the first character.
/// * `hexaco_b` - The 6-dimensional personality vector for the second character.
///
/// # Returns
/// A compatibility score from 0.0 (completely incompatible) to 1.0 (perfectly compatible).
pub fn calculate_compatibility(hexaco_a: &[f32; 6], hexaco_b: &[f32; 6]) -> f32 {
    let weighted_squared_distance: f32 = hexaco_a
        .iter()
        .zip(hexaco_b.iter())
        .zip(HEXACO_WEIGHTS.iter())
        .map(|((&a, &b), &weight)| {
            // Calculate the squared difference for one trait
            let diff = a - b;
            // Apply the weight
            weight * (diff * diff)
        })
        .sum();

    // Normalize the distance to a [0, 1] range.
    // A distance of 0 should yield a compatibility of 1.
    // The maximum possible distance should yield a compatibility of 0.
    let normalized_distance = weighted_squared_distance / MAX_WEIGHTED_DISTANCE;

    // Invert the normalized distance to get a compatibility score.
    // We use `max(0.0, ...)` to prevent any potential floating point inaccuracies
    // from pushing the value slightly below zero.
    (1.0 - normalized_distance).max(0.0)
}

This function is clean, efficient, and gives you a powerful lever to control your game's social simulation just by tweaking the HEXACO_WEIGHTS.


4. Calculating the Final Interaction Success Rate

The compatibility score is the foundation, but it's not the final success rate. A risky social maneuver should have a lower chance of success than a simple greeting, regardless of compatibility.

We'll add a baseSuccessRate to our interaction data. This is the probability of success assuming neutral compatibility.

First, update your Interaction struct in Rust and an example in your JSON files.

JSON (friends.json):

// ... inside a topic ...
"interactions": [
    {
        "id": "interaction_tell_joke",
        "text": "Tell a Joke",
        "baseSuccessRate": 0.7, // 70% chance of success with neutral compatibility
        "conditions": { /* ... */ }
    },
    {
        "id": "interaction_share_deep_secret",
        "text": "Share a Deep Secret",
        "baseSuccessRate": 0.4, // Risky! Only a 40% base chance
        "conditions": { /* ... */ }
    }
]

Rust (conversation.rs):




#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Interaction {
    pub id: String,
    pub text: String,



    #[serde(default = "default_base_success_rate")]
    pub base_success_rate: f32, // Add this field



    #[serde(default)]
    pub conditions: InteractionConditions,
    // ... other fields
}

// A helper function for serde to provide a default value if the field is missing.
fn default_base_success_rate() -> f32 {
    0.8 // Default to a high success rate for simple interactions.
}

Now, we can combine the baseSuccessRate and our compatibility_score to get the final probability. A good way to model this is to have the compatibility score act as a signed modifier.

The academic paper on simulating NPC emotions provides a useful formula pattern we can adapt.

Simulation of the Dynamics of Non-Player Characters' Emotions

This research paper details a computational model for NPC emotions. Its approach to applying personality as a multiplier is directly applicable to our success rate calculation.

Read the subsection titled 'Computation of the intensity of triggered emotions'. Focus on the final formula, which looks something like e_di(e) = e_d'(e) * (1 + i(p)). Here, i(p) is the influence of personality. This 'base * (1 + modifier)' pattern is exactly what we need.

Let's implement this pattern. We'll define a function that takes the base rate and the compatibility score and returns the final success probability, clamped between 0% and 100%.

// In your `social.rs` module

/// How much the compatibility score can swing the base success rate.
/// A value of 0.5 means a perfect score adds 50% of the base rate,
/// and a terrible score removes 50%.
const COMPATIBILITY_SENSITIVITY: f32 = 0.5;

/// Calculates the final success rate of an interaction based on its base rate
/// and the compatibility between the two characters.
///
/// # Arguments
/// * `base_rate` - The interaction's base success rate, from 0.0 to 1.0.
/// * `compatibility_score` - The character compatibility, from 0.0 to 1.0.
///
/// # Returns
/// A final success probability, clamped to the [0.0, 1.0] range.
pub fn calculate_success_rate(base_rate: f32, compatibility_score: f32) -> f32 {
    // Convert compatibility_score from [0, 1] to a [-1, 1] modifier.
    // 0.0 compatibility -> -1.0 modifier
    // 0.5 compatibility ->  0.0 modifier
    // 1.0 compatibility -> +1.0 modifier
    let modifier = (compatibility_score * 2.0) - 1.0;

    // Apply the modifier to the base rate using the pattern:
    // final = base * (1 + modifier * sensitivity)
    let final_rate = base_rate * (1.0 + modifier * COMPATIBILITY_SENSITIVITY);

    // Clamp the result to ensure it's a valid probability.
    final_rate.clamp(0.0, 1.0)
}

Now you have all the pieces. When a character performs a social interaction, you can:

  1. Call calculate_compatibility() with their HEXACO profiles.
  2. Pass the result, along with the interaction's baseSuccessRate, to calculate_success_rate().
  3. Use the final rate to run a random check (e.g., rand::thread_rng().gen::<f32>() < final_rate) to determine if the interaction succeeds or fails.


Conclusion

Excellent work. You've just implemented one of the most important systems for emergent social behavior. By moving beyond simple pass/fail checks and introducing probability based on personality, you've laid the groundwork for richer, more surprising, and more replayable stories to unfold in your game.

Key Takeaways:

  • Compatibility as a Metric: You can model the complex chemistry between two personalities using a single, computable score.
  • Weighted Distance: The weighted Euclidean distance is a powerful and tunable tool for measuring similarity between multi-dimensional data points like personality vectors.
  • Base Rate + Modifier: You designed a robust system for calculating final outcomes by combining a static baseSuccessRate with a dynamic compatibility_score. This pattern is widely applicable in game design.
  • Data-Driven Design: By defining baseSuccessRate in your JSON files, you've empowered yourself (and future modders) to easily balance and create new social interactions without touching the core Rust code.

In our next lesson, we will address the consequences of this system. Now that an interaction can succeed or fail, we need to handle both outcomes. The next learning outcome is: "Display the interaction outcome (success/failure, mood/relationship changes) in the UI." This will involve updating character states in Rust and communicating those changes to Unity for the player to see.