npc-clients-unity/Documentation
2025-09-17 08:37:13 -04:00
..
README.md Add .gitignore files and clean up tracked files 2025-09-17 08:37:13 -04:00
README.md.meta Initial commit of Unity SDK for GumYum NPC client 2025-09-15 09:59:21 -04:00

GumYum NPC SDK for Unity

Welcome to the GumYum NPC SDK for Unity! This SDK allows you to integrate AI-powered NPCs with deterministic spawning and dynamic dialogue into your Unity games.

Features

  • API Key Authentication: Simple setup with API keys from the GumYum dashboard
  • Deterministic NPC Spawning: Spawn the same NPCs with the same seed
  • Filtered Spawning: Spawn NPCs based on profession, location, personality type, and more
  • OpenAI-Compatible Chat: Use familiar chat completion APIs
  • Mood System: NPCs have dynamic moods that change based on conversations
  • Streaming Support: Real-time streaming of NPC responses
  • Unity Coroutine Support: Seamless integration with Unity's async patterns
  • Conversation History: Maintain context across multiple interactions

Quick Start

1. Installation

  1. Import the GumYum NPC package from the Unity Asset Store
  2. The package will be installed in Assets/GumYumNPC/

2. Get API Credentials

  1. Sign up at https://npc.gumyum.com
  2. Navigate to your dashboard
  3. Create a new API key pair
  4. Copy your public and secret keys

3. Basic Setup

Add the GumYumUnityClient to your scene:

using GumYum.NPC.Unity;

public class GameManager : MonoBehaviour
{
    private GumYumUnityClient client;
    
    void Start()
    {
        // Get the singleton instance
        client = GumYumUnityClient.Instance;
        
        // Set your API credentials
        client.SetCredentials("your-public-key", "your-secret-key");
        
        // Start spawning NPCs!
        StartCoroutine(SpawnMyFirstNPC());
    }
    
    IEnumerator SpawnMyFirstNPC()
    {
        // Spawn a random NPC from the "blade-runner" universe
        yield return client.SpawnNPCCoroutine("blade-runner", 42, null, npc =>
        {
            if (npc != null)
            {
                Debug.Log($"Spawned {npc.Name} the {npc.Profession}!");
            }
        });
    }
}

4. Chat with NPCs

IEnumerator ChatWithNPC(NPC npc, string message)
{
    yield return client.SimpleChatCoroutine(npc, message, response =>
    {
        if (response != null)
        {
            Debug.Log($"{npc.Name}: {response}");
        }
    });
}

Examples

The SDK includes several example scenes in the Samples~ folder:

  1. Basic Chat: Simple NPC spawning and chat interface
  2. Advanced Features: Filtered spawning, mood transitions, and streaming

To import samples:

  1. Open Package Manager (Window > Package Manager)
  2. Find GumYum NPC SDK
  3. Click on "Samples" and import the examples you want

Advanced Usage

Filtered NPC Spawning

// Spawn a female warrior or knight from the barracks
var filters = new Dictionary<string, object>
{
    ["profession"] = new[] { "warrior", "knight" },
    ["location"] = new[] { "barracks" },
    ["sex"] = new[] { "female" }
};

yield return client.SpawnFilteredNPCCoroutine("kingdom", 12345, filters, npc =>
{
    if (npc != null)
    {
        Debug.Log($"Spawned filtered NPC: {npc.GetInfo()}");
    }
});

Mood Transitions

NPCs have dynamic moods that change based on conversations:

// Check mood before conversation
string previousMood = npc.GetMood();

// Have a conversation...
yield return ChatWithNPC(npc, "I have terrible news!");

// Check if mood changed
if (npc.HasMoodChanged())
{
    Debug.Log($"Mood changed from {npc.GetPreviousMood()} to {npc.GetMood()}");
    Debug.Log($"Reason: {npc.GetLastMoodChangeReason()}");
}

Streaming Responses

For real-time chat experiences:

var messages = new List<ChatMessage>
{
    new ChatMessage { Role = "user", Content = "Tell me a story" }
};

// Enable streaming in the chat call
yield return client.ChatCoroutine(npc, messages, response =>
{
    // Final response received
}, temperature: 0.8, maxTokens: 2000);

// Listen to streaming events
npc.DialogueChunkReceived += (sender, chunk) =>
{
    // Update UI with each chunk as it arrives
    chatText.text += chunk;
};

Async/Await Support

