Files
trail-into-darkness/docs/plans/2026-04-05-ingame-logging-design.md
Sebastian Bularca fa15608f3a added in-game logger
2026-04-05 12:32:42 +02:00

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 IGameLogStore passed 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, fires OnEntryAdded
  • Clear() resets head/count, fires OnCleared
  • Clear(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 a GameLogSaveData
  • RestoreFromSaveData() 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))

UI Design

GameLogView (MonoBehaviour)

  • Attached to a prefab with ScrollRect + Content (VerticalLayoutGroup)
  • Holds reference to a LogEntryView prefab (for pooling)
  • Subscribes to IGameLogStore.OnEntryAdded and OnCleared
  • Object pool: pre-warms ~20 LogEntryView instances
  • When pool is exhausted, recycles oldest visible entries
  • Optional LogChannel filter field (null = show all)
  • Auto-scrolls to bottom; pauses auto-scroll when user scrolls up manually

LogEntryView (MonoBehaviour)

  • Minimal: single TMP_Text serialized field reference
  • SetEntry(LogEntry entry) sets text content
  • TMP richText = true by 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.InGameLogging references: Jovian.SaveSystem, Unity.TextMeshPro, Newtonsoft.Json
  • Jovian.InGameLogging.Editor references: Jovian.InGameLogging

Integration Points (Game Project)

  1. EntryPoint.cs: Create GameLogStore instance, pass to game states that need logging
  2. Game state constructors: Receive IGameLogStore, create InGameLogger structs per channel
  3. UI prefab: Instantiate GameLogView prefab, call Initialize(IGameLogStore)
  4. 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