Skip to main content

Welcome to a Grasp lesson

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

Lesson 52

Show Interaction Results

Recommended resources

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

In our last lesson, we built a fantastic system for determining the success or failure of a social interaction based on character compatibility. This added a crucial layer of probabilistic depth to your social simulation. But a result isn't meaningful to the player until they can see its impact.

Today, we'll connect that backend logic to the frontend presentation. The learning outcome for this lesson is to display the interaction outcome (success/failure, mood/relationship changes) in the UI. We'll make the consequences of social actions immediately visible and understandable to the player, which is key to making the simulation feel responsive and alive.

1. Defining Outcomes in Your Data Model

First, we need to specify what happens on success or failure. We'll extend our Interaction JSON structure to include outcome effects. This makes your system data-driven and easily moddable.

In your conversation JSON files (e.g., friends.json), update your interactions to include onSuccess and onFailure objects.

Example friends.json Interaction:

{
  "id": "interaction_tell_joke",
  "text": "Tell a Joke",
  "baseSuccessRate": 0.7,
  "conditions": {
    "minMood": 0.3
  },
  "onSuccess": {
    "moodChange": 0.1,
    "friendshipChange": 5
  },
  "onFailure": {
    "moodChange": -0.05,
    "friendshipChange": -2
  }
}

Now, let's update our Rust structs to match this new data structure. We will create a struct for the effects and add optional fields to our Interaction struct.

In src/conversation.rs (or your relevant module):

use serde::{Deserialize, Serialize};

// This struct will hold the effects of an outcome



#[derive(Serialize, Deserialize, Debug, Default, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OutcomeEffects {



    #[serde(default)]
    pub mood_change: f32,



    #[serde(default)]
    pub friendship_change: f32,



    #[serde(default)]
    pub romance_change: f32,
}

// Update the Interaction struct



#[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,



    #[serde(default)]
    pub conditions: InteractionConditions,
    pub on_success: Option<OutcomeEffects>,
    pub on_failure: Option<OutcomeEffects>,
}

// You already have this helper function from the previous lesson
fn default_base_success_rate() -> f32 {
    0.8
}

2. Processing the Outcome in Rust

With our data model updated, we can now process the outcome in Rust. After you determine if the interaction succeeded, you'll apply the corresponding effects and package the results to send back to Unity.

First, let's create a struct that represents the data we'll send to Unity. This struct will be serialized into a JSON string.

In a relevant Rust module (e.g., src/game_state.rs):

use serde::Serialize;
// ... assuming OutcomeEffects is in a module that can be imported




#[derive(Serialize, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct InteractionOutcome {
    pub success: bool,
    pub effects: OutcomeEffects,
}

Now, modify the Rust function that handles a social interaction. It should now return the serialized InteractionOutcome.

// In the function where you handle the interaction logic

// ... Assume you have determined `success: bool` using the rate from the last lesson
// ... Assume `interaction: &Interaction` is the interaction being performed

// 1. Get the appropriate effects, or default if none are defined
let effects = if success {
    interaction.on_success.clone().unwrap_or_default()
} else {
    interaction.on_failure.clone().unwrap_or_default()
};

// 2. Apply the effects to the game state
//    (This is a simplified example. You would fetch and update the actual
//    character and relationship objects from your game state.)
/*
let active_char = game_state.get_mut_character(active_char_id);
active_char.mood += effects.mood_change;

let relationship = game_state.get_mut_relationship(active_char_id, target_char_id);
relationship.friendship_score += effects.friendship_change;
relationship.romance_score += effects.romance_change;
*/

// 3. Create the outcome struct to be sent to Unity
let outcome_for_unity = InteractionOutcome {
    success,
    effects: effects.clone(), // Clone the effects to send them
};

// 4. Serialize to JSON and return it through the FFI
let json_response = serde_json::to_string(&outcome_for_unity).unwrap();

// Return json_response as a C-style string

Your Rust backend is now equipped to calculate, apply, and report the results of social interactions.


3. Creating Visual Feedback in Unity

Now for the fun part: showing the player what happened. A common and effective method is "floating text" that appears above a character's head.