The SDK also supports modern C# async patterns:

async Task SpawnAndChatAsync()
{
    var npc = await client.NPCs.SpawnAsync("blade-runner", 42);
    
    var messages = new List<ChatMessage>
    {
        new ChatMessage { Role = "user", Content = "Hello!" }
    };
    
    var response = await npc.Chat.CompletionsAsync(messages);
    Debug.Log(response.Choices[0].Message.Content);
}

API Reference

GumYumUnityClient

  • SetCredentials(apiKey, apiSecret): Set API credentials
  • SpawnNPCCoroutine(universeId, seed, npcId, callback): Spawn an NPC
  • SpawnFilteredNPCCoroutine(universeId, seed, filters, callback): Spawn filtered NPC
  • ChatCoroutine(npc, messages, callback, temperature, maxTokens): Chat with NPC
  • SimpleChatCoroutine(npc, message, callback, temperature, maxTokens): Simple chat helper

NPC Class

  • Name, Profession, PersonalityType: NPC properties
  • GetMood(), GetStressLevel(): Current state
  • Chat.CompletionsAsync(messages): Chat with the NPC
  • ChatWithHistoryAsync(message): Chat with conversation history
  • SaveToServerAsync(customName): Save NPC for later retrieval

Best Practices

  1. Singleton Usage: Use GumYumUnityClient.Instance for a single client across your game
  2. Error Handling: Always check if NPCs are null after spawning
  3. API Keys: Never commit API keys to source control. Use environment variables or Unity's secret management
  4. Coroutines: Use coroutines for better Unity integration, especially for UI updates
  5. Mood Awareness: Design conversations considering NPC mood states

Troubleshooting

NPCs not spawning

  • Check your API credentials are correct
  • Ensure you have an active internet connection
  • Check the Unity console for error messages

Mood not changing

  • Not all messages trigger mood changes
  • Mood transitions depend on NPC personality and context
  • Check HasMoodChanged() after conversations

Performance issues

  • Use appropriate max_tokens limits
  • Consider implementing response caching
  • Use streaming for long responses

Support

License

This SDK is licensed under the MIT License. See LICENSE file for details.


Enterprise Documentation

The following sections provide in-depth coverage for production game development.

Persistence & Save Systems

NPCs automatically track conversation history. Save and load NPCs with their complete state:

using UnityEngine;
using System.Collections.Generic;
using System.IO;
using GumYum.NPC;

public class NPCPersistenceManager : MonoBehaviour
{
    private Dictionary<int, NPC> activeNPCs = new Dictionary<int, NPC>();
    private Client client;
    
    void Start()
    {
        // Initialize client
        client = new Client()
        {
            ApiKey = "your-key",
            ApiSecret = "your-secret"
        };
    }
    
    // AUTOMATIC HISTORY TRACKING
    async void DemonstrateHistoryTracking()
    {
        // Spawn an NPC
        var npc = await client.NPCs.SpawnAsync("kingdom", 42, 123456789L);
        
        // Every chat interaction is automatically saved to npc.ChatHistory
        
        // Method 1: Manual message list with automatic tracking
        var response1 = await npc.Chat.CompletionsAsync(new List<ChatMessage>
        {
            new ChatMessage { Role = "user", Content = "Hello there!" }
        });
        // npc.ChatHistory now contains both user message and NPC response
        
        // Method 2: Use ChatWithHistoryAsync() for convenience
        // This automatically includes all previous messages
        var response2 = await npc.ChatWithHistoryAsync("What goods do you sell?");
        var response3 = await npc.ChatWithHistoryAsync("How much for a healing potion?");
        
        // Check conversation history
        Debug.Log($"Total messages: {npc.ChatHistory.Count}");
        foreach (var msg in npc.ChatHistory)
        {
            string role = msg.Role == "user" ? "Player" : npc.Name;
            Debug.Log($"{role}: {msg.Content}");
        }
        
        // SAVE NPC WITH FULL CONVERSATION
        SaveNPC(npc);
    }
    
