Skip to content

LoL Engine Architecture Guide

This guide explains how LoL Engine is structured and the patterns you should follow when extending the framework in your game project.

This package-facing guide focuses on the extension patterns needed by game projects.


Overview

LoL Engine is built on four pillars:

Pillar What it means
Service Locator Services implement ILoLEngineService and are accessed via ServiceLocator.Instance.Get<T>()
ScriptableObject config ServiceConfiguration toggles services; per-service configs live in Resources/Configs/
Phased initialization Core → Feature → Game → Custom → External registrars → LateInitialize()
Event-driven communication Type-safe GameEvent bus for decoupled systems
// Typical game code pattern
ServiceAwaiter.WaitForServices(this, () =>
{
    var audio = ServiceLocator.Instance.Get<IAudioService>();
    var save  = ServiceLocator.Instance.Get<ISaveSystem>();
});

See Service Locator, Config, and Getting Started for setup details.


Engine internals (good to know)

These patterns govern core engine code. You rarely need to change them, but understanding them prevents confusion when reading source or debugging initialization.

ADR-001: Defensive initialization (ResourceService)

ResourceService.Initialize() works without ResourceManagementConfig registered — it falls back to a 512 MB memory budget and registers all loaders. Full configuration is applied in InitializeAsync().

For you: If ResourceService boots before your config asset is wired, that is expected — not a bug.

ADR-002: Cache invalidation (AudioExtensions)

Static extension caches use ServiceLocator.Instance.GetHashCode() as a version stamp. Same singleton instance → same hash (cache hits). After domain reload, a new singleton → new hash → cache invalidates automatically.

ADR-003: Distributed domain reload

Multiple classes reset their own static state via [RuntimeInitializeOnLoadMethod(SubsystemRegistration)]. There is no central coordinator — each class manages its own cleanup independently.

ADR-004: Multi-tier audio handle tracking (AudioService)

Three collections serve different roles: _activeHandles (fast GUID lookup), _persistentHandles (survive scene changes), _monitoredHandles (auto-cleanup for non-looping sounds). All three are cleaned up on completion, periodic sweep, and shutdown.

ADR-005: Fail-fast service initialization

Core service init failures throw immediately — the game cannot run without EventManager, ResourceService, etc. Custom services use graceful degradation (catch and continue).

ADR-006: Sink-based logging (LoLLogger)

LoLLogger is a thin static facade dispatching to pluggable ILogSink implementations (console, file, history). Formatting and I/O live in sinks, not in LoLLogger itself. See Logger.md.


Extending LoL Engine (ADR-007)

Game projects register their own services without modifying engine code.

Three layers

  1. IServiceRegistrar — auto-discovered from your game assembly; runs after all engine services init
  2. ServiceRegistrationInfo — inspector-driven custom services on ServiceConfiguration
  3. LateInitialize() — default method on ILoLEngineService; safe to resolve cross-service dependencies

Initialization order

Phase 1: Core services (Events, Pool, Time, Resources)
Phase 2: Feature services (Audio, Serialization, Save, …)
Phase 3: Game services (GameStateManager, …)
Phase 4: Custom services (ServiceConfiguration inspector list)
Phase 5: External registrars (IServiceRegistrar auto-discovery)
Phase 6: LateInitialize() on ALL registered services

Rules

  • Use IServiceRegistrar for game-specific service registration
  • Specify both interface and implementation types in ServiceRegistrationInfo
  • Use LateInitialize() when a service needs other services that may not exist during Initialize()
  • Don't resolve other services inside Initialize() — use LateInitialize() instead
  • Don't register under the implementation type only — Get<IMyService>() won't find it

Example: IServiceRegistrar

See Samples~/Scripts/09_GameServiceRegistrarSample.cs for a complete working example.

public class GameServiceRegistrar : IServiceRegistrar
{
    public int Priority => 0;