Floating Combat Text Example in Unity
This image shows floating text for combat, but the principle is identical for social outcomes like relationship or mood changes.

We will create a reusable prefab in Unity for this floating text. The following video provides a comprehensive guide to this process.

Floating Text in Unity

The video 'Floating Text in Unity' by BMo is an excellent tutorial on creating dynamic, animated text elements. We'll follow its steps to build our feedback prefab.

Please watch the video to create your floating text prefab. Focus on these key steps: 00:34 - 01:27: Setting up the TextMeshPro object and its basic properties. 01:35 - 03:56: Using the Animation window to make the text float upwards and fade out. Uncheck 'Loop Time' on the generated animation clip. 03:56 - 08:11: Turning the animated text object into a prefab. Pay close attention to creating a parent object for the text to handle positional animations cleanly. 08:11 - 09:05: Creating a simple script to destroy the text object after a set time to prevent cluttering the scene. By the end, you should have a prefab that you can instantiate via code.


4. Decoupling UI from Game Logic with Event Channels

A key principle of good game architecture is loose coupling. The script that manages interactions shouldn't need to know about specific UI prefabs. A powerful pattern to achieve this is the Event Channel system, using ScriptableObjects.

The idea is simple:

  • An Event Channel is a ScriptableObject asset that acts as a broadcast station.
  • A Broadcaster (e.g., our interaction manager) sends a message (an "event") to the channel.
  • A Listener (e.g., a UI manager) subscribes to the channel and reacts when a message comes in.

This way, the interaction manager just shouts "An interaction happened!" and doesn't care who is listening or what they do about it.

Use ScriptableObjects as event channels in game code

This official Unity article explains the concept and implementation of ScriptableObject-based event channels. It's a foundational pattern for building scalable projects.

Read through these sections to understand the pattern. We will implement a version of this. 'Configuring event channels': Understand what an event channel is (a ScriptableObject with a delegate and a raising method). 'Raising events' & 'Listening for events': Grasp the core broadcaster/listener relationship. 'The function signature of a base event' & 'Creating concrete event channels': This is the most important part. It shows how to create generic event channels that can carry a data 'payload' (like our interaction outcome data).

Let's implement this.

Step 4.1: Create the Event Channel Scripts

First, we need a C# class for our InteractionOutcome data, mirroring the Rust struct.

// In a new C# script, e.g., InteractionOutcome.cs
[System.Serializable]
public class OutcomeEffects {
    public float moodChange;
    public float friendshipChange;
    public float romanceChange;
}

[System.Serializable]
public class InteractionOutcome {
    public bool success;
    public OutcomeEffects effects;
}

Now, create a generic base class for your event channels.

// BaseEventChannel.cs
using UnityEngine;
using UnityEngine.Events;

public abstract class BaseEventChannel<T> : ScriptableObject
{
    public UnityAction<T> OnEventRaised;

    public void RaiseEvent(T value)
    {
        OnEventRaised?.Invoke(value);
    }
}

And a specific channel for our InteractionOutcome.

// InteractionOutcomeEventChannel.cs
using UnityEngine;

[CreateAssetMenu(menuName = "Events/Interaction Outcome Event Channel")]
public class InteractionOutcomeEventChannel : BaseEventChannel<InteractionOutcome> { }

Step 4.2: Create the Asset

In your Unity Project window, right-click, go to Create > Events > Interaction Outcome Event Channel, and name it something like InteractionOutcomeChannel. You now have a ScriptableObject asset that can broadcast interaction results!


5. Connecting Everything

Now we wire it all up.

Step 5.1: The Broadcaster

The C# script that calls your Rust FFI function will now receive the outcome JSON. It will deserialize it and raise the event.

public class InteractionController : MonoBehaviour
{
    // Assign this in the Inspector
    public InteractionOutcomeEventChannel interactionOutcomeChannel;

    // ... other code ...

    public void OnInteractionSelected( /* ... params ... */ )
    {
        // Call your Rust FFI function which returns the outcome JSON string
        string outcomeJson = YourRustFFI.ExecuteInteraction(...);

        // Deserialize the JSON
        InteractionOutcome outcome = JsonUtility.FromJson<InteractionOutcome>(outcomeJson);
        
        // Raise the event for any listeners
        if (interactionOutcomeChannel != null)
        {
            interactionOutcomeChannel.RaiseEvent(outcome);
        }
    }
}