    // SAVE SYSTEM INTEGRATION
    void SaveNPC(NPC npc)
    {
        // Convert to dictionary (useful for custom storage)
        var npcData = npc.ToDict();
        // Contains: npc_id, name, profession, personality_type, universe_id,
        // seed, spawned data, cached status, and full chat_history
        
        // Convert to JSON string
        string jsonStr = npc.ToJson();
        
        // Save to PlayerPrefs (simple approach)
        PlayerPrefs.SetString($"NPC_{npc.NpcId}", jsonStr);
        PlayerPrefs.Save();
        
        // Or save to file (persistent data path)
        string filePath = Path.Combine(Application.persistentDataPath, $"npc_{npc.NpcId}.json");
        File.WriteAllText(filePath, jsonStr);
        
        Debug.Log($"Saved {npc.Name} with {npc.ChatHistory.Count} messages");
    }
    
    // LOAD AND CONTINUE CONVERSATIONS
    NPC LoadNPC(int npcId)
    {
        // Load from PlayerPrefs
        string jsonStr = PlayerPrefs.GetString($"NPC_{npcId}", "");
        if (string.IsNullOrEmpty(jsonStr))
        {
            // Try loading from file
            string filePath = Path.Combine(Application.persistentDataPath, $"npc_{npcId}.json");
            if (File.Exists(filePath))
            {
                jsonStr = File.ReadAllText(filePath);
            }
        }
        
        if (!string.IsNullOrEmpty(jsonStr))
        {
            // Restore NPC with full conversation history
            var loadedNPC = NPC.FromJson(jsonStr, client);
            activeNPCs[npcId] = loadedNPC;
            
            Debug.Log($"Loaded {loadedNPC.Name} with {loadedNPC.ChatHistory.Count} messages");
            return loadedNPC;
        }
        
        return null;
    }
    
    // CONTINUE CONVERSATION AFTER LOADING
    async void ContinueConversation(int npcId)
    {
        var npc = LoadNPC(npcId);
        if (npc != null)
        {
            // Continue from where we left off
            var response = await npc.ChatWithHistoryAsync("Do you remember our last conversation?");
            Debug.Log($"{npc.Name}: {response.Choices[0].Message.Content}");
            
            // NPC will reference previous conversation naturally
        }
    }
    
    // UNITY SAVE GAME EXAMPLE
    [System.Serializable]
    public class GameSaveData
    {
        public string saveSlot;
        public float timestamp;
        public PlayerData player;
        public Dictionary<string, string> npcs = new Dictionary<string, string>();
    }
    
    [System.Serializable]
    public class PlayerData
    {
        public int level;
        public Vector3 position;
        public int health;
    }
    
    public void SaveGame(string slotName)
    {
        var saveData = new GameSaveData
        {
            saveSlot = slotName,
            timestamp = Time.time,
            player = new PlayerData
            {
                level = 10,
                position = transform.position,
                health = 100
            }
        };
        
        // Save each active NPC with their conversation
        foreach (var kvp in activeNPCs)
        {
            saveData.npcs[kvp.Key.ToString()] = kvp.Value.ToJson();
        }
        
        // Serialize game save
        string saveJson = JsonUtility.ToJson(saveData, true);
        string savePath = Path.Combine(Application.persistentDataPath, $"save_{slotName}.json");
        File.WriteAllText(savePath, saveJson);
        
        Debug.Log($"Saved game to slot: {slotName}");
        Debug.Log($"- {activeNPCs.Count} NPCs with conversations");
    }
    
    public void LoadGame(string slotName)
    {
        string savePath = Path.Combine(Application.persistentDataPath, $"save_{slotName}.json");
        
        if (!File.Exists(savePath))
        {
            Debug.LogError($"Save file not found: {slotName}");
            return;
        }
        
        string saveJson = File.ReadAllText(savePath);
        var saveData = JsonUtility.FromJson<GameSaveData>(saveJson);
        
        // Restore player state
        transform.position = saveData.player.position;
        // ... restore other player data
        
        // Restore NPCs with their conversations
        activeNPCs.Clear();
        foreach (var kvp in saveData.npcs)
        {
            int npcId = int.Parse(kvp.Key);
            var npc = NPC.FromJson(kvp.Value, client);
            activeNPCs[npcId] = npc;
            Debug.Log($"Loaded {npc.Name} with {npc.ChatHistory.Count} messages");
        }
        
        Debug.Log($"Loaded game from slot: {slotName}");
    }
    
    // SCRIPTABLEOBJECT APPROACH (Unity-specific)
    [CreateAssetMenu(fileName = "NPCData", menuName = "GumYum/NPC Data")]
    public class NPCDataAsset : ScriptableObject
    {
        public string npcJson;
        public int messageCount;
        public string lastUpdated;
        
