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¶
IServiceRegistrar— auto-discovered from your game assembly; runs after all engine services initServiceRegistrationInfo— inspector-driven custom services onServiceConfigurationLateInitialize()— default method onILoLEngineService; 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
IServiceRegistrarfor game-specific service registration - Specify both interface and implementation types in
ServiceRegistrationInfo - Use
LateInitialize()when a service needs other services that may not exist duringInitialize() - Don't resolve other services inside
Initialize()— useLateInitialize()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; storeRngServiceSnapshotin save data - Use
GetStream/SetStreamwhen mutating a stream across multiple operations - Don't use
UnityEngine.Randomfor 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
ModelIdstrings in save data; resolve viaModelDb.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 viaMutableClone() - 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 viaILocalizationService.Format()DynamicVar—BaseValue,CalculatedValue,PreviewValue,WasJustUpgradedflagILocStringFormatter— custom specifiers like{damage:highlight}
Rules¶
- Use
LocStringfor any text with runtime numeric variables - Run
Tools > LoL Engine > Validate Localization Tables(orLocValidator.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.SchemaVersionwhen serialized fields change; register oneIDomainMigrationper version step - Check
ISaveSystem.LastLoadReportafter 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 firstDraw - Serialize remaining items as stable IDs; rebuild on load
- Don't catch
PoolExhaustedExceptionsilently — 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
Rngsubstream at construction - Save and restore
CurrentValuealongside 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/
Recommended game patterns (ADR-015 – ADR-018)¶
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¶
- CHEATSHEET-How-To-Use.md — quick reference for every system
- Service Dependencies — optional attribute-based ordering
- IL2CPP.md — required before shipping IL2CPP builds