# In-Game Logging System Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build `com.jovian.ingame-logging`, a low-allocation in-game logging package with pooled UI, channel-based filtering, and save system integration. **Architecture:** Injectable `IGameLogStore` service with ring buffer storage. `InGameLogger` readonly struct facades scoped to a `LogChannel`. Pooled `GameLogView` MonoBehaviour for ScrollRect UI. Save data as `GameLogSaveData` serializable type, persisted as a field in the game's `NoxSavedDataSet`. **Tech Stack:** Unity 6 / C# 9, TextMeshPro, Newtonsoft.Json, Jovian SaveSystem --- ### Task 1: Package scaffold **Files:** - Create: `Packages/com.jovian.ingame-logging/package.json` - Create: `Packages/com.jovian.ingame-logging/Runtime/Jovian.InGameLogging.asmdef` - Create: `Packages/com.jovian.ingame-logging/Editor/Jovian.InGameLogging.Editor.asmdef` **Step 1: Create package.json** ```json { "name": "com.jovian.ingame-logging", "version": "0.1.0", "displayName": "Jovian In-Game Logging", "description": "An optimized, low-allocation in-game logging system with pooled UI, channel-based filtering, and save system integration.", "unity": "2022.3", "dependencies": { "com.unity.textmeshpro": "3.0.6", "com.unity.nuget.newtonsoft-json": "3.2.1", "com.jovian.savesystem": "0.1.0" }, "keywords": [ "logging", "ui", "ingame" ], "author": { "name": "Jovian" } } ``` **Step 2: Create Runtime asmdef** ```json { "name": "Jovian.InGameLogging", "rootNamespace": "Jovian.InGameLogging", "references": [ "Unity.TextMeshPro", "Jovian.SaveSystem" ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, "precompiledReferences": [ "Newtonsoft.Json.dll" ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false } ``` **Step 3: Create Editor asmdef** ```json { "name": "Jovian.InGameLogging.Editor", "rootNamespace": "Jovian.InGameLogging.Editor", "references": [ "Jovian.InGameLogging" ], "includePlatforms": ["Editor"], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false } ``` **Step 4: Commit** ```bash git add Packages/com.jovian.ingame-logging/ git commit -m "feat: scaffold com.jovian.ingame-logging package" ``` --- ### Task 2: LogChannel readonly struct **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/LogChannel.cs` **Step 1: Implement LogChannel** ```csharp using System; using UnityEngine; namespace Jovian.InGameLogging { [Serializable] public readonly struct LogChannel : IEquatable { [SerializeField] readonly string id; public string Id => id; public LogChannel(string id) { this.id = id; } public static readonly LogChannel Combat = new("Combat"); public static readonly LogChannel CharacterCreation = new("CharacterCreation"); public static readonly LogChannel World = new("World"); public static readonly LogChannel General = new("General"); public bool Equals(LogChannel other) => string.Equals(id, other.id, StringComparison.Ordinal); public override bool Equals(object obj) => obj is LogChannel other && Equals(other); public override int GetHashCode() => id != null ? id.GetHashCode() : 0; public override string ToString() => id ?? string.Empty; public static bool operator ==(LogChannel left, LogChannel right) => left.Equals(right); public static bool operator !=(LogChannel left, LogChannel right) => !left.Equals(right); } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/LogChannel.cs git commit -m "feat: add LogChannel readonly struct with built-in channels" ``` --- ### Task 3: LogChannelJsonConverter **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/LogChannelJsonConverter.cs` **Step 1: Implement Newtonsoft converter** ```csharp using System; using Newtonsoft.Json; namespace Jovian.InGameLogging { public sealed class LogChannelJsonConverter : JsonConverter { public override void WriteJson(JsonWriter writer, LogChannel value, JsonSerializer serializer) { writer.WriteValue(value.Id); } public override LogChannel ReadJson(JsonReader reader, Type objectType, LogChannel existingValue, bool hasExistingValue, JsonSerializer serializer) { var id = reader.Value as string; return new LogChannel(id); } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/LogChannelJsonConverter.cs git commit -m "feat: add Newtonsoft JSON converter for LogChannel" ``` --- ### Task 4: LogEntry readonly struct **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/LogEntry.cs` **Step 1: Implement LogEntry** ```csharp using System; using Newtonsoft.Json; namespace Jovian.InGameLogging { [Serializable] public readonly struct LogEntry { public readonly string message; [JsonConverter(typeof(LogChannelJsonConverter))] public readonly LogChannel channel; public readonly float gameTime; public LogEntry(string message, LogChannel channel, float gameTime) { this.message = message; this.channel = channel; this.gameTime = gameTime; } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/LogEntry.cs git commit -m "feat: add LogEntry readonly struct" ``` --- ### Task 5: GameLogSaveData **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/GameLogSaveData.cs` **Step 1: Implement serializable save container** ```csharp using System; using System.Collections.Generic; namespace Jovian.InGameLogging { [Serializable] public sealed class GameLogSaveData { public List entries; public GameLogSaveData() { entries = new List(); } public GameLogSaveData(List entries) { this.entries = entries; } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/GameLogSaveData.cs git commit -m "feat: add GameLogSaveData serializable container" ``` --- ### Task 6: IGameLogStore interface **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/IGameLogStore.cs` **Step 1: Define the store interface** ```csharp using System; using System.Collections.Generic; namespace Jovian.InGameLogging { public interface IGameLogStore { int Count { get; } int Capacity { get; } void Add(LogChannel channel, string message); void Clear(); void Clear(LogChannel channel); ReadOnlySpan GetEntries(); int GetEntries(LogChannel channel, List results); event Action OnEntryAdded; event Action OnCleared; GameLogSaveData GetSaveData(); void RestoreFromSaveData(GameLogSaveData data); } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/IGameLogStore.cs git commit -m "feat: add IGameLogStore interface" ``` --- ### Task 7: GameLogStore ring buffer implementation **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs` **Step 1: Implement the ring buffer store** ```csharp using System; using System.Collections.Generic; using UnityEngine; namespace Jovian.InGameLogging { public sealed class GameLogStore : IGameLogStore { readonly LogEntry[] buffer; int head; int count; public int Count => count; public int Capacity => buffer.Length; public event Action OnEntryAdded; public event Action OnCleared; public GameLogStore(int capacity = 500) { buffer = new LogEntry[capacity]; head = 0; count = 0; } public void Add(LogChannel channel, string message) { var entry = new LogEntry(message, channel, Time.time); buffer[head] = entry; head = (head + 1) % buffer.Length; if(count < buffer.Length) { count++; } OnEntryAdded?.Invoke(entry); } public void Clear() { head = 0; count = 0; OnCleared?.Invoke(); } public void Clear(LogChannel channel) { // Compact: copy non-matching entries into a temp span, then rebuild var kept = new List(count); var entries = GetEntries(); for(int i = 0; i < entries.Length; i++) { if(entries[i].channel != channel) { kept.Add(entries[i]); } } head = 0; count = 0; for(int i = 0; i < kept.Count; i++) { buffer[i] = kept[i]; count++; } head = count % buffer.Length; OnCleared?.Invoke(); } public ReadOnlySpan GetEntries() { if(count < buffer.Length) { return new ReadOnlySpan(buffer, 0, count); } // Buffer is full and wrapping - need to return in chronological order var result = new LogEntry[count]; int start = head; // head points to the oldest entry when full for(int i = 0; i < count; i++) { result[i] = buffer[(start + i) % buffer.Length]; } return result; } public int GetEntries(LogChannel channel, List results) { results.Clear(); var entries = GetEntries(); for(int i = 0; i < entries.Length; i++) { if(entries[i].channel == channel) { results.Add(entries[i]); } } return results.Count; } public GameLogSaveData GetSaveData() { var entries = GetEntries(); var list = new List(entries.Length); for(int i = 0; i < entries.Length; i++) { list.Add(entries[i]); } return new GameLogSaveData(list); } public void RestoreFromSaveData(GameLogSaveData data) { head = 0; count = 0; if(data?.entries == null) { OnCleared?.Invoke(); return; } // Only restore up to capacity, taking the most recent entries int startIndex = Math.Max(0, data.entries.Count - buffer.Length); for(int i = startIndex; i < data.entries.Count; i++) { buffer[count] = data.entries[i]; count++; } head = count % buffer.Length; OnCleared?.Invoke(); // Signal UI to rebuild from scratch } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs git commit -m "feat: add GameLogStore ring buffer implementation" ``` --- ### Task 8: InGameLogger facade struct **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/InGameLogger.cs` **Step 1: Implement the per-caller facade** ```csharp using System.Runtime.CompilerServices; namespace Jovian.InGameLogging { public readonly struct InGameLogger { readonly IGameLogStore store; readonly LogChannel channel; public InGameLogger(IGameLogStore store, LogChannel channel) { this.store = store; this.channel = channel; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Log(string message) { store.Add(channel, message); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Log(string message, string hexColor) { store.Add(channel, $"{message}"); } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/InGameLogger.cs git commit -m "feat: add InGameLogger readonly struct facade" ``` --- ### Task 9: LogEntryView MonoBehaviour **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/UI/LogEntryView.cs` **Step 1: Implement the minimal UI entry component** ```csharp using TMPro; using UnityEngine; namespace Jovian.InGameLogging.UI { public class LogEntryView : MonoBehaviour { [SerializeField] TMP_Text messageText; public void SetEntry(in LogEntry entry) { messageText.text = entry.message; } public void ClearEntry() { messageText.text = string.Empty; } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/UI/LogEntryView.cs git commit -m "feat: add LogEntryView MonoBehaviour for pooled UI entries" ``` --- ### Task 10: GameLogView MonoBehaviour with object pooling **Files:** - Create: `Packages/com.jovian.ingame-logging/Runtime/UI/GameLogView.cs` **Step 1: Implement the scrollable log view with object pool** ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; namespace Jovian.InGameLogging.UI { public class GameLogView : MonoBehaviour { [SerializeField] ScrollRect scrollRect; [SerializeField] RectTransform content; [SerializeField] LogEntryView entryPrefab; [SerializeField] int poolSize = 20; IGameLogStore store; LogChannel? channelFilter; bool autoScroll = true; readonly List activeEntries = new(); readonly Stack pool = new(); public void Initialize(IGameLogStore store, LogChannel? channelFilter = null) { this.store = store; this.channelFilter = channelFilter; WarmPool(); store.OnEntryAdded += HandleEntryAdded; store.OnCleared += HandleCleared; scrollRect.onValueChanged.AddListener(HandleScrollChanged); // Populate existing entries RebuildFromStore(); } void OnDestroy() { if(store != null) { store.OnEntryAdded -= HandleEntryAdded; store.OnCleared -= HandleCleared; } } void WarmPool() { for(int i = 0; i < poolSize; i++) { var entry = Instantiate(entryPrefab, content); entry.gameObject.SetActive(false); pool.Push(entry); } } LogEntryView GetFromPool() { LogEntryView entry; if(pool.Count > 0) { entry = pool.Pop(); } else { // Recycle oldest active entry entry = activeEntries[0]; activeEntries.RemoveAt(0); } entry.gameObject.SetActive(true); entry.transform.SetAsLastSibling(); return entry; } void ReturnToPool(LogEntryView entry) { entry.ClearEntry(); entry.gameObject.SetActive(false); pool.Push(entry); } void HandleEntryAdded(LogEntry entry) { if(channelFilter.HasValue && entry.channel != channelFilter.Value) { return; } var view = GetFromPool(); view.SetEntry(in entry); activeEntries.Add(view); if(autoScroll) { Canvas.ForceUpdateCanvases(); scrollRect.verticalNormalizedPosition = 0f; } } void HandleCleared() { for(int i = activeEntries.Count - 1; i >= 0; i--) { ReturnToPool(activeEntries[i]); } activeEntries.Clear(); // If store still has entries (channel-specific clear), rebuild if(store.Count > 0) { RebuildFromStore(); } } void RebuildFromStore() { var entries = store.GetEntries(); for(int i = 0; i < entries.Length; i++) { if(channelFilter.HasValue && entries[i].channel != channelFilter.Value) { continue; } var view = GetFromPool(); view.SetEntry(in entries[i]); activeEntries.Add(view); } if(autoScroll) { Canvas.ForceUpdateCanvases(); scrollRect.verticalNormalizedPosition = 0f; } } void HandleScrollChanged(Vector2 position) { // Pause auto-scroll when user scrolls up, resume when at bottom autoScroll = position.y <= 0.01f; } } } ``` **Step 2: Commit** ```bash git add Packages/com.jovian.ingame-logging/Runtime/UI/GameLogView.cs git commit -m "feat: add GameLogView with object pooling and scroll management" ``` --- ### Task 11: Generate Unity .meta files Unity requires `.meta` files for every file and folder. These will be auto-generated by Unity when the editor imports the package. However, to make the package work without opening Unity, we need to ensure the directory structure is correct. **Step 1: Create placeholder to ensure Editor directory exists** Create an empty `Editor/.gitkeep` or let Unity generate metas on next editor open. Since this project builds through Unity Editor, the meta files will be generated automatically on next load. **Step 2: Commit all remaining files** ```bash git add Packages/com.jovian.ingame-logging/ git commit -m "feat: complete com.jovian.ingame-logging package" ``` --- ### Task 12: Game integration - Add GameLogSaveData to NoxSavedDataSet **Files:** - Modify: `Assets/Code/GameState/NoxSaveData.cs` (add `GameLogSaveData` field to `NoxSavedDataSet`) **Step 1: Add the field** Add to `NoxSavedDataSet` class at `Assets/Code/GameState/NoxSaveData.cs:50`: ```csharp using Jovian.InGameLogging; // add to imports // Inside NoxSavedDataSet class, add: public GameLogSaveData gameLogData; ``` **Step 2: Commit** ```bash git add Assets/Code/GameState/NoxSaveData.cs git commit -m "feat: add GameLogSaveData field to NoxSavedDataSet" ``` --- ### Task 13: Game integration - Wire GameLogStore in EntryPoint **Files:** - Modify: `Assets/Code/Core/EntryPoint.cs` **Step 1: Create GameLogStore and pass it to game states** In `EntryPoint.CreateApplicationStates()`, after the character systems creation (~line 109), add: ```csharp using Jovian.InGameLogging; // add to imports // After characterSystems creation: var gameLogStore = new GameLogStore(500); ``` Then pass `gameLogStore` as `IGameLogStore` to the game state constructors that need it. The exact constructors depend on which states need logging - at minimum `GameModeGameState` for combat/adventure logging. **Step 2: Commit** ```bash git add Assets/Code/Core/EntryPoint.cs git commit -m "feat: wire GameLogStore in EntryPoint" ``` --- ### Task 14: Game integration - Save/Load log data **Files:** - Modify: wherever `NoxSavedDataSet` is populated before save (look for `saveSystem.Save` calls) - Modify: `Assets/Code/GameState/NoxSaveData.cs` (`RestoreSavedData` method) **Step 1: Populate log data before save** Wherever the game creates `NoxSavedDataSet` before saving, add: ```csharp savedDataSet.gameLogData = gameLogStore.GetSaveData(); ``` **Step 2: Restore log data after load** In `NoxSaveData.RestoreSavedData()`, after loading save data (~line 35), add: ```csharp // Pass gameLogStore as parameter to RestoreSavedData, then: if(saveData.gameLogData != null) { gameLogStore.RestoreFromSaveData(saveData.gameLogData); } ``` **Step 3: Commit** ```bash git add Assets/Code/GameState/NoxSaveData.cs git commit -m "feat: integrate log save/load with game persistence" ``` --- ## File Summary | File | Type | Purpose | |------|------|---------| | `Packages/com.jovian.ingame-logging/package.json` | Create | Package manifest | | `Packages/com.jovian.ingame-logging/Runtime/Jovian.InGameLogging.asmdef` | Create | Runtime assembly | | `Packages/com.jovian.ingame-logging/Editor/Jovian.InGameLogging.Editor.asmdef` | Create | Editor assembly | | `Packages/com.jovian.ingame-logging/Runtime/LogChannel.cs` | Create | Channel type (readonly struct) | | `Packages/com.jovian.ingame-logging/Runtime/LogChannelJsonConverter.cs` | Create | Newtonsoft serialization | | `Packages/com.jovian.ingame-logging/Runtime/LogEntry.cs` | Create | Entry type (readonly struct) | | `Packages/com.jovian.ingame-logging/Runtime/GameLogSaveData.cs` | Create | Serializable save container | | `Packages/com.jovian.ingame-logging/Runtime/IGameLogStore.cs` | Create | Store interface | | `Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs` | Create | Ring buffer implementation | | `Packages/com.jovian.ingame-logging/Runtime/InGameLogger.cs` | Create | Per-caller facade struct | | `Packages/com.jovian.ingame-logging/Runtime/UI/LogEntryView.cs` | Create | Entry UI component | | `Packages/com.jovian.ingame-logging/Runtime/UI/GameLogView.cs` | Create | Scrollable log view with pool | | `Assets/Code/GameState/NoxSaveData.cs` | Modify | Add GameLogSaveData field + restore | | `Assets/Code/Core/EntryPoint.cs` | Modify | Create and wire GameLogStore |