        public void SaveNPC(NPC npc)
        {
            npcJson = npc.ToJson();
            messageCount = npc.ChatHistory.Count;
            lastUpdated = System.DateTime.Now.ToString();
            
            #if UNITY_EDITOR
            UnityEditor.EditorUtility.SetDirty(this);
            #endif
        }
        
        public NPC LoadNPC(Client client)
        {
            if (!string.IsNullOrEmpty(npcJson))
            {
                return NPC.FromJson(npcJson, client);
            }
            return null;
        }
    }
}

Modern Async/Await Patterns

Use C# async/await for cleaner code and better performance:

using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using UnityEngine;
using GumYum.NPC;

// Modern C# async/await patterns for Unity 2021.3+
public class AsyncNPCManager : MonoBehaviour
{
    private Client client;
    private Dictionary<int, NPC> activeNPCs = new Dictionary<int, NPC>();
    
    async void Start()
    {
        // Initialize client
        client = new Client()
        {
            ApiKey = "your-key",
            ApiSecret = "your-secret",
            DebugMode = true
        };
        
        try
        {
            // Spawn NPCs concurrently
            await SpawnMultipleNPCsAsync();
            
            // Chat with all NPCs
            await ChatWithAllNPCsAsync("Hello everyone!");
        }
        catch (Exception e)
        {
            Debug.LogError($"Failed to initialize NPCs: {e.Message}");
        }
    }
    
    // CONCURRENT NPC SPAWNING
    async Task SpawnMultipleNPCsAsync()
    {
        var npcIds = new[] { 1001, 1002, 1003, 2001, 2002 };
        var spawnTasks = new List<Task<NPC>>();
        
        // Start all spawn tasks
        foreach (long id in npcIds)
        {
            var task = client.NPCs.SpawnAsync("kingdom", 42, id);
            spawnTasks.Add(task);
        }
        
        // Wait for all to complete
        var npcs = await Task.WhenAll(spawnTasks);
        
        // Store spawned NPCs
        foreach (var npc in npcs)
        {
            activeNPCs[npc.NpcId] = npc;
            Debug.Log($"Spawned: {npc.Name} - {npc.Profession}");
        }
    }
    
    // CHAT WITH MULTIPLE NPCS
    async Task ChatWithAllNPCsAsync(string message)
    {
        var chatTasks = new List<Task<ChatCompletionResponse>>();
        
        // Start chat tasks for all NPCs
        foreach (var npc in activeNPCs.Values)
        {
            var task = npc.ChatWithHistoryAsync(message);
            chatTasks.Add(task);
        }
        
        // Wait for all responses
        var responses = await Task.WhenAll(chatTasks);
        
        // Process responses
        int i = 0;
        foreach (var npc in activeNPCs.Values)
        {
            var response = responses[i++];
            Debug.Log($"{npc.Name}: {response.Choices[0].Message.Content}");
            
            if (npc.HasMoodChanged())
            {
                Debug.Log($"  Mood: {npc.GetPreviousMood()} → {npc.GetMood()}");
            }
        }
    }
    
    // FILTERED SPAWNING WITH RETRY
    async Task<NPC> SpawnFilteredWithRetryAsync(
        string universeId, 
        int seed, 
        object filters, 
        int maxRetries = 3)
    {
        for (int attempt = 0; attempt < maxRetries; attempt++)
        {
            try
            {
                var npc = await client.NPCs.SpawnFilteredAsync(universeId, seed, filters);
                return npc;
            }
            catch (Exception e)
            {
                Debug.LogWarning($"Spawn attempt {attempt + 1} failed: {e.Message}");
                
                if (attempt == maxRetries - 1)
                    throw;
                    
                // Wait before retry with exponential backoff
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
            }
        }
        
        return null;
    }
    
    // ERROR HANDLING PATTERNS
    async Task SafeNPCInteractionAsync()
    {
        try
        {
            // Try to spawn in non-existent universe
            var npc = await client.NPCs.SpawnAsync("unknown-universe", 12345, 1L);
        }
        catch (System.Net.Http.HttpRequestException httpEx)
        {
            // Handle API errors
            Debug.LogError($"API Error: {httpEx.Message}");
            
            // Show user-friendly message
            ShowErrorUI("Failed to connect to NPC service. Please check your connection.");
        }
        catch (TaskCanceledException)
        {
            // Handle timeout
            Debug.LogError("Request timed out");
            ShowErrorUI("The request took too long. Please try again.");
        }
        catch (Exception ex)
        {
            // Handle unexpected errors
            Debug.LogError($"Unexpected error: {ex.Message}");
            ShowErrorUI("Something went wrong. Please try again later.");
        }
    }
    