    public void RegisterServices(IServiceLocator locator, ServiceConfiguration config)
    {
        var wallet = new WalletService();
        wallet.Initialize();
        locator.Register<IWalletService>(wallet);
    }
}

Deterministic RNG (ADR-008)

Use IRngService for any gameplay randomness that must be reproducible (replays, tests, save/load resume).

Rules

  • Give every concern its own named stream (combat, loot, AI, map, …)
  • Call SetSeed() once at run start; store RngServiceSnapshot in save data
  • Use GetStream / SetStream when mutating a stream across multiple operations
  • Don't use UnityEngine.Random for reproducible gameplay logic
  • Don't share one stream between unrelated systems
public enum MyStreams { Combat, Loot, Map, Ai }

_rngService.SetStreamSet(new MyStreamSet());
_rngService.SetSeed(runSeed);

int roll = _rngService.DrawInt((int)MyStreams.Loot, 1, 101);

// Save/load
var snapshot = _rngService.TakeSnapshot();
_rngService.RestoreSnapshot(loadedSnapshot);

Files: Runtime/Scripts/Core/Rng/


Content registry (ADR-009)

ModelDb maps stable ModelId keys to canonical content objects. Registration is explicit at bootstrap — no reflection at runtime (IL2CPP-safe).

Rules

  • Register canonical templates once at bootstrap (ContentBootstrap.RegisterAll())
  • Store ModelId strings in save data; resolve via ModelDb.GetByIdOrNull<T>() on load
  • Keep IDs stable after shipping — saves depend on them
  • Don't save the template object itself — save the ID and re-resolve
  • Don't mutate canonical instances registered in ModelDb
ModelDb.Register(new ModelId("Enemy", "goblin"), new GoblinModel());

var template = ModelDb.Require<GoblinModel>(new ModelId("Enemy", "goblin"));

Files: Runtime/Scripts/Runtime/Models/ModelDb.cs, ModelId.cs


Canonical vs mutable models (ADR-010)

AbstractModel prevents silent template corruption: canonical instances (IsMutable = false) throw if mutated; runtime instances come from MutableClone().

Rules

  • Register canonicals in ModelDb; spawn runtime copies via MutableClone()
  • Call AssertMutable() at the top of every mutating method
  • Don't call mutating methods on templates retrieved from ModelDb
public class GoblinModel : AbstractModel
{
    private int _currentHp;

    public void TakeDamage(int amount)
    {
        AssertMutable();
        _currentHp -= amount;
    }
}

var instance = (GoblinModel)template.MutableClone();
instance.TakeDamage(10); // safe — template unchanged

LocString (ADR-011)

Structured localized text with runtime variable substitution. Use instead of GetText(key) for dynamic tooltips, card descriptions, and upgrade previews.

Key types

  • LocString(LocTable, key, DynamicVar[]) resolved via ILocalizationService.Format()
  • DynamicVarBaseValue, CalculatedValue, PreviewValue, WasJustUpgraded flag
  • ILocStringFormatter — custom specifiers like {damage:highlight}

Rules

  • Use LocString for any text with runtime numeric variables
  • Run Tools > LoL Engine > Validate Localization Tables (or LocValidator.ValidateAll()) in CI
  • Don't store resolved strings — re-format when values change

See Localization.md and LOCALIZATION_MIGRATION.md.


Save system design (ADR-012)

Three features work together for production-grade persistence:

Feature Purpose
ISaveStore Pluggable I/O — LocalFileSaveStore (disk) or InMemorySaveStore (tests)
IDomainMigration Per-domain schema versioning — migrate JSON between versions on load
Quarantine Corrupt files moved to Saves/Quarantine/ instead of deleted

Rules

  • Increment PersistableData.SchemaVersion when serialized fields change; register one IDomainMigration per version step
  • Check ISaveSystem.LastLoadReport after load to surface quarantine events
  • Don't ignore quarantined saves silently — offer the player a recovery path

See DataPersistence.md.


