forked from Shardstone/trail-into-darkness
7.4 KiB
7.4 KiB
In-Game Logging System Design
Package: com.jovian.ingame-logging
Date: 2026-04-05
Purpose
An optimized, low-allocation in-game logging system for displaying gameplay messages (roll values, damage, events) to the player. Injectable via constructor DI, not static. Saves/loads with the Jovian save system.
Core Data Types
LogChannel (readonly struct)
Zero-allocation value type with string-based identity for serialization.
[Serializable]
public readonly struct LogChannel : IEquatable<LogChannel> {
[SerializeField] readonly string id;
public string Id => id;
public LogChannel(string id) => this.id = id;
// Built-in channels
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");
// IEquatable, ==, !=, GetHashCode via id
}
Game code can define additional channels: new LogChannel("Crafting").
Serialized via Newtonsoft JsonConverter that reads/writes the id string.
LogEntry (readonly struct)
[Serializable]
public readonly struct LogEntry {
public readonly string message; // supports TMP rich text tags
public readonly LogChannel channel;
public readonly float gameTime; // Time.time when logged
}
Architecture
Game Code (CharacterCreationView, CombatSystem, etc.)
│ var log = new InGameLogger(store, LogChannel.Combat);
│ log.Log("Rolled 18 for Might");
│ log.Log("<color=#FFD700>Critical Hit!</color> 24 damage");
▼
InGameLogger (readonly struct, per-caller facade)
│ Holds: IGameLogStore reference + LogChannel
│ log.Log(msg) → store.Add(channel, msg)
│ log.Log(msg, color) → store.Add(channel, colored msg)
▼
IGameLogStore (singleton service, created in EntryPoint)
│ Ring buffer with configurable max capacity (default 500)
│ Add(channel, message), Clear(), Clear(channel)
│ GetEntries(), GetEntries(channel)
│ event Action<LogEntry> OnEntryAdded
│ event Action OnCleared
│ SaveLog(ISaveSystem, sessionId), LoadLog(ISaveSystem, sessionId)
▼
GameLogView (MonoBehaviour on UI prefab)
ScrollRect + VerticalLayoutGroup
Object pool of LogEntryView instances
Subscribes to IGameLogStore.OnEntryAdded / OnCleared
Optional channel filter (show one or all channels)
Auto-scroll to bottom, pause on manual scroll-up
InGameLogger (Facade Struct)
public readonly struct InGameLogger {
readonly IGameLogStore store;
readonly LogChannel channel;
public InGameLogger(IGameLogStore store, LogChannel channel) {
this.store = store;
this.channel = channel;
}
public void Log(string message) => store.Add(channel, message);
public void Log(string message, string hexColor)
=> store.Add(channel, $"<color={hexColor}>{message}</color>");
}
- No allocations beyond the message string itself
- Each caller (view, system) creates its own instance scoped to a channel
- Injected via
IGameLogStorepassed through constructor DI
IGameLogStore (Service Interface)
public interface IGameLogStore {
int Count { get; }
int Capacity { get; }
void Add(LogChannel channel, string message);
void Clear();
void Clear(LogChannel channel);
ReadOnlySpan<LogEntry> GetEntries();
// Filtered access for UI
int GetEntries(LogChannel channel, List<LogEntry> results);
event Action<LogEntry> OnEntryAdded;
event Action OnCleared;
// Save integration
GameLogSaveData GetSaveData();
void RestoreFromSaveData(GameLogSaveData data);
}
GameLogStore (Implementation)
- Ring buffer backed by
LogEntry[]array - Configurable capacity (default 500), oldest entries evicted when full
Add()writes to next slot, increments head pointer, firesOnEntryAddedClear()resets head/count, firesOnClearedClear(LogChannel)removes entries matching channel, compacts buffer
Save Integration
Dependency direction: com.jovian.ingame-logging depends on com.jovian.savesystem.
[Serializable]
public sealed class GameLogSaveData {
public List<LogEntry> entries; // all channels, in order
}
GetSaveData()snapshots the ring buffer into aGameLogSaveDataRestoreFromSaveData()rebuilds the ring buffer and notifies UI- All channels saved together in one payload, each entry tagged with its
LogChannel - Game code calls save/load alongside its own persistence:
- Save:
saveSystem.Save(logStore.GetSaveData())in the same session - Load:
logStore.RestoreFromSaveData(saveSystem.Load<GameLogSaveData>(slot))
- Save:
UI Design
GameLogView (MonoBehaviour)
- Attached to a prefab with
ScrollRect+Content(VerticalLayoutGroup) - Holds reference to a
LogEntryViewprefab (for pooling) - Subscribes to
IGameLogStore.OnEntryAddedandOnCleared - Object pool: pre-warms ~20
LogEntryViewinstances - When pool is exhausted, recycles oldest visible entries
- Optional
LogChannelfilter field (null = show all) - Auto-scrolls to bottom; pauses auto-scroll when user scrolls up manually
LogEntryView (MonoBehaviour)
- Minimal: single
TMP_Textserialized field reference SetEntry(LogEntry entry)sets text content- TMP
richText = trueby default (supports<color>,<b>,<size>, etc.)
Package Structure
Packages/com.jovian.ingame-logging/
├── package.json # com.jovian.ingame-logging, deps: savesystem, TMP
├── Runtime/
│ ├── Jovian.InGameLogging.asmdef
│ ├── LogChannel.cs
│ ├── LogEntry.cs
│ ├── InGameLogger.cs
│ ├── IGameLogStore.cs
│ ├── GameLogStore.cs
│ ├── GameLogSaveData.cs
│ ├── LogChannelJsonConverter.cs
│ └── UI/
│ ├── GameLogView.cs
│ └── LogEntryView.cs
└── Editor/
└── Jovian.InGameLogging.Editor.asmdef
Dependencies
com.jovian.savesystem(for ISaveSystem)com.unity.textmeshpro(for TMP_Text)com.unity.nuget.newtonsoft-json(for LogChannel serialization)
Assembly References
Jovian.InGameLoggingreferences:Jovian.SaveSystem,Unity.TextMeshPro,Newtonsoft.JsonJovian.InGameLogging.Editorreferences:Jovian.InGameLogging
Integration Points (Game Project)
- EntryPoint.cs: Create
GameLogStoreinstance, pass to game states that need logging - Game state constructors: Receive
IGameLogStore, createInGameLoggerstructs per channel - UI prefab: Instantiate
GameLogViewprefab, callInitialize(IGameLogStore) - Save/Load: Call
GetSaveData()/RestoreFromSaveData()alongside existing save flow
Design Decisions
| Decision | Rationale |
|---|---|
| Readonly struct for LogChannel | Zero-alloc, value semantics, serializable via string id |
| Readonly struct for InGameLogger | Per-caller facade with no heap allocation |
| Ring buffer over List | Fixed capacity, no resizing, O(1) add, oldest eviction built-in |
| Object pool for UI | Eliminates instantiation after warmup, O(visible) rendering cost |
| Separate save file via save system | Logging depends on save system, not the other way around |
| TMP rich text passthrough | No parsing overhead; callers compose rich text directly |
| Simple Clear() API | Package stays decoupled from game-specific events |