    // CANCELLATION SUPPORT
    private System.Threading.CancellationTokenSource cts;
    
    async Task CancellableOperationAsync()
    {
        cts = new System.Threading.CancellationTokenSource();
        
        try
        {
            // Set a timeout
            cts.CancelAfter(TimeSpan.FromSeconds(30));
            
            // Pass cancellation token to async operations
            var npc = await client.NPCs.SpawnAsync(
                "kingdom", 42, null,
                cancellationToken: cts.Token
            );
            
            var response = await npc.ChatWithHistoryAsync(
                "Tell me a long story",
                cancellationToken: cts.Token
            );
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Operation was cancelled");
        }
    }
    
    void OnDestroy()
    {
        // Cancel any pending operations
        cts?.Cancel();
        cts?.Dispose();
        
        // Dispose of the client
        client?.Dispose();
    }
    
    void ShowErrorUI(string message)
    {
        // Your UI error display logic
        Debug.LogError($"UI Error: {message}");
    }
}

// UNITY-SPECIFIC ASYNC HELPERS
public static class UnityAsyncHelpers
{
    // Convert async operation to Unity Coroutine
    public static IEnumerator AsCoroutine(this Task task)
    {
        while (!task.IsCompleted)
        {
            yield return null;
        }
        
        if (task.IsFaulted)
        {
            throw task.Exception.GetBaseException();
        }
    }
    
    // Await with timeout
    public static async Task<T> WithTimeout<T>(this Task<T> task, TimeSpan timeout)
    {
        using (var cts = new System.Threading.CancellationTokenSource())
        {
            var delayTask = Task.Delay(timeout, cts.Token);
            var completedTask = await Task.WhenAny(task, delayTask);
            
            if (completedTask == delayTask)
            {
                throw new TimeoutException("Operation timed out");
            }
            
            cts.Cancel(); // Cancel the delay task
            return await task;
        }
    }
}

Unity Editor Integration

Custom inspectors and editor windows for easier development:

// Custom Unity Editor tools for GumYum NPCs
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using GumYum.NPC;
using GumYum.NPC.Unity;

// CUSTOM INSPECTOR FOR NPCS
[CustomEditor(typeof(NPCBehaviour))]
public class NPCInspector : Editor
{
    private NPCBehaviour npcBehaviour;
    
    void OnEnable()
    {
        npcBehaviour = (NPCBehaviour)target;
    }
    
    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();
        
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("NPC Information", EditorStyles.boldLabel);
        
        if (Application.isPlaying && npcBehaviour.CurrentNPC != null)
        {
            var npc = npcBehaviour.CurrentNPC;
            
            EditorGUILayout.LabelField("Name:", npc.Name);
            EditorGUILayout.LabelField("Profession:", npc.Profession);
            EditorGUILayout.LabelField("Personality Type:", npc.PersonalityType.ToString());
            
            EditorGUILayout.Space();
            EditorGUILayout.LabelField("Current State", EditorStyles.boldLabel);
            EditorGUILayout.LabelField("Mood:", npc.GetMood());
            EditorGUILayout.LabelField("Stress Level:", npc.GetStressLevel().ToString());
            EditorGUILayout.LabelField("Location:", npc.GetLocation());
            
            if (npc.HasMoodChanged())
            {
                EditorGUILayout.HelpBox(
                    $"Mood changed: {npc.GetPreviousMood()} → {npc.GetMood()}\n" +
                    $"Reason: {npc.GetLastMoodChangeReason()}",
                    MessageType.Info
                );
            }
            
            EditorGUILayout.Space();
            EditorGUILayout.LabelField("Chat History", EditorStyles.boldLabel);
            EditorGUILayout.LabelField($"Messages: {npc.ChatHistory.Count}");
            
            if (GUILayout.Button("Save NPC to Asset"))
            {
                SaveNPCAsset(npc);
            }
            
            if (GUILayout.Button("Export Chat History"))
            {
                ExportChatHistory(npc);
            }
        }
        else if (Application.isPlaying)
        {
            EditorGUILayout.HelpBox("No NPC spawned yet", MessageType.Info);
        }
        