TypedPoolBag (ADR-013)

Genre-neutral shuffled pool with per-bucket cascade (loot tables, relic pools, event decks).

Rules

  • Register buckets in cascade priority order (preferred → fallback)
  • Call Shuffle(ref rng) before the first Draw
  • Serialize remaining items as stable IDs; rebuild on load
  • Don't catch PoolExhaustedException silently — it signals a content bug
var bag = new TypedPoolBag<Tier, RelicModel>()
    .AddBucket(Tier.High, rareRelics)
    .AddBucket(Tier.Mid, uncommonRelics)
    .AddBucket(Tier.Low, commonRelics)
    .Shuffle(ref rng);

var relic = bag.Draw(Tier.High);

Files: Runtime/Scripts/Utility/TypedPoolBag.cs


Probabilistic rolls (ADR-014)

AbstractOdds<TOutcome> and AntiPityOdds<T> encapsulate weighted rolls with persistent pity state.

Rules

  • Inject a named Rng substream at construction
  • Save and restore CurrentValue alongside RNG snapshot on load
  • Don't share one odds instance across unrelated roll contexts
var rarityOdds = new AntiPityOdds<Rarity>(rng,
    outcomes: new[] { (Rarity.Common, 60f), (Rarity.Rare, 3f) },
    pityTarget: Rarity.Rare,
    pityIncrement: 1f);

Rarity result = rarityOdds.Roll();

Files: Runtime/Scripts/Utility/Odds/


These are guidance for your game code — no engine API required.

ADR-015: Fluent option builders

Use readonly structs with With* methods for service calls with many optional parameters. The engine already uses this in PlayOptions, PlaylistPlayOptions, and ResourceLoadOptions.

public readonly struct SpawnOptions
{
    public static SpawnOptions Default => new() { Level = 1 };
    public int Level { get; init; }
    public SpawnOptions WithLevel(int l) => this with { Level = l };
}

spawner.Spawn(template, SpawnOptions.Default.WithLevel(3));

ADR-016: ExtraFields kitchen drawer

Mutable AbstractModel subclasses may use Dictionary<string, object> ExtraFields for one-off flags that appear fewer than three times. Graduate to a real field once a flag is accessed by more than one system.

public void ApplyCharm() { AssertMutable(); ExtraFields["charmed"] = true; }
public bool IsCharmed => ExtraFields.TryGetValue("charmed", out var v) && v is true;

ADR-017: Null-object for optional scope

When a scope is validly absent (no active run in the main menu), implement NullFoo.Instance that satisfies the interface with safe no-ops. Call sites use the interface unconditionally.

public sealed class NullRunState : IRunState
{
    public static readonly NullRunState Instance = new();
    private NullRunState() { }
    public void AddGold(int amount) { }
    public int Gold => 0;
}

ADR-018: Test escape-hatch naming

Engine test seams use the prefix NeverEverCallThisOutsideOfTests_. Never call these from game code.

Examples: AbstractModel.NeverEverCallThisOutsideOfTests_SetIsMutable, SaveSystem.NeverEverCallThisOutsideOfTests_InjectSerializer.


ADR index

ADR Topic Audience
001 Defensive initialization Engine internals
002 GetHashCode() cache invalidation Engine internals
003 Distributed domain reload Engine internals
004 Multi-tier audio handles Engine internals
005 Fail-fast initialization Engine internals
006 Sink-based logger Engine + game logging
007 Service extensibility Game developers
008 Deterministic RNG Game developers
009 ModelDb content registry Game developers
010 AbstractModel boundary Game developers
011 LocString architecture Game developers
012 Save schemas & quarantine Game developers
013 TypedPoolBag Game developers
014 AbstractOdds / AntiPityOdds Game developers
015 Fluent option builders Game developers
016 ExtraFields kitchen drawer Game developers
017 Null-object pattern Game developers
018 Test escape-hatch naming Test authors

Further reading