Step 5.2: The Listener

Create a new UIFeedbackManager script. This will listen for the event and spawn the floating text.

using UnityEngine;
using TMPro;

public class UIFeedbackManager : MonoBehaviour
{
    // Assign these in the Inspector
    public InteractionOutcomeEventChannel interactionOutcomeChannel;
    public GameObject floatingTextPrefab; // The prefab you made
    public Transform canvasTransform; // The parent canvas for UI elements

    private void OnEnable()
    {
        if (interactionOutcomeChannel != null)
        {
            interactionOutcomeChannel.OnEventRaised += HandleInteractionOutcome;
        }
    }

    private void OnDisable()
    {
        if (interactionOutcomeChannel != null)
        {
            interactionOutcomeChannel.OnEventRaised -= HandleInteractionOutcome;
        }
    }

    private void HandleInteractionOutcome(InteractionOutcome outcome)
    {
        // For now, let's assume we show feedback above the active character.
        // You would pass the character's transform as part of the event payload
        // for more complex scenarios.
        Transform characterTransform = GetActiveCharacterTransform(); // Placeholder

        // Instantiate the floating text
        GameObject textObject = Instantiate(floatingTextPrefab, canvasTransform);
        
        // Find the text component on the instantiated prefab
        TextMeshProUGUI tmp = textObject.GetComponentInChildren<TextMeshProUGUI>();

        // Format and color the text
        string feedbackText = "";
        if (outcome.effects.friendshipChange != 0)
        {
            feedbackText = $"Friendship {(outcome.effects.friendshipChange > 0 ? "+" : "")}{outcome.effects.friendshipChange}";
            tmp.color = outcome.effects.friendshipChange > 0 ? Color.green : Color.red;
        }
        // ... add more logic for mood, romance, etc.
        
        tmp.text = feedbackText;

        // Position the text above the character
        // We'll add a helper method for this
        StartCoroutine(TrackObjectPosition(textObject.GetComponent<RectTransform>(), characterTransform));
    }
    
    // Coroutine to keep the UI element attached to the world object
    private System.Collections.IEnumerator TrackObjectPosition(RectTransform uiElement, Transform worldObject)
    {
        // Run while the UI element is active
        while(uiElement.gameObject.activeInHierarchy)
        {
             // Convert world position to screen position
            Vector3 screenPosition = Camera.main.WorldToScreenPoint(worldObject.position + Vector3.up * 2.0f); // Offset a bit
            
            // Hide if behind the camera
            uiElement.gameObject.SetActive(screenPosition.z > 0);

            uiElement.position = screenPosition;

            yield return null; // Wait for the next frame
        }
    }

    private Transform GetActiveCharacterTransform()
    {
        // Placeholder: implement logic to get the current character's transform
        return GameObject.FindWithTag("Player").transform;
    }
}

The positioning logic using Camera.WorldToScreenPoint in LateUpdate or a coroutine is crucial for this effect, as discussed in the Unity Discussions resource LINK. We've implemented it here in a coroutine that runs as long as the text object is alive.



Conclusion

Congratulations! You have now closed the loop from a social action to its visible consequences. This is a massive step towards making your simulation feel interactive and understandable.

Key Takeaways:

  • Data-Driven Outcomes: You defined the effects of success and failure in your JSON files, making your game easy to balance and mod.
  • Decoupled Architecture: You implemented the powerful Event Channel pattern, which keeps your game logic clean and independent of your UI systems. This is a professional practice that will pay dividends as your project grows.
  • Dynamic UI Feedback: You created a dynamic, reusable floating text system to provide players with immediate, contextual feedback on their actions.
  • World-to-Screen Mapping: You learned how to position a 2D UI element based on a 3D object's position in the world, a fundamental technique in game UI development.

In our next and final lesson of this module, we will explore one of the most exciting features of your project: "Generate contextual dialogue text from the LLM based on a chosen conversation interaction." We'll set up a local LLM and have it generate unique dialogue, bringing your characters' personalities to life in a truly dynamic way.