        if (!Application.isPlaying)
        {
            EditorGUILayout.HelpBox("Enter Play Mode to spawn NPCs", MessageType.Info);
        }
    }
    
    void SaveNPCAsset(NPC npc)
    {
        var asset = CreateInstance<NPCDataAsset>();
        asset.SaveNPC(npc);
        
        string path = EditorUtility.SaveFilePanelInProject(
            "Save NPC Data",
            $"NPC_{npc.Name}_{npc.NpcId}",
            "asset",
            "Save NPC data as ScriptableObject asset"
        );
        
        if (!string.IsNullOrEmpty(path))
        {
            AssetDatabase.CreateAsset(asset, path);
            AssetDatabase.SaveAssets();
            EditorUtility.DisplayDialog("Success", "NPC data saved!", "OK");
        }
    }
    
    void ExportChatHistory(NPC npc)
    {
        string path = EditorUtility.SaveFilePanel(
            "Export Chat History",
            "",
            $"{npc.Name}_chat_history",
            "txt"
        );
        
        if (!string.IsNullOrEmpty(path))
        {
            var content = new System.Text.StringBuilder();
            content.AppendLine($"Chat History with {npc.Name}");
            content.AppendLine($"Universe: {npc.UniverseId}");
            content.AppendLine($"NPC ID: {npc.NpcId}");
            content.AppendLine("=" + new string('=', 50));
            content.AppendLine();
            
            foreach (var msg in npc.ChatHistory)
            {
                string speaker = msg.Role == "user" ? "Player" : npc.Name;
                content.AppendLine($"{speaker}: {msg.Content}");
                content.AppendLine();
            }
            
            System.IO.File.WriteAllText(path, content.ToString());
            EditorUtility.DisplayDialog("Success", "Chat history exported!", "OK");
        }
    }
}

// GUMYUM SETTINGS WINDOW
public class GumYumSettingsWindow : EditorWindow
{
    private string apiKey = "";
    private string apiSecret = "";
    private bool showSecret = false;
    private Vector2 scrollPos;
    
    [MenuItem("Window/GumYum NPC/Settings")]
    public static void ShowWindow()
    {
        var window = GetWindow<GumYumSettingsWindow>("GumYum NPC Settings");
        window.minSize = new Vector2(400, 300);
    }
    
    void OnEnable()
    {
        // Load saved credentials
        apiKey = EditorPrefs.GetString("GumYum_APIKey", "");
        apiSecret = EditorPrefs.GetString("GumYum_APISecret", "");
    }
    
    void OnGUI()
    {
        scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
        
        GUILayout.Label("GumYum NPC SDK Settings", EditorStyles.boldLabel);
        GUILayout.Space(10);
        
        // API Credentials Section
        EditorGUILayout.BeginVertical(EditorStyles.helpBox);
        GUILayout.Label("API Credentials", EditorStyles.boldLabel);
        GUILayout.Label("Get your API keys from https://npc.gumyum.com", EditorStyles.miniLabel);
        
        EditorGUILayout.BeginHorizontal();
        GUILayout.Label("API Key:", GUILayout.Width(80));
        apiKey = EditorGUILayout.TextField(apiKey);
        EditorGUILayout.EndHorizontal();
        
        EditorGUILayout.BeginHorizontal();
        GUILayout.Label("API Secret:", GUILayout.Width(80));
        if (showSecret)
        {
            apiSecret = EditorGUILayout.TextField(apiSecret);
        }
        else
        {
            apiSecret = EditorGUILayout.PasswordField(apiSecret);
        }
        showSecret = GUILayout.Toggle(showSecret, "Show", GUILayout.Width(50));
        EditorGUILayout.EndHorizontal();
        
        GUILayout.Space(5);
        
        if (GUILayout.Button("Save Settings"))
        {
            SaveSettings();
        }
        
        EditorGUILayout.EndVertical();
        
        GUILayout.Space(10);
        
        // Test Connection
        EditorGUILayout.BeginVertical(EditorStyles.helpBox);
        GUILayout.Label("Test Connection", EditorStyles.boldLabel);
        
        if (GUILayout.Button("Test API Connection"))
        {
            TestConnection();
        }
        
        EditorGUILayout.EndVertical();
        
        GUILayout.Space(10);
        
        // Quick Actions
        EditorGUILayout.BeginVertical(EditorStyles.helpBox);
        GUILayout.Label("Quick Actions", EditorStyles.boldLabel);
        
        if (GUILayout.Button("Create Chat Demo Scene"))
        {
            CreateChatDemoScene();
        }
        
        if (GUILayout.Button("Open Documentation"))
        {
            Application.OpenURL("https://docs.gumyum.com/sdk/unity");
        }
        
        if (GUILayout.Button("Get API Keys"))
        {
            Application.OpenURL("https://npc.gumyum.com");
        }
        
        EditorGUILayout.EndVertical();
        
        GUILayout.Space(10);
        
        // Info box
        EditorGUILayout.HelpBox(
            "These credentials are saved in Editor preferences and are not included in builds. " +
            "For production, set credentials at runtime or use environment variables.",
            MessageType.Info
        );
        
        EditorGUILayout.EndScrollView();
    }
    
    void SaveSettings()
    {
        EditorPrefs.SetString("GumYum_APIKey", apiKey);
        EditorPrefs.SetString("GumYum_APISecret", apiSecret);
        
        EditorUtility.DisplayDialog("Success", "Settings saved!", "OK");
        
        // If in play mode, update the singleton
        if (Application.isPlaying && GumYumUnityClient.Instance != null)
        {
            GumYumUnityClient.Instance.SetCredentials(apiKey, apiSecret);
        }
    }
    
    async void TestConnection()
    {
        if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(apiSecret))
        {
            EditorUtility.DisplayDialog("Error", "Please enter API credentials first", "OK");
            return;
        }
        
        EditorUtility.DisplayProgressBar("Testing Connection", "Connecting to GumYum API...", 0.5f);
        
        try
        {
            var client = new Client()
            {
                ApiKey = apiKey,
                ApiSecret = apiSecret
            };
            
            // Try to list public universes
            var universes = await client.Universes.ListPublicAsync();
            
            EditorUtility.ClearProgressBar();
            EditorUtility.DisplayDialog(
                "Success", 
                $"Connection successful!\nFound {universes.Count} public universes.", 
                "OK"
            );
        }
        catch (Exception e)
        {
            EditorUtility.ClearProgressBar();
            EditorUtility.DisplayDialog(
                "Connection Failed",
                $"Failed to connect: {e.Message}",
                "OK"
            );
        }
    }
    
    void CreateChatDemoScene()
    {
        // Implementation to create a demo scene with UI setup
        EditorUtility.DisplayDialog(
            "Demo Scene",
            "Check the Samples folder for example scenes!",
            "OK"
        );
    }
}

// Example MonoBehaviour for NPCs
public class NPCBehaviour : MonoBehaviour
{
    [HideInInspector]
    public NPC CurrentNPC { get; private set; }
    
    public async void SpawnNPC(string universeId, int seed, long? npcId = null)
    {
        var client = GumYumUnityClient.Instance.Client;
        CurrentNPC = await client.NPCs.SpawnAsync(universeId, seed, npcId);
    }
}

// ScriptableObject for NPC data persistence
[CreateAssetMenu(fileName = "NPCData", menuName = "GumYum/NPC Data")]
public class NPCDataAsset : ScriptableObject
{
    [TextArea(10, 20)]
    public string npcJson;
    public int messageCount;
    public string lastUpdated;
    
    public void SaveNPC(NPC npc)
    {
        npcJson = npc.ToJson();
        messageCount = npc.ChatHistory.Count;
        lastUpdated = System.DateTime.Now.ToString();
        
        #if UNITY_EDITOR
        EditorUtility.SetDirty(this);
        #endif
    }
    
    public NPC LoadNPC(Client client)
    {
        return !string.IsNullOrEmpty(npcJson) ? NPC.FromJson(npcJson, client) : null;
    }
}

Advanced Streaming Implementation

Real-time streaming with full event handling:

using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
using GumYum.NPC;
using GumYum.NPC.Unity;

public class StreamingChatExample : MonoBehaviour
{
    [Header("UI")]
    public Text dialogueText;
    public float typewriterSpeed = 0.02f;
    
    private GumYumUnityClient client;
    private NPC currentNPC;
    private Coroutine typewriterCoroutine;
    
    void Start()
    {
        client = GumYumUnityClient.Instance;
        
        // Subscribe to streaming events
        client.OnDialogueChunkReceived += OnChunkReceived;
        client.OnDialogueStreamStarted += OnStreamStarted;
        client.OnDialogueStreamEnded += OnStreamEnded;
    }
    
    // STREAMING WITH COROUTINES
    IEnumerator StreamingChatCoroutine(NPC npc, string userMessage)
    {
        var messages = new List<ChatMessage>
        {
            new ChatMessage { Role = "user", Content = userMessage }
        };
        
        // Track streaming state
        bool isStreaming = true;
        string fullResponse = "";
        
        // Set up event handlers
        void OnChunk(object sender, string chunk)
        {
            fullResponse += chunk;
            AppendToDialogue(chunk);
        }
        
        void OnStreamEnd(object sender, DialogueStreamEndedEventArgs e)
        {
            isStreaming = false;
            
            // Check mood changes
            if (npc.HasMoodChanged())
            {
                Debug.Log($"Mood changed: {npc.GetPreviousMood()} → {npc.GetMood()}");
                Debug.Log($"Reason: {npc.GetLastMoodChangeReason()}");
            }
        }
        
        // Subscribe to NPC's streaming events
        npc.DialogueChunkReceived += OnChunk;
        npc.DialogueStreamEnded += OnStreamEnd;
        
        // Start streaming request
        var streamTask = npc.Chat.CompletionsAsync(
            messages,
            temperature: 0.8,
            maxTokens: 2000,
            stream: true  // Enable streaming
        );
        
        // Show typing indicator
        ShowTypingIndicator(npc.Name);
        
        // Wait for stream to complete
        yield return new WaitUntil(() => !isStreaming || streamTask.IsCompleted);
        
        // Clean up
        npc.DialogueChunkReceived -= OnChunk;
        npc.DialogueStreamEnded -= OnStreamEnd;
        HideTypingIndicator();
        
        // Continue conversation with history
        yield return new WaitForSeconds(1f);
        
        // Use chat_with_history for follow-up
        var followUp = npc.ChatWithHistoryAsync(
            "Can you elaborate on that?",
            stream: true
        );
        
        // Handle the follow-up stream...
    }
    
    // STREAMING WITH ASYNC/AWAIT
    async void StreamingChatAsync(NPC npc, string userMessage)
    {
        try
        {
            // Build on conversation history
            var response = await npc.ChatWithHistoryAsync(
                userMessage,
                temperature: 0.8,
                maxTokens: 2000,
                stream: true
            );
            
            // Response contains the full text after streaming
            Debug.Log($"Full response: {response.Choices[0].Message.Content}");
            
            // Check for mood transitions
            if (response.MoodTransition != null && response.MoodTransition.Count > 0)
            {
                var mood = response.MoodTransition;
                Debug.Log($"Mood analysis: {mood}");
            }
        }
        catch (System.Exception e)
        {
            Debug.LogError($"Streaming failed: {e.Message}");
        }
    }
    
    // UI HELPERS
    void AppendToDialogue(string chunk)
    {
        if (typewriterCoroutine != null)
            StopCoroutine(typewriterCoroutine);
            
        typewriterCoroutine = StartCoroutine(TypewriterEffect(chunk));
    }
    
    IEnumerator TypewriterEffect(string text)
    {
        foreach (char c in text)
        {
            dialogueText.text += c;
            yield return new WaitForSeconds(typewriterSpeed);
        }
    }
    
    void ShowTypingIndicator(string npcName)
    {
        dialogueText.text += $"\n<i>{npcName} is typing...</i>";
    }
    
    void HideTypingIndicator()
    {
        // Remove typing indicator
        string text = dialogueText.text;
        int lastNewline = text.LastIndexOf('\n');
        if (lastNewline > 0)
        {
            dialogueText.text = text.Substring(0, lastNewline);
        }
    }
    
    // EVENT HANDLERS
    void OnChunkReceived(string chunk)
    {
        // Global handler for any streaming chunk
        // Useful for sound effects or animations
    }
    
    void OnStreamStarted()
    {
        // Play typing sound
        // Show chat bubble animation
    }
    
    void OnStreamEnded(
        string fullResponse, 
        Dictionary<string, object> context, 
        Dictionary<string, object> moodTransition)
    {
        // Stream complete
        // Update UI state
        // Process mood changes
    }
    
    void OnDestroy()
    {
        // Unsubscribe from events
        if (client != null)
        {
            client.OnDialogueChunkReceived -= OnChunkReceived;
            client.OnDialogueStreamStarted -= OnStreamStarted;
            client.OnDialogueStreamEnded -= OnStreamEnded;
        }
